import os import unicodedata import uuid from typing import List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from pydantic import BaseModel from sqlmodel import Session, select from app.config import UPLOAD_DIR from app.database import get_session from app.models.media import Attachment, Media class MediaPatch(BaseModel): entity_type: Optional[str] = None entity_id: Optional[int] = None titre: Optional[str] = None CANONICAL_ENTITY_TYPES = { "jardin", "plante", "adventice", "outil", "plantation", "bibliotheque", } ENTITY_TYPE_ALIASES = { # Canonique "jardin": "jardin", "plante": "plante", "adventice": "adventice", "outil": "outil", "plantation": "plantation", "bibliotheque": "bibliotheque", # Variantes FR "jardins": "jardin", "plantes": "plante", "adventices": "adventice", "outils": "outil", "plantations": "plantation", "bibliotheques": "bibliotheque", "bibliotheque_media": "bibliotheque", # Variantes EN (courantes via API) "garden": "jardin", "gardens": "jardin", "plant": "plante", "plants": "plante", "weed": "adventice", "weeds": "adventice", "tool": "outil", "tools": "outil", "planting": "plantation", "plantings": "plantation", "library": "bibliotheque", "media_library": "bibliotheque", } router = APIRouter(tags=["media"]) def _normalize_token(value: str) -> str: token = (value or "").strip().lower() token = unicodedata.normalize("NFKD", token).encode("ascii", "ignore").decode("ascii") return token.replace("-", "_").replace(" ", "_") def _normalize_entity_type(value: str, *, strict: bool = True) -> str: token = _normalize_token(value) canonical = ENTITY_TYPE_ALIASES.get(token, token) if canonical in CANONICAL_ENTITY_TYPES: return canonical if strict: allowed = ", ".join(sorted(CANONICAL_ENTITY_TYPES)) raise HTTPException( status_code=422, detail=f"entity_type invalide: '{value}'. Valeurs autorisees: {allowed}", ) return value def _entity_type_candidates(value: str) -> set[str]: canonical = _normalize_entity_type(value, strict=True) candidates = {canonical} for alias, target in ENTITY_TYPE_ALIASES.items(): if target == canonical: candidates.add(alias) return candidates def _canonicalize_rows(rows: List[Media], session: Session) -> None: changed = False for media in rows: normalized = _normalize_entity_type(media.entity_type, strict=False) if normalized in CANONICAL_ENTITY_TYPES and normalized != media.entity_type: media.entity_type = normalized session.add(media) changed = True if changed: session.commit() def _save_webp(data: bytes, max_px: int) -> str: try: from PIL import Image import io img = Image.open(io.BytesIO(data)).convert("RGB") img.thumbnail((max_px, max_px)) name = f"{uuid.uuid4()}.webp" path = os.path.join(UPLOAD_DIR, name) img.save(path, "WEBP", quality=85) return name except Exception: name = f"{uuid.uuid4()}.bin" path = os.path.join(UPLOAD_DIR, name) with open(path, "wb") as f: f.write(data) return name @router.post("/upload") async def upload_file(file: UploadFile = File(...)): os.makedirs(UPLOAD_DIR, exist_ok=True) data = await file.read() ct = file.content_type or "" if ct.startswith("image/"): name = _save_webp(data, 1200) thumb = _save_webp(data, 300) return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"} name = f"{uuid.uuid4()}_{file.filename}" path = os.path.join(UPLOAD_DIR, name) with open(path, "wb") as f: f.write(data) return {"url": f"/uploads/{name}", "thumbnail_url": None} @router.get("/media/all", response_model=List[Media]) def list_all_media( entity_type: Optional[str] = Query(default=None), session: Session = Depends(get_session), ): """Retourne tous les médias, filtrés optionnellement par entity_type.""" q = select(Media).order_by(Media.created_at.desc()) if entity_type: q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type))) rows = session.exec(q).all() _canonicalize_rows(rows, session) return rows @router.get("/media", response_model=List[Media]) def list_media( entity_type: str = Query(...), entity_id: int = Query(...), session: Session = Depends(get_session), ): rows = session.exec( select(Media).where( Media.entity_type.in_(_entity_type_candidates(entity_type)), Media.entity_id == entity_id, ) ).all() _canonicalize_rows(rows, session) return rows @router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED) def create_media(m: Media, session: Session = Depends(get_session)): m.entity_type = _normalize_entity_type(m.entity_type, strict=True) session.add(m) session.commit() session.refresh(m) return m @router.patch("/media/{id}", response_model=Media) def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_session)): m = session.get(Media, id) if not m: raise HTTPException(404, "Media introuvable") updates = payload.model_dump(exclude_none=True) if "entity_type" in updates: updates["entity_type"] = _normalize_entity_type(updates["entity_type"], strict=True) for k, v in updates.items(): setattr(m, k, v) session.add(m) session.commit() session.refresh(m) return m @router.delete("/media/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_media(id: int, session: Session = Depends(get_session)): m = session.get(Media, id) if not m: raise HTTPException(404, "Media introuvable") session.delete(m) session.commit() @router.get("/attachments", response_model=List[Attachment]) def list_attachments( entity_type: str = Query(...), entity_id: int = Query(...), session: Session = Depends(get_session), ): return session.exec( select(Attachment).where( Attachment.entity_type == entity_type, Attachment.entity_id == entity_id, ) ).all() @router.post("/attachments", response_model=Attachment, status_code=status.HTTP_201_CREATED) def create_attachment(a: Attachment, session: Session = Depends(get_session)): session.add(a) session.commit() session.refresh(a) return a