recipe-inator/backend/app/routers/recipes.py

165 lines
5.4 KiB
Python

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