from datetime import datetime, timedelta, timezone from pathlib import Path from secrets import token_hex from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File from pydantic import BaseModel from sqlalchemy.orm import Session as DbSession from app.database import get_db from app.models import ( ApiKey, Profile, Postcard, User, PERM_PROFILES_READ, PERM_PROFILES_WRITE, PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE, PERM_IMAGES_UPLOAD, ) from app.config import UPLOAD_DIR router = APIRouter(prefix="/api/v1", tags=["api"]) # ─── Auth Dependencies ──────────────────────────────────────────── def _parse_permissions(raw: str) -> list[str]: """Parse JSON permissions string from DB, return list of scope strings.""" import json try: val = json.loads(raw) return val if isinstance(val, list) else [] except (json.JSONDecodeError, TypeError): return [] def get_api_key( x_api_key: str = Header(..., alias="X-API-Key"), db: DbSession = Depends(get_db), ) -> ApiKey: """Validate X-API-Key header and return the ApiKey object (with permissions).""" ak = db.query(ApiKey).filter(ApiKey.key == x_api_key).first() if not ak or not ak.is_active: raise HTTPException(status_code=401, detail="Invalid or inactive API key") ak.last_used_at = datetime.now(timezone.utc) db.commit() return ak def get_api_user(ak: ApiKey = Depends(get_api_key)) -> User: """Return the User associated with a valid API key (no permission check).""" return ak.user def require_scopes(*scopes: str): """Return a dependency that checks the API key has ALL given scopes. Keys with empty permissions list (legacy) are treated as full-access. """ def _dep(ak: ApiKey = Depends(get_api_key)) -> ApiKey: perms = _parse_permissions(ak.permissions) if perms: missing = [s for s in scopes if s not in perms] if missing: raise HTTPException( status_code=403, detail=f"Missing required permissions: {', '.join(missing)}", ) return ak return _dep def get_api_user_optional( x_api_key: str | None = Header(None, alias="X-API-Key"), db: DbSession = Depends(get_db), ) -> User | None: """Like get_api_user but returns None if no key provided (for public endpoints).""" if not x_api_key: return None ak = get_api_key(x_api_key, db) return ak.user # ─── Pydantic Schemas ──────────────────────────────────────────── class ProfileCreate(BaseModel): nickname: str country: str | None = None class ProfileUpdate(BaseModel): nickname: str | None = None country: str | None = None class ProfileOut(BaseModel): id: int nickname: str country: str | None showcase_enabled: bool showcase_visible: str notes: str created_at: datetime model_config = {"from_attributes": True} class PostcardCreate(BaseModel): card_number: str status: str recipient_name: str | None = None country: str | None = None country_from: str | None = None send_time: str | None = None arrival_time: str | None = None sender_name: str | None = None receive_time: str | None = None notes: str | None = None class PostcardUpdate(BaseModel): card_number: str | None = None status: str | None = None recipient_name: str | None = None country: str | None = None country_from: str | None = None send_time: str | None = None arrival_time: str | None = None sender_name: str | None = None receive_time: str | None = None notes: str | None = None class PostcardOut(BaseModel): id: int profile_id: int card_number: str status: str recipient_name: str | None country: str | None country_from: str | None send_time: datetime | None arrival_time: datetime | None sender_name: str | None receive_time: datetime | None notes: str image_front: str | None image_back: str | None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class ApiKeyCreate(BaseModel): name: str = "" permissions: list[str] = [] class ApiKeyOut(BaseModel): id: int key: str name: str is_active: bool permissions: list[str] created_at: datetime last_used_at: datetime | None model_config = {"from_attributes": True} # ─── Helpers ────────────────────────────────────────────────────── def _parse_date(s: str | None) -> datetime | None: """Parse a date or datetime string to datetime.""" if not s: return None s = s.strip() for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(s, fmt) except ValueError: continue raise HTTPException(status_code=400, detail=f"Invalid date format: {s}") def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile: p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first() if not p: raise HTTPException(status_code=404, detail="Profile not found") return p def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard: _profile_or_404(user, profile_id, db) pc = db.query(Postcard).filter( Postcard.id == postcard_id, Postcard.profile_id == profile_id ).first() if not pc: raise HTTPException(status_code=404, detail="Postcard not found") return pc # ═══════════════════════════════════════════════════════════════════ # Profiles # ═══════════════════════════════════════════════════════════════════ @router.get("/profiles", response_model=list[ProfileOut]) def list_profiles( db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)), ): return db.query(Profile).filter(Profile.user_id == ak.user_id).order_by(Profile.id).all() @router.post("/profiles", response_model=ProfileOut, status_code=201) def create_profile( body: ProfileCreate, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)), ): c = body.country.strip().upper() if body.country else None p = Profile(nickname=body.nickname.strip(), user_id=ak.user_id, country=c) db.add(p) db.commit() db.refresh(p) return p @router.get("/profiles/{profile_id}", response_model=ProfileOut) def get_profile( profile_id: int, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_READ)), ): return _profile_or_404(ak.user, profile_id, db) @router.put("/profiles/{profile_id}", response_model=ProfileOut) def update_profile( profile_id: int, body: ProfileUpdate, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)), ): p = _profile_or_404(ak.user, profile_id, db) if body.nickname is not None: p.nickname = body.nickname.strip() if body.country is not None: p.country = body.country.strip().upper() if body.country else None db.commit() db.refresh(p) return p @router.delete("/profiles/{profile_id}") def delete_profile( profile_id: int, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_PROFILES_WRITE)), ): p = _profile_or_404(ak.user, profile_id, db) db.delete(p) db.commit() return {"ok": True} # ═══════════════════════════════════════════════════════════════════ # Postcards # ═══════════════════════════════════════════════════════════════════ @router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut]) def list_postcards( profile_id: int, status: str | None = Query(None), db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)), ): _profile_or_404(ak.user, profile_id, db) q = db.query(Postcard).filter(Postcard.profile_id == profile_id) if status: q = q.filter(Postcard.status == status) return q.order_by(Postcard.id.desc()).all() @router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201) def create_postcard( profile_id: int, body: PostcardCreate, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)), ): _profile_or_404(ak.user, profile_id, db) cn = body.card_number.strip() dup = db.query(Postcard).join(Profile).filter( Postcard.card_number == cn, Profile.user_id == ak.user.id ).first() if dup: raise HTTPException(status_code=409, detail="Card number already exists") pc = Postcard( profile_id=profile_id, card_number=cn, status=body.status, recipient_name=body.recipient_name, country=body.country.strip().upper() if body.country else None, country_from=body.country_from.strip().upper() if body.country_from else None, send_time=_parse_date(body.send_time), arrival_time=_parse_date(body.arrival_time), sender_name=body.sender_name, receive_time=_parse_date(body.receive_time), notes=body.notes or "", ) db.add(pc) db.commit() db.refresh(pc) return pc @router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut) def get_postcard( profile_id: int, postcard_id: int, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_READ)), ): return _postcard_or_404(ak.user, profile_id, postcard_id, db) @router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut) def update_postcard( profile_id: int, postcard_id: int, body: PostcardUpdate, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)), ): pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) for field in ("status", "recipient_name", "sender_name", "notes"): val = getattr(body, field) if val is not None: setattr(pc, field, val.strip() if isinstance(val, str) else val) if body.card_number is not None: cn = body.card_number.strip() dup = db.query(Postcard).join(Profile).filter( Postcard.card_number == cn, Postcard.id != postcard_id, Profile.user_id == ak.user.id ).first() if dup: raise HTTPException(status_code=409, detail="Card number already exists") pc.card_number = cn if body.country is not None: pc.country = body.country.strip().upper() if body.country else None if body.country_from is not None: pc.country_from = body.country_from.strip().upper() if body.country_from else None for field in ("send_time", "arrival_time", "receive_time"): val = getattr(body, field) if val is not None: setattr(pc, field, _parse_date(val)) db.commit() db.refresh(pc) return pc @router.delete("/profiles/{profile_id}/postcards/{postcard_id}") def delete_postcard( profile_id: int, postcard_id: int, db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_POSTCARDS_WRITE)), ): pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) db.delete(pc) db.commit() return {"ok": True} # ═══════════════════════════════════════════════════════════════════ # Image Upload # ═══════════════════════════════════════════════════════════════════ @router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut) async def upload_image( profile_id: int, postcard_id: int, side: str = Query("front", pattern="^(front|back)$"), file: UploadFile = File(...), db: DbSession = Depends(get_db), ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)), ): pc = _postcard_or_404(ak.user, profile_id, postcard_id, db) ext = Path(file.filename).suffix if file.filename else ".jpg" save_name = f"pc_{pc.id}_{side}{ext}" save_path = UPLOAD_DIR / save_name content = await file.read() save_path.write_bytes(content) if side == "front": pc.image_front = f"/uploads/{save_name}" else: pc.image_back = f"/uploads/{save_name}" db.commit() db.refresh(pc) return pc # ═══════════════════════════════════════════════════════════════════ # API Key Management # ═══════════════════════════════════════════════════════════════════ @router.get("/keys", response_model=list[ApiKeyOut]) def list_api_keys( db: DbSession = Depends(get_db), user: User = Depends(get_api_user), ): return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() @router.post("/keys", response_model=ApiKeyOut, status_code=201) def create_api_key( body: ApiKeyCreate, db: DbSession = Depends(get_db), user: User = Depends(get_api_user), ): key = f"mv_{token_hex(24)}" import json perms = json.dumps(body.permissions) if body.permissions else "[]" new_ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id, permissions=perms) db.add(new_ak) db.commit() db.refresh(new_ak) return new_ak class ApiKeyUpdate(BaseModel): name: str | None = None permissions: list[str] | None = None @router.put("/keys/{key_id}", response_model=ApiKeyOut) def update_api_key( key_id: int, body: ApiKeyUpdate, db: DbSession = Depends(get_db), user: User = Depends(get_api_user), ): import json ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() if not ak: raise HTTPException(status_code=404, detail="API key not found") if body.name is not None: ak.name = body.name.strip() if body.permissions is not None: valid = [p for p in body.permissions if p in ALL_PERMISSIONS] ak.permissions = json.dumps(valid) if valid else "[]" db.commit() db.refresh(ak) return ak @router.delete("/keys/{key_id}") def delete_api_key( key_id: int, db: DbSession = Depends(get_db), user: User = Depends(get_api_user), ): target = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first() if not target: raise HTTPException(status_code=404, detail="API key not found") db.delete(target) db.commit() return {"ok": True} # ═══════════════════════════════════════════════════════════════════ # Showcase (public, no auth required) # ═══════════════════════════════════════════════════════════════════ class ShowcaseProfileOut(BaseModel): id: int nickname: str showcase_visible: str sent: list[PostcardOut] received: list[PostcardOut] model_config = {"from_attributes": True} class ShowcaseUserOut(BaseModel): username: str showcase_mode: str profiles: list[ShowcaseProfileOut] @router.get("/showcase/{username}", response_model=ShowcaseUserOut) def showcase_user(username: str, db: DbSession = Depends(get_db)): user = db.query(User).filter(User.username == username).first() if not user: raise HTTPException(status_code=404, detail="User not found") profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all() result_profiles = [] for p in profiles: cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all() if p.showcase_visible in ("sent", "both"): sent = [c for c in cards if c.status in ("sent", "delivered")] else: sent = [] if p.showcase_visible in ("received", "both"): received = [c for c in cards if c.status == "received"] else: received = [] if sent or received: sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True) received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) result_profiles.append(ShowcaseProfileOut( id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received, )) return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles) @router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut) def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)): user = db.query(User).filter(User.username == username).first() if not user: raise HTTPException(status_code=404, detail="User not found") p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first() if not p: raise HTTPException(status_code=404, detail="Profile not found or showcase disabled") cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all() sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else [] received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else [] sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True) received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received)