""" Photo Management Endpoints for Mitai Jinkendo Handles progress photo uploads and retrieval. """ import os import uuid import logging from pathlib import Path from typing import Optional from fastapi import APIRouter, UploadFile, File, Form, Header, HTTPException, Depends from fastapi.responses import FileResponse import aiofiles from db import get_db, get_cursor, r2d from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage router = APIRouter(prefix="/api/photos", tags=["photos"]) logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) def resolve_photo_path(stored: Optional[str]) -> Optional[Path]: """ Map DB `path` to an existing file. Supports legacy rows (absolute path or nested) and new rows (filename only under PHOTOS_DIR). """ if not stored or not str(stored).strip(): return None raw = str(stored).strip() p = Path(raw) if p.is_absolute() and p.exists(): return p cand = PHOTOS_DIR / raw if cand.exists(): return cand cand2 = PHOTOS_DIR / Path(raw).name if cand2.exists(): return cand2 return None @router.post("") async def upload_photo(file: UploadFile=File(...), date: str=Form(""), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Upload progress photo.""" pid = get_pid(x_profile_id) # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'photos') log_feature_usage(pid, 'photos', access, 'upload') if not access['allowed']: logger.warning( f"[FEATURE-LIMIT] User {pid} blocked: " f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) raise HTTPException( status_code=403, detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). " f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." ) fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg' fname = f"{fid}{ext}" path = PHOTOS_DIR / fname async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) # Convert empty string to NULL for date field photo_date = date if date and date.strip() else None with get_db() as conn: cur = get_cursor(conn) # Nur Dateiname in DB — Auflösung über PHOTOS_DIR (auch für ältere absolute Pfade: resolve_photo_path) cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (fid,pid,photo_date,fname)) # Phase 2: Increment usage counter increment_feature_usage(pid, 'photos') return {"id":fid,"date":photo_date} @router.get("/{fid}") def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): """Get photo by ID. Auth via header or query param ssetoken (for tags).""" profile_id = str(session["profile_id"]) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT path, profile_id FROM photos WHERE id=%s", (fid,)) row = cur.fetchone() if not row: raise HTTPException(404, "Photo not found") if str(row["profile_id"]) != profile_id: raise HTTPException(404, "Photo not found") photo_path = resolve_photo_path(row["path"]) if not photo_path: raise HTTPException(404, "Photo file not found") return FileResponse(photo_path) @router.get("") def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get all photos for current profile.""" pid = get_pid(x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT * FROM photos WHERE profile_id=%s ORDER BY date DESC NULLS LAST, created DESC LIMIT 500 """, (pid,), ) return [r2d(r) for r in cur.fetchall()] @router.delete("/{fid}") def delete_photo( fid: str, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """Delete a photo (DB row + Datei auf der Platte).""" pid = get_pid(x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT path FROM photos WHERE id=%s AND profile_id=%s", (fid, pid), ) row = cur.fetchone() if not row: raise HTTPException(404, "Photo not found") photo_path = resolve_photo_path(row["path"]) cur.execute("DELETE FROM photos WHERE id=%s AND profile_id=%s", (fid, pid)) if photo_path and photo_path.exists(): try: photo_path.unlink() except OSError as e: logger.warning("Could not delete photo file %s: %s", photo_path, e) return {"ok": True, "id": fid}