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

+112
View File
@@ -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"])