Files
guyg1474andClaude Opus 5.5 0fb3521c2f 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>
2026-10-15 21:49:45 +02:00

113 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"])