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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user