# backend/app/api/shopping.py import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy import select, text 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, ProductCreate, ProductUpdate, ProductResponse, StoreCreate, StoreUpdate, StoreResponse, ) router = APIRouter() def _iso_week_label() -> str: now = datetime.now(tz=timezone.utc) iso = now.isocalendar() return f"S{iso[1]} {iso[0]}" 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() @router.post("/stores", response_model=StoreResponse, status_code=201) async def create_store(payload: StoreCreate, session: AsyncSession = Depends(get_session)): store = Store(**payload.model_dump()) session.add(store) await session.commit() await session.refresh(store) return store @router.patch("/stores/{store_id}", response_model=StoreResponse) async def update_store(store_id: uuid.UUID, payload: StoreUpdate, session: AsyncSession = Depends(get_session)): store = await session.get(Store, store_id) if not store: raise HTTPException(404, "Boutique introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(store, field, value) await session.commit() await session.refresh(store) return store @router.delete("/stores/{store_id}", status_code=204) async def delete_store(store_id: uuid.UUID, session: AsyncSession = Depends(get_session)): store = await session.get(Store, store_id) if not store: raise HTTPException(404, "Boutique introuvable") await session.delete(store) await session.commit() return Response(status_code=204) # ── Products ────────────────────────────────────────────────────────────────── @router.get("/products", response_model=list[ProductResponse]) async def search_products( q: str | None = Query(default=None), limit: int = 50, 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() @router.post("/products", response_model=ProductResponse, status_code=201) async def create_product(payload: ProductCreate, session: AsyncSession = Depends(get_session)): product = Product(**payload.model_dump()) session.add(product) await session.commit() await session.refresh(product) return product @router.patch("/products/{product_id}", response_model=ProductResponse) async def update_product( product_id: uuid.UUID, payload: ProductUpdate, session: AsyncSession = Depends(get_session), ): product = await session.get(Product, product_id) if not product: raise HTTPException(404, "Produit introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(product, field, value) await session.commit() await session.refresh(product) return product @router.delete("/products/{product_id}", status_code=204) async def delete_product(product_id: uuid.UUID, session: AsyncSession = Depends(get_session)): product = await session.get(Product, product_id) if not product: raise HTTPException(404, "Produit introuvable") await session.delete(product) await session.commit() return Response(status_code=204) # ── 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), ): data = payload.model_dump() if not data.get('name'): data['name'] = _iso_week_label() lst = ShoppingList(**data) 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, ["product"]) 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/generate", response_model=ShoppingListDetailResponse, status_code=201) async def generate_magic_list(session: AsyncSession = Depends(get_session)): """Génère une liste à partir du score de fréquence (retard / intervalle moyen).""" query = text(""" WITH achats AS ( SELECT COALESCE(li.custom_name, p.name) AS nom, li.product_id, li.custom_name, p.default_unit, sl.created_at::date AS date_achat FROM shopping.list_items li JOIN shopping.lists sl ON sl.id = li.list_id LEFT JOIN shopping.products p ON p.id = li.product_id WHERE li.is_checked = true ), achats_with_lag AS ( SELECT nom, product_id, custom_name, default_unit, date_achat, date_achat - LAG(date_achat) OVER (PARTITION BY nom ORDER BY date_achat) AS interval_days FROM achats ), stats AS ( SELECT nom, product_id, custom_name, default_unit, MAX(date_achat) AS last_purchased, COUNT(*) AS nb_achats, AVG(interval_days) AS avg_interval_days FROM achats_with_lag GROUP BY nom, product_id, custom_name, default_unit ) SELECT nom, product_id, custom_name, default_unit, last_purchased, nb_achats, COALESCE(avg_interval_days, 30) AS avg_interval_days, (CURRENT_DATE - last_purchased)::float / NULLIF(COALESCE(avg_interval_days, 30), 0) AS score FROM stats WHERE (CURRENT_DATE - last_purchased)::float / NULLIF(COALESCE(avg_interval_days, 30), 0) >= 0.7 ORDER BY score DESC LIMIT 50 """) result = await session.execute(query) rows = result.mappings().all() new_list = ShoppingList(name=_iso_week_label(), status="draft") session.add(new_list) await session.flush() for row in rows: session.add(ListItem( list_id=new_list.id, product_id=row["product_id"], custom_name=row["custom_name"], unit=row["default_unit"], )) await session.commit() stmt = ( select(ShoppingList) .where(ShoppingList.id == new_list.id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result2 = await session.execute(stmt) new_list = result2.scalar_one() sorted_items = sorted(new_list.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(new_list).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @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", name=_iso_week_label()) 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], )