Files
home_hub/backend/app/api/shopping.py
T
gilles 85093f1b99 feat(shopping): refonte UX + CRUD catalogue/boutiques + champs enrichis
- UX : vue par défaut = liste en cours, landing si pas de liste (+ vert +
  baguette magique), suppression des vues "listes" et "mode magasin" séparés
- Articles cochés barrés et déplacés en bas, tri alphabétique par section
- Nom de liste auto avec numéro de semaine ISO (S21 2026)
- Wake lock activé dès qu'une liste est ouverte
- CRUD boutiques : POST/PATCH/DELETE /stores + modal Boutiques
- CRUD articles : POST/PATCH/DELETE /products + modal Catalogue
- Champs enrichis produits : description, prix, quantité/unité, boutique défaut
- Champs enrichis boutiques : url, store_type (alimentaire, bricolage…)
- Migration 003 : nouveaux champs en base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:21:45 +02:00

420 lines
15 KiB
Python

# 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],
)