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