from __future__ import annotations from decimal import Decimal from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.database import get_db from app.models import Ingredient, IngredientGroceryMatch, IngredientMatchSource from app.schemas.ingredient import ( IngredientCreate, IngredientGroceryMatchRead, IngredientRead, IngredientUpdate, ) from app.security import require_admin public_router = APIRouter(prefix="/api/ingredients", tags=["ingredients"]) admin_router = APIRouter( prefix="/api/admin/ingredients", tags=["ingredients-admin"], dependencies=[Depends(require_admin)], ) @public_router.get("", response_model=List[IngredientRead]) def list_ingredients( q: Optional[str] = Query(default=None), limit: int = Query(default=100, le=500), db: Session = Depends(get_db), ) -> List[Ingredient]: query = db.query(Ingredient) if q: like = f"%{q.lower()}%" query = query.filter( or_( Ingredient.name_lower.ilike(like), Ingredient.aliases.any(q), ) ) return query.order_by(Ingredient.name).limit(limit).all() @public_router.get("/{ingredient_id}", response_model=IngredientRead) def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingredient: row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() if row is None: raise HTTPException(status_code=404, detail="ingredient not found") return row @public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: name_lower = payload.name.lower() # idempotent: return existing if name or alias already known existing = ( db.query(Ingredient) .filter( or_( Ingredient.name_lower == name_lower, Ingredient.aliases.any(payload.name.lower()), ) ) .first() ) if existing: return existing row = Ingredient( name=payload.name, name_lower=name_lower, aliases=payload.aliases, aisle=payload.aisle, unit=payload.unit, typical_price=payload.typical_price, ) db.add(row) try: db.commit() except IntegrityError: db.rollback() # race-window safety: re-check after rollback existing = ( db.query(Ingredient) .filter( or_( Ingredient.name_lower == name_lower, Ingredient.aliases.any(payload.name.lower()), ) ) .first() ) if existing: return existing raise HTTPException(status_code=409, detail="ingredient name already exists") db.refresh(row) return row @admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: row = Ingredient( name=payload.name, name_lower=payload.name.lower(), aliases=payload.aliases, aisle=payload.aisle, unit=payload.unit, typical_price=payload.typical_price, ) db.add(row) try: db.commit() except IntegrityError: db.rollback() raise HTTPException(status_code=409, detail="ingredient name already exists") db.refresh(row) return row @admin_router.patch("/{ingredient_id}", response_model=IngredientRead) def update_ingredient( ingredient_id: UUID, payload: IngredientUpdate, db: Session = Depends(get_db), ) -> Ingredient: row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() if row is None: raise HTTPException(status_code=404, detail="ingredient not found") data = payload.model_dump(exclude_unset=True) if "name" in data: row.name = data["name"] row.name_lower = data["name"].lower() for field in ("aliases", "aisle", "unit", "typical_price"): if field in data: setattr(row, field, data[field]) try: db.commit() except IntegrityError: db.rollback() raise HTTPException(status_code=409, detail="ingredient name conflict") db.refresh(row) return row @admin_router.delete( "/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, ) def delete_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)): row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() if row is None: raise HTTPException(status_code=404, detail="ingredient not found") db.delete(row) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) class _PinMatchBody(BaseModel): grocery_item_id: UUID confidence: float = 1.0 @admin_router.post( "/{ingredient_id}/matches", response_model=IngredientGroceryMatchRead, status_code=status.HTTP_201_CREATED, ) def pin_match( ingredient_id: UUID, payload: _PinMatchBody, db: Session = Depends(get_db), ): existing = ( db.query(IngredientGroceryMatch) .filter( IngredientGroceryMatch.ingredient_id == ingredient_id, IngredientGroceryMatch.grocery_item_id == payload.grocery_item_id, ) .first() ) if existing: existing.source = IngredientMatchSource.MANUAL existing.confidence = Decimal(str(round(payload.confidence, 3))) db.commit() db.refresh(existing) return existing row = IngredientGroceryMatch( ingredient_id=ingredient_id, grocery_item_id=payload.grocery_item_id, confidence=Decimal(str(round(payload.confidence, 3))), source=IngredientMatchSource.MANUAL, ) db.add(row) db.commit() db.refresh(row) return row _match_admin_router = APIRouter( prefix="/api/admin/ingredient-matches", tags=["ingredients-admin"], dependencies=[Depends(require_admin)], ) @_match_admin_router.delete( "/{match_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, ) def unpin_match(match_id: UUID, db: Session = Depends(get_db)) -> Response: row = db.query(IngredientGroceryMatch).filter(IngredientGroceryMatch.id == match_id).first() if row is None: raise HTTPException(status_code=404, detail="match not found") db.delete(row) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)