diff --git a/backend/app/api/shopping.py b/backend/app/api/shopping.py new file mode 100644 index 0000000..c227650 --- /dev/null +++ b/backend/app/api/shopping.py @@ -0,0 +1,254 @@ +# backend/app/api/shopping.py +import uuid +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_session +from app.models.shopping import ShoppingList, ListItem, Product, Store +from app.schemas.shopping import ( + ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse, + ShoppingListDetailResponse, ListItemCreate, ListItemUpdate, + ListItemResponse, ProductResponse, StoreResponse, +) + +router = APIRouter() + + +def _item_to_response(item: ListItem) -> ListItemResponse: + display_name = item.custom_name or (item.product.name if item.product else "Article inconnu") + return ListItemResponse( + id=item.id, + product_id=item.product_id, + custom_name=item.custom_name, + display_name=display_name, + quantity=item.quantity, + unit=item.unit, + is_checked=item.is_checked, + price_recorded=item.price_recorded, + carried_over=item.carried_over, + sort_order=item.sort_order, + ) + + +def _list_to_response(lst: ShoppingList) -> ShoppingListResponse: + items = lst.items if lst.items is not None else [] + return ShoppingListResponse( + id=lst.id, + name=lst.name, + store_id=lst.store_id, + week_date=lst.week_date, + status=lst.status, + created_at=lst.created_at, + item_count=len(items), + checked_count=sum(1 for i in items if i.is_checked), + ) + + +# ── Stores ──────────────────────────────────────────────────────────────────── + +@router.get("/stores", response_model=list[StoreResponse]) +async def list_stores(session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Store).order_by(Store.name)) + return result.scalars().all() + + +# ── Products ────────────────────────────────────────────────────────────────── + +@router.get("/products", response_model=list[ProductResponse]) +async def search_products( + q: str | None = Query(default=None), + limit: int = 30, + session: AsyncSession = Depends(get_session), +): + stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name) + if q: + stmt = stmt.where(Product.name.ilike(f"%{q}%")) + stmt = stmt.limit(limit) + result = await session.execute(stmt) + return result.scalars().all() + + +# ── Lists ───────────────────────────────────────────────────────────────────── + +@router.get("/lists", response_model=list[ShoppingListResponse]) +async def list_shopping_lists(session: AsyncSession = Depends(get_session)): + stmt = ( + select(ShoppingList) + .options(selectinload(ShoppingList.items)) + .order_by(ShoppingList.created_at.desc()) + ) + result = await session.execute(stmt) + lists = result.scalars().all() + return [_list_to_response(lst) for lst in lists] + + +@router.post("/lists", response_model=ShoppingListDetailResponse, status_code=201) +async def create_shopping_list( + payload: ShoppingListCreate, + session: AsyncSession = Depends(get_session), +): + lst = ShoppingList(**payload.model_dump()) + session.add(lst) + await session.commit() + await session.refresh(lst, ["items"]) + return ShoppingListDetailResponse( + **_list_to_response(lst).model_dump(), + items=[], + ) + + +@router.get("/lists/{list_id}", response_model=ShoppingListDetailResponse) +async def get_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + stmt = ( + select(ShoppingList) + .where(ShoppingList.id == list_id) + .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + lst = result.scalar_one_or_none() + if not lst: + raise HTTPException(404, "Liste introuvable") + sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) + return ShoppingListDetailResponse( + **_list_to_response(lst).model_dump(), + items=[_item_to_response(i) for i in sorted_items], + ) + + +@router.patch("/lists/{list_id}", response_model=ShoppingListDetailResponse) +async def update_shopping_list( + list_id: uuid.UUID, + payload: ShoppingListUpdate, + session: AsyncSession = Depends(get_session), +): + stmt = ( + select(ShoppingList) + .where(ShoppingList.id == list_id) + .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + lst = result.scalar_one_or_none() + if not lst: + raise HTTPException(404, "Liste introuvable") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(lst, field, value) + await session.commit() + sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) + return ShoppingListDetailResponse( + **_list_to_response(lst).model_dump(), + items=[_item_to_response(i) for i in sorted_items], + ) + + +@router.delete("/lists/{list_id}", status_code=204) +async def delete_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + lst = await session.get(ShoppingList, list_id) + if not lst: + raise HTTPException(404, "Liste introuvable") + await session.delete(lst) + await session.commit() + return Response(status_code=204) + + +# ── Items ───────────────────────────────────────────────────────────────────── + +@router.post("/lists/{list_id}/items", response_model=ListItemResponse, status_code=201) +async def add_item( + list_id: uuid.UUID, + payload: ListItemCreate, + session: AsyncSession = Depends(get_session), +): + lst = await session.get(ShoppingList, list_id) + if not lst: + raise HTTPException(404, "Liste introuvable") + item = ListItem(list_id=list_id, **payload.model_dump()) + session.add(item) + await session.commit() + stmt = ( + select(ListItem) + .where(ListItem.id == item.id) + .options(selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + item = result.scalar_one() + return _item_to_response(item) + + +@router.patch("/lists/{list_id}/items/{item_id}", response_model=ListItemResponse) +async def update_item( + list_id: uuid.UUID, + item_id: uuid.UUID, + payload: ListItemUpdate, + session: AsyncSession = Depends(get_session), +): + stmt = ( + select(ListItem) + .where(ListItem.id == item_id, ListItem.list_id == list_id) + .options(selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(404, "Article introuvable") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + await session.commit() + await session.refresh(item) + return _item_to_response(item) + + +@router.delete("/lists/{list_id}/items/{item_id}", status_code=204) +async def delete_item( + list_id: uuid.UUID, + item_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +): + stmt = select(ListItem).where(ListItem.id == item_id, ListItem.list_id == list_id) + result = await session.execute(stmt) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(404, "Article introuvable") + await session.delete(item) + await session.commit() + return Response(status_code=204) + + +@router.post("/lists/{list_id}/finish", response_model=ShoppingListDetailResponse) +async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + stmt = ( + select(ShoppingList) + .where(ShoppingList.id == list_id) + .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) + ) + result = await session.execute(stmt) + lst = result.scalar_one_or_none() + if not lst: + raise HTTPException(404, "Liste introuvable") + + lst.status = "done" + + unchecked = [i for i in lst.items if not i.is_checked] + if unchecked: + new_list = ShoppingList(store_id=lst.store_id, status="draft") + session.add(new_list) + await session.flush() + for item in unchecked: + session.add(ListItem( + list_id=new_list.id, + product_id=item.product_id, + custom_name=item.custom_name, + quantity=item.quantity, + unit=item.unit, + sort_order=item.sort_order, + carried_over=True, + )) + + await session.commit() + sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) + return ShoppingListDetailResponse( + **_list_to_response(lst).model_dump(), + items=[_item_to_response(i) for i in sorted_items], + ) diff --git a/backend/app/main.py b/backend/app/main.py index e2a4eb5..ffc9207 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router from app.api.media import router as media_router from app.api.todos import router as todos_router +from app.api.shopping import router as shopping_router from app.core.config import settings from app.data.seed import run_seed @@ -27,3 +28,4 @@ app.add_middleware( app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") app.include_router(todos_router, prefix="/api/todos") +app.include_router(shopping_router, prefix="/api/shopping")