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 from app.config import UPLOAD_DIR router = APIRouter(prefix="/api/v1", tags=["api"]) # ─── Auth Dependency ────────────────────────────────────────────── def get_api_user( x_api_key: str = Header(..., alias="X-API-Key"), db: DbSession = Depends(get_db), ) -> User: """Validate X-API-Key header and return the associated user.""" api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first() if not api_key or not api_key.is_active: raise HTTPException(status_code=401, detail="Invalid or inactive API key") # Update last_used_at api_key.last_used_at = datetime.now(timezone.utc) db.commit() return api_key.user 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 return get_api_user(x_api_key, db) # ─── Pydantic Schemas ──────────────────────────────────────────── class ProfileCreate(BaseModel): nickname: str class ProfileUpdate(BaseModel): nickname: str | None = None class ProfileOut(BaseModel): id: int nickname: str 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 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 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 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 = "" class ApiKeyOut(BaseModel): id: int key: str name: str is_active: bool 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(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all() @router.post("/profiles", response_model=ProfileOut, status_code=201) def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): p = Profile(nickname=body.nickname.strip(), user_id=user.id) db.add(p) db.commit() db.refresh(p) return p @router.get("/profiles/{profile_id}", response_model=ProfileOut) def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): return _profile_or_404(user, profile_id, db) @router.put("/profiles/{profile_id}", response_model=ProfileOut) def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): p = _profile_or_404(user, profile_id, db) if body.nickname is not None: p.nickname = body.nickname.strip() db.commit() db.refresh(p) return p @router.delete("/profiles/{profile_id}") def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): p = _profile_or_404(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), user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): _profile_or_404(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, user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): _profile_or_404(user, profile_id, db) cn = body.card_number.strip() if db.query(Postcard).filter(Postcard.card_number == cn).first(): 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, 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, user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): return _postcard_or_404(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, user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): pc = _postcard_or_404(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).filter(Postcard.card_number == cn, Postcard.id != postcard_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 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, user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): pc = _postcard_or_404(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(...), user: User = Depends(get_api_user), db: DbSession = Depends(get_db), ): pc = _postcard_or_404(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(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): 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, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): key = f"mv_{token_hex(24)}" ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id) db.add(ak) db.commit() db.refresh(ak) return ak @router.delete("/keys/{key_id}") def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)): 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") db.delete(ak) 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)