7bf6caa3dd
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>
472 lines
17 KiB
Python
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],
|
|
)
|