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
parent 925e077afe
commit 85093f1b99
8 changed files with 1056 additions and 442 deletions
@@ -0,0 +1,38 @@
"""shopping enrichments — champs enrichis produits et boutiques"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Nouveaux champs produits
op.add_column('products', sa.Column('description', sa.Text(), nullable=True), schema='shopping')
op.add_column('products', sa.Column('price', sa.Numeric(8, 2), nullable=True), schema='shopping')
op.add_column('products', sa.Column('quantity_per_unit', sa.Numeric(8, 3), nullable=True), schema='shopping')
op.add_column('products', sa.Column('default_store_id', UUID(as_uuid=True), nullable=True), schema='shopping')
op.create_foreign_key(
'fk_products_default_store',
'products', 'stores',
['default_store_id'], ['id'],
source_schema='shopping', referent_schema='shopping',
ondelete='SET NULL',
)
# Nouveaux champs boutiques
op.add_column('stores', sa.Column('url', sa.Text(), nullable=True), schema='shopping')
op.add_column('stores', sa.Column('store_type', sa.String(50), nullable=True), schema='shopping')
def downgrade() -> None:
op.drop_constraint('fk_products_default_store', 'products', schema='shopping', type_='foreignkey')
op.drop_column('products', 'description', schema='shopping')
op.drop_column('products', 'price', schema='shopping')
op.drop_column('products', 'quantity_per_unit', schema='shopping')
op.drop_column('products', 'default_store_id', schema='shopping')
op.drop_column('stores', 'url', schema='shopping')
op.drop_column('stores', 'store_type', schema='shopping')
+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:
+6
View File
@@ -15,10 +15,14 @@ class Product(Base):
name: Mapped[str] = mapped_column(String(150), nullable=False)
brand: Mapped[str | None] = mapped_column(String(100))
category: Mapped[str | None] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(Text)
image_path: Mapped[str | None] = mapped_column(String(255))
thumbnail_path: Mapped[str | None] = mapped_column(String(255))
default_unit: Mapped[str | None] = mapped_column(String(20))
barcode: Mapped[str | None] = mapped_column(String(50))
price: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
default_store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0"))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
@@ -31,6 +35,8 @@ class Store(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
location: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
store_type: Mapped[str | None] = mapped_column(String(50))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
+45
View File
@@ -5,11 +5,51 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, model_validator
class StoreCreate(BaseModel):
name: str
location: str | None = None
url: str | None = None
store_type: str | None = None
class StoreUpdate(BaseModel):
name: str | None = None
location: str | None = None
url: str | None = None
store_type: str | None = None
class StoreResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
location: str | None
url: str | None
store_type: str | None
class ProductCreate(BaseModel):
name: str
brand: str | None = None
category: str | None = None
description: str | None = None
default_unit: str | None = None
barcode: str | None = None
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
class ProductUpdate(BaseModel):
name: str | None = None
brand: str | None = None
category: str | None = None
description: str | None = None
default_unit: str | None = None
barcode: str | None = None
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
class ProductResponse(BaseModel):
@@ -18,7 +58,12 @@ class ProductResponse(BaseModel):
name: str
brand: str | None
category: str | None
description: str | None
default_unit: str | None
barcode: str | None
price: Decimal | None
quantity_per_unit: Decimal | None
default_store_id: uuid.UUID | None
frequency_score: int