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

+133 -54
View File
@@ -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]