Freunde, privates Teilen, Artikelrechte, Reiserouten

- Freunde (freunde.py, Tabelle friendships): suchen, anfragen, annehmen,
  ablehnen, entfernen; neue Seite "Freunde" im Menü
- Vorratslisten und Kanban-Projekte immer privat: sichtbar für Ersteller und
  Mitglieder; teilen nur mit Freunden (Admins: mit allen); Kanban-Mitglieder
  (Tabelle kanban_project_members), Bearbeiter nur aus dem Projekt,
  ausgetretene Mitglieder verlieren ihre Zuteilung
- Artikel: lesen alle; bearbeiten Autor, Mitarbeitende verknüpfter
  Kanban-Projekte und Admins; verknüpfen nur bearbeitbare Artikel
  (sonst ließen sich Rechte erschleichen); Board-Liste auf der Artikelseite
  zeigt nur sichtbare Boards
- Administratoren sehen und bearbeiten alles
- Reisen: Städte nur von Freunden (Admins: alle); "Reiserouten zeigen"
  zeichnet die Strecken der Kilometer-Rechnung als Bögen
- Migration Schritt 6: bisher gemeinsame Listen und alle Projekte bekommen
  die vorhandenen Konten als Mitglieder, ohne Ersteller → erster Admin;
  erste Liste einer neuen Installation gehört dem ersten Konto
- Tests: test_freunde.py, test_zugriff.py, bestehende auf Privat-Modell
  umgestellt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 21:49:45 +02:00
1 parent 1d7497bead
commit 0fb3521c2f
34 files changed
+833 -245

No files matched your search

+19 -20
View File
@@ -1,8 +1,8 @@
"""Vorrat und Einkauf in mehreren Listen sowie Produktkatalog für einheitliche Namen.
Jede Liste (z. B. "Zuhause", "Urlaub") hat ihren eigenen Vorrat und ihre eigene Einkaufsliste.
Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte. Ändern
(Name, Sichtbarkeit) und löschen dürfen der Ersteller und Administratoren. Jedes Produkt steht pro Liste und Einheit
Listen sind privat: Sehen dürfen sie der Ersteller und die Freunde, mit denen er sie teilt (Mitglieder).
Ändern (Name, Mitglieder) und löschen dürfen der Ersteller und Administratoren; Administratoren sehen alles. Jedes Produkt steht pro Liste und Einheit
(Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
"""
@@ -14,6 +14,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction
from .freunde import check_shareable, shareable
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
router = APIRouter()
@@ -147,11 +148,9 @@ def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id
# ---------- Listen und wer sie sieht ----------
# Sichtbar: für alle freigegeben, selbst angelegt oder als Mitglied eingetragen.
# Listen eines gelöschten Kontos ohne Mitglieder sehen Administratoren, damit sie nicht verwaist bleiben.
_VISIBLE = """(l.everyone = 1 OR l.created_by = :uid
OR EXISTS (SELECT 1 FROM shopping_list_members m WHERE m.list_id = l.id AND m.user_id = :uid)
OR (:admin AND l.created_by IS NULL))"""
# Sichtbar: selbst angelegt oder als Mitglied eingetragen; Administratoren sehen alle Listen.
_VISIBLE = """(:admin OR l.created_by = :uid
OR EXISTS (SELECT 1 FROM shopping_list_members m WHERE m.list_id = l.id AND m.user_id = :uid))"""
def _who(user: dict) -> dict:
@@ -185,7 +184,6 @@ def _shopping_list(conn, row, user: dict) -> dict:
return {
"id": row["id"],
"name": row["name"],
"everyone": bool(row["everyone"]),
"members": members,
"createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
"canManage": _can_manage(row, user),
@@ -206,12 +204,10 @@ def _category_order(value) -> list:
return order + [k for k in CATEGORY_KEYS if k not in order]
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
def _list_fields(conn, data: dict, user: dict, owner_id, current=()) -> tuple:
"""Name und Mitglieder prüfen. Mitglieder müssen Freunde sein (Admins: beliebig); schon eingetragene dürfen bleiben.
Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME)
everyone = data.get("everyone", True)
if not isinstance(everyone, bool):
raise ValidationError("Ungültige Sichtbarkeit.")
members = data.get("members", [])
if not isinstance(members, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in members):
raise ValidationError("Ungültige Auswahl der Konten.")
@@ -220,7 +216,8 @@ def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
known = {r["id"] for r in conn.execute(f"SELECT id FROM users WHERE id IN ({','.join('?' * len(members))})", list(members))}
if known != members:
raise ValidationError("Ein ausgewähltes Konto gibt es nicht mehr.")
return name, everyone, sorted(members) if not everyone else []
check_shareable(conn, user, members, keep=current)
return name, sorted(members)
def _set_members(conn, list_id: int, members: list) -> None:
@@ -253,7 +250,8 @@ def _state(conn, user: dict) -> dict:
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn),
"users": user_list(conn), # Namen für die Anzeige der Mitglieder
"shareable": shareable(conn, user), # wählbar beim Teilen: Freunde (Admins: alle)
"products": [r["name"] for r in products],
"productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products},
"categories": [{"key": k, "title": t} for k, t in CATEGORIES],
@@ -394,13 +392,13 @@ def delete_item(item_id: int, user: dict = Depends(current_user)):
@router.post("/api/haushalt/listen", status_code=201)
def create_list(data: dict = Body(...), user: dict = Depends(current_user)):
with transaction() as conn:
name, everyone, members = _list_fields(conn, data, user, user["id"])
name, members = _list_fields(conn, data, user, user["id"])
if conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] >= MAX_LISTS:
raise ValidationError(f"Es gibt schon {MAX_LISTS} Listen. Bitte erst alte löschen.")
position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM shopping_lists").fetchone()[0]
list_id = conn.execute(
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
(name, int(everyone), position, now_iso(), user["id"]),
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, 0, ?, ?, ?)",
(name, position, now_iso(), user["id"]),
).lastrowid
_set_members(conn, list_id, members)
return {**_state(conn, user), "created": list_id}
@@ -412,8 +410,9 @@ def update_list(list_id: int, data: dict = Body(...), user: dict = Depends(curre
row = _get_list(conn, list_id, user)
if not _can_manage(row, user):
raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie ändern.")
name, everyone, members = _list_fields(conn, data, user, row["created_by"])
conn.execute("UPDATE shopping_lists SET name = ?, everyone = ? WHERE id = ?", (name, int(everyone), list_id))
current = [r["user_id"] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (list_id,))]
name, members = _list_fields(conn, data, user, row["created_by"], current)
conn.execute("UPDATE shopping_lists SET name = ? WHERE id = ?", (name, list_id))
_set_members(conn, list_id, members)
return _state(conn, user)