206 lines
7.2 KiB
Python
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.")
|