mirror of
http://88.130.71.182:3000/BlitTech/badoHair_be.git
synced 2026-06-12 23:23:22 +00:00
Initial Commit
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.venv/
|
||||||
|
.claude/
|
||||||
|
.junie/
|
||||||
|
.agents/
|
||||||
|
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.idea/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
40
app/config.py
Normal file
40
app/config.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
SUPABASE_URL: str = ""
|
||||||
|
SUPABASE_ANON_KEY: str = ""
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: str = ""
|
||||||
|
SUPABASE_JWT_SECRET: str = ""
|
||||||
|
DATABASE_URL: str = ""
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: str = ""
|
||||||
|
STRIPE_WEBHOOK_SECRET: str = ""
|
||||||
|
STRIPE_CURRENCY: str = "eur"
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_ENV: str = "development"
|
||||||
|
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||||
|
BUSINESS_TIMEZONE: str = "Europe/Berlin"
|
||||||
|
BUSINESS_NAME: str = "Bado Hair"
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST: str = ""
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
EMAIL_FROM: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.APP_ENV == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
10
app/core/pagination.py
Normal file
10
app/core/pagination.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Query
|
||||||
|
|
||||||
|
|
||||||
|
def pagination_params(
|
||||||
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
|
per_page: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
return page, per_page, offset
|
||||||
42
app/core/responses.py
Normal file
42
app/core/responses.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Any, TypeVar, Generic
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorDetail(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class APIResponse(BaseModel, Generic[T]):
|
||||||
|
success: bool
|
||||||
|
data: T | None = None
|
||||||
|
error: ErrorDetail | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationMeta(BaseModel):
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedAPIResponse(BaseModel, Generic[T]):
|
||||||
|
success: bool = True
|
||||||
|
data: list[T] = []
|
||||||
|
meta: PaginationMeta
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data: Any = None) -> dict:
|
||||||
|
return {"success": True, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def paginated(data: list, total: int, page: int, per_page: int) -> dict:
|
||||||
|
pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": data,
|
||||||
|
"meta": {"total": total, "page": page, "per_page": per_page, "pages": pages},
|
||||||
|
}
|
||||||
58
app/core/security.py
Normal file
58
app/core/security.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
_jwks_cache: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_key(kid: str) -> object:
|
||||||
|
if kid in _jwks_cache:
|
||||||
|
return _jwks_cache[kid]
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
url = f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json"
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
|
jwks = json.loads(resp.read())
|
||||||
|
|
||||||
|
for key_data in jwks.get("keys", []):
|
||||||
|
if key_data.get("kid") == kid:
|
||||||
|
public_key = jwt.algorithms.ECAlgorithm.from_jwk(key_data)
|
||||||
|
_jwks_cache[kid] = public_key
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
raise UnauthorizedError("Public key not found")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
alg = header.get("alg", "HS256")
|
||||||
|
kid = header.get("kid")
|
||||||
|
|
||||||
|
if alg == "HS256":
|
||||||
|
settings = get_settings()
|
||||||
|
key = settings.SUPABASE_JWT_SECRET
|
||||||
|
else:
|
||||||
|
key = _get_public_key(kid)
|
||||||
|
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
algorithms=[alg],
|
||||||
|
audience="authenticated",
|
||||||
|
options={"verify_exp": True},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise UnauthorizedError("Token has expired")
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
raise UnauthorizedError(f"Invalid token: {e}")
|
||||||
|
except UnauthorizedError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise UnauthorizedError(f"Token validation failed: {e}")
|
||||||
42
app/database.py
Normal file
42
app/database.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncpg
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool() -> asyncpg.Pool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_pool = await asyncpg.create_pool(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10,
|
||||||
|
command_timeout=60,
|
||||||
|
init=_init_connection,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_connection(conn: asyncpg.Connection):
|
||||||
|
await conn.set_type_codec(
|
||||||
|
"jsonb",
|
||||||
|
encoder=lambda v: __import__("json").dumps(v),
|
||||||
|
decoder=lambda v: __import__("json").loads(v),
|
||||||
|
schema="pg_catalog",
|
||||||
|
format="text",
|
||||||
|
)
|
||||||
|
await conn.set_type_codec(
|
||||||
|
"json",
|
||||||
|
encoder=lambda v: __import__("json").dumps(v),
|
||||||
|
decoder=lambda v: __import__("json").loads(v),
|
||||||
|
schema="pg_catalog",
|
||||||
|
format="text",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool():
|
||||||
|
global _pool
|
||||||
|
if _pool:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
56
app/dependencies.py
Normal file
56
app/dependencies.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from typing import AsyncGenerator
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import Depends, Security
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.exceptions import UnauthorizedError, ForbiddenError, UserBlockedError
|
||||||
|
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
_bearer_optional = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[asyncpg.Connection, None]:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Security(_bearer),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
profile = await db.fetchrow(
|
||||||
|
"SELECT id, email, full_name, phone, role, is_blocked FROM profiles WHERE id = $1",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if not profile:
|
||||||
|
raise UnauthorizedError("User profile not found")
|
||||||
|
if profile["is_blocked"]:
|
||||||
|
raise UserBlockedError()
|
||||||
|
|
||||||
|
return dict(profile)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if user["role"] != "admin":
|
||||||
|
raise ForbiddenError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_optional(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Security(_bearer_optional),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
) -> dict | None:
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await get_current_user(credentials, db)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
58
app/exceptions.py
Normal file
58
app/exceptions.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
def __init__(self, code: str, message: str, status_code: int = 400, details: Any = None):
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
def __init__(self, resource: str, details: Any = None):
|
||||||
|
code = f"{resource.upper().replace(' ', '_')}_NOT_FOUND"
|
||||||
|
message = f"{resource.replace('_', ' ').capitalize()} not found"
|
||||||
|
super().__init__(code=code, message=message, status_code=404, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(AppError):
|
||||||
|
def __init__(self, message: str = "Authentication required"):
|
||||||
|
super().__init__(code="UNAUTHORIZED", message=message, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(AppError):
|
||||||
|
def __init__(self, message: str = "Insufficient permissions"):
|
||||||
|
super().__init__(code="FORBIDDEN", message=message, status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppError):
|
||||||
|
def __init__(self, code: str, message: str, details: Any = None):
|
||||||
|
super().__init__(code=code, message=message, status_code=409, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentError(AppError):
|
||||||
|
def __init__(self, message: str, details: Any = None):
|
||||||
|
super().__init__(code="PAYMENT_FAILED", message=message, status_code=402, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
def __init__(self, message: str, details: Any = None):
|
||||||
|
super().__init__(code="VALIDATION_ERROR", message=message, status_code=422, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfStockError(AppError):
|
||||||
|
def __init__(self, product_name: str = ""):
|
||||||
|
msg = f"'{product_name}' is out of stock" if product_name else "One or more items are out of stock"
|
||||||
|
super().__init__(code="OUT_OF_STOCK", message=msg, status_code=409)
|
||||||
|
|
||||||
|
|
||||||
|
class SlotUnavailableError(AppError):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(code="SLOT_UNAVAILABLE", message="This time slot is no longer available", status_code=409)
|
||||||
|
|
||||||
|
|
||||||
|
class UserBlockedError(AppError):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(code="USER_BLOCKED", message="Your account has been suspended", status_code=403)
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
77
app/models/admin.py
Normal file
77
app/models/admin.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
revenue_today: float
|
||||||
|
revenue_week: float
|
||||||
|
revenue_month: float
|
||||||
|
orders_pending: int
|
||||||
|
bookings_upcoming: int
|
||||||
|
low_stock_count: int
|
||||||
|
new_customers_month: int
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueStats(BaseModel):
|
||||||
|
period: str
|
||||||
|
revenue: float
|
||||||
|
orders_count: int
|
||||||
|
bookings_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
actor_name: str | None = None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: UUID | None = None
|
||||||
|
metadata: dict | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: str
|
||||||
|
full_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
is_blocked: bool
|
||||||
|
orders_count: int = 0
|
||||||
|
bookings_count: int = 0
|
||||||
|
total_spent: float = 0.0
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCustomer(BaseModel):
|
||||||
|
is_blocked: bool | None = None
|
||||||
|
full_name: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreSettingOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: Any
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StoreSettingUpdate(BaseModel):
|
||||||
|
value: Any
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserCreate(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
full_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplateOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
subject: str
|
||||||
|
body_html: str
|
||||||
|
body_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplateUpdate(BaseModel):
|
||||||
|
subject: str
|
||||||
|
body_html: str
|
||||||
|
body_text: str | None = None
|
||||||
50
app/models/auth.py
Normal file
50
app/models/auth.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
# Accept either `name` (frontend) or `full_name`
|
||||||
|
name: str | None = Field(None, min_length=2, max_length=100)
|
||||||
|
full_name: str | None = Field(None, min_length=2, max_length=100)
|
||||||
|
phone: str | None = None
|
||||||
|
|
||||||
|
def resolved_name(self) -> str:
|
||||||
|
return self.name or self.full_name or ""
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
full_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileRequest(BaseModel):
|
||||||
|
full_name: str | None = Field(None, min_length=2, max_length=100)
|
||||||
|
phone: str | None = None
|
||||||
99
app/models/bookings.py
Normal file
99
app/models/bookings.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime, date, time
|
||||||
|
|
||||||
|
BookingStatus = Literal["pending", "confirmed", "cancelled", "completed", "no_show"]
|
||||||
|
|
||||||
|
|
||||||
|
class SlotCreate(BaseModel):
|
||||||
|
date: date
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyScheduleCreate(BaseModel):
|
||||||
|
day_of_week: int = Field(ge=0, le=6, description="0=Monday, 6=Sunday")
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
slot_duration_minutes: int = Field(ge=15, le=480, default=60)
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyScheduleOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
day_of_week: int
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
slot_duration_minutes: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateSlotsRequest(BaseModel):
|
||||||
|
from_date: date
|
||||||
|
to_date: date = Field(description="Max 90 days from from_date")
|
||||||
|
|
||||||
|
|
||||||
|
class SlotOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date: date
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
is_blocked: bool
|
||||||
|
block_reason: str | None = None
|
||||||
|
is_booked: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSlotRequest(BaseModel):
|
||||||
|
is_blocked: bool
|
||||||
|
block_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedDateCreate(BaseModel):
|
||||||
|
date: date
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedDateOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date: date
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookingCreate(BaseModel):
|
||||||
|
slot_id: UUID
|
||||||
|
service_note: str | None = Field(None, max_length=500)
|
||||||
|
# Guest fields — required when not authenticated
|
||||||
|
guest_name: str | None = Field(None, max_length=100)
|
||||||
|
guest_email: str | None = None
|
||||||
|
guest_phone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookingOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID | None = None
|
||||||
|
slot_id: UUID
|
||||||
|
slot_date: date
|
||||||
|
slot_start: str # "HH:MM"
|
||||||
|
slot_end: str # "HH:MM"
|
||||||
|
service_note: str | None = None
|
||||||
|
# Resolved client info (from profile or guest fields)
|
||||||
|
client_name: str | None = None
|
||||||
|
client_email: str | None = None
|
||||||
|
client_phone: str | None = None
|
||||||
|
status: str
|
||||||
|
amount_paid: float | None = None
|
||||||
|
stripe_payment_intent_id: str | None = None
|
||||||
|
admin_notes: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BookingCheckoutResponse(BaseModel):
|
||||||
|
booking_id: UUID
|
||||||
|
client_secret: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateBookingStatus(BaseModel):
|
||||||
|
status: BookingStatus
|
||||||
|
admin_notes: str | None = None
|
||||||
57
app/models/orders.py
Normal file
57
app/models/orders.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
OrderStatus = Literal["pending", "paid", "processing", "shipped", "delivered", "cancelled", "refunded"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemCreate(BaseModel):
|
||||||
|
product_id: UUID
|
||||||
|
quantity: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreate(BaseModel):
|
||||||
|
items: list[OrderItemCreate] = Field(min_length=1)
|
||||||
|
shipping_address: dict | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
product_id: UUID
|
||||||
|
product_name: str
|
||||||
|
quantity: int
|
||||||
|
unit_price: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtotal(self) -> float:
|
||||||
|
return round(self.quantity * self.unit_price, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
status: str
|
||||||
|
total_amount: float
|
||||||
|
items: list[OrderItemOut] = []
|
||||||
|
shipping_address: dict | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
stripe_payment_intent_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutResponse(BaseModel):
|
||||||
|
order_id: UUID
|
||||||
|
client_secret: str
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOrderStatus(BaseModel):
|
||||||
|
status: OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
class RefundRequest(BaseModel):
|
||||||
|
reason: str | None = None
|
||||||
|
amount: float | None = Field(None, gt=0)
|
||||||
70
app/models/products.py
Normal file
70
app/models/products.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
ProductCategory = Literal["clip-in", "tape-in", "ponytail", "keratin"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
price: float = Field(gt=0)
|
||||||
|
original_price: float | None = Field(None, gt=0)
|
||||||
|
category: ProductCategory | None = None
|
||||||
|
colors: list[str] = []
|
||||||
|
lengths: list[str] = []
|
||||||
|
features: list[str] = []
|
||||||
|
stock_quantity: int = Field(ge=0, default=0)
|
||||||
|
is_featured: bool = False
|
||||||
|
is_hidden: bool = False
|
||||||
|
is_new: bool = False
|
||||||
|
is_bestseller: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
description: str | None = None
|
||||||
|
price: float | None = Field(None, gt=0)
|
||||||
|
original_price: float | None = Field(None, gt=0)
|
||||||
|
category: ProductCategory | None = None
|
||||||
|
colors: list[str] | None = None
|
||||||
|
lengths: list[str] | None = None
|
||||||
|
features: list[str] | None = None
|
||||||
|
stock_quantity: int | None = Field(None, ge=0)
|
||||||
|
is_featured: bool | None = None
|
||||||
|
is_hidden: bool | None = None
|
||||||
|
is_new: bool | None = None
|
||||||
|
is_bestseller: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StockUpdate(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
stock_quantity: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkStockUpdateRequest(BaseModel):
|
||||||
|
updates: list[StockUpdate]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
price: float
|
||||||
|
original_price: float | None = None
|
||||||
|
category: str | None = None
|
||||||
|
image: str = ""
|
||||||
|
images: list[str] = []
|
||||||
|
colors: list[str] = []
|
||||||
|
lengths: list[str] = []
|
||||||
|
features: list[str] = []
|
||||||
|
stock_quantity: int
|
||||||
|
is_featured: bool
|
||||||
|
is_hidden: bool
|
||||||
|
is_new: bool
|
||||||
|
is_bestseller: bool
|
||||||
|
rating: float = 0
|
||||||
|
review_count: int = 0
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
0
app/routers/admin/__init__.py
Normal file
0
app/routers/admin/__init__.py
Normal file
179
app/routers/admin/bookings.py
Normal file
179
app/routers/admin/bookings.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.models.bookings import (
|
||||||
|
UpdateBookingStatus, SlotCreate, WeeklyScheduleCreate,
|
||||||
|
GenerateSlotsRequest, BlockedDateCreate, UpdateSlotRequest,
|
||||||
|
)
|
||||||
|
from app.services import booking_service, slot_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Admin — Bookings"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bookings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/bookings")
|
||||||
|
async def list_bookings(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
status: str | None = Query(None),
|
||||||
|
from_date: date | None = Query(None),
|
||||||
|
to_date: date | None = Query(None),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
bookings, total = await booking_service.list_bookings(
|
||||||
|
db, page, per_page, offset,
|
||||||
|
status=status, from_date=from_date, to_date=to_date,
|
||||||
|
)
|
||||||
|
return paginated(bookings, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bookings/{booking_id}")
|
||||||
|
async def get_booking(
|
||||||
|
booking_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
booking = await booking_service.get_booking(db, booking_id)
|
||||||
|
return ok(booking)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/bookings/{booking_id}")
|
||||||
|
async def update_booking(
|
||||||
|
booking_id: str,
|
||||||
|
body: UpdateBookingStatus,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
booking = await booking_service.admin_update_booking(
|
||||||
|
db, booking_id, body.status, body.admin_notes, str(admin["id"])
|
||||||
|
)
|
||||||
|
return ok(booking)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/bookings/{booking_id}", status_code=204)
|
||||||
|
async def delete_booking(
|
||||||
|
booking_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await booking_service.admin_delete_booking(db, booking_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Slots ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/slots")
|
||||||
|
async def list_slots(
|
||||||
|
from_date: date = Query(...),
|
||||||
|
to_date: date = Query(...),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
slots = await slot_service.list_slots(db, from_date, to_date, available_only=False)
|
||||||
|
return ok(slots)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/slots", status_code=201)
|
||||||
|
async def create_slot(
|
||||||
|
body: SlotCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
slot = await slot_service.create_slot(db, body)
|
||||||
|
return ok(slot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/slots/{slot_id}")
|
||||||
|
async def update_slot(
|
||||||
|
slot_id: str,
|
||||||
|
body: UpdateSlotRequest,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
slot = await slot_service.update_slot(db, slot_id, body.is_blocked, body.block_reason)
|
||||||
|
return ok(slot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/slots/{slot_id}", status_code=204)
|
||||||
|
async def delete_slot(
|
||||||
|
slot_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await slot_service.delete_slot(db, slot_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/slots/generate")
|
||||||
|
async def generate_slots(
|
||||||
|
body: GenerateSlotsRequest,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
created = await slot_service.generate_slots(db, body.from_date, body.to_date)
|
||||||
|
return ok({"created": created})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Weekly schedule ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/schedule")
|
||||||
|
async def get_schedule(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
schedule = await slot_service.get_schedule(db)
|
||||||
|
return ok(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/schedule", status_code=201)
|
||||||
|
async def create_schedule(
|
||||||
|
body: WeeklyScheduleCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
entry = await slot_service.create_schedule_entry(db, body)
|
||||||
|
return ok(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/schedule/{schedule_id}", status_code=204)
|
||||||
|
async def delete_schedule(
|
||||||
|
schedule_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await slot_service.delete_schedule_entry(db, schedule_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blocked dates ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/blocked-dates")
|
||||||
|
async def get_blocked_dates(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
dates = await slot_service.get_blocked_dates(db)
|
||||||
|
return ok(dates)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/blocked-dates", status_code=201)
|
||||||
|
async def add_blocked_date(
|
||||||
|
body: BlockedDateCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
entry = await slot_service.add_blocked_date(db, body.date, body.reason)
|
||||||
|
return ok(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/blocked-dates/{blocked_date_id}", status_code=204)
|
||||||
|
async def remove_blocked_date(
|
||||||
|
blocked_date_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await slot_service.remove_blocked_date(db, blocked_date_id)
|
||||||
147
app/routers/admin/customers.py
Normal file
147
app/routers/admin/customers.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.exceptions import NotFoundError
|
||||||
|
from app.models.admin import UpdateCustomer
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/customers", tags=["Admin — Customers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_customers(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
search: str | None = Query(None),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
conditions = ["role = 'client'"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if search:
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
conditions.append(f"(email ILIKE ${len(params)} OR full_name ILIKE ${len(params)})")
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}"
|
||||||
|
total = await db.fetchval(f"SELECT COUNT(*) FROM profiles {where}", *params)
|
||||||
|
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
|
||||||
|
COUNT(DISTINCT o.id) AS orders_count,
|
||||||
|
COUNT(DISTINCT b.id) AS bookings_count,
|
||||||
|
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
|
||||||
|
FROM profiles p
|
||||||
|
LEFT JOIN orders o ON o.user_id = p.id
|
||||||
|
LEFT JOIN bookings b ON b.user_id = p.id
|
||||||
|
{where}
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return paginated([dict(r) for r in rows], total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_customers(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT id, email, full_name, phone, is_blocked, created_at FROM profiles WHERE role = 'client' ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["ID", "Email", "Name", "Phone", "Blocked", "Joined"])
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([r["id"], r["email"], r["full_name"], r["phone"], r["is_blocked"], r["created_at"]])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=customers.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}")
|
||||||
|
async def get_customer(
|
||||||
|
customer_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
|
||||||
|
COUNT(DISTINCT o.id) AS orders_count,
|
||||||
|
COUNT(DISTINCT b.id) AS bookings_count,
|
||||||
|
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
|
||||||
|
FROM profiles p
|
||||||
|
LEFT JOIN orders o ON o.user_id = p.id
|
||||||
|
LEFT JOIN bookings b ON b.user_id = p.id
|
||||||
|
WHERE p.id = $1
|
||||||
|
GROUP BY p.id
|
||||||
|
""",
|
||||||
|
customer_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("customer")
|
||||||
|
|
||||||
|
orders = await db.fetch(
|
||||||
|
"SELECT id, status, total_amount, created_at FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10",
|
||||||
|
customer_id,
|
||||||
|
)
|
||||||
|
bookings = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT b.id, b.status, b.amount_paid, ts.date, ts.start_time
|
||||||
|
FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id
|
||||||
|
WHERE b.user_id = $1
|
||||||
|
ORDER BY ts.date DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(row)
|
||||||
|
result["recent_orders"] = [dict(r) for r in orders]
|
||||||
|
result["recent_bookings"] = [dict(r) for r in bookings]
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}")
|
||||||
|
async def update_customer(
|
||||||
|
customer_id: str,
|
||||||
|
body: UpdateCustomer,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
if not updates:
|
||||||
|
raise NotFoundError("customer")
|
||||||
|
|
||||||
|
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 AND role = 'client' RETURNING *",
|
||||||
|
customer_id, *updates.values(),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("customer")
|
||||||
|
|
||||||
|
action = "customer.blocked" if updates.get("is_blocked") else "customer.updated"
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id) VALUES ($1, $2, 'customer', $3)",
|
||||||
|
str(admin["id"]), action, customer_id,
|
||||||
|
)
|
||||||
|
return ok(dict(row))
|
||||||
114
app/routers/admin/dashboard.py
Normal file
114
app/routers/admin/dashboard.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stats", tags=["Admin — Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def overview(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
# Revenue
|
||||||
|
revenue_today = float(await db.fetchval(
|
||||||
|
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND DATE(created_at)=CURRENT_DATE"
|
||||||
|
) or 0)
|
||||||
|
revenue_week = float(await db.fetchval(
|
||||||
|
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('week',now())"
|
||||||
|
) or 0)
|
||||||
|
revenue_month = float(await db.fetchval(
|
||||||
|
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('month',now())"
|
||||||
|
) or 0)
|
||||||
|
|
||||||
|
# Booking counts — what the frontend dashboard shows
|
||||||
|
orders_pending = int(await db.fetchval("SELECT COUNT(*) FROM orders WHERE status='pending'") or 0)
|
||||||
|
bookings_pending = int(await db.fetchval("SELECT COUNT(*) FROM bookings WHERE status='pending'") or 0)
|
||||||
|
bookings_confirmed = int(await db.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id=b.slot_id WHERE b.status='confirmed' AND ts.date>=CURRENT_DATE"
|
||||||
|
) or 0)
|
||||||
|
|
||||||
|
# Products
|
||||||
|
products_count = int(await db.fetchval("SELECT COUNT(*) FROM products WHERE is_hidden=false") or 0)
|
||||||
|
catalog_value = float(await db.fetchval(
|
||||||
|
"SELECT COALESCE(SUM(price),0) FROM products WHERE is_hidden=false"
|
||||||
|
) or 0)
|
||||||
|
low_stock_count = int(await db.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM products WHERE stock_quantity<=5 AND is_hidden=false"
|
||||||
|
) or 0)
|
||||||
|
|
||||||
|
new_customers_month = int(await db.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM profiles WHERE role='client' AND created_at>=date_trunc('month',now())"
|
||||||
|
) or 0)
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
# Revenue stats
|
||||||
|
"revenue_today": revenue_today,
|
||||||
|
"revenue_week": revenue_week,
|
||||||
|
"revenue_month": revenue_month,
|
||||||
|
# Booking stats (matches frontend dashboard cards)
|
||||||
|
"orders_pending": orders_pending,
|
||||||
|
"bookings_pending": bookings_pending,
|
||||||
|
"bookings_confirmed": bookings_confirmed,
|
||||||
|
"bookings_upcoming": bookings_confirmed,
|
||||||
|
# Product stats (matches frontend dashboard cards)
|
||||||
|
"products_count": products_count,
|
||||||
|
"catalog_value": catalog_value,
|
||||||
|
"low_stock_count": low_stock_count,
|
||||||
|
# Customer stats
|
||||||
|
"new_customers_month": new_customers_month,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/revenue")
|
||||||
|
async def revenue(
|
||||||
|
period: str = Query("month", pattern="^(today|week|month|year)$"),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
trunc = {"today": "day", "week": "week", "month": "month", "year": "year"}[period]
|
||||||
|
rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT date_trunc('{trunc}', created_at) AS period,
|
||||||
|
COALESCE(SUM(total_amount), 0) AS revenue,
|
||||||
|
COUNT(*) AS orders_count
|
||||||
|
FROM orders
|
||||||
|
WHERE status IN ('paid','processing','shipped','delivered')
|
||||||
|
AND created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
|
||||||
|
GROUP BY 1 ORDER BY 1 DESC
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
booking_rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT date_trunc('{trunc}', b.created_at) AS period,
|
||||||
|
COALESCE(SUM(b.amount_paid), 0) AS revenue,
|
||||||
|
COUNT(*) AS bookings_count
|
||||||
|
FROM bookings b
|
||||||
|
WHERE b.status IN ('confirmed','completed')
|
||||||
|
AND b.created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
|
||||||
|
GROUP BY 1 ORDER BY 1 DESC
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
return ok({"orders": [dict(r) for r in rows], "bookings": [dict(r) for r in booking_rows]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activity", tags=["Admin — Dashboard"])
|
||||||
|
async def activity(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT al.id, p.full_name AS actor_name, al.action,
|
||||||
|
al.entity_type, al.entity_id, al.metadata, al.created_at
|
||||||
|
FROM activity_log al
|
||||||
|
LEFT JOIN profiles p ON p.id = al.actor_id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
89
app/routers/admin/orders.py
Normal file
89
app/routers/admin/orders.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.models.orders import UpdateOrderStatus, RefundRequest
|
||||||
|
from app.services import order_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/orders", tags=["Admin — Orders"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_orders(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
status: str | None = Query(None),
|
||||||
|
from_date: date | None = Query(None),
|
||||||
|
to_date: date | None = Query(None),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
orders, total = await order_service.list_orders(db, page, per_page, offset, status=status)
|
||||||
|
return paginated(orders, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_orders(
|
||||||
|
status: str | None = Query(None),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT o.id, p.email, p.full_name, o.status, o.total_amount, o.created_at
|
||||||
|
FROM orders o
|
||||||
|
JOIN profiles p ON p.id = o.user_id
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["ID", "Email", "Name", "Status", "Total", "Date"])
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([r["id"], r["email"], r["full_name"], r["status"], r["total_amount"], r["created_at"]])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=orders.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}")
|
||||||
|
async def get_order(
|
||||||
|
order_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
order = await order_service.get_order(db, order_id)
|
||||||
|
return ok(order)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{order_id}/status")
|
||||||
|
async def update_status(
|
||||||
|
order_id: str,
|
||||||
|
body: UpdateOrderStatus,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
order = await order_service.update_order_status(db, order_id, body.status, str(admin["id"]))
|
||||||
|
return ok(order)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/refund")
|
||||||
|
async def refund_order(
|
||||||
|
order_id: str,
|
||||||
|
body: RefundRequest,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
order = await order_service.refund_order(db, order_id, str(admin["id"]), body.amount, body.reason)
|
||||||
|
return ok(order)
|
||||||
116
app/routers/admin/products.py
Normal file
116
app/routers/admin/products.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.exceptions import ValidationError
|
||||||
|
from app.models.products import ProductCreate, ProductUpdate, BulkStockUpdateRequest
|
||||||
|
from app.services import product_service, storage_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["Admin — Products"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_products(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
search: str | None = Query(None),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
bestseller: bool = Query(False),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
products, total = await product_service.list_products(
|
||||||
|
db, page, per_page, offset,
|
||||||
|
include_hidden=True,
|
||||||
|
category=category,
|
||||||
|
bestseller=bestseller,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
return paginated(products, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_product(
|
||||||
|
body: ProductCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
product = await product_service.create_product(db, body)
|
||||||
|
await _log(db, admin, "product.created", str(product["id"]))
|
||||||
|
return ok(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}")
|
||||||
|
async def update_product(
|
||||||
|
product_id: str,
|
||||||
|
body: ProductUpdate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
product = await product_service.update_product(db, product_id, body)
|
||||||
|
await _log(db, admin, "product.updated", product_id)
|
||||||
|
return ok(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", status_code=204)
|
||||||
|
async def delete_product(
|
||||||
|
product_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await product_service.delete_product(db, product_id)
|
||||||
|
await _log(db, admin, "product.deleted", product_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{product_id}/images", status_code=201)
|
||||||
|
async def upload_image(
|
||||||
|
product_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
|
raise ValidationError("Only image files are allowed")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise ValidationError("Image must be under 10MB")
|
||||||
|
|
||||||
|
image_data = await storage_service.upload_product_image(product_id, content, file.content_type)
|
||||||
|
product = await product_service.add_image(db, product_id, image_data["url"])
|
||||||
|
return ok(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}/images")
|
||||||
|
async def delete_image(
|
||||||
|
product_id: str,
|
||||||
|
url: str = Query(..., description="Full image URL to remove"),
|
||||||
|
storage_path: str = Query(..., description="Storage path for deletion from bucket"),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
await storage_service.delete_product_image(storage_path)
|
||||||
|
product = await product_service.remove_image(db, product_id, url)
|
||||||
|
return ok(product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-stock")
|
||||||
|
async def bulk_stock(
|
||||||
|
body: BulkStockUpdateRequest,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
updates = [u.model_dump() for u in body.updates]
|
||||||
|
await product_service.bulk_stock_update(db, updates)
|
||||||
|
await _log(db, admin, "product.bulk_stock_updated", None)
|
||||||
|
return ok({"updated": len(updates)})
|
||||||
|
|
||||||
|
|
||||||
|
async def _log(db, admin, action, entity_id, metadata=None):
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, 'product', $3, $4)",
|
||||||
|
str(admin["id"]), action, entity_id, metadata,
|
||||||
|
)
|
||||||
88
app/routers/admin/services.py
Normal file
88
app/routers/admin/services.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.exceptions import NotFoundError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/services", tags=["Admin — Services"])
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
description: str | None = None
|
||||||
|
duration_minutes: int = Field(default=60, ge=5, le=480)
|
||||||
|
price: float = Field(default=0.0, ge=0)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=120)
|
||||||
|
description: str | None = None
|
||||||
|
duration_minutes: int | None = Field(None, ge=5, le=480)
|
||||||
|
price: float | None = Field(None, ge=0)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_services(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT * FROM services ORDER BY price ASC, name ASC"
|
||||||
|
)
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_service(
|
||||||
|
body: ServiceCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO services (name, description, duration_minutes, price, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
body.name, body.description, body.duration_minutes, body.price, body.is_active,
|
||||||
|
)
|
||||||
|
return ok(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{service_id}")
|
||||||
|
async def update_service(
|
||||||
|
service_id: str,
|
||||||
|
body: ServiceUpdate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
if not updates:
|
||||||
|
row = await db.fetchrow("SELECT * FROM services WHERE id = $1", service_id)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("service")
|
||||||
|
return ok(dict(row))
|
||||||
|
|
||||||
|
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE services SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
service_id, *updates.values(),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("service")
|
||||||
|
return ok(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{service_id}", status_code=204)
|
||||||
|
async def delete_service(
|
||||||
|
service_id: str,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
result = await db.execute("DELETE FROM services WHERE id = $1", service_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("service")
|
||||||
99
app/routers/admin/settings.py
Normal file
99
app/routers/admin/settings.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db, require_admin
|
||||||
|
from app.exceptions import NotFoundError
|
||||||
|
from app.models.admin import StoreSettingUpdate, AdminUserCreate, EmailTemplateUpdate
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Admin — Settings"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Store settings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def get_settings(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch("SELECT key, value, updated_at FROM store_settings ORDER BY key")
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/{key}")
|
||||||
|
async def update_setting(
|
||||||
|
key: str,
|
||||||
|
body: StoreSettingUpdate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO store_settings (key, value)
|
||||||
|
VALUES ($1, $2::jsonb)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = now()
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
key, json.dumps(body.value),
|
||||||
|
)
|
||||||
|
return ok(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Email templates ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/email-templates")
|
||||||
|
async def get_templates(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch("SELECT * FROM email_templates ORDER BY name")
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email-templates/{name}")
|
||||||
|
async def update_template(
|
||||||
|
name: str,
|
||||||
|
body: EmailTemplateUpdate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO email_templates (name, subject, body_html, body_text)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET subject = $2, body_html = $3, body_text = $4, updated_at = now()
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
name, body.subject, body.body_html, body.body_text,
|
||||||
|
)
|
||||||
|
return ok(dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin users ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_admin_users(
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT id, email, full_name, created_at FROM profiles WHERE role = 'admin' ORDER BY created_at"
|
||||||
|
)
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", status_code=201)
|
||||||
|
async def create_admin_user(
|
||||||
|
body: AdminUserCreate,
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
user = await auth_service.create_admin_user(body.email, body.password, body.full_name, db)
|
||||||
|
return ok({
|
||||||
|
"id": str(user["id"]),
|
||||||
|
"email": user["email"],
|
||||||
|
"full_name": user["full_name"],
|
||||||
|
"role": user["role"],
|
||||||
|
})
|
||||||
60
app/routers/auth.py
Normal file
60
app/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db, get_current_user
|
||||||
|
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest, ForgotPasswordRequest, UpdateProfileRequest
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", status_code=201)
|
||||||
|
async def register(body: RegisterRequest, db: asyncpg.Connection = Depends(get_db)):
|
||||||
|
data = await auth_service.register(body, db)
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginRequest):
|
||||||
|
data = await auth_service.login(body)
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh(body: RefreshRequest):
|
||||||
|
data = await auth_service.refresh(body)
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password(body: ForgotPasswordRequest):
|
||||||
|
await auth_service.forgot_password(body.email)
|
||||||
|
return ok({"message": "If that email exists, a reset link has been sent."})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user: dict = Depends(get_current_user)):
|
||||||
|
return ok({
|
||||||
|
"id": str(user["id"]),
|
||||||
|
"email": user["email"],
|
||||||
|
"full_name": user["full_name"],
|
||||||
|
"phone": user["phone"],
|
||||||
|
"role": user["role"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me")
|
||||||
|
async def update_me(
|
||||||
|
body: UpdateProfileRequest,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
updated = await auth_service.update_profile(str(user["id"]), db, body.full_name, body.phone)
|
||||||
|
return ok({
|
||||||
|
"id": str(updated["id"]),
|
||||||
|
"email": updated["email"],
|
||||||
|
"full_name": updated["full_name"],
|
||||||
|
"phone": updated["phone"],
|
||||||
|
"role": updated["role"],
|
||||||
|
})
|
||||||
65
app/routers/bookings.py
Normal file
65
app/routers/bookings.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, get_current_user, get_current_user_optional
|
||||||
|
from app.models.bookings import BookingCreate
|
||||||
|
from app.services import booking_service, slot_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bookings", tags=["Bookings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slots")
|
||||||
|
async def get_available_slots(
|
||||||
|
from_date: date = Query(...),
|
||||||
|
to_date: date = Query(...),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
slots = await slot_service.list_slots(db, from_date, to_date, available_only=True)
|
||||||
|
return ok(slots)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_booking(
|
||||||
|
body: BookingCreate,
|
||||||
|
user: dict | None = Depends(get_current_user_optional),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await booking_service.create_booking(db, body, user=user)
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_my_bookings(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
status: str | None = Query(None),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
bookings, total = await booking_service.list_bookings(
|
||||||
|
db, page, per_page, offset, user_id=str(user["id"]), status=status
|
||||||
|
)
|
||||||
|
return paginated(bookings, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{booking_id}")
|
||||||
|
async def get_booking(
|
||||||
|
booking_id: str,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
booking = await booking_service.get_booking(db, booking_id, user_id=str(user["id"]))
|
||||||
|
return ok(booking)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{booking_id}", status_code=204)
|
||||||
|
async def cancel_booking(
|
||||||
|
booking_id: str,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
await booking_service.cancel_booking(db, booking_id, str(user["id"]))
|
||||||
23
app/routers/contact.py
Normal file
23
app/routers/contact.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contact", tags=["Contact"])
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: EmailStr
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def submit_contact(body: ContactRequest, db: asyncpg.Connection = Depends(get_db)):
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO contact_messages (name, email, message) VALUES ($1, $2, $3)",
|
||||||
|
body.name, body.email, body.message,
|
||||||
|
)
|
||||||
|
return ok({"message": "Your message has been received. We will get back to you shortly."})
|
||||||
43
app/routers/orders.py
Normal file
43
app/routers/orders.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db, get_current_user
|
||||||
|
from app.models.orders import OrderCreate
|
||||||
|
from app.services import order_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/orders", tags=["Orders"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_order(
|
||||||
|
body: OrderCreate,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await order_service.create_order(db, str(user["id"]), body)
|
||||||
|
return ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_my_orders(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
status: str | None = Query(None),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
orders, total = await order_service.list_orders(db, page, per_page, offset, user_id=str(user["id"]), status=status)
|
||||||
|
return paginated(orders, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}")
|
||||||
|
async def get_order(
|
||||||
|
order_id: str,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
order = await order_service.get_order(db, order_id, user_id=str(user["id"]))
|
||||||
|
return ok(order)
|
||||||
47
app/routers/payments.py
Normal file
47
app/routers/payments.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter, Request, Header
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.exceptions import PaymentError
|
||||||
|
from app.services import stripe_service, order_service, booking_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/payments", tags=["Payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
stripe_signature: str = Header(None, alias="stripe-signature"),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
payload = await request.body()
|
||||||
|
|
||||||
|
if not stripe_signature:
|
||||||
|
raise PaymentError("Missing signature")
|
||||||
|
|
||||||
|
event = stripe_service.verify_webhook(payload, stripe_signature)
|
||||||
|
|
||||||
|
intent = event.get("data", {}).get("object", {})
|
||||||
|
metadata = intent.get("metadata", {})
|
||||||
|
entity_type = metadata.get("entity_type")
|
||||||
|
entity_id = metadata.get("entity_id")
|
||||||
|
payment_intent_id = intent.get("id")
|
||||||
|
|
||||||
|
if not entity_type or not entity_id or entity_id == "pending":
|
||||||
|
return ok({"received": True})
|
||||||
|
|
||||||
|
if event["type"] == "payment_intent.succeeded":
|
||||||
|
if entity_type == "order":
|
||||||
|
await order_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
|
||||||
|
elif entity_type == "booking":
|
||||||
|
await booking_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
|
||||||
|
|
||||||
|
elif event["type"] == "payment_intent.payment_failed":
|
||||||
|
if entity_type == "order":
|
||||||
|
await order_service.handle_payment_failed(db, payment_intent_id, entity_id)
|
||||||
|
elif entity_type == "booking":
|
||||||
|
await booking_service.handle_payment_failed(db, payment_intent_id, entity_id)
|
||||||
|
|
||||||
|
return ok({"received": True})
|
||||||
39
app/routers/products.py
Normal file
39
app/routers/products.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.pagination import pagination_params
|
||||||
|
from app.core.responses import ok, paginated
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.services import product_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/products", tags=["Products"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_products(
|
||||||
|
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||||
|
search: str | None = Query(None),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
bestseller: bool = Query(False),
|
||||||
|
is_new: bool = Query(False),
|
||||||
|
exclude: str | None = Query(None, description="Exclude a product ID (e.g. for similar products)"),
|
||||||
|
db: asyncpg.Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
page, per_page, offset = pagination
|
||||||
|
products, total = await product_service.list_products(
|
||||||
|
db, page, per_page, offset,
|
||||||
|
include_hidden=False,
|
||||||
|
category=category,
|
||||||
|
bestseller=bestseller,
|
||||||
|
is_new=is_new,
|
||||||
|
search=search,
|
||||||
|
exclude_id=exclude,
|
||||||
|
)
|
||||||
|
return paginated(products, total, page, per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}")
|
||||||
|
async def get_product(product_id: str, db: asyncpg.Connection = Depends(get_db)):
|
||||||
|
product = await product_service.get_product(db, product_id)
|
||||||
|
return ok(product)
|
||||||
15
app/routers/services.py
Normal file
15
app/routers/services.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.core.responses import ok
|
||||||
|
from app.dependencies import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/services", tags=["Services"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_services(db: asyncpg.Connection = Depends(get_db)):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"SELECT id, name, description, duration_minutes, price FROM services WHERE is_active = true ORDER BY price ASC"
|
||||||
|
)
|
||||||
|
return ok([dict(r) for r in rows])
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
127
app/services/auth_service.py
Normal file
127
app/services/auth_service.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import asyncpg
|
||||||
|
from supabase import create_client, Client
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.exceptions import AppError, UnauthorizedError
|
||||||
|
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> Client:
|
||||||
|
s = get_settings()
|
||||||
|
return create_client(s.SUPABASE_URL, s.SUPABASE_ANON_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_client() -> Client:
|
||||||
|
s = get_settings()
|
||||||
|
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
async def register(req: RegisterRequest, db: asyncpg.Connection) -> dict:
|
||||||
|
try:
|
||||||
|
result = _client().auth.sign_up({"email": req.email, "password": req.password})
|
||||||
|
except Exception as e:
|
||||||
|
raise AppError("REGISTRATION_FAILED", str(e), 400)
|
||||||
|
|
||||||
|
if not result.user:
|
||||||
|
raise AppError("REGISTRATION_FAILED", "Could not create account", 400)
|
||||||
|
|
||||||
|
user_id = str(result.user.id)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profiles (id, email, full_name, phone, role)
|
||||||
|
VALUES ($1, $2, $3, $4, 'client')
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
user_id, req.email, req.resolved_name(), req.phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.session:
|
||||||
|
return {
|
||||||
|
"access_token": result.session.access_token,
|
||||||
|
"refresh_token": result.session.refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": result.session.expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"message": "Account created successfully."}
|
||||||
|
|
||||||
|
|
||||||
|
async def login(req: LoginRequest) -> dict:
|
||||||
|
try:
|
||||||
|
result = _client().auth.sign_in_with_password({"email": req.email, "password": req.password})
|
||||||
|
except Exception:
|
||||||
|
raise UnauthorizedError("Invalid email or password")
|
||||||
|
|
||||||
|
if not result.session:
|
||||||
|
raise UnauthorizedError("Invalid email or password")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": result.session.access_token,
|
||||||
|
"refresh_token": result.session.refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": result.session.expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh(req: RefreshRequest) -> dict:
|
||||||
|
try:
|
||||||
|
result = _client().auth.refresh_session(req.refresh_token)
|
||||||
|
except Exception:
|
||||||
|
raise UnauthorizedError("Invalid or expired refresh token")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": result.session.access_token,
|
||||||
|
"refresh_token": result.session.refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": result.session.expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def forgot_password(email: str):
|
||||||
|
try:
|
||||||
|
_client().auth.reset_password_email(email)
|
||||||
|
except Exception:
|
||||||
|
pass # Always return success to avoid email enumeration
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile(user_id: str, db: asyncpg.Connection, full_name: str | None, phone: str | None) -> dict:
|
||||||
|
updates = {}
|
||||||
|
if full_name is not None:
|
||||||
|
updates["full_name"] = full_name
|
||||||
|
if phone is not None:
|
||||||
|
updates["phone"] = phone
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
user_id, *updates.values(),
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_admin_user(email: str, password: str, full_name: str, db: asyncpg.Connection) -> dict:
|
||||||
|
try:
|
||||||
|
result = _admin_client().auth.admin.create_user({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"email_confirm": True,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
raise AppError("USER_CREATE_FAILED", str(e), 400)
|
||||||
|
|
||||||
|
user_id = str(result.user.id)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profiles (id, email, full_name, role)
|
||||||
|
VALUES ($1, $2, $3, 'admin')
|
||||||
|
ON CONFLICT (id) DO UPDATE SET role = 'admin'
|
||||||
|
""",
|
||||||
|
user_id, email, full_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
|
||||||
|
return dict(row)
|
||||||
272
app/services/booking_service.py
Normal file
272
app/services/booking_service.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.exceptions import NotFoundError, SlotUnavailableError, AppError, ValidationError
|
||||||
|
from app.models.bookings import BookingCreate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_booking(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
data: BookingCreate,
|
||||||
|
user: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
# Resolve identity
|
||||||
|
if user:
|
||||||
|
user_id = str(user["id"])
|
||||||
|
guest_name = guest_email = guest_phone = None
|
||||||
|
else:
|
||||||
|
if not data.guest_email or not data.guest_name:
|
||||||
|
raise ValidationError("guest_name and guest_email are required for unauthenticated bookings")
|
||||||
|
user_id = None
|
||||||
|
guest_name = data.guest_name
|
||||||
|
guest_email = data.guest_email
|
||||||
|
guest_phone = data.guest_phone
|
||||||
|
|
||||||
|
async with db.transaction():
|
||||||
|
slot = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT ts.* FROM time_slots ts
|
||||||
|
WHERE ts.id = $1
|
||||||
|
AND ts.is_blocked = false
|
||||||
|
AND ts.date >= CURRENT_DATE
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM bookings b
|
||||||
|
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
str(data.slot_id),
|
||||||
|
)
|
||||||
|
if not slot:
|
||||||
|
raise SlotUnavailableError()
|
||||||
|
|
||||||
|
booking = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO bookings
|
||||||
|
(user_id, slot_id, service_note, status,
|
||||||
|
guest_name, guest_email, guest_phone)
|
||||||
|
VALUES ($1, $2, $3, 'pending', $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
user_id, str(data.slot_id), data.service_note,
|
||||||
|
guest_name, guest_email, guest_phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"booking_id": str(booking["id"])}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_booking(db: asyncpg.Connection, booking_id: str, user_id: str | None = None) -> dict:
|
||||||
|
query = """
|
||||||
|
SELECT b.*,
|
||||||
|
ts.date AS slot_date,
|
||||||
|
ts.start_time AS slot_start,
|
||||||
|
ts.end_time AS slot_end,
|
||||||
|
p.full_name AS profile_name,
|
||||||
|
p.email AS profile_email,
|
||||||
|
p.phone AS profile_phone,
|
||||||
|
s.name AS service_name
|
||||||
|
FROM bookings b
|
||||||
|
JOIN time_slots ts ON ts.id = b.slot_id
|
||||||
|
LEFT JOIN profiles p ON p.id = b.user_id
|
||||||
|
LEFT JOIN services s ON s.id = b.service_id
|
||||||
|
WHERE b.id = $1
|
||||||
|
"""
|
||||||
|
params: list = [booking_id]
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query += " AND b.user_id = $2"
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
row = await db.fetchrow(query, *params)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("booking")
|
||||||
|
return _shape(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_bookings(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
offset: int,
|
||||||
|
user_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
from_date=None,
|
||||||
|
to_date=None,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
params.append(user_id)
|
||||||
|
conditions.append(f"b.user_id = ${len(params)}")
|
||||||
|
if status:
|
||||||
|
params.append(status)
|
||||||
|
conditions.append(f"b.status = ${len(params)}")
|
||||||
|
if from_date:
|
||||||
|
params.append(from_date)
|
||||||
|
conditions.append(f"ts.date >= ${len(params)}")
|
||||||
|
if to_date:
|
||||||
|
params.append(to_date)
|
||||||
|
conditions.append(f"ts.date <= ${len(params)}")
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
total = await db.fetchval(
|
||||||
|
f"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id {where}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT b.*,
|
||||||
|
ts.date AS slot_date,
|
||||||
|
ts.start_time AS slot_start,
|
||||||
|
ts.end_time AS slot_end,
|
||||||
|
p.full_name AS profile_name,
|
||||||
|
p.email AS profile_email,
|
||||||
|
p.phone AS profile_phone,
|
||||||
|
s.name AS service_name
|
||||||
|
FROM bookings b
|
||||||
|
JOIN time_slots ts ON ts.id = b.slot_id
|
||||||
|
LEFT JOIN profiles p ON p.id = b.user_id
|
||||||
|
LEFT JOIN services s ON s.id = b.service_id
|
||||||
|
{where}
|
||||||
|
ORDER BY ts.date DESC, ts.start_time DESC
|
||||||
|
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return [_shape(r) for r in rows], total
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_booking(db: asyncpg.Connection, booking_id: str, user_id: str):
|
||||||
|
booking = await db.fetchrow(
|
||||||
|
"SELECT * FROM bookings WHERE id = $1 AND user_id = $2",
|
||||||
|
booking_id, user_id,
|
||||||
|
)
|
||||||
|
if not booking:
|
||||||
|
raise NotFoundError("booking")
|
||||||
|
if booking["status"] not in ("pending", "confirmed"):
|
||||||
|
raise AppError("CANNOT_CANCEL", "Only pending or confirmed bookings can be cancelled", 400)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1",
|
||||||
|
booking_id,
|
||||||
|
)
|
||||||
|
if booking["stripe_payment_intent_id"] and booking["status"] == "confirmed":
|
||||||
|
await stripe_service.create_refund(booking["stripe_payment_intent_id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_update_booking(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
booking_id: str,
|
||||||
|
status: str,
|
||||||
|
admin_notes: str | None,
|
||||||
|
actor_id: str,
|
||||||
|
) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
UPDATE bookings SET status = $2, admin_notes = COALESCE($3, admin_notes), updated_at = now()
|
||||||
|
WHERE id = $1 RETURNING *
|
||||||
|
""",
|
||||||
|
booking_id, status, admin_notes,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("booking")
|
||||||
|
|
||||||
|
booking = dict(row)
|
||||||
|
email = booking.get("guest_email")
|
||||||
|
if not email and booking.get("user_id"):
|
||||||
|
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
|
||||||
|
if profile:
|
||||||
|
email = profile["email"]
|
||||||
|
|
||||||
|
if email:
|
||||||
|
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
|
||||||
|
if slot:
|
||||||
|
from app.services import email_service
|
||||||
|
if status == "confirmed":
|
||||||
|
await email_service.send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||||
|
elif status == "cancelled":
|
||||||
|
await email_service.send_booking_cancelled(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||||
|
|
||||||
|
await _log(db, actor_id, f"booking.{status}", "booking", booking_id)
|
||||||
|
return await get_booking(db, booking_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_delete_booking(db: asyncpg.Connection, booking_id: str):
|
||||||
|
result = await db.execute("DELETE FROM bookings WHERE id = $1", booking_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("booking")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||||
|
booking = await db.fetchrow(
|
||||||
|
"SELECT * FROM bookings WHERE id = $1 AND stripe_payment_intent_id = $2",
|
||||||
|
entity_id, payment_intent_id,
|
||||||
|
)
|
||||||
|
if not booking:
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE bookings SET status = 'confirmed', updated_at = now() WHERE id = $1",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
email = booking["guest_email"]
|
||||||
|
if not email and booking["user_id"]:
|
||||||
|
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
|
||||||
|
if profile:
|
||||||
|
email = profile["email"]
|
||||||
|
|
||||||
|
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
|
||||||
|
if email and slot:
|
||||||
|
from app.services.email_service import send_booking_confirmed
|
||||||
|
await send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1 AND status = 'pending'",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_booking_price(db: asyncpg.Connection) -> float:
|
||||||
|
val = await db.fetchval("SELECT value FROM store_settings WHERE key = 'default_booking_price'")
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _shape(r) -> dict:
|
||||||
|
d = dict(r)
|
||||||
|
d["slot_start"] = _fmt_time(d.get("slot_start"))
|
||||||
|
d["slot_end"] = _fmt_time(d.get("slot_end"))
|
||||||
|
# Resolve client identity: profile takes priority over guest fields
|
||||||
|
d["client_name"] = d.pop("profile_name", None) or d.get("guest_name")
|
||||||
|
d["client_email"] = d.pop("profile_email", None) or d.get("guest_email")
|
||||||
|
d["client_phone"] = d.pop("profile_phone", None) or d.get("guest_phone")
|
||||||
|
# Clean up internal fields
|
||||||
|
for f in ("guest_name", "guest_email", "guest_phone", "service_name"):
|
||||||
|
d.pop(f, None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(t) -> str:
|
||||||
|
if t is None:
|
||||||
|
return ""
|
||||||
|
s = str(t)
|
||||||
|
# "HH:MM:SS" → "HH:MM"
|
||||||
|
return s[:5] if len(s) >= 5 else s
|
||||||
|
|
||||||
|
|
||||||
|
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
actor_id, action, entity_type, entity_id, metadata,
|
||||||
|
)
|
||||||
71
app/services/email_service.py
Normal file
71
app/services/email_service.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import aiosmtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(to: str, subject: str, html_body: str, text_body: str | None = None):
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.SMTP_HOST:
|
||||||
|
return # Email non configuré, on ignore silencieusement
|
||||||
|
|
||||||
|
message = MIMEMultipart("alternative")
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = settings.EMAIL_FROM
|
||||||
|
message["To"] = to
|
||||||
|
|
||||||
|
if text_body:
|
||||||
|
message.attach(MIMEText(text_body, "plain"))
|
||||||
|
message.attach(MIMEText(html_body, "html"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.SMTP_HOST,
|
||||||
|
port=settings.SMTP_PORT,
|
||||||
|
start_tls=True,
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
await smtp.send_message(message)
|
||||||
|
except Exception:
|
||||||
|
pass # Ne jamais laisser un échec d'email planter la requête
|
||||||
|
|
||||||
|
|
||||||
|
async def send_booking_confirmed(to: str, booking_date: str, booking_time: str):
|
||||||
|
settings = get_settings()
|
||||||
|
html = f"""
|
||||||
|
<h2>Votre rendez-vous est confirmé !</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Votre rendez-vous chez <strong>{settings.BUSINESS_NAME}</strong> est confirmé.</p>
|
||||||
|
<p><strong>Date :</strong> {booking_date}<br>
|
||||||
|
<strong>Heure :</strong> {booking_time}</p>
|
||||||
|
<p>Nous vous contacterons pour finaliser le paiement. À très bientôt !</p>
|
||||||
|
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||||
|
"""
|
||||||
|
await send_email(to, f"Rendez-vous confirmé – {settings.BUSINESS_NAME}", html)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_booking_cancelled(to: str, booking_date: str, booking_time: str):
|
||||||
|
settings = get_settings()
|
||||||
|
html = f"""
|
||||||
|
<h2>Annulation de votre rendez-vous</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Votre rendez-vous du {booking_date} à {booking_time} a été annulé.</p>
|
||||||
|
<p>Pour toute question, n'hésitez pas à nous contacter.</p>
|
||||||
|
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||||
|
"""
|
||||||
|
await send_email(to, f"Rendez-vous annulé – {settings.BUSINESS_NAME}", html)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_order_confirmed(to: str, order_id: str, total: float):
|
||||||
|
settings = get_settings()
|
||||||
|
html = f"""
|
||||||
|
<h2>Commande enregistrée !</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Merci pour votre commande chez <strong>{settings.BUSINESS_NAME}</strong>.</p>
|
||||||
|
<p><strong>Numéro de commande :</strong> {order_id}<br>
|
||||||
|
<strong>Total :</strong> {total:.2f} €</p>
|
||||||
|
<p>Nous vous contacterons pour confirmer les modalités de livraison et de paiement.</p>
|
||||||
|
<p>— L'équipe {settings.BUSINESS_NAME}</p>
|
||||||
|
"""
|
||||||
|
await send_email(to, f"Commande confirmée – {settings.BUSINESS_NAME}", html)
|
||||||
194
app/services/order_service.py
Normal file
194
app/services/order_service.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.exceptions import NotFoundError, OutOfStockError
|
||||||
|
from app.models.orders import OrderCreate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_order(db: asyncpg.Connection, user_id: str, data: OrderCreate) -> dict:
|
||||||
|
async with db.transaction():
|
||||||
|
total = 0.0
|
||||||
|
line_items = []
|
||||||
|
|
||||||
|
for item in data.items:
|
||||||
|
product = await db.fetchrow(
|
||||||
|
"SELECT id, name, price, stock_quantity FROM products WHERE id = $1 AND is_hidden = false",
|
||||||
|
str(item.product_id),
|
||||||
|
)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError("product")
|
||||||
|
if product["stock_quantity"] < item.quantity:
|
||||||
|
raise OutOfStockError(product["name"])
|
||||||
|
|
||||||
|
line_items.append({
|
||||||
|
"product_id": str(item.product_id),
|
||||||
|
"product_name": product["name"],
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": float(product["price"]),
|
||||||
|
})
|
||||||
|
total += float(product["price"]) * item.quantity
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE products SET stock_quantity = stock_quantity - $2 WHERE id = $1",
|
||||||
|
str(item.product_id), item.quantity,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = round(total, 2)
|
||||||
|
order = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (user_id, status, total_amount, shipping_address, notes)
|
||||||
|
VALUES ($1, 'pending', $2, $3, $4)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
user_id, total,
|
||||||
|
data.shipping_address, data.notes,
|
||||||
|
)
|
||||||
|
order_id = str(order["id"])
|
||||||
|
|
||||||
|
for li in line_items:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
""",
|
||||||
|
order_id, li["product_id"], li["quantity"], li["unit_price"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"order_id": order_id, "amount": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_order(db: asyncpg.Connection, order_id: UUID, user_id: str | None = None) -> dict:
|
||||||
|
query = "SELECT o.* FROM orders o WHERE o.id = $1"
|
||||||
|
params: list = [str(order_id)]
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query += " AND o.user_id = $2"
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
row = await db.fetchrow(query, *params)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("order")
|
||||||
|
|
||||||
|
items = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT oi.id, oi.product_id, p.name as product_name, oi.quantity, oi.unit_price
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN products p ON p.id = oi.product_id
|
||||||
|
WHERE oi.order_id = $1
|
||||||
|
""",
|
||||||
|
str(order_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(row)
|
||||||
|
result["items"] = [dict(i) for i in items]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def list_orders(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
offset: int,
|
||||||
|
user_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
params.append(user_id)
|
||||||
|
conditions.append(f"o.user_id = ${len(params)}")
|
||||||
|
if status:
|
||||||
|
params.append(status)
|
||||||
|
conditions.append(f"o.status = ${len(params)}")
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
total = await db.fetchval(f"SELECT COUNT(*) FROM orders o {where}", *params)
|
||||||
|
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT o.id, o.user_id, o.status, o.total_amount,
|
||||||
|
o.shipping_address, o.notes, o.created_at, o.updated_at,
|
||||||
|
p.full_name AS client_name, p.email AS client_email, p.phone AS client_phone
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN profiles p ON p.id = o.user_id
|
||||||
|
{where}
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows], total
|
||||||
|
|
||||||
|
|
||||||
|
async def update_order_status(db: asyncpg.Connection, order_id: UUID, status: str, actor_id: str) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"UPDATE orders SET status = $2, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
str(order_id), status,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("order")
|
||||||
|
await _log(db, actor_id, "order.status_updated", "order", str(order_id), {"status": status})
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def refund_order(db: asyncpg.Connection, order_id: UUID, actor_id: str, amount: float | None, reason: str | None) -> dict:
|
||||||
|
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", str(order_id))
|
||||||
|
if not order:
|
||||||
|
raise NotFoundError("order")
|
||||||
|
if not order["stripe_payment_intent_id"]:
|
||||||
|
from app.exceptions import AppError
|
||||||
|
raise AppError("NO_PAYMENT", "No payment found for this order", 400)
|
||||||
|
|
||||||
|
refund = await stripe_service.create_refund(
|
||||||
|
order["stripe_payment_intent_id"],
|
||||||
|
amount=amount,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_status = "refunded"
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"UPDATE orders SET status = $2, stripe_refund_id = $3, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
str(order_id), new_status, refund.id,
|
||||||
|
)
|
||||||
|
await _log(db, actor_id, "order.refunded", "order", str(order_id), {"refund_id": refund.id})
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"UPDATE orders SET status = 'paid', updated_at = now() WHERE id = $1 AND stripe_payment_intent_id = $2 RETURNING *",
|
||||||
|
entity_id, payment_intent_id,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
user = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(row["user_id"]))
|
||||||
|
if user:
|
||||||
|
from app.services.email_service import send_order_confirmed
|
||||||
|
await send_order_confirmed(user["email"], entity_id, float(row["total_amount"]))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
|
||||||
|
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", entity_id)
|
||||||
|
if not order or order["status"] != "pending":
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE orders SET status = 'cancelled', updated_at = now() WHERE id = $1",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
# Restore stock
|
||||||
|
items = await db.fetch("SELECT product_id, quantity FROM order_items WHERE order_id = $1", entity_id)
|
||||||
|
for item in items:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE products SET stock_quantity = stock_quantity + $2 WHERE id = $1",
|
||||||
|
str(item["product_id"]), item["quantity"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
actor_id, action, entity_type, entity_id, metadata,
|
||||||
|
)
|
||||||
159
app/services/product_service.py
Normal file
159
app/services/product_service.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.exceptions import NotFoundError
|
||||||
|
from app.models.products import ProductCreate, ProductUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def list_products(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
offset: int,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
category: str | None = None,
|
||||||
|
bestseller: bool = False,
|
||||||
|
is_new: bool = False,
|
||||||
|
search: str | None = None,
|
||||||
|
exclude_id: str | None = None,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if not include_hidden:
|
||||||
|
conditions.append("is_hidden = false")
|
||||||
|
if category:
|
||||||
|
params.append(category)
|
||||||
|
conditions.append(f"category = ${len(params)}")
|
||||||
|
if bestseller:
|
||||||
|
conditions.append("is_bestseller = true")
|
||||||
|
if is_new:
|
||||||
|
conditions.append("is_new = true")
|
||||||
|
if search:
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
conditions.append(f"(name ILIKE ${len(params)} OR description ILIKE ${len(params)})")
|
||||||
|
if exclude_id:
|
||||||
|
params.append(exclude_id)
|
||||||
|
conditions.append(f"id != ${len(params)}")
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
total = await db.fetchval(f"SELECT COUNT(*) FROM products {where}", *params)
|
||||||
|
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
rows = await db.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT id, name, description, price, original_price, category,
|
||||||
|
images, colors, lengths, features, stock_quantity,
|
||||||
|
is_featured, is_hidden, is_new, is_bestseller,
|
||||||
|
rating, review_count, created_at, updated_at
|
||||||
|
FROM products {where}
|
||||||
|
ORDER BY is_bestseller DESC, is_new DESC, created_at DESC
|
||||||
|
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return [_row(r) for r in rows], total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_product(db: asyncpg.Connection, product_id: str) -> dict:
|
||||||
|
row = await db.fetchrow("SELECT * FROM products WHERE id = $1", product_id)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("product")
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_product(db: asyncpg.Connection, data: ProductCreate) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO products (
|
||||||
|
name, description, price, original_price, category,
|
||||||
|
colors, lengths, features, stock_quantity,
|
||||||
|
is_featured, is_hidden, is_new, is_bestseller, images
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'[]'::jsonb)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
data.name, data.description, data.price, data.original_price, data.category,
|
||||||
|
data.colors, data.lengths, data.features,
|
||||||
|
data.stock_quantity, data.is_featured, data.is_hidden, data.is_new, data.is_bestseller,
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_product(db: asyncpg.Connection, product_id: str, data: ProductUpdate) -> dict:
|
||||||
|
await get_product(db, product_id)
|
||||||
|
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||||
|
if not updates:
|
||||||
|
return await get_product(db, product_id)
|
||||||
|
|
||||||
|
json_fields = {"colors", "lengths", "features"}
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for i, (k, v) in enumerate(updates.items()):
|
||||||
|
set_parts.append(f"{k} = ${i + 2}")
|
||||||
|
values.append(v)
|
||||||
|
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f"UPDATE products SET {', '.join(set_parts)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
product_id, *values,
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_product(db: asyncpg.Connection, product_id: str):
|
||||||
|
result = await db.execute("DELETE FROM products WHERE id = $1", product_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("product")
|
||||||
|
|
||||||
|
|
||||||
|
async def add_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
|
||||||
|
await get_product(db, product_id)
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
UPDATE products
|
||||||
|
SET images = images || $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
product_id, [url],
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
|
||||||
|
product = await get_product(db, product_id)
|
||||||
|
images = [img for img in product["_raw_images"] if img != url]
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"UPDATE products SET images = $2, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
product_id, images,
|
||||||
|
)
|
||||||
|
return _row(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_stock_update(db: asyncpg.Connection, updates: list[dict]):
|
||||||
|
async with db.transaction():
|
||||||
|
for u in updates:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE products SET stock_quantity = $2, updated_at = now() WHERE id = $1",
|
||||||
|
str(u["id"]), u["stock_quantity"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> dict:
|
||||||
|
d = dict(r)
|
||||||
|
|
||||||
|
# Decode JSONB lists
|
||||||
|
for field in ("images", "colors", "lengths", "features"):
|
||||||
|
val = d.get(field, [])
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = json.loads(val)
|
||||||
|
d[field] = val if isinstance(val, list) else []
|
||||||
|
|
||||||
|
# images stored as plain URL strings; keep a raw copy for internal use
|
||||||
|
d["_raw_images"] = d["images"]
|
||||||
|
|
||||||
|
# Derive convenience `image` field (first URL for product cards)
|
||||||
|
d["image"] = d["images"][0] if d["images"] else ""
|
||||||
|
|
||||||
|
return d
|
||||||
175
app/services/slot_service.py
Normal file
175
app/services/slot_service.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from datetime import date, timedelta, datetime, time
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.exceptions import NotFoundError, AppError
|
||||||
|
from app.models.bookings import WeeklyScheduleCreate, SlotCreate
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_time(t) -> str:
|
||||||
|
if t is None:
|
||||||
|
return ""
|
||||||
|
s = str(t)
|
||||||
|
return s[:5]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_schedule(db: asyncpg.Connection) -> list[dict]:
|
||||||
|
rows = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true ORDER BY day_of_week, start_time")
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_schedule_entry(db: asyncpg.Connection, data: WeeklyScheduleCreate) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO weekly_schedule (day_of_week, start_time, end_time, slot_duration_minutes)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
data.day_of_week, data.start_time, data.end_time, data.slot_duration_minutes,
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_schedule_entry(db: asyncpg.Connection, schedule_id: str):
|
||||||
|
result = await db.execute("DELETE FROM weekly_schedule WHERE id = $1", schedule_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("schedule entry")
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_slots(db: asyncpg.Connection, from_date: date, to_date: date) -> int:
|
||||||
|
if (to_date - from_date).days > 90:
|
||||||
|
raise AppError("RANGE_TOO_LARGE", "Date range cannot exceed 90 days", 400)
|
||||||
|
|
||||||
|
schedule = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true")
|
||||||
|
if not schedule:
|
||||||
|
raise AppError("NO_SCHEDULE", "No active weekly schedule configured", 400)
|
||||||
|
|
||||||
|
blocked = await db.fetch(
|
||||||
|
"SELECT date FROM blocked_dates WHERE date BETWEEN $1 AND $2",
|
||||||
|
from_date, to_date,
|
||||||
|
)
|
||||||
|
blocked_set = {r["date"] for r in blocked}
|
||||||
|
|
||||||
|
tz = pytz.timezone(get_settings().BUSINESS_TIMEZONE)
|
||||||
|
created = 0
|
||||||
|
current = from_date
|
||||||
|
|
||||||
|
async with db.transaction():
|
||||||
|
while current <= to_date:
|
||||||
|
if current in blocked_set:
|
||||||
|
current += timedelta(days=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
day_schedules = [s for s in schedule if s["day_of_week"] == current.weekday()]
|
||||||
|
|
||||||
|
for sched in day_schedules:
|
||||||
|
cursor = datetime.combine(current, sched["start_time"])
|
||||||
|
end_limit = datetime.combine(current, sched["end_time"])
|
||||||
|
duration = timedelta(minutes=sched["slot_duration_minutes"])
|
||||||
|
|
||||||
|
while cursor + duration <= end_limit:
|
||||||
|
slot_end = cursor + duration
|
||||||
|
exists = await db.fetchval(
|
||||||
|
"SELECT 1 FROM time_slots WHERE date = $1 AND start_time = $2",
|
||||||
|
current, cursor.time(),
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3)",
|
||||||
|
current, cursor.time(), slot_end.time(),
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
cursor += duration
|
||||||
|
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def create_slot(db: asyncpg.Connection, data: SlotCreate) -> dict:
|
||||||
|
if data.start_time >= data.end_time:
|
||||||
|
raise AppError("INVALID_SLOT", "start_time must be before end_time", 400)
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3) RETURNING *",
|
||||||
|
data.date, data.start_time, data.end_time,
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_slot(db: asyncpg.Connection, slot_id: str):
|
||||||
|
booked = await db.fetchval(
|
||||||
|
"SELECT 1 FROM bookings WHERE slot_id = $1 AND status IN ('pending', 'confirmed')",
|
||||||
|
slot_id,
|
||||||
|
)
|
||||||
|
if booked:
|
||||||
|
raise AppError("SLOT_BOOKED", "Cannot delete a slot with active bookings", 409)
|
||||||
|
result = await db.execute("DELETE FROM time_slots WHERE id = $1", slot_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("slot")
|
||||||
|
|
||||||
|
|
||||||
|
async def update_slot(db: asyncpg.Connection, slot_id: str, is_blocked: bool, block_reason: str | None) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"UPDATE time_slots SET is_blocked = $2, block_reason = $3 WHERE id = $1 RETURNING *",
|
||||||
|
slot_id, is_blocked, block_reason,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise NotFoundError("slot")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_slots(
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
from_date: date,
|
||||||
|
to_date: date,
|
||||||
|
available_only: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
ts.id, ts.date, ts.start_time, ts.end_time,
|
||||||
|
ts.is_blocked, ts.block_reason,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM bookings b
|
||||||
|
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||||
|
) AS is_booked
|
||||||
|
FROM time_slots ts
|
||||||
|
WHERE ts.date BETWEEN $1 AND $2
|
||||||
|
"""
|
||||||
|
params: list = [from_date, to_date]
|
||||||
|
|
||||||
|
if available_only:
|
||||||
|
query += " AND ts.is_blocked = false"
|
||||||
|
query += """
|
||||||
|
AND NOT EXISTS(
|
||||||
|
SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date
|
||||||
|
)
|
||||||
|
AND NOT EXISTS(
|
||||||
|
SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
|
||||||
|
)
|
||||||
|
AND ts.date >= CURRENT_DATE
|
||||||
|
"""
|
||||||
|
|
||||||
|
query += " ORDER BY ts.date, ts.start_time"
|
||||||
|
rows = await db.fetch(query, *params)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_blocked_dates(db: asyncpg.Connection) -> list[dict]:
|
||||||
|
rows = await db.fetch("SELECT * FROM blocked_dates ORDER BY date")
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_blocked_date(db: asyncpg.Connection, date: date, reason: str | None) -> dict:
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"INSERT INTO blocked_dates (date, reason) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET reason = $2 RETURNING *",
|
||||||
|
date, reason,
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_blocked_date(db: asyncpg.Connection, blocked_date_id: str):
|
||||||
|
result = await db.execute("DELETE FROM blocked_dates WHERE id = $1", blocked_date_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise NotFoundError("blocked date")
|
||||||
36
app/services/storage_service.py
Normal file
36
app/services/storage_service.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from supabase import create_client
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
BUCKET = "product-images"
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = get_settings()
|
||||||
|
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_product_image(product_id: str, file_bytes: bytes, content_type: str) -> dict:
|
||||||
|
ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1]
|
||||||
|
path = f"{product_id}/{uuid.uuid4()}.{ext}"
|
||||||
|
|
||||||
|
client = _client()
|
||||||
|
result = client.storage.from_(BUCKET).upload(
|
||||||
|
path,
|
||||||
|
file_bytes,
|
||||||
|
{"content-type": content_type, "upsert": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# supabase-py v2 raises StorageException on failure, but guard against
|
||||||
|
# unexpected response shapes that silently skip the upload
|
||||||
|
if hasattr(result, "error") and result.error:
|
||||||
|
raise RuntimeError(f"Storage upload failed: {result.error}")
|
||||||
|
|
||||||
|
url = client.storage.from_(BUCKET).get_public_url(path)
|
||||||
|
return {"url": url, "storage_path": path}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_product_image(storage_path: str):
|
||||||
|
_client().storage.from_(BUCKET).remove([storage_path])
|
||||||
61
app/services/stripe_service.py
Normal file
61
app/services/stripe_service.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import asyncio
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.exceptions import PaymentError
|
||||||
|
|
||||||
|
_stripe_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def _init_stripe():
|
||||||
|
global _stripe_initialized
|
||||||
|
if not _stripe_initialized:
|
||||||
|
stripe.api_key = get_settings().STRIPE_SECRET_KEY
|
||||||
|
_stripe_initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
async def create_payment_intent(
|
||||||
|
amount: float,
|
||||||
|
metadata: dict,
|
||||||
|
description: str = "",
|
||||||
|
) -> stripe.PaymentIntent:
|
||||||
|
_init_stripe()
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
intent = await asyncio.to_thread(
|
||||||
|
stripe.PaymentIntent.create,
|
||||||
|
amount=int(round(amount * 100)),
|
||||||
|
currency=settings.STRIPE_CURRENCY,
|
||||||
|
metadata=metadata,
|
||||||
|
description=description,
|
||||||
|
automatic_payment_methods={"enabled": True},
|
||||||
|
)
|
||||||
|
return intent
|
||||||
|
except stripe.StripeError as e:
|
||||||
|
raise PaymentError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_refund(
|
||||||
|
payment_intent_id: str,
|
||||||
|
amount: float | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> stripe.Refund:
|
||||||
|
_init_stripe()
|
||||||
|
try:
|
||||||
|
kwargs: dict = {"payment_intent": payment_intent_id}
|
||||||
|
if amount is not None:
|
||||||
|
kwargs["amount"] = int(round(amount * 100))
|
||||||
|
if reason:
|
||||||
|
kwargs["reason"] = reason
|
||||||
|
return await asyncio.to_thread(stripe.Refund.create, **kwargs)
|
||||||
|
except stripe.StripeError as e:
|
||||||
|
raise PaymentError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event:
|
||||||
|
_init_stripe()
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
return stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
|
||||||
|
except stripe.SignatureVerificationError:
|
||||||
|
raise PaymentError("Invalid webhook signature")
|
||||||
3
conftest.py
Normal file
3
conftest.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import sys, os
|
||||||
|
# Ensure the project root is on the path so `from main import app` works
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
117
main.py
Normal file
117
main.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_pool, close_pool
|
||||||
|
from app.exceptions import AppError
|
||||||
|
from app.routers import auth, products, orders, bookings, payments, services, contact
|
||||||
|
from app.routers.admin import dashboard, products as admin_products, orders as admin_orders
|
||||||
|
from app.routers.admin import bookings as admin_bookings, customers, settings as admin_settings
|
||||||
|
from app.routers.admin import services as admin_services
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await get_pool() # warm up the pool on startup
|
||||||
|
yield
|
||||||
|
await close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
cfg = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Bado Hair API",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/docs" if not cfg.is_production else None,
|
||||||
|
redoc_url="/redoc" if not cfg.is_production else None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── CORS ──────────────────────────────────────────────────────────────────
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=cfg.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Error handlers ────────────────────────────────────────────────────────
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(request: Request, exc: AppError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": exc.code,
|
||||||
|
"message": exc.message,
|
||||||
|
"details": exc.details,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Request validation failed",
|
||||||
|
"details": exc.errors(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_error_handler(request: Request, exc: Exception):
|
||||||
|
import traceback
|
||||||
|
if not cfg.is_production:
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": "An unexpected error occurred",
|
||||||
|
"details": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Customer routes (/api/v1) ─────────────────────────────────────────────
|
||||||
|
v1 = "/api/v1"
|
||||||
|
app.include_router(auth.router, prefix=v1)
|
||||||
|
app.include_router(products.router, prefix=v1)
|
||||||
|
app.include_router(orders.router, prefix=v1)
|
||||||
|
app.include_router(bookings.router, prefix=v1)
|
||||||
|
app.include_router(payments.router, prefix=v1)
|
||||||
|
app.include_router(services.router, prefix=v1)
|
||||||
|
app.include_router(contact.router, prefix=v1)
|
||||||
|
|
||||||
|
# ── Admin routes (/api/v1/admin) ──────────────────────────────────────────
|
||||||
|
adm = f"{v1}/admin"
|
||||||
|
app.include_router(dashboard.router, prefix=adm)
|
||||||
|
app.include_router(admin_products.router, prefix=adm)
|
||||||
|
app.include_router(admin_orders.router, prefix=adm)
|
||||||
|
app.include_router(admin_bookings.router, prefix=adm)
|
||||||
|
app.include_router(customers.router, prefix=adm)
|
||||||
|
app.include_router(admin_services.router, prefix=adm)
|
||||||
|
app.include_router(admin_settings.router, prefix=adm)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[project]
|
||||||
|
name = "badohair-be"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.136.1",
|
||||||
|
"uvicorn[standard]>=0.46.0",
|
||||||
|
"pydantic[email]>=2.9.0",
|
||||||
|
"pydantic-settings>=2.5.0",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"supabase>=2.15.0",
|
||||||
|
"stripe>=12.0.0",
|
||||||
|
"PyJWT>=2.9.0",
|
||||||
|
"cryptography>=42.0.0",
|
||||||
|
"python-multipart>=0.0.18",
|
||||||
|
"aiosmtplib>=3.0.0",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"python-dateutil>=2.9.0",
|
||||||
|
"pytz>=2024.2",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"pytest-cov>=5.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["app"]
|
||||||
|
omit = ["app/services/email_service.py"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0.3",
|
||||||
|
"pytest-asyncio>=1.3.0",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
|
]
|
||||||
11
test_main.http
Normal file
11
test_main.http
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Test your FastAPI endpoints
|
||||||
|
|
||||||
|
GET http://127.0.0.1:8000/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://127.0.0.1:8000/hello/User
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
202
tests/conftest.py
Normal file
202
tests/conftest.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Shared fixtures for all tests.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- Override `get_db` with an async generator that yields a mock asyncpg connection.
|
||||||
|
- Override `get_current_user` / `require_admin` directly with plain async functions.
|
||||||
|
- Patch `app.database.get_pool` so the lifespan startup never touches a real DB.
|
||||||
|
- Patch `app.services.stripe_service` globally so no real Stripe calls are made.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, date, time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from app.dependencies import get_db, get_current_user, get_current_user_optional, require_admin
|
||||||
|
|
||||||
|
# ── Sample data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
USER_ID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
ADMIN_ID = "22222222-2222-2222-2222-222222222222"
|
||||||
|
PRODUCT_ID = "33333333-3333-3333-3333-333333333333"
|
||||||
|
SLOT_ID = "44444444-4444-4444-4444-444444444444"
|
||||||
|
BOOKING_ID = "55555555-5555-5555-5555-555555555555"
|
||||||
|
ORDER_ID = "66666666-6666-6666-6666-666666666666"
|
||||||
|
|
||||||
|
SAMPLE_USER = {
|
||||||
|
"id": USER_ID,
|
||||||
|
"email": "user@test.com",
|
||||||
|
"full_name": "Test User",
|
||||||
|
"phone": "+49123456789",
|
||||||
|
"role": "client",
|
||||||
|
"is_blocked": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_ADMIN = {
|
||||||
|
"id": ADMIN_ID,
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"full_name": "Admin User",
|
||||||
|
"phone": None,
|
||||||
|
"role": "admin",
|
||||||
|
"is_blocked": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_PRODUCT = {
|
||||||
|
"id": UUID(PRODUCT_ID),
|
||||||
|
"name": "Extensions Clip-In Luxe",
|
||||||
|
"description": "Top quality",
|
||||||
|
"price": 189.0,
|
||||||
|
"original_price": 229.0,
|
||||||
|
"category": "clip-in",
|
||||||
|
"images": ["https://example.com/img.jpg"],
|
||||||
|
"_raw_images": ["https://example.com/img.jpg"],
|
||||||
|
"image": "https://example.com/img.jpg",
|
||||||
|
"colors": ["Black", "Brown"],
|
||||||
|
"lengths": ["40cm", "50cm"],
|
||||||
|
"features": ["100% Remy"],
|
||||||
|
"stock_quantity": 10,
|
||||||
|
"is_featured": False,
|
||||||
|
"is_hidden": False,
|
||||||
|
"is_new": True,
|
||||||
|
"is_bestseller": True,
|
||||||
|
"rating": 4.8,
|
||||||
|
"review_count": 124,
|
||||||
|
"created_at": datetime(2026, 1, 1),
|
||||||
|
"updated_at": datetime(2026, 1, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_SLOT = {
|
||||||
|
"id": UUID(SLOT_ID),
|
||||||
|
"date": date(2026, 6, 1),
|
||||||
|
"start_time": time(10, 0),
|
||||||
|
"end_time": time(11, 0),
|
||||||
|
"is_blocked": False,
|
||||||
|
"block_reason": None,
|
||||||
|
"is_booked": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_BOOKING = {
|
||||||
|
"id": UUID(BOOKING_ID),
|
||||||
|
"user_id": UUID(USER_ID),
|
||||||
|
"slot_id": UUID(SLOT_ID),
|
||||||
|
"slot_date": date(2026, 6, 1),
|
||||||
|
"slot_start": "10:00",
|
||||||
|
"slot_end": "11:00",
|
||||||
|
"service_note": "Box braids",
|
||||||
|
"client_name": "Test User",
|
||||||
|
"client_email": "user@test.com",
|
||||||
|
"client_phone": "+49123456789",
|
||||||
|
"status": "confirmed",
|
||||||
|
"amount_paid": 50.0,
|
||||||
|
"stripe_payment_intent_id": "pi_test123",
|
||||||
|
"admin_notes": None,
|
||||||
|
"created_at": datetime(2026, 1, 1),
|
||||||
|
"updated_at": datetime(2026, 1, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_SERVICE = {
|
||||||
|
"id": UUID("77777777-7777-7777-7777-777777777777"),
|
||||||
|
"name": "Pose complète",
|
||||||
|
"description": "Full extension installation",
|
||||||
|
"duration_minutes": 150,
|
||||||
|
"price": 150.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mock asyncpg transaction context manager ──────────────────────────────────
|
||||||
|
|
||||||
|
class MockTransaction:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB mock factory ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_mock_db() -> AsyncMock:
|
||||||
|
db = AsyncMock()
|
||||||
|
db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
db.fetch = AsyncMock(return_value=[])
|
||||||
|
db.fetchval = AsyncMock(return_value=0)
|
||||||
|
db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
db.transaction = MagicMock(return_value=MockTransaction())
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def db_override(mock_conn):
|
||||||
|
async def _override():
|
||||||
|
yield mock_conn
|
||||||
|
return _override
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
return make_mock_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_pool():
|
||||||
|
"""Prevent lifespan from opening a real DB pool."""
|
||||||
|
with patch("app.database.get_pool", new_callable=AsyncMock) as m:
|
||||||
|
m.return_value = AsyncMock()
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_stripe():
|
||||||
|
"""Prevent any real Stripe calls."""
|
||||||
|
intent = MagicMock()
|
||||||
|
intent.id = "pi_test123"
|
||||||
|
intent.client_secret = "pi_test123_secret"
|
||||||
|
with patch("app.services.stripe_service.create_payment_intent", new_callable=AsyncMock, return_value=intent):
|
||||||
|
with patch("app.services.stripe_service.create_refund", new_callable=AsyncMock):
|
||||||
|
with patch("app.services.stripe_service.verify_webhook") as mock_wh:
|
||||||
|
mock_wh.return_value = MagicMock(
|
||||||
|
type="payment_intent.succeeded",
|
||||||
|
get=MagicMock(return_value={
|
||||||
|
"object": {"id": "pi_test123", "metadata": {"entity_type": "order", "entity_id": ORDER_ID}}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_stripe_modify():
|
||||||
|
"""Patch the inline stripe.PaymentIntent.modify call in booking_service."""
|
||||||
|
with patch("stripe.PaymentIntent.modify", return_value=MagicMock()):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def anon_client(mock_db):
|
||||||
|
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_client(mock_db):
|
||||||
|
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: SAMPLE_USER
|
||||||
|
app.dependency_overrides[get_current_user_optional] = lambda: SAMPLE_USER
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_client(mock_db):
|
||||||
|
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: SAMPLE_ADMIN
|
||||||
|
app.dependency_overrides[require_admin] = lambda: SAMPLE_ADMIN
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
57
tests/test_admin_dashboard.py
Normal file
57
tests/test_admin_dashboard.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Admin dashboard stats — verify all fields the frontend needs are present."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overview_returns_all_frontend_fields(admin_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
r = await admin_client.get("/api/v1/admin/stats/overview")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()["data"]
|
||||||
|
|
||||||
|
# Revenue (extra, for future admin analytics)
|
||||||
|
assert "revenue_today" in data
|
||||||
|
assert "revenue_week" in data
|
||||||
|
assert "revenue_month" in data
|
||||||
|
|
||||||
|
# Fields the frontend dashboard currently displays
|
||||||
|
assert "products_count" in data
|
||||||
|
assert "bookings_pending" in data
|
||||||
|
assert "bookings_confirmed" in data
|
||||||
|
assert "catalog_value" in data
|
||||||
|
|
||||||
|
# Bonus fields
|
||||||
|
assert "low_stock_count" in data
|
||||||
|
assert "new_customers_month" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overview_requires_admin(auth_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=0)
|
||||||
|
r = await auth_client.get("/api/v1/admin/stats/overview")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_overview_requires_auth(anon_client):
|
||||||
|
r = await anon_client.get("/api/v1/admin/stats/overview")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revenue_stats(admin_client, mock_db):
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
r = await admin_client.get("/api/v1/admin/stats/revenue?period=month")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()["data"]
|
||||||
|
assert "orders" in data
|
||||||
|
assert "bookings" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revenue_invalid_period(admin_client):
|
||||||
|
r = await admin_client.get("/api/v1/admin/stats/revenue?period=decade")
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_activity_feed(admin_client, mock_db):
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
r = await admin_client.get("/api/v1/admin/stats/activity")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json()["data"], list)
|
||||||
113
tests/test_auth.py
Normal file
113
tests/test_auth.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Auth endpoint tests."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from tests.conftest import SAMPLE_USER
|
||||||
|
|
||||||
|
|
||||||
|
def _supabase_session(access_token="tok_access", refresh_token="tok_refresh"):
|
||||||
|
session = MagicMock()
|
||||||
|
session.access_token = access_token
|
||||||
|
session.refresh_token = refresh_token
|
||||||
|
session.expires_in = 3600
|
||||||
|
user = MagicMock()
|
||||||
|
user.id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
result = MagicMock()
|
||||||
|
result.session = session
|
||||||
|
result.user = user
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_success(anon_client):
|
||||||
|
with patch("app.services.auth_service._client") as mock_client:
|
||||||
|
mock_client.return_value.auth.sign_in_with_password.return_value = _supabase_session()
|
||||||
|
r = await anon_client.post("/api/v1/auth/login", json={"email": "user@test.com", "password": "password123"})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert "access_token" in body["data"]
|
||||||
|
assert body["data"]["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_invalid_credentials(anon_client):
|
||||||
|
with patch("app.services.auth_service._client") as mock_client:
|
||||||
|
mock_client.return_value.auth.sign_in_with_password.side_effect = Exception("Invalid login")
|
||||||
|
r = await anon_client.post("/api/v1/auth/login", json={"email": "wrong@test.com", "password": "bad"})
|
||||||
|
|
||||||
|
assert r.status_code == 401
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_with_name_field(anon_client, mock_db):
|
||||||
|
"""Frontend sends `name`, not `full_name` — both must be accepted."""
|
||||||
|
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
|
||||||
|
with patch("app.services.auth_service._client") as mock_client:
|
||||||
|
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
|
||||||
|
r = await anon_client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "new@test.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_with_full_name_field(anon_client, mock_db):
|
||||||
|
"""full_name field also accepted."""
|
||||||
|
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
|
||||||
|
with patch("app.services.auth_service._client") as mock_client:
|
||||||
|
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
|
||||||
|
r = await anon_client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "new2@test.com",
|
||||||
|
"password": "password123",
|
||||||
|
"full_name": "Jane Doe",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_password_too_short(anon_client):
|
||||||
|
r = await anon_client.post("/api/v1/auth/register", json={
|
||||||
|
"email": "new@test.com",
|
||||||
|
"password": "short",
|
||||||
|
"name": "Jane",
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_me(auth_client):
|
||||||
|
r = await auth_client.get("/api/v1/auth/me")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()["data"]
|
||||||
|
assert data["email"] == SAMPLE_USER["email"]
|
||||||
|
assert data["role"] == "client"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_me_unauthenticated(anon_client):
|
||||||
|
r = await anon_client.get("/api/v1/auth/me")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forgot_password_always_200(anon_client):
|
||||||
|
"""Never reveals whether email exists."""
|
||||||
|
with patch("app.services.auth_service._client"):
|
||||||
|
r = await anon_client.post("/api/v1/auth/forgot-password", json={"email": "anyone@test.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_profile(auth_client, mock_db):
|
||||||
|
from tests.conftest import SAMPLE_USER
|
||||||
|
updated = dict(SAMPLE_USER, full_name="Updated Name")
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=updated)
|
||||||
|
|
||||||
|
r = await auth_client.patch("/api/v1/auth/me", json={"full_name": "Updated Name"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"]["full_name"] == "Updated Name"
|
||||||
190
tests/test_bookings.py
Normal file
190
tests/test_bookings.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Booking endpoint tests — guest booking, auth booking, slots, admin actions."""
|
||||||
|
from datetime import date, time
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from tests.conftest import SAMPLE_BOOKING, SAMPLE_SLOT, SLOT_ID, BOOKING_ID, USER_ID
|
||||||
|
|
||||||
|
|
||||||
|
# ── Available slots ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_get_available_slots(anon_client, mock_db):
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SLOT])
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01&to_date=2026-06-07")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert isinstance(body["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_slots_missing_date_param(anon_client):
|
||||||
|
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01")
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ── Guest booking ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_guest_booking_success(anon_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||||
|
SAMPLE_SLOT, # slot availability check
|
||||||
|
SAMPLE_BOOKING, # INSERT booking
|
||||||
|
])
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=50.0) # default booking price
|
||||||
|
|
||||||
|
r = await anon_client.post("/api/v1/bookings", json={
|
||||||
|
"slot_id": SLOT_ID,
|
||||||
|
"service_note": "Box braids",
|
||||||
|
"guest_name": "Marie Dupont",
|
||||||
|
"guest_email": "marie@test.com",
|
||||||
|
"guest_phone": "+49123456789",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert "client_secret" in body["data"]
|
||||||
|
assert "booking_id" in body["data"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guest_booking_missing_email_rejected(anon_client, mock_db):
|
||||||
|
"""Unauthenticated booking without guest_email must fail with 422."""
|
||||||
|
r = await anon_client.post("/api/v1/bookings", json={
|
||||||
|
"slot_id": SLOT_ID,
|
||||||
|
"guest_name": "Marie",
|
||||||
|
# guest_email missing
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guest_booking_slot_unavailable(anon_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None) # slot check returns nothing → unavailable
|
||||||
|
|
||||||
|
r = await anon_client.post("/api/v1/bookings", json={
|
||||||
|
"slot_id": SLOT_ID,
|
||||||
|
"guest_name": "Marie",
|
||||||
|
"guest_email": "marie@test.com",
|
||||||
|
})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "SLOT_UNAVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authenticated booking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_auth_booking_success(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||||
|
SAMPLE_SLOT,
|
||||||
|
SAMPLE_BOOKING,
|
||||||
|
])
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=50.0)
|
||||||
|
|
||||||
|
r = await auth_client.post("/api/v1/bookings", json={
|
||||||
|
"slot_id": SLOT_ID,
|
||||||
|
"service_note": "Tape-in maintenance",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── List / get my bookings ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_list_my_bookings(auth_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
|
||||||
|
|
||||||
|
r = await auth_client.get("/api/v1/bookings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["meta"]["total"] == 1
|
||||||
|
assert body["data"][0]["status"] == "confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_booking_by_id(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_BOOKING)
|
||||||
|
|
||||||
|
r = await auth_client.get(f"/api/v1/bookings/{BOOKING_ID}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"]["id"] == BOOKING_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_booking_not_found(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
r = await auth_client.get(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_bookings_requires_auth(anon_client):
|
||||||
|
r = await anon_client.get("/api/v1/bookings")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cancel booking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_cancel_booking_success(auth_client, mock_db):
|
||||||
|
pending_booking = {**SAMPLE_BOOKING, "status": "pending", "user_id": USER_ID}
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=pending_booking)
|
||||||
|
mock_db.execute = AsyncMock(return_value="UPDATE 1")
|
||||||
|
|
||||||
|
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_already_cancelled_booking(auth_client, mock_db):
|
||||||
|
cancelled = {**SAMPLE_BOOKING, "status": "cancelled", "user_id": USER_ID}
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=cancelled)
|
||||||
|
|
||||||
|
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["error"]["code"] == "CANNOT_CANCEL"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_booking_not_found(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
r = await auth_client.delete(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin booking management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_admin_list_bookings(admin_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
|
||||||
|
|
||||||
|
r = await admin_client.get("/api/v1/admin/bookings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["meta"]["total"] == 1
|
||||||
|
# Verify admin response includes resolved client info
|
||||||
|
record = body["data"][0]
|
||||||
|
assert "client_name" in record
|
||||||
|
assert "client_email" in record
|
||||||
|
assert "slot_date" in record
|
||||||
|
assert "slot_start" in record
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_confirm_booking(admin_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||||
|
SAMPLE_BOOKING, # UPDATE bookings RETURNING *
|
||||||
|
None, # SELECT email FROM profiles (skip email sending)
|
||||||
|
SAMPLE_BOOKING, # get_booking JOIN query at the end
|
||||||
|
])
|
||||||
|
mock_db.execute = AsyncMock(return_value="INSERT 1") # activity_log
|
||||||
|
|
||||||
|
r = await admin_client.patch(f"/api/v1/admin/bookings/{BOOKING_ID}", json={"status": "confirmed"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_delete_booking(admin_client, mock_db):
|
||||||
|
mock_db.execute = AsyncMock(return_value="DELETE 1")
|
||||||
|
r = await admin_client.delete(f"/api/v1/admin/bookings/{BOOKING_ID}")
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_delete_booking_not_found(admin_client, mock_db):
|
||||||
|
mock_db.execute = AsyncMock(return_value="DELETE 0")
|
||||||
|
r = await admin_client.delete(f"/api/v1/admin/bookings/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_bookings_list_filter_by_status(admin_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=0)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
r = await admin_client.get("/api/v1/admin/bookings?status=pending")
|
||||||
|
assert r.status_code == 200
|
||||||
59
tests/test_error_handling.py
Normal file
59
tests/test_error_handling.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Verify the error envelope is consistent across all error types."""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_404_uses_error_envelope(anon_client, mock_db):
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/products/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert "code" in body["error"]
|
||||||
|
assert "message" in body["error"]
|
||||||
|
assert "data" not in body or body.get("data") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_422_uses_error_envelope(auth_client):
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={"items": []})
|
||||||
|
assert r.status_code == 422
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
assert isinstance(body["error"]["details"], list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_401_uses_error_envelope(anon_client):
|
||||||
|
from unittest.mock import patch
|
||||||
|
from app.exceptions import UnauthorizedError
|
||||||
|
# Provide a bad token
|
||||||
|
r = await anon_client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid.token.here"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "UNAUTHORIZED"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_403_uses_error_envelope(auth_client):
|
||||||
|
r = await auth_client.get("/api/v1/admin/products")
|
||||||
|
assert r.status_code == 403
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "FORBIDDEN"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_409_out_of_stock_uses_error_envelope(auth_client, mock_db):
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value={
|
||||||
|
"id": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"name": "Test Product",
|
||||||
|
"price": 100.0,
|
||||||
|
"stock_quantity": 0,
|
||||||
|
})
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={
|
||||||
|
"items": [{"product_id": "33333333-3333-3333-3333-333333333333", "quantity": 1}]
|
||||||
|
})
|
||||||
|
assert r.status_code == 409
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "OUT_OF_STOCK"
|
||||||
4
tests/test_health.py
Normal file
4
tests/test_health.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
async def test_health(anon_client):
|
||||||
|
r = await anon_client.get("/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "ok"
|
||||||
123
tests/test_orders.py
Normal file
123
tests/test_orders.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Order creation, stock deduction, and webhook handling."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from tests.conftest import PRODUCT_ID, ORDER_ID
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_PRODUCT_ROW = {
|
||||||
|
"id": PRODUCT_ID,
|
||||||
|
"name": "Extensions Clip-In Luxe",
|
||||||
|
"price": 189.0,
|
||||||
|
"stock_quantity": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_ORDER_ROW = {
|
||||||
|
"id": ORDER_ID,
|
||||||
|
"user_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"status": "pending",
|
||||||
|
"total_amount": 189.0,
|
||||||
|
"stripe_payment_intent_id": "pi_test123",
|
||||||
|
"shipping_address": None,
|
||||||
|
"notes": None,
|
||||||
|
"created_at": "2026-01-01T00:00:00",
|
||||||
|
"updated_at": "2026-01-01T00:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_order_success(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||||
|
SAMPLE_PRODUCT_ROW, # product lookup
|
||||||
|
{"id": ORDER_ID, **SAMPLE_ORDER_ROW}, # INSERT order
|
||||||
|
])
|
||||||
|
mock_db.execute = AsyncMock(return_value="UPDATE 1")
|
||||||
|
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={
|
||||||
|
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||||
|
})
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert "client_secret" in body["data"]
|
||||||
|
assert "order_id" in body["data"]
|
||||||
|
assert body["data"]["amount"] == 189.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_order_requires_auth(anon_client):
|
||||||
|
r = await anon_client.post("/api/v1/orders", json={
|
||||||
|
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||||
|
})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_order_empty_items_rejected(auth_client):
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={"items": []})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_order_product_not_found(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={
|
||||||
|
"items": [{"product_id": "00000000-0000-0000-0000-000000000000", "quantity": 1}],
|
||||||
|
})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["error"]["code"] == "PRODUCT_NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_order_out_of_stock(auth_client, mock_db):
|
||||||
|
low_stock = {**SAMPLE_PRODUCT_ROW, "stock_quantity": 0}
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=low_stock)
|
||||||
|
|
||||||
|
r = await auth_client.post("/api/v1/orders", json={
|
||||||
|
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||||
|
})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "OUT_OF_STOCK"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_my_orders(auth_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
|
||||||
|
|
||||||
|
r = await auth_client.get("/api/v1/orders")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["meta"]["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_order_by_id(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_ORDER_ROW)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[]) # order items
|
||||||
|
|
||||||
|
r = await auth_client.get(f"/api/v1/orders/{ORDER_ID}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_order_not_found(auth_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
r = await auth_client.get(f"/api/v1/orders/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin order management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_admin_list_orders(admin_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
|
||||||
|
|
||||||
|
r = await admin_client.get("/api/v1/admin/orders")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_update_order_status(admin_client, mock_db):
|
||||||
|
updated = {**SAMPLE_ORDER_ROW, "status": "shipped"}
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=updated)
|
||||||
|
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
|
||||||
|
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "shipped"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"]["status"] == "shipped"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_update_order_invalid_status(admin_client):
|
||||||
|
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "exploded"})
|
||||||
|
assert r.status_code == 422
|
||||||
154
tests/test_products.py
Normal file
154
tests/test_products.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Product endpoint tests — both public and admin."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from tests.conftest import SAMPLE_PRODUCT, PRODUCT_ID
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public endpoints ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_list_products_returns_paginated(anon_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/products")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert isinstance(body["data"], list)
|
||||||
|
assert body["meta"]["total"] == 1
|
||||||
|
assert body["data"][0]["name"] == "Extensions Clip-In Luxe"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_products_category_filter(anon_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=0)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/products?category=tape-in")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["meta"]["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_products_bestseller_filter(anon_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=1)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/products?bestseller=true")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"][0]["is_bestseller"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_products_exclude_param(anon_client, mock_db):
|
||||||
|
"""Used to fetch similar products on the product detail page."""
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=0)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
r = await anon_client.get(f"/api/v1/products?category=clip-in&exclude={PRODUCT_ID}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_product_found(anon_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||||
|
|
||||||
|
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()["data"]
|
||||||
|
assert data["id"] == PRODUCT_ID
|
||||||
|
assert data["category"] == "clip-in"
|
||||||
|
assert isinstance(data["colors"], list)
|
||||||
|
assert isinstance(data["images"], list)
|
||||||
|
assert isinstance(data["image"], str)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_product_not_found(anon_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
r = await anon_client.get(f"/api/v1/products/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["error"]["code"] == "PRODUCT_NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_product_response_has_required_frontend_fields(anon_client, mock_db):
|
||||||
|
"""Verify every field the frontend Product interface needs is present."""
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||||
|
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
|
||||||
|
data = r.json()["data"]
|
||||||
|
|
||||||
|
required = ["id", "name", "category", "price", "originalPrice", "image", "images",
|
||||||
|
"colors", "lengths", "description", "features", "isNew", "isBestseller",
|
||||||
|
"rating", "reviewCount"]
|
||||||
|
# Map snake_case API → camelCase frontend expectations
|
||||||
|
api_fields = set(data.keys())
|
||||||
|
# These must exist (snake_case form in our API)
|
||||||
|
for field in ["id", "name", "category", "price", "original_price", "image", "images",
|
||||||
|
"colors", "lengths", "description", "features", "is_new", "is_bestseller",
|
||||||
|
"rating", "review_count"]:
|
||||||
|
assert field in api_fields, f"Missing field: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin product endpoints ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_admin_list_products_includes_hidden(admin_client, mock_db):
|
||||||
|
mock_db.fetchval = AsyncMock(return_value=2)
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT, {**SAMPLE_PRODUCT, "is_hidden": True}])
|
||||||
|
|
||||||
|
r = await admin_client.get("/api/v1/admin/products")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["meta"]["total"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_create_product(admin_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||||
|
|
||||||
|
r = await admin_client.post("/api/v1/admin/products", json={
|
||||||
|
"name": "New Extensions",
|
||||||
|
"price": 199.0,
|
||||||
|
"category": "tape-in",
|
||||||
|
"colors": ["Blond", "Brun"],
|
||||||
|
"lengths": ["50cm"],
|
||||||
|
"features": ["100% Remy"],
|
||||||
|
"stock_quantity": 5,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_update_product(admin_client, mock_db):
|
||||||
|
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||||
|
|
||||||
|
r = await admin_client.put(f"/api/v1/admin/products/{PRODUCT_ID}", json={"price": 175.0})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_delete_product(admin_client, mock_db):
|
||||||
|
mock_db.execute = AsyncMock(return_value="DELETE 1")
|
||||||
|
|
||||||
|
r = await admin_client.delete(f"/api/v1/admin/products/{PRODUCT_ID}")
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_delete_product_not_found(admin_client, mock_db):
|
||||||
|
mock_db.execute = AsyncMock(return_value="DELETE 0")
|
||||||
|
|
||||||
|
r = await admin_client.delete(f"/api/v1/admin/products/00000000-0000-0000-0000-000000000000")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_bulk_stock_update(admin_client, mock_db):
|
||||||
|
r = await admin_client.post("/api/v1/admin/products/bulk-stock", json={
|
||||||
|
"updates": [{"id": PRODUCT_ID, "stock_quantity": 20}]
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"]["updated"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_products_requires_admin_role(auth_client):
|
||||||
|
"""A client-role user must get 403."""
|
||||||
|
r = await auth_client.get("/api/v1/admin/products")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["error"]["code"] == "FORBIDDEN"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_products_requires_auth(anon_client):
|
||||||
|
r = await anon_client.get("/api/v1/admin/products")
|
||||||
|
assert r.status_code == 401
|
||||||
55
tests/test_services_contact.py
Normal file
55
tests/test_services_contact.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Services list and contact form endpoint tests."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from tests.conftest import SAMPLE_SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_services(anon_client, mock_db):
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SERVICE])
|
||||||
|
|
||||||
|
r = await anon_client.get("/api/v1/services")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert isinstance(body["data"], list)
|
||||||
|
assert body["data"][0]["name"] == "Pose complète"
|
||||||
|
assert "duration_minutes" in body["data"][0]
|
||||||
|
assert "price" in body["data"][0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_services_empty(anon_client, mock_db):
|
||||||
|
mock_db.fetch = AsyncMock(return_value=[])
|
||||||
|
r = await anon_client.get("/api/v1/services")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["data"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_form_success(anon_client, mock_db):
|
||||||
|
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
|
||||||
|
r = await anon_client.post("/api/v1/contact", json={
|
||||||
|
"name": "Marie Dupont",
|
||||||
|
"email": "marie@test.com",
|
||||||
|
"message": "Je voudrais prendre rendez-vous.",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["success"] is True
|
||||||
|
mock_db.execute.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_form_invalid_email(anon_client):
|
||||||
|
r = await anon_client.post("/api/v1/contact", json={
|
||||||
|
"name": "Marie",
|
||||||
|
"email": "not-an-email",
|
||||||
|
"message": "Hello",
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_contact_form_missing_field(anon_client):
|
||||||
|
r = await anon_client.post("/api/v1/contact", json={
|
||||||
|
"name": "Marie",
|
||||||
|
"email": "marie@test.com",
|
||||||
|
# message missing
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
114
tests/test_slot_service.py
Normal file
114
tests/test_slot_service.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Unit tests for slot generation and calendar logic — no HTTP involved."""
|
||||||
|
import asyncio
|
||||||
|
from datetime import date, time, timedelta
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.slot_service import generate_slots, _fmt_time # type: ignore
|
||||||
|
from app.exceptions import AppError
|
||||||
|
|
||||||
|
|
||||||
|
def make_mock_db(schedule=None, blocked=None):
|
||||||
|
db = AsyncMock()
|
||||||
|
db.transaction = MagicMock(return_value=_TxCtx())
|
||||||
|
db.fetch = AsyncMock(side_effect=lambda q, *a: _route_fetch(q, schedule, blocked))
|
||||||
|
db.fetchval = AsyncMock(return_value=None)
|
||||||
|
db.execute = AsyncMock(return_value="INSERT 1")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _route_fetch(query, schedule, blocked):
|
||||||
|
if "weekly_schedule" in query:
|
||||||
|
return schedule or []
|
||||||
|
if "blocked_dates" in query:
|
||||||
|
return blocked or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class _TxCtx:
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
|
||||||
|
MONDAY = 0 # weekday() value for Monday
|
||||||
|
|
||||||
|
SCHED_ROW = {
|
||||||
|
"day_of_week": MONDAY,
|
||||||
|
"start_time": time(9, 0),
|
||||||
|
"end_time": time(11, 0),
|
||||||
|
"slot_duration_minutes": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_slots_creates_correct_count():
|
||||||
|
"""Mon 09:00–11:00 with 60-min slots → 2 slots per Monday."""
|
||||||
|
db = make_mock_db(schedule=[SCHED_ROW])
|
||||||
|
|
||||||
|
# Find the next Monday
|
||||||
|
today = date.today()
|
||||||
|
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||||
|
next_monday = today + timedelta(days=days_ahead)
|
||||||
|
|
||||||
|
db.fetchval = AsyncMock(return_value=None) # no existing slots
|
||||||
|
|
||||||
|
created = await generate_slots(db, next_monday, next_monday)
|
||||||
|
assert created == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_slots_skips_blocked_dates():
|
||||||
|
"""A blocked Monday produces 0 slots."""
|
||||||
|
today = date.today()
|
||||||
|
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||||
|
next_monday = today + timedelta(days=days_ahead)
|
||||||
|
|
||||||
|
blocked = [{"date": next_monday}]
|
||||||
|
db = make_mock_db(schedule=[SCHED_ROW], blocked=blocked)
|
||||||
|
db.fetchval = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
created = await generate_slots(db, next_monday, next_monday)
|
||||||
|
assert created == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_slots_skips_existing_slots():
|
||||||
|
"""If a slot already exists, it is not duplicated."""
|
||||||
|
today = date.today()
|
||||||
|
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||||
|
next_monday = today + timedelta(days=days_ahead)
|
||||||
|
|
||||||
|
db = make_mock_db(schedule=[SCHED_ROW])
|
||||||
|
db.fetchval = AsyncMock(return_value=1) # all slots already exist
|
||||||
|
|
||||||
|
created = await generate_slots(db, next_monday, next_monday)
|
||||||
|
assert created == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_slots_range_over_90_days_rejected():
|
||||||
|
with pytest.raises(AppError) as exc_info:
|
||||||
|
db = AsyncMock()
|
||||||
|
await generate_slots(db, date(2026, 1, 1), date(2026, 5, 1))
|
||||||
|
assert exc_info.value.code == "RANGE_TOO_LARGE"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_slots_no_schedule_raises():
|
||||||
|
db = make_mock_db(schedule=[])
|
||||||
|
with pytest.raises(AppError) as exc_info:
|
||||||
|
await generate_slots(db, date(2026, 6, 1), date(2026, 6, 7))
|
||||||
|
assert exc_info.value.code == "NO_SCHEDULE"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Time formatting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fmt_time_strips_seconds():
|
||||||
|
from datetime import time as t
|
||||||
|
assert _fmt_time(t(10, 0, 0)) == "10:00"
|
||||||
|
assert _fmt_time(t(9, 30)) == "09:30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_time_string_input():
|
||||||
|
assert _fmt_time("14:30:00") == "14:30"
|
||||||
|
assert _fmt_time("08:00") == "08:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_time_none():
|
||||||
|
assert _fmt_time(None) == ""
|
||||||
Reference in New Issue
Block a user