import uuid from fastapi import APIRouter, Depends, HTTPException, UploadFile from slugify import slugify from sqlalchemy import select from sqlalchemy.orm import Session from app.database import UPLOAD_DIR, get_db from app.models import Ingredient, Recipe, Step from app.schemas import RecipeIn, RecipeOut, RecipeSummary router = APIRouter(prefix="/api/recipes", tags=["recipes"]) ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} def _unique_slug(db: Session, name: str, *, exclude_id: int | None = None) -> str: base = slugify(name) or "recipe" slug = base suffix = 2 while True: stmt = select(Recipe.id).where(Recipe.slug == slug) if exclude_id is not None: stmt = stmt.where(Recipe.id != exclude_id) if db.scalar(stmt) is None: return slug slug = f"{base}-{suffix}" suffix += 1 def _apply_children(db: Session, recipe: Recipe, payload: RecipeIn) -> None: recipe.ingredients.clear() for position, ing in enumerate(payload.ingredients): recipe.ingredients.append( Ingredient( position=position, kind=ing.kind, quantity=ing.quantity, unit=ing.unit, text=ing.text, ) ) recipe.steps.clear() for position, step in enumerate(payload.steps): recipe.steps.append(Step(position=position, text=step.text)) @router.get("", response_model=list[RecipeSummary]) def list_recipes(search: str | None = None, db: Session = Depends(get_db)): stmt = select(Recipe).order_by(Recipe.name) if search: like = f"%{search.lower()}%" stmt = stmt.where( Recipe.name.ilike(like) | Recipe.category.ilike(like) ) return db.scalars(stmt).all() @router.get("/{recipe_id}", response_model=RecipeOut) def get_recipe(recipe_id: int, db: Session = Depends(get_db)): recipe = db.get(Recipe, recipe_id) if recipe is None: raise HTTPException(status_code=404, detail="Recipe not found") return recipe @router.post("", response_model=RecipeOut, status_code=201) def create_recipe(payload: RecipeIn, db: Session = Depends(get_db)): recipe = Recipe( name=payload.name, slug=_unique_slug(db, payload.name), category=payload.category, preptime=payload.preptime, cooktime=payload.cooktime, baketime=payload.baketime, cooltime=payload.cooltime, marinade_time=payload.marinade_time, robot_time=payload.robot_time, servings=payload.servings, notes=payload.notes, kcal=payload.kcal, kh=payload.kh, protein=payload.protein, fett=payload.fett, ) _apply_children(db, recipe, payload) db.add(recipe) db.commit() db.refresh(recipe) return recipe @router.put("/{recipe_id}", response_model=RecipeOut) def update_recipe(recipe_id: int, payload: RecipeIn, db: Session = Depends(get_db)): recipe = db.get(Recipe, recipe_id) if recipe is None: raise HTTPException(status_code=404, detail="Recipe not found") if payload.name != recipe.name: recipe.slug = _unique_slug(db, payload.name, exclude_id=recipe_id) recipe.name = payload.name recipe.category = payload.category recipe.preptime = payload.preptime recipe.cooktime = payload.cooktime recipe.baketime = payload.baketime recipe.cooltime = payload.cooltime recipe.marinade_time = payload.marinade_time recipe.robot_time = payload.robot_time recipe.servings = payload.servings recipe.notes = payload.notes recipe.kcal = payload.kcal recipe.kh = payload.kh recipe.protein = payload.protein recipe.fett = payload.fett _apply_children(db, recipe, payload) db.commit() db.refresh(recipe) return recipe @router.delete("/{recipe_id}", status_code=204) def delete_recipe(recipe_id: int, db: Session = Depends(get_db)): recipe = db.get(Recipe, recipe_id) if recipe is None: raise HTTPException(status_code=404, detail="Recipe not found") if recipe.image_filename: (UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True) db.delete(recipe) db.commit() @router.post("/{recipe_id}/image", response_model=RecipeOut) async def upload_recipe_image(recipe_id: int, file: UploadFile, db: Session = Depends(get_db)): recipe = db.get(Recipe, recipe_id) if recipe is None: raise HTTPException(status_code=404, detail="Recipe not found") ext = ALLOWED_IMAGE_TYPES.get(file.content_type or "") if ext is None: raise HTTPException(status_code=400, detail="Only JPEG, PNG or WEBP images are allowed") if recipe.image_filename: (UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True) filename = f"{uuid.uuid4().hex}{ext}" dest = UPLOAD_DIR / filename dest.write_bytes(await file.read()) recipe.image_filename = filename db.commit() db.refresh(recipe) return recipe @router.delete("/{recipe_id}/image", response_model=RecipeOut) def delete_recipe_image(recipe_id: int, db: Session = Depends(get_db)): recipe = db.get(Recipe, recipe_id) if recipe is None: raise HTTPException(status_code=404, detail="Recipe not found") if recipe.image_filename: (UPLOAD_DIR / recipe.image_filename).unlink(missing_ok=True) recipe.image_filename = None db.commit() db.refresh(recipe) return recipe