from fastapi import APIRouter, HTTPException, status from sqlalchemy import func, select from app.deps import DbSession, VerifiedUser from app.models import Category, ListMember, Market, ShoppingList, User from app.permissions import OwnedList, ReadableList from app.schemas import MessageOut from app.list_view import build_view from app.schemas_shopping import ( CategoryOut, ListCreateIn, ListOut, ListSnapshot, ListUpdateIn, MarketOut, MemberOut, MemberRoleIn, ) from app.security import utcnow router = APIRouter(prefix="/api/lists", tags=["lists"]) MAX_LISTS_PER_USER = 100 def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut: count = db.scalar( select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id) ) return ListOut( id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev, role=role, may_share_public=(role == "owner") or may_share, member_count=count or 0, created_at=lst.created_at, ) @router.get("", response_model=list[ListOut]) def my_lists(db: DbSession, user: VerifiedUser): rows = db.execute( select(ShoppingList, ListMember.role, ListMember.may_share_public) .join(ListMember, ListMember.list_id == ShoppingList.id) .where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None)) .order_by(ShoppingList.created_at) ).all() return [_to_out(db, lst, role, may) for lst, role, may in rows] @router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED) def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser): owned = db.scalar( select(func.count()).select_from(ShoppingList).where( ShoppingList.owner_id == user.id, ShoppingList.deleted_at.is_(None) ) ) if (owned or 0) >= MAX_LISTS_PER_USER: raise HTTPException( status.HTTP_409_CONFLICT, f"Höchstens {MAX_LISTS_PER_USER} eigene Listen.", ) lst = ShoppingList(name=payload.name.strip(), owner_id=user.id, rev=1) db.add(lst) db.flush() db.add(ListMember( list_id=lst.id, user_id=user.id, role="owner", may_share_public=True )) db.commit() return _to_out(db, lst, "owner") @router.get("/{list_id}", response_model=ListOut) def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser): member = db.get(ListMember, (lst.id, user.id)) return _to_out(db, lst, member.role, member.may_share_public) @router.get("/{list_id}/snapshot", response_model=ListSnapshot) def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser): """Gesamtstand in einem Aufruf: Liste, gruppierte Ansicht, Märkte, Warengruppen. Ersetzt vier einzelne Abfragen.""" member = db.get(ListMember, (lst.id, user.id)) markets = db.scalars( select(Market) .where(Market.list_id == lst.id, Market.deleted_at.is_(None)) .order_by(Market.sort_order, Market.name) ).all() categories = db.scalars( select(Category) .where(Category.list_id == lst.id, Category.deleted_at.is_(None)) .order_by(Category.sort_order, Category.name) ).all() return ListSnapshot( list=_to_out(db, lst, member.role, member.may_share_public), view=build_view(db, lst), markets=[MarketOut.model_validate(m) for m in markets], categories=[CategoryOut.model_validate(c) for c in categories], ) @router.patch("/{list_id}", response_model=ListOut) def rename_list(payload: ListUpdateIn, lst: OwnedList, db: DbSession, user: VerifiedUser): lst.name = payload.name.strip() lst.rev += 1 db.commit() return _to_out(db, lst, "owner") @router.delete("/{list_id}", response_model=MessageOut) def delete_list(lst: OwnedList, db: DbSession): # Soft Delete: offline-Clients sollen das Verschwinden noch mitbekommen. lst.deleted_at = utcnow() lst.rev += 1 db.commit() return MessageOut(detail="Liste gelöscht.") # -------------------------------------------------------------------------- # Mitgliedschaften # Einladungen per Mail und QR-Code folgen in Phase 5. Hier lassen sich # bestehende Mitgliedschaften nur ansehen, ändern und beenden. # -------------------------------------------------------------------------- @router.get("/{list_id}/members", response_model=list[MemberOut]) def list_members(lst: ReadableList, db: DbSession): rows = db.execute( select(ListMember, User) .join(User, User.id == ListMember.user_id) .where(ListMember.list_id == lst.id) .order_by(ListMember.joined_at) ).all() return [ MemberOut( user_id=u.id, email=u.email, display_name=u.display_name, role=m.role, may_share_public=m.may_share_public, joined_at=m.joined_at, ) for m, u in rows ] @router.put("/{list_id}/members/{user_id}", response_model=MemberOut) def set_member_role( user_id: str, payload: MemberRoleIn, lst: OwnedList, db: DbSession, user: VerifiedUser, ): if user_id == lst.owner_id: raise HTTPException( status.HTTP_409_CONFLICT, "Die Rolle des Eigentümers lässt sich nicht ändern. " "Übertrage stattdessen die Liste.", ) member = db.get(ListMember, (lst.id, user_id)) if member is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste") member.role = payload.role if payload.may_share_public is not None: member.may_share_public = payload.may_share_public lst.rev += 1 db.commit() target = db.get(User, user_id) return MemberOut( user_id=target.id, email=target.email, display_name=target.display_name, role=member.role, may_share_public=member.may_share_public, joined_at=member.joined_at, ) @router.delete("/{list_id}/members/{user_id}", response_model=MessageOut) def remove_member(user_id: str, list_id: str, db: DbSession, user: VerifiedUser): """Der Eigentümer entzieht anderen den Zugriff; jedes Mitglied kann sich selbst entfernen (bequemer über POST /api/lists/{id}/leave). Der Eigentümer kann sich nicht selbst entfernen - die Liste stünde sonst ohne Verwaltung da. Dafür gibt es den Eigentümerwechsel.""" lst = db.scalar( select(ShoppingList).where( ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None) ) ) own = db.get(ListMember, (list_id, user.id)) if lst is None or own is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden") if user_id != user.id and own.role != "owner": raise HTTPException( status.HTTP_403_FORBIDDEN, "Nur der Eigentümer kann andere entfernen." ) if user_id == lst.owner_id: raise HTTPException( status.HTTP_409_CONFLICT, "Der Eigentümer kann die Liste nicht verlassen. Lösche sie oder " "übertrage sie zuvor.", ) member = db.get(ListMember, (list_id, user_id)) if member is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste") db.delete(member) lst.rev += 1 db.commit() return MessageOut(detail="Mitgliedschaft beendet.")