- 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>
113 lines
5.0 KiB
Python
113 lines
5.0 KiB
Python
"""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"])
|