Files
einkaufsapp/backend/app/routers/lists.py
2026-08-08 20:31:58 +02:00

206 lines
7.2 KiB
Python

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.")