Files
home_hub/backend/app/api/shopping.py
T
gilles 7bf6caa3dd feat(shopping): listes projet + déduplication nommage hebdo
Backend :
- Migration 007 : list_type VARCHAR(20) sur shopping.lists (weekly/project),
  url/description/image_url sur shopping.list_items
- Modèle ShoppingList : champ list_type
- Modèle ListItem : champs url, description, image_url
- Schémas : list_type sur Create/Response, nouveaux champs sur ItemCreate/Update/Response
- _unique_week_label() : évite les doublons S22 2026 → S22 2026 (2)
- finish_shopping : carry-over uniquement pour list_type='weekly'

Frontend :
- api/shopping.ts : list_type, champs enrichis item, createProjectList()
- ProjectItemCard.tsx : carte avec image, description, URL, boutique, cochage
- ShoppingPage :
  · Séparation weekly / project dans la sélection de liste active
  · Section "Listes projet" sur l'écran vide avec navigation
  · Badge PROJET dans l'en-tête
  · Bouton "Clôturer la semaine" et badge "semaine dépassée" masqués sur projet
  · Bouton "+ Ajouter" (mobile + laptop) sur les listes projet
  · Vue grille ProjectItemCard pour les listes projet
  · Modale création liste projet (nom + boutique)
  · Modale ajout/édition item projet (nom, description, URL, image URL)

v0.5.14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:59:53 +02:00

472 lines
17 KiB
Python

import uuid
from datetime import datetime, timezone, date as date_type
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import select, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.broadcaster import broadcaster
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]}"
async def _unique_week_label(session: AsyncSession) -> str:
base = _iso_week_label()
existing = (await session.execute(
select(ShoppingList.name).where(ShoppingList.name.like(f"{base}%"))
)).scalars().all()
if base not in existing:
return base
counter = 2
while f"{base} ({counter})" in existing:
counter += 1
return f"{base} ({counter})"
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,
url=item.url,
description=item.description,
image_url=item.image_url,
)
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,
list_type=lst.list_type,
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 = 500,
session: AsyncSession = Depends(get_session),
):
stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name)
if q:
stmt = stmt.where(or_(Product.name.ilike(f"%{q}%"), Product.brand.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') and data.get('list_type', 'weekly') == 'weekly':
data['name'] = await _unique_week_label(session)
lst = ShoppingList(**data)
session.add(lst)
await session.commit()
await session.refresh(lst, ["items"])
broadcaster.broadcast("shopping_changed")
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()
broadcaster.broadcast("shopping_changed")
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()
broadcaster.broadcast("shopping_changed")
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()
broadcaster.broadcast("shopping_changed")
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")
was_checked = item.is_checked
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
# Mise à jour des stats produit lors du premier cochage
if not was_checked and item.is_checked and item.product_id:
product = await session.get(Product, item.product_id)
if product:
today = date_type.today()
if product.last_purchased_at and product.last_purchased_at < today:
days = (today - product.last_purchased_at).days
if product.avg_interval_days is None:
product.avg_interval_days = Decimal(str(days))
else:
# Moyenne mobile exponentielle (70 % passé, 30 % nouvel intervalle)
product.avg_interval_days = Decimal(str(
round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1)
))
product.last_purchased_at = today
product.frequency_score += 1
await session.commit()
broadcaster.broadcast("shopping_changed")
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()
broadcaster.broadcast("shopping_changed")
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=await _unique_week_label(session), list_type="weekly", 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()
broadcaster.broadcast("shopping_changed")
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 and lst.list_type == 'weekly':
new_list = ShoppingList(
store_id=lst.store_id,
list_type="weekly",
status="draft",
name=await _unique_week_label(session),
)
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()
broadcaster.broadcast("shopping_changed")
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],
)