diff --git a/README.md b/README.md index 6ecc962..339be99 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,18 @@ Die ganze Seite ist nur nach Anmeldung erreichbar. - Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an (dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen). - Unter **Menü → Mein Konto** ändert jeder seinen Anzeigenamen und sein Passwort. +- **Freunde** (Menü → Freunde): Konto suchen, Anfrage schicken; gilt erst, wenn beide zugestimmt haben. + Freunde sind die Grundlage fürs Teilen. - Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte** - (die Städte der anderen lassen sich auf der Karte einblenden). -- Gemeinsam für alle Konten: **Kanban**. -- **Vorrat & Einkauf** in Listen (z. B. Zuhause, Urlaub): wer eine Liste anlegt, bestimmt, ob alle - Konten sie sehen oder nur ausgewählte. -- **Artikel** sind gemeinsam: alle Konten können sie lesen und bearbeiten; angezeigt wird, - wer ihn geschrieben und wer ihn zuletzt geändert hat. Löschen dürfen nur der Autor und - Administratoren. + (Städte von Freunden lassen sich auf der Karte einblenden). +- **Vorratslisten** und **Kanban-Projekte** sind privat: Sehen und bearbeiten dürfen sie der Ersteller und + die Freunde, mit denen er sie teilt (Mitglieder). Mitglieder verwalten und löschen: Ersteller. +- **Artikel** („P42 Projekte“) kann jeder lesen. Bearbeiten dürfen der Autor und alle, die an einem + verknüpften Kanban-Projekt mitarbeiten; verknüpfen lassen sich nur Artikel, die man selbst bearbeiten + darf. Löschen dürfen der Autor und Administratoren. +- **Administratoren** sehen und bearbeiten alles; teilen können sie mit allen Konten. +- Beim Update auf dieses Modell bekommen bisher gemeinsame Listen und alle Kanban-Projekte die vorhandenen + Konten als Mitglieder (niemand verliert den Zugriff); ohne Ersteller gehören sie dem ersten Administrator. Passwort vergessen und kein anderer Admin vorhanden? Im Ordner `backend/`: @@ -150,12 +154,9 @@ Oben wählst du die **Liste**, z. B. „Zuhause“ oder „Urlaub“. Jede Liste **Vorrat** (was da ist) und **Einkaufen** (was fehlt) – im Urlaub gibt es also einen eigenen Bestand und einen eigenen Einkaufszettel. -- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an. Dabei festlegen, wer sie - sieht: **alle Konten** oder **nur ausgewählte**. Der Ersteller sieht seine Liste immer. - Name, Sichtbarkeit und Löschen („✎ Liste bearbeiten“) nur für den Ersteller und Administratoren – - Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste. -- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“ - (für alle sichtbar). +- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an und ist privat. Unter + „Teilen mit“ Freunde auswählen. Name, Mitglieder und Löschen („✎ Liste bearbeiten“) nur für den + Ersteller und Administratoren. Löschen entfernt Vorrat und Einkauf der Liste. - **Mindestbestand** („⚑ Mindestbestand“ im Vorrat): pro Produkt eine Untergrenze, z. B. „Milch: immer 2“. Fällt der Vorrat darunter (weniger, aufgebraucht, gelöscht), kommt genau die fehlende Menge auf die Einkaufsliste. Die Zeile im Vorrat zeigt „min 2“, darunter hervorgehoben. @@ -210,9 +211,12 @@ Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames). - „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal, Tage am damaligen Wohnort nicht. -Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe, -einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde -Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen. +Der Schalter **Städte von Freunden zeigen** (über der Karte) blendet die Städte deiner Freunde ein – je +Person in einer eigenen Farbe, einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden +(gemerkt pro Browser). Fremde Städte kann man nur ansehen, nicht ändern; Administratoren sehen alle. + +**Reiserouten zeigen** zeichnet die Strecken aus der Kilometer-Rechnung als Bögen auf die Karte +(Ausflüge gepunktet, Umzüge gestrichelt). **⤓ Karte herunterladen** speichert die eigenen Städte und besuchten Länder als Bild; Ausschnitt „Meine Orte“ (auf die Städte gezoomt) oder ganze Welt: @@ -227,8 +231,9 @@ Design; erstellt wird das Bild im Browser. Ob eine Stadt Hauptstadt ist, liefert ### Kanban -Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben -haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop oder im Dialog. +Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Projekte sind privat: +unter „Mitarbeit mit“ Freunde als Mitglieder wählen. Aufgaben haben Titel, Beschreibung, Status und +Bearbeiter (nur wer am Projekt mitarbeitet); verschieben per Drag & Drop oder im Dialog. Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen und löschen (löschen: wer angelegt hat und Administratoren). @@ -283,6 +288,7 @@ backend/ app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei) app/auth.py Konten, Anmeldung, Benutzerverwaltung + app/freunde.py Freunde (Anfragen), Grundlage fürs Teilen app/projects.py Projekte/Artikel, Bild-Uploads app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder) app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen diff --git a/backend/app/auth.py b/backend/app/auth.py index 4b1f1c3..e8b9de6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -169,6 +169,9 @@ def setup(request: Request, response: Response, data: dict = Body(...)): # Bisherige Projekte ohne Autor diesem Konto zuordnen conn.execute("UPDATE projects SET created_by = ? WHERE created_by IS NULL", (user_id,)) conn.execute("UPDATE projects SET updated_by = ? WHERE updated_by IS NULL", (user_id,)) + # Listen und Kanban-Projekte ohne Besitzer (z. B. die erste Liste "Zuhause") gehören ihm + conn.execute("UPDATE shopping_lists SET created_by = ? WHERE created_by IS NULL", (user_id,)) + conn.execute("UPDATE kanban_projects SET created_by = ? WHERE created_by IS NULL", (user_id,)) # Bisherige Links (links.json bzw. config.json) übernehmen legacy = get_meta(conn, "legacy_links") links = json.loads(legacy) if legacy else CONFIG_LINKS diff --git a/backend/app/db.py b/backend/app/db.py index 05d77e4..66d19a8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -73,6 +73,18 @@ CREATE TABLE IF NOT EXISTS sessions ( expires_at REAL NOT NULL ); +-- Freundschaften: user_a < user_b; erst nach Annahme (accepted = 1) wirksam. +-- Teilen (Listen, Kanban) und fremde Reise-Punkte gibt es nur unter Freunden (Admins: alle). +CREATE TABLE IF NOT EXISTS friendships ( + user_a INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_b INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + requested_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + accepted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (user_a, user_b), + CHECK (user_a < user_b) +); + CREATE TABLE IF NOT EXISTS user_links ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, key TEXT NOT NULL, @@ -270,6 +282,13 @@ CREATE TABLE IF NOT EXISTS info_cache ( -- Kanban: Projekte (je ein Board) → Aufgaben; gemeinsam für alle Konten CREATE TABLE IF NOT EXISTS kanban_projects {KANBAN_PROJECTS_TABLE}; +-- Mitglieder eines Kanban-Projekts (zusätzlich zum Ersteller); Projekte sind privat +CREATE TABLE IF NOT EXISTS kanban_project_members ( + project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (project_id, user_id) +); + -- Verknüpfung Kanban-Projekt ↔ Artikel aus "P42 Projekte" CREATE TABLE IF NOT EXISTS kanban_project_links ( project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, diff --git a/backend/app/freunde.py b/backend/app/freunde.py new file mode 100644 index 0000000..cb401a3 --- /dev/null +++ b/backend/app/freunde.py @@ -0,0 +1,112 @@ +"""Freunde: Anfrage schicken, annehmen, ablehnen, entfernen. + +Freunde sind die Grundlage fürs Teilen: Vorratslisten und Kanban-Projekte lassen sich nur mit Freunden teilen, +auf der Reise-Karte erscheinen nur Städte von Freunden. Administratoren gelten diese Einschränkungen nicht. +""" +from fastapi import APIRouter, Depends, HTTPException + +from .auth import current_user +from .common import now_iso +from .db import transaction + +router = APIRouter() + +MIN_SEARCH = 2 + + +def _pair(a: int, b: int) -> tuple: + return (a, b) if a < b else (b, a) + + +def friend_ids(conn, user_id: int) -> set: + """IDs aller angenommenen Freunde.""" + rows = conn.execute( + "SELECT user_a, user_b FROM friendships WHERE accepted = 1 AND (user_a = ? OR user_b = ?)", (user_id, user_id) + ) + return {r["user_b"] if r["user_a"] == user_id else r["user_a"] for r in rows} + + +def shareable(conn, user: dict) -> list: + """Konten, mit denen man teilen darf: Freunde – Administratoren alle (außer sich selbst).""" + rows = conn.execute("SELECT id, display_name FROM users WHERE id != ? ORDER BY display_name COLLATE NOCASE", (user["id"],)) + allowed = None if user["isAdmin"] else friend_ids(conn, user["id"]) + return [{"id": r["id"], "name": r["display_name"]} for r in rows if allowed is None or r["id"] in allowed] + + +def check_shareable(conn, user: dict, ids, keep=()) -> None: + """Neue Mitglieder müssen Freunde sein (Admins: beliebig). keep: schon eingetragene Mitglieder dürfen bleiben.""" + allowed = {u["id"] for u in shareable(conn, user)} | set(keep) + if any(i not in allowed for i in ids): + raise HTTPException(400, "Teilen geht nur mit Freunden – bitte zuerst unter „Freunde“ hinzufügen.") + + +def _person(row) -> dict: + return {"id": row["id"], "name": row["display_name"], "username": row["username"]} + + +def _state(conn, user_id: int) -> dict: + rows = conn.execute( + """SELECT u.id, u.username, u.display_name, f.accepted, f.requested_by FROM friendships f + JOIN users u ON u.id = CASE WHEN f.user_a = ? THEN f.user_b ELSE f.user_a END + WHERE f.user_a = ? OR f.user_b = ? ORDER BY u.display_name COLLATE NOCASE""", + (user_id, user_id, user_id), + ).fetchall() + return { + "friends": [_person(r) for r in rows if r["accepted"]], + "incoming": [_person(r) for r in rows if not r["accepted"] and r["requested_by"] != user_id], + "outgoing": [_person(r) for r in rows if not r["accepted"] and r["requested_by"] == user_id], + } + + +@router.get("/api/freunde") +def get_friends(user: dict = Depends(current_user)): + with transaction() as conn: + return _state(conn, user["id"]) + + +@router.get("/api/freunde/suche") +def search_users(q: str = "", user: dict = Depends(current_user)): + """Konten nach Name oder Benutzername suchen (ab 2 Zeichen), mit Stand der Freundschaft.""" + q = q.strip() + if len(q) < MIN_SEARCH: + return [] + like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + with transaction() as conn: + rows = conn.execute( + """SELECT id, username, display_name FROM users + WHERE id != ? AND (display_name LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\') + ORDER BY display_name COLLATE NOCASE LIMIT 20""", + (user["id"], like, like), + ).fetchall() + state = _state(conn, user["id"]) + status = {p["id"]: s for s, key in (("freund", "friends"), ("angefragt", "outgoing"), ("eingeladen", "incoming")) for p in state[key]} + return [{**_person(r), "status": status.get(r["id"])} for r in rows] + + +@router.post("/api/freunde/{other_id}") +def befriend(other_id: int, user: dict = Depends(current_user)): + """Anfrage schicken – oder eine offene Anfrage der anderen Person annehmen.""" + if other_id == user["id"]: + raise HTTPException(400, "Mit sich selbst befreundet sein geht nicht.") + a, b = _pair(user["id"], other_id) + with transaction() as conn: + if not conn.execute("SELECT 1 FROM users WHERE id = ?", (other_id,)).fetchone(): + raise HTTPException(404, "Konto nicht gefunden.") + row = conn.execute("SELECT * FROM friendships WHERE user_a = ? AND user_b = ?", (a, b)).fetchone() + if not row: + conn.execute( + "INSERT INTO friendships (user_a, user_b, requested_by, accepted, created_at) VALUES (?, ?, ?, 0, ?)", + (a, b, user["id"], now_iso()), + ) + elif not row["accepted"] and row["requested_by"] != user["id"]: + conn.execute("UPDATE friendships SET accepted = 1 WHERE user_a = ? AND user_b = ?", (a, b)) + return _state(conn, user["id"]) + + +@router.delete("/api/freunde/{other_id}") +def unfriend(other_id: int, user: dict = Depends(current_user)): + """Anfrage zurückziehen oder ablehnen bzw. Freundschaft beenden.""" + a, b = _pair(user["id"], other_id) + with transaction() as conn: + conn.execute("DELETE FROM friendships WHERE user_a = ? AND user_b = ?", (a, b)) + return _state(conn, user["id"]) diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index 535e9af..ad864a4 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -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) diff --git a/backend/app/kanban.py b/backend/app/kanban.py index cda651c..5b5f68f 100644 --- a/backend/app/kanban.py +++ b/backend/app/kanban.py @@ -1,6 +1,8 @@ """Kanban: Projekte (je ein Board) → Aufgaben mit Status, Bearbeiter und Beschreibung. -Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren. +Projekte sind privat: Sehen und bearbeiten dürfen sie der Ersteller und die Mitglieder (Freunde, mit denen er das +Projekt umsetzt); Administratoren sehen alles. Mitglieder verwalten und das Projekt löschen dürfen der Ersteller +und Administratoren, Aufgaben löschen deren Ersteller und Administratoren. Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen. `position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte. Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gelöscht (gezählt ab "erledigt"). @@ -15,6 +17,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list from .db import transaction +from .freunde import check_shareable, shareable router = APIRouter() @@ -59,6 +62,64 @@ def _person(user_id, name) -> dict | None: return {"id": user_id, "name": name} if user_id is not None and name else None +# Sichtbar: Ersteller, Mitglieder, Administratoren +_VISIBLE = """(:admin OR k.created_by = :uid + OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))""" + + +def _who(user: dict) -> dict: + return {"uid": user["id"], "admin": int(user["isAdmin"])} + + +def _get_project(conn, project_id: int, user: dict): + row = conn.execute(f"SELECT k.* FROM kanban_projects k WHERE k.id = :id AND {_VISIBLE}", {"id": project_id, **_who(user)}).fetchone() + if not row: + raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gelöscht oder nicht mit dir geteilt.") + return row + + +def _member_ids(conn, project_id: int) -> list: + return [r["user_id"] for r in conn.execute("SELECT user_id FROM kanban_project_members WHERE project_id = ?", (project_id,))] + + +def _people(conn, row) -> list: + """Wer am Projekt arbeitet (Ersteller + Mitglieder) – wählbar als Bearbeiter.""" + ids = {row["created_by"], *_member_ids(conn, row["id"])} - {None} + return [u for u in user_list(conn) if u["id"] in ids] + + +def _members(conn, value, user: dict, owner_id, current=()) -> list: + if not isinstance(value, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in value): + raise ValidationError("Ungültige Mitglieder.") + ids = sorted(set(value) - {owner_id}) + check_shareable(conn, user, ids, keep=current) + return ids + + +def _save_members(conn, project_id: int, ids: list) -> None: + conn.execute("DELETE FROM kanban_project_members WHERE project_id = ?", (project_id,)) + conn.executemany("INSERT INTO kanban_project_members (project_id, user_id) VALUES (?, ?)", [(project_id, i) for i in ids]) + # Wer nicht mehr dabei ist, kann auch nicht mehr Bearbeiter sein + people = {r["created_by"] for r in conn.execute("SELECT created_by FROM kanban_projects WHERE id = ?", (project_id,))} | set(ids) + conn.execute( + f"UPDATE kanban_tasks SET assignee = NULL WHERE project_id = ? AND assignee NOT IN ({','.join('?' * len(people))})", + (project_id, *people), + ) + + +def can_edit_article(conn, user: dict, article_id: str) -> bool: + """Artikel bearbeiten: Autor, wer an einem verknüpften Kanban-Projekt arbeitet, Administratoren.""" + if user["isAdmin"]: + return True + return conn.execute( + """SELECT 1 FROM projects p WHERE p.id = :a AND (p.created_by = :uid OR EXISTS ( + SELECT 1 FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id + WHERE l.article_id = p.id AND (k.created_by = :uid + OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))))""", + {"a": article_id, "uid": user["id"]}, + ).fetchone() is not None + + def _may_delete(user: dict, created_by) -> bool: return user["isAdmin"] or created_by == user["id"] @@ -97,13 +158,12 @@ def _status(value) -> str: return value -def _assignee(conn, value): +def _assignee(conn, value, project): + """Bearbeiter muss am Projekt arbeiten (Ersteller oder Mitglied).""" if value is None: return None - if isinstance(value, bool) or not isinstance(value, int) or not conn.execute( - "SELECT 1 FROM users WHERE id = ?", (value,) - ).fetchone(): - raise ValidationError("Unbekannter Bearbeiter.") + if isinstance(value, bool) or not isinstance(value, int) or value not in {u["id"] for u in _people(conn, project)}: + raise ValidationError("Bearbeiter muss an diesem Projekt mitarbeiten.") return value @@ -113,7 +173,9 @@ def _position(value) -> float: return float(value) -def _links(conn, value) -> list: +def _links(conn, value, user: dict, current=()) -> list: + """Verknüpfte Artikel: nur solche, die man selbst bearbeiten darf (sonst verschaffte die Verknüpfung + Bearbeitungsrechte); schon verknüpfte dürfen bleiben.""" if value is None: return [] if not isinstance(value, list) or len(value) > MAX_LINKS or not all(isinstance(v, str) for v in value): @@ -124,6 +186,8 @@ def _links(conn, value) -> list: )} if ids else set() if len(found) != len(ids): raise ValidationError("Ein verknüpfter Artikel existiert nicht mehr.") + if any(a not in current and not can_edit_article(conn, user, a) for a in ids): + raise ValidationError("Verknüpfen lassen sich nur Artikel, die du bearbeiten darfst.") return ids @@ -146,15 +210,14 @@ def _project_links(conn) -> dict: return links -def _projects(conn, project_id=None) -> list: +def _projects(conn, user: dict, project_id=None) -> list: rows = conn.execute( - """SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name - FROM kanban_projects k - LEFT JOIN users cu ON cu.id = k.created_by - LEFT JOIN users uu ON uu.id = k.updated_by""" - + (" WHERE k.id = ?" if project_id is not None else "") - + " ORDER BY k.position", - (project_id,) if project_id is not None else (), + f"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name + FROM kanban_projects k + LEFT JOIN users cu ON cu.id = k.created_by + LEFT JOIN users uu ON uu.id = k.updated_by + WHERE {_VISIBLE}""" + (" AND k.id = :id" if project_id is not None else "") + " ORDER BY k.position", + {"id": project_id, **_who(user)}, ).fetchall() counts = {} for r in conn.execute("SELECT project_id, status, COUNT(*) AS n FROM kanban_tasks GROUP BY project_id, status"): @@ -173,18 +236,28 @@ def _projects(conn, project_id=None) -> list: "createdBy": _person(r["created_by"], r["created_by_name"]), "updatedAt": r["updated_at"], "updatedBy": _person(r["updated_by"], r["updated_by_name"]), + "members": _member_ids(conn, r["id"]), + "canManage": _may_delete(user, r["created_by"]), } for r in rows ] -def _overview(conn) -> dict: +def _articles(conn, user: dict) -> list: + """Artikel zum Verknüpfen; editable = darf man selbst verknüpfen.""" + return [ + {"id": a["id"], "title": a["title"], "editable": can_edit_article(conn, user, a["id"])} + for a in conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE") + ] + + +def _overview(conn, user: dict) -> dict: cleanup(conn) - articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall() return { - "projects": _projects(conn), - "articles": [{"id": a["id"], "title": a["title"]} for a in articles], + "projects": _projects(conn, user), + "articles": _articles(conn, user), "users": user_list(conn), + "shareable": shareable(conn, user), } @@ -298,8 +371,8 @@ def _ensure_unlocked(conn, task_id: int, status: str) -> None: raise ValidationError("Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: " + ", ".join(f"„{t}“" for t in missing)) -def _board(conn, project_id: int) -> dict: - projects = _projects(conn, project_id) +def _board(conn, project_id: int, user: dict) -> dict: + projects = _projects(conn, user, project_id) if not projects: raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.") project = projects[0] @@ -312,7 +385,6 @@ def _board(conn, project_id: int) -> dict: WHERE t.project_id = ? ORDER BY t.status, t.priority, t.position""", (project_id,), ).fetchall() - articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall() archive_before = iso_ago(ARCHIVE_AFTER) archived = sorted( (r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before), @@ -328,8 +400,10 @@ def _board(conn, project_id: int) -> dict: "archived": [ {**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived ], - "users": user_list(conn), - "articles": [{"id": a["id"], "title": a["title"]} for a in articles], + "users": user_list(conn), # Namen (auch ehemaliger Mitglieder) für die Anzeige + "people": _people(conn, conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()), + "shareable": shareable(conn, user), + "articles": _articles(conn, user), } @@ -344,9 +418,9 @@ def _save_links(conn, project_id: int, links: list) -> None: @router.get("/api/kanban") -def get_overview(_: dict = Depends(current_user)): +def get_overview(user: dict = Depends(current_user)): with transaction() as conn: - return _overview(conn) + return _overview(conn, user) # ---------- Projekte ---------- @@ -357,7 +431,8 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)): name = _name(data.get("name")) description = _description(data.get("description")) with transaction() as conn: - links = _links(conn, data.get("links")) + links = _links(conn, data.get("links"), user) + members = _members(conn, data.get("members", []), user, user["id"]) if conn.execute("SELECT COUNT(*) FROM kanban_projects").fetchone()[0] >= MAX_PROJECTS: raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.") now = now_iso() @@ -367,20 +442,20 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)): (name, description, _next(conn, "kanban_projects"), now, user["id"], now, user["id"]), ).lastrowid _save_links(conn, project_id, links) - return {"id": project_id, **_overview(conn)} + _save_members(conn, project_id, members) + return {"id": project_id, **_overview(conn, user)} @router.get("/api/kanban/projekte/{project_id}") -def get_board(project_id: int, _: dict = Depends(current_user)): +def get_board(project_id: int, user: dict = Depends(current_user)): with transaction() as conn: - return _board(conn, project_id) + return _board(conn, project_id, user) @router.patch("/api/kanban/projekte/{project_id}") def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)): with transaction() as conn: - if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): - raise HTTPException(404, "Kanban-Projekt nicht gefunden.") + project = _get_project(conn, project_id, user) values = {} if "name" in data: values["name"] = _name(data["name"]) @@ -391,32 +466,36 @@ def update_project(project_id: int, data: dict = Body(...), user: dict = Depends raise ValidationError("Ungültiger Wert für abgeschlossen.") values["completed_at"] = now_iso() if data["completed"] else None if "links" in data: - _save_links(conn, project_id, _links(conn, data["links"])) + current = [r["article_id"] for r in conn.execute("SELECT article_id FROM kanban_project_links WHERE project_id = ?", (project_id,))] + _save_links(conn, project_id, _links(conn, data["links"], user, current)) + if "members" in data: + if not _may_delete(user, project["created_by"]): + raise HTTPException(403, "Mitglieder verwalten dürfen nur wer das Projekt angelegt hat und Administratoren.") + _save_members(conn, project_id, _members(conn, data["members"], user, project["created_by"], _member_ids(conn, project_id))) values.update(updated_at=now_iso(), updated_by=user["id"]) columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben conn.execute(f"UPDATE kanban_projects SET {columns} WHERE id = ?", (*values.values(), project_id)) - return _board(conn, project_id) + return _board(conn, project_id, user) @router.delete("/api/kanban/projekte/{project_id}") def delete_project(project_id: int, user: dict = Depends(current_user)): with transaction() as conn: - row = conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone() - if not row: - raise HTTPException(404, "Kanban-Projekt nicht gefunden.") + row = _get_project(conn, project_id, user) if not _may_delete(user, row["created_by"]): raise HTTPException(403, "Nur wer das Projekt angelegt hat oder ein Administrator darf es löschen.") conn.execute("DELETE FROM kanban_projects WHERE id = ?", (project_id,)) # Aufgaben & Links per CASCADE - return _overview(conn) + return _overview(conn, user) # ---------- Aufgaben ---------- -def _get_task(conn, task_id: int): +def _get_task(conn, task_id: int, user: dict): row = conn.execute("SELECT * FROM kanban_tasks WHERE id = ?", (task_id,)).fetchone() if not row: raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.") + _get_project(conn, row["project_id"], user) return row @@ -432,8 +511,7 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu priority = _priority(data.get("priority", "mittel")) checklist = _checklist(data.get("checklist", [])) with transaction() as conn: - if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): - raise HTTPException(404, "Kanban-Projekt nicht gefunden.") + project = _get_project(conn, project_id, user) requires = _requires(conn, data.get("requires", []), project_id) if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS: raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.") @@ -442,28 +520,28 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu """INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position, done_at, created_at, created_by, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (project_id, title, description, status, _assignee(conn, data.get("assignee")), priority, + (project_id, title, description, status, _assignee(conn, data.get("assignee"), project), priority, _end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]), ) task_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] _save_checklist(conn, task_id, checklist) _save_requires(conn, task_id, requires) _ensure_unlocked(conn, task_id, status) - return _board(conn, project_id) + return _board(conn, project_id, user) @router.patch("/api/kanban/aufgaben/{task_id}") def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(current_user)): """Felder einzeln ändern. Neuer Status oder neue Priorität ohne Position: ans Ende der Stufe.""" with transaction() as conn: - row = _get_task(conn, task_id) + row = _get_task(conn, task_id, user) values = {} if "title" in data: values["title"] = _name(data["title"], "Titel", MAX_TITLE) if "description" in data: values["description"] = _description(data["description"]) if "assignee" in data: - values["assignee"] = _assignee(conn, data["assignee"]) + values["assignee"] = _assignee(conn, data["assignee"], _get_project(conn, row["project_id"], user)) if "status" in data: values["status"] = _status(data["status"]) if values["status"] != row["status"]: @@ -490,39 +568,40 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre values.update(updated_at=now_iso(), updated_by=user["id"]) columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id)) - return _board(conn, row["project_id"]) + return _board(conn, row["project_id"], user) @router.post("/api/kanban/aufgaben/{task_id}/zurueckholen") def restore_task(task_id: int, user: dict = Depends(current_user)): """Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu.""" with transaction() as conn: - row = _get_task(conn, task_id) + row = _get_task(conn, task_id, user) now = now_iso() conn.execute( "UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?", (now, now, user["id"], task_id), ) - return _board(conn, row["project_id"]) + return _board(conn, row["project_id"], user) @router.delete("/api/kanban/aufgaben/{task_id}") def delete_task(task_id: int, user: dict = Depends(current_user)): with transaction() as conn: - row = _get_task(conn, task_id) + row = _get_task(conn, task_id, user) if not _may_delete(user, row["created_by"]): raise HTTPException(403, "Nur wer die Aufgabe angelegt hat oder ein Administrator darf sie löschen.") conn.execute("DELETE FROM kanban_tasks WHERE id = ?", (task_id,)) - return _board(conn, row["project_id"]) + return _board(conn, row["project_id"], user) # ---------- Für die Artikelseite ---------- -def boards_for_article(conn, article_id: str) -> list: +def boards_for_article(conn, article_id: str, user: dict) -> list: + """Verknüpfte Boards, die das Konto sehen darf.""" rows = conn.execute( - """SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id - WHERE l.article_id = ? ORDER BY k.name COLLATE NOCASE""", - (article_id,), + f"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id + WHERE l.article_id = :a AND {_VISIBLE} ORDER BY k.name COLLATE NOCASE""", + {"a": article_id, **_who(user)}, ).fetchall() return [{"id": r["id"], "name": r["name"]} for r in rows] diff --git a/backend/app/main.py b/backend/app/main.py index c0731ca..ad10725 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from . import auth, haushalt, kanban, links, monitoring, projects, reisen, rezept +from . import auth, freunde, haushalt, kanban, links, monitoring, projects, reisen, rezept from .auth import current_user from .common import ValidationError from .config import FRONTEND_DIST @@ -61,6 +61,7 @@ async def validation_error(_: Request, exc: ValidationError): # ---------- API ---------- app.include_router(auth.router) +app.include_router(freunde.router) app.include_router(links.router) app.include_router(projects.router) app.include_router(monitoring.router) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 0f4c253..86202bc 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -22,8 +22,8 @@ log = logging.getLogger(__name__) def start(conn) -> None: - """Neue, leere Datenbank: erste Liste anlegen und gleich auf den neuesten Stand setzen.""" - conn.execute("INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),)) + """Neue, leere Datenbank: erste Liste anlegen (bekommt das erste Konto) und gleich auf den neuesten Stand setzen.""" + conn.execute("INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 0, 1, ?)", (now_iso(),)) conn.execute(f"PRAGMA user_version = {len(STEPS)}") @@ -270,6 +270,28 @@ def _v5_wohnorte(conn) -> None: _add_column(conn, "travel_visits", "continued", "INTEGER") +# ---------- Schritt 6: Listen und Kanban-Projekte privat ---------- + + +def _v6_privat(conn) -> None: + """Listen und Kanban-Projekte sind ab jetzt privat. Damit niemand den Zugriff verliert, bekommen bisher + gemeinsame Listen und alle Projekte die vorhandenen Konten als Mitglieder; ohne Ersteller gehören sie + dem ersten Administrator.""" + owner = conn.execute("SELECT id FROM users ORDER BY is_admin DESC, id LIMIT 1").fetchone() + if owner: + conn.execute("UPDATE shopping_lists SET created_by = ? WHERE created_by IS NULL", (owner["id"],)) + conn.execute("UPDATE kanban_projects SET created_by = ? WHERE created_by IS NULL", (owner["id"],)) + conn.execute( + """INSERT OR IGNORE INTO shopping_list_members (list_id, user_id) + SELECT l.id, u.id FROM shopping_lists l JOIN users u ON u.id != l.created_by WHERE l.everyone = 1""" + ) + conn.execute("UPDATE shopping_lists SET everyone = 0") + conn.execute( + """INSERT OR IGNORE INTO kanban_project_members (project_id, user_id) + SELECT k.id, u.id FROM kanban_projects k JOIN users u ON u.id != k.created_by""" + ) + + # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. STEPS = [ _v1_altbestand, @@ -277,4 +299,5 @@ STEPS = [ _v3_kategorien, _v4_wunschliste, _v5_wohnorte, + _v6_privat, ] diff --git a/backend/app/projects.py b/backend/app/projects.py index 21b3e45..cd3e3b8 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -12,7 +12,7 @@ from .config import DATA_DIR from .common import ValidationError, clean_text, now_iso from .db import transaction from .htmlclean import sanitize -from .kanban import boards_for_article +from .kanban import boards_for_article, can_edit_article router = APIRouter() @@ -133,12 +133,18 @@ def _summary(row) -> dict: } -def _get(conn, project_id: str) -> dict | None: +def _get(conn, project_id: str, user: dict) -> dict | None: row = conn.execute(f"{_SELECT} WHERE p.id = ?", (project_id,)).fetchone() if not row: return None # Verknüpfte Kanban-Boards für die Seitenleiste - return {**_summary(row), "cover": row["cover"], "content": row["content"], "boards": boards_for_article(conn, project_id)} + return { + **_summary(row), "cover": row["cover"], "content": row["content"], + "boards": boards_for_article(conn, project_id, user), + # Lesen dürfen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Administratoren + "canEdit": can_edit_article(conn, user, project_id), + "canDelete": user["isAdmin"] or row["created_by"] == user["id"], + } def _not_found(): @@ -169,13 +175,13 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)): now, user["id"], now, user["id"], ), ) - return _get(conn, project_id) + return _get(conn, project_id, user) @router.get("/api/projects/{project_id}") -def get_project(project_id: str, _: dict = Depends(current_user)): +def get_project(project_id: str, user: dict = Depends(current_user)): with transaction() as conn: - return _get(conn, project_id) or _raise(_not_found()) + return _get(conn, project_id, user) or _raise(_not_found()) @router.patch("/api/projects/{project_id}") @@ -197,10 +203,12 @@ def update_project(project_id: str, data: dict = Body(...), user: dict = Depends old = conn.execute("SELECT cover, content FROM projects WHERE id = ?", (project_id,)).fetchone() if not old: raise _not_found() + if not can_edit_article(conn, user, project_id): + raise HTTPException(403, "Bearbeiten dürfen nur der Autor und wer an einem verknüpften Kanban-Projekt mitarbeitet.") assignments = ", ".join(f"{k} = ?" for k in fields) conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id)) _remove_unused(conn, [old["cover"], *_content_images(old["content"])]) - return _get(conn, project_id) + return _get(conn, project_id, user) @router.delete("/api/projects/{project_id}") diff --git a/backend/app/reisen.py b/backend/app/reisen.py index 06db7f5..b75976e 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -22,6 +22,7 @@ from .auth import current_user from .config import WIKIPEDIA_CONTACT from .common import ValidationError, clean_text, now_iso from .db import transaction +from .freunde import friend_ids from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused router = APIRouter() @@ -51,15 +52,18 @@ def _city(row) -> dict: } -def _others(conn, user_id: int) -> list: - """Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto.""" +def _others(conn, user: dict) -> list: + """Städte von Freunden (nur zum Anzeigen auf der Karte), gruppiert nach Konto; Administratoren sehen alle.""" rows = conn.execute( """SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id WHERE c.user_id != ? AND c.wish = 0 ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""", - (user_id,), + (user["id"],), ).fetchall() + allowed = None if user["isAdmin"] else friend_ids(conn, user["id"]) people = {} for r in rows: + if allowed is not None and r["user_id"] not in allowed: + continue person = people.setdefault(r["user_id"], {"id": r["user_id"], "name": r["display_name"], "cities": []}) person["cities"].append(_city(r)) return list(people.values()) @@ -88,7 +92,8 @@ def _visits(conn, user_id: int) -> list: ] -def _state(conn, user_id: int) -> dict: +def _state(conn, user: dict) -> dict: + user_id = user["id"] cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall() countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall() homes = conn.execute( @@ -103,7 +108,7 @@ def _state(conn, user_id: int) -> dict: for h in homes ], "visits": _visits(conn, user_id), - "others": _others(conn, user_id), + "others": _others(conn, user), } @@ -113,7 +118,7 @@ def _state(conn, user_id: int) -> dict: @router.get("/api/reisen") def get_travel(user: dict = Depends(current_user)): with transaction() as conn: - return _state(conn, user["id"]) + return _state(conn, user) def _number(value, field: str, low: float, high: float) -> float: @@ -156,7 +161,7 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)): VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""", values, ) - return _state(conn, user["id"]) + return _state(conn, user) @router.delete("/api/reisen/staedte/{city_id}") @@ -171,7 +176,7 @@ def delete_city(city_id: int, user: dict = Depends(current_user)): if not cur.rowcount: raise HTTPException(404, "Stadt nicht gefunden.") remove_unused(conn, photos) - return _state(conn, user["id"]) + return _state(conn, user) @router.patch("/api/reisen/staedte/{city_id}") @@ -184,7 +189,7 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre if data["wish"] and conn.execute("SELECT 1 FROM travel_visits WHERE city_id = ?", (city_id,)).fetchone(): raise ValidationError("Für diese Stadt sind schon Besuche eingetragen.") conn.execute("UPDATE travel_cities SET wish = ? WHERE id = ?", (int(data["wish"]), city_id)) - return _state(conn, user["id"]) + return _state(conn, user) # ---------- Wohnorte (mit "seit"-Datum) ---------- @@ -228,7 +233,7 @@ def add_home(data: dict = Body(...), user: dict = Depends(current_user)): since = _date(data.get("since"), "Seit", False) with transaction() as conn: _add_home(conn, user["id"], city, since) - return _state(conn, user["id"]) + return _state(conn, user) @router.delete("/api/reisen/wohnorte/{home_id}") @@ -237,7 +242,7 @@ def delete_home(home_id: int, user: dict = Depends(current_user)): cur = conn.execute("DELETE FROM travel_homes WHERE id = ? AND user_id = ?", (home_id, user["id"])) if not cur.rowcount: raise HTTPException(404, "Wohnort nicht gefunden.") - return _state(conn, user["id"]) + return _state(conn, user) @router.post("/api/reisen/besuche/{visit_id}/gewohnt") @@ -258,7 +263,7 @@ def lived_there(visit_id: int, user: dict = Depends(current_user)): _add_home(conn, user["id"], dict(city), visit["date_from"]) if before and not later and before["geoname_id"] != city["geoname_id"]: _add_home(conn, user["id"], dict(before), after) - return _state(conn, user["id"]) + return _state(conn, user) # ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ---------- @@ -345,7 +350,7 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current ).lastrowid _save_photos(conn, visit_id, photos) conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,)) - return _state(conn, user["id"]) + return _state(conn, user) @router.put("/api/reisen/besuche/{visit_id}") @@ -359,7 +364,7 @@ def update_visit(visit_id: int, data: dict = Body(...), user: dict = Depends(cur (date_from, date_to, companions, notes, None if continued is None else int(continued), visit_id), ) remove_unused(conn, _save_photos(conn, visit_id, photos)) - return _state(conn, user["id"]) + return _state(conn, user) @router.delete("/api/reisen/besuche/{visit_id}") @@ -369,7 +374,7 @@ def delete_visit(visit_id: int, user: dict = Depends(current_user)): photos = [r["file"] for r in conn.execute("SELECT file FROM travel_visit_photos WHERE visit_id = ?", (visit_id,))] conn.execute("DELETE FROM travel_visits WHERE id = ?", (visit_id,)) remove_unused(conn, photos) - return _state(conn, user["id"]) + return _state(conn, user) def _country_code(code: str) -> str: @@ -384,14 +389,14 @@ def mark_country(code: str, user: dict = Depends(current_user)): conn.execute( "INSERT OR IGNORE INTO travel_countries (user_id, code) VALUES (?, ?)", (user["id"], _country_code(code)) ) - return _state(conn, user["id"]) + return _state(conn, user) @router.delete("/api/reisen/laender/{code}") def unmark_country(code: str, user: dict = Depends(current_user)): with transaction() as conn: conn.execute("DELETE FROM travel_countries WHERE user_id = ? AND code = ?", (user["id"], _country_code(code))) - return _state(conn, user["id"]) + return _state(conn, user) # ---------- Städtesuche ---------- diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 37c2794..7821921 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -48,3 +48,12 @@ def login(): client.user = {"id": user_id, "name": username.capitalize()} return client return make + + +@pytest.fixture +def befriend(): + """befriend(anna, ben): Freundschaftsanfrage + Annahme.""" + def make(a: TestClient, b: TestClient) -> None: + a.post(f"/api/freunde/{b.user['id']}") + assert b.post(f"/api/freunde/{a.user['id']}").json()["friends"] + return make diff --git a/backend/tests/test_datenschutz.py b/backend/tests/test_datenschutz.py index fbec053..c1659fe 100644 --- a/backend/tests/test_datenschutz.py +++ b/backend/tests/test_datenschutz.py @@ -1,6 +1,8 @@ """Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku.""" import io +from test_haushalt import new_list + from fastapi.testclient import TestClient from PIL import Image, PngImagePlugin @@ -68,8 +70,9 @@ def private_liste(anna): return lid -def test_privates_produkt_nicht_in_fremden_vorschlaegen(login): +def test_privates_produkt_nicht_in_fremden_vorschlaegen(login, befriend): anna, ben = login("anna"), login("ben") + befriend(anna, ben) lid = private_liste(anna) assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"] assert "Schwangerschaftstest" not in ben.get("/api/haushalt").json()["products"] @@ -89,9 +92,10 @@ def test_rezept_zuordnung_verraet_nichts(login): assert ing["known"] is True and ing["product"] == "Kondome" -def test_vorschlag_loeschen_nur_in_eigenen_listen(login): +def test_vorschlag_loeschen_nur_in_eigenen_listen(login, befriend): anna, ben = login("anna"), login("ben") - home = anna.get("/api/haushalt").json()["lists"][0]["id"] + befriend(anna, ben) + home = new_list(anna, "Zuhause", members=[ben.user["id"]]) private_liste(anna) anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": home}) # Ben löscht den Vorschlag: weg aus "Zuhause", in Annas privater Liste bleibt er @@ -125,7 +129,7 @@ def test_api_doku_nicht_oeffentlich(): def test_umstellung_bestehender_daten(conn, login): anna = login("anna") - home = anna.get("/api/haushalt").json()["lists"][0]["id"] + home = new_list(anna, "Zuhause") with conn: # Stand vor Schritt 2: Katalog ohne Listen-Zuordnung, Artikel mit fremdem Bild, Foto mit GPS conn.execute("INSERT INTO shopping_lists (id, name, everyone, position, created_at, created_by) VALUES (99, 'P', 0, 2, 'x', ?)", (anna.user["id"],)) @@ -133,6 +137,7 @@ def test_umstellung_bestehender_daten(conn, login): conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 3, 'x')", (key, name)) conn.execute("INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at) VALUES ('geheim', 'Geheim', 'einkauf', 99, 1, 'stk', 'x')") conn.execute("DELETE FROM list_products") + conn.execute("UPDATE shopping_lists SET everyone = 1 WHERE id = ?", (home,)) # damals für alle sichtbar conn.execute("""INSERT INTO projects (id, title, status, content, created_at, updated_at) VALUES ('a1', 'T', 'aktiv', '
x
{error}
} diff --git a/frontend/src/components/haushalt/ListSwitcher.jsx b/frontend/src/components/haushalt/ListSwitcher.jsx index 77b49d9..ebee344 100644 --- a/frontend/src/components/haushalt/ListSwitcher.jsx +++ b/frontend/src/components/haushalt/ListSwitcher.jsx @@ -1,7 +1,6 @@ // Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht function visibleFor(list, users) { - if (list.everyone) return 'alle Konten'; const ids = new Set([list.createdBy?.id, ...list.members]); const names = users.filter((u) => ids.has(u.id)).map((u) => u.name); return names.length ? names.join(', ') : 'niemanden'; @@ -21,7 +20,7 @@ export default function ListSwitcher({ lists, current, users, onChoose, onNew, o onClick={() => onChoose(l.id)} title={`Sichtbar für ${visibleFor(l, users)}`} > - {!l.everyone && 🔒 } + {l.members.length === 0 && 🔒 } {l.name} diff --git a/frontend/src/components/kanban/KanbanProjectDialog.jsx b/frontend/src/components/kanban/KanbanProjectDialog.jsx index 8680c15..c3513eb 100644 --- a/frontend/src/components/kanban/KanbanProjectDialog.jsx +++ b/frontend/src/components/kanban/KanbanProjectDialog.jsx @@ -1,15 +1,19 @@ import { useState } from 'react'; +import { Link } from 'react-router-dom'; /** - * Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte". - * initial: { name, description, links: [articleId] } oder null (Dialog geschlossen). + * Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung, Mitglieder (nur Freunde) und verknüpfte Artikel. + * initial: { name, description, links, members } oder null (Dialog geschlossen). + * canManage: Mitglieder ändern dürfen nur Ersteller und Admins. Verknüpfen lassen sich nur Artikel, + * die man selbst bearbeiten darf (articles[].editable) – schon verknüpfte bleiben sichtbar. */ -export default function KanbanProjectDialog({ ref, initial, isNew, articles, onSave }) { +export default function KanbanProjectDialog({ ref, initial, isNew, articles, users = [], shareable = [], ownerId, canManage = true, onSave }) { // Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt aus "initial" vorbelegen const [form, setForm] = useState(() => ({ name: initial?.name || '', description: initial?.description || '', links: initial?.links || [], + members: initial?.members || [], })); const [search, setSearch] = useState(''); const [error, setError] = useState(null); @@ -17,9 +21,13 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) }); const toggle = (id) => setForm((f) => ({ ...f, links: f.links.includes(id) ? f.links.filter((x) => x !== id) : [...f.links, id] })); + const toggleMember = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] })); + // Wählbar: Freunde und schon eingetragene Mitglieder (auch wenn keine Freunde mehr) + const choosable = users.filter((u) => u.id !== ownerId && (shareable.some((f) => f.id === u.id) || (initial?.members || []).includes(u.id))); const q = search.trim().toLocaleLowerCase('de-DE'); // Verknüpfte zuerst, dann nach Suche gefiltert const shown = [...articles] + .filter((a) => a.editable || (initial?.links || []).includes(a.id)) .filter((a) => !q || a.title.toLocaleLowerCase('de-DE').includes(q)) .sort((a, b) => Number(form.links.includes(b.id)) - Number(form.links.includes(a.id))); @@ -31,6 +39,7 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS name: form.name, description: form.description, links: form.links, + ...(canManage ? { members: form.members } : {}), }); setBusy(false); if (err) setError(err); @@ -49,10 +58,29 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS Beschreibung (optional) +