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>
This commit is contained in:
2026-05-24 16:21:45 +02:00
co-authored by Claude Sonnet 4.6
parent 925e077afe
commit 85093f1b99
8 changed files with 1056 additions and 442 deletions
+82 -5
View File
@@ -1,5 +1,6 @@
# 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
@@ -11,12 +12,19 @@ from app.models.shopping import ShoppingList, ListItem, Product, Store
from app.schemas.shopping import (
ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse,
ShoppingListDetailResponse, ListItemCreate, ListItemUpdate,
ListItemResponse, ProductResponse, StoreResponse,
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(
@@ -55,12 +63,43 @@ async def list_stores(session: AsyncSession = Depends(get_session)):
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 = 30,
limit: int = 50,
session: AsyncSession = Depends(get_session),
):
stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name)
@@ -71,6 +110,41 @@ async def search_products(
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])
@@ -90,7 +164,10 @@ async def create_shopping_list(
payload: ShoppingListCreate,
session: AsyncSession = Depends(get_session),
):
lst = ShoppingList(**payload.model_dump())
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"])
@@ -275,7 +352,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
result = await session.execute(query)
rows = result.mappings().all()
new_list = ShoppingList(name="Liste magique", status="draft")
new_list = ShoppingList(name=_iso_week_label(), status="draft")
session.add(new_list)
await session.flush()
@@ -320,7 +397,7 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
unchecked = [i for i in lst.items if not i.is_checked]
if unchecked:
new_list = ShoppingList(store_id=lst.store_id, status="draft")
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: