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>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""007 - list_type sur shopping.lists, url/description/image_url sur list_items
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-05-30
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
'lists',
|
||||
sa.Column('list_type', sa.String(20), nullable=False, server_default='weekly'),
|
||||
schema='shopping',
|
||||
)
|
||||
op.add_column('list_items', sa.Column('url', sa.Text, nullable=True), schema='shopping')
|
||||
op.add_column('list_items', sa.Column('description', sa.Text, nullable=True), schema='shopping')
|
||||
op.add_column('list_items', sa.Column('image_url', sa.String(255), nullable=True), schema='shopping')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('list_items', 'image_url', schema='shopping')
|
||||
op.drop_column('list_items', 'description', schema='shopping')
|
||||
op.drop_column('list_items', 'url', schema='shopping')
|
||||
op.drop_column('lists', 'list_type', schema='shopping')
|
||||
@@ -27,6 +27,19 @@ def _iso_week_label() -> str:
|
||||
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(
|
||||
@@ -40,6 +53,9 @@ def _item_to_response(item: ListItem) -> ListItemResponse:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,6 +64,7 @@ def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
|
||||
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,
|
||||
@@ -167,8 +184,8 @@ async def create_shopping_list(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.model_dump()
|
||||
if not data.get('name'):
|
||||
data['name'] = _iso_week_label()
|
||||
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()
|
||||
@@ -380,7 +397,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(query)
|
||||
rows = result.mappings().all()
|
||||
|
||||
new_list = ShoppingList(name=_iso_week_label(), status="draft")
|
||||
new_list = ShoppingList(name=await _unique_week_label(session), list_type="weekly", status="draft")
|
||||
session.add(new_list)
|
||||
await session.flush()
|
||||
|
||||
@@ -425,8 +442,13 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
|
||||
lst.status = "done"
|
||||
|
||||
unchecked = [i for i in lst.items if not i.is_checked]
|
||||
if unchecked:
|
||||
new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label())
|
||||
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:
|
||||
|
||||
@@ -63,6 +63,7 @@ class ShoppingList(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str | None] = mapped_column(String(100))
|
||||
list_type: Mapped[str] = mapped_column(String(20), server_default="weekly")
|
||||
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
|
||||
week_date: Mapped[date | None] = mapped_column(Date)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default="draft")
|
||||
@@ -86,6 +87,9 @@ class ListItem(Base):
|
||||
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
|
||||
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
||||
sort_order: Mapped[int | None] = mapped_column(Integer)
|
||||
url: Mapped[str | None] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_url: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
|
||||
product: Mapped["Product | None"] = relationship("Product", lazy="select")
|
||||
|
||||
@@ -83,6 +83,9 @@ class ListItemCreate(BaseModel):
|
||||
custom_name: str | None = None
|
||||
quantity: Decimal | None = None
|
||||
unit: str | None = None
|
||||
url: str | None = None
|
||||
description: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def must_have_name(self) -> 'ListItemCreate':
|
||||
@@ -96,6 +99,9 @@ class ListItemUpdate(BaseModel):
|
||||
quantity: Decimal | None = None
|
||||
unit: str | None = None
|
||||
price_recorded: Decimal | None = None
|
||||
url: str | None = None
|
||||
description: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ListItemResponse(BaseModel):
|
||||
@@ -110,13 +116,23 @@ class ListItemResponse(BaseModel):
|
||||
price_recorded: Decimal | None
|
||||
carried_over: bool
|
||||
sort_order: int | None
|
||||
url: str | None
|
||||
description: str | None
|
||||
image_url: str | None
|
||||
|
||||
|
||||
class ShoppingListCreate(BaseModel):
|
||||
name: str | None = None
|
||||
list_type: Literal['weekly', 'project'] = 'weekly'
|
||||
store_id: uuid.UUID | None = None
|
||||
week_date: date | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def project_requires_name(self) -> 'ShoppingListCreate':
|
||||
if self.list_type == 'project' and not self.name:
|
||||
raise ValueError('Une liste projet doit avoir un nom')
|
||||
return self
|
||||
|
||||
|
||||
class ShoppingListUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
@@ -128,6 +144,7 @@ class ShoppingListResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
name: str | None
|
||||
list_type: str
|
||||
store_id: uuid.UUID | None
|
||||
week_date: date | None
|
||||
status: str
|
||||
@@ -140,6 +157,7 @@ class ShoppingListDetailResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
name: str | None
|
||||
list_type: str
|
||||
store_id: uuid.UUID | None
|
||||
week_date: date | None
|
||||
status: str
|
||||
|
||||
Reference in New Issue
Block a user