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

+24 -18
View File
@@ -81,14 +81,18 @@ Die ganze Seite ist nur nach Anmeldung erreichbar.
- Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an - Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an
(dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen). (dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen).
- Unter **Menü → Mein Konto** ändert jeder seinen Anzeigenamen und sein Passwort. - 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** - Pro Konto eigenständig: **Links** auf der Startseite, **Monitoring** und die **Reise-Karte**
(die Städte der anderen lassen sich auf der Karte einblenden). (Städte von Freunden lassen sich auf der Karte einblenden).
- Gemeinsam für alle Konten: **Kanban**. - **Vorratslisten** und **Kanban-Projekte** sind privat: Sehen und bearbeiten dürfen sie der Ersteller und
- **Vorrat & Einkauf** in Listen (z. B. Zuhause, Urlaub): wer eine Liste anlegt, bestimmt, ob alle die Freunde, mit denen er sie teilt (Mitglieder). Mitglieder verwalten und löschen: Ersteller.
Konten sie sehen oder nur ausgewählte. - **Artikel** („P42 Projekte“) kann jeder lesen. Bearbeiten dürfen der Autor und alle, die an einem
- **Artikel** sind gemeinsam: alle Konten können sie lesen und bearbeiten; angezeigt wird, verknüpften Kanban-Projekt mitarbeiten; verknüpfen lassen sich nur Artikel, die man selbst bearbeiten
wer ihn geschrieben und wer ihn zuletzt geändert hat. Löschen dürfen nur der Autor und darf. Löschen dürfen der Autor und Administratoren.
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/`: 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 **Vorrat** (was da ist) und **Einkaufen** (was fehlt) – im Urlaub gibt es also einen eigenen Bestand
und einen eigenen Einkaufszettel. und einen eigenen Einkaufszettel.
- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an. Dabei festlegen, wer sie - **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an und ist privat. Unter
sieht: **alle Konten** oder **nur ausgewählte**. Der Ersteller sieht seine Liste immer. „Teilen mit“ Freunde auswählen. Name, Mitglieder und Löschen („✎ Liste bearbeiten“) nur für den
Name, Sichtbarkeit und Löschen („✎ Liste bearbeiten“) nur für den Ersteller und Administratoren – Ersteller und Administratoren. Löschen entfernt Vorrat und Einkauf der Liste.
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).
- **Mindestbestand** („⚑ Mindestbestand“ im Vorrat): pro Produkt eine Untergrenze, z. B. „Milch: immer 2“. - **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 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. 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 - „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal, Tage am damaligen
Wohnort nicht. 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, Der Schalter **Städte von Freunden zeigen** (über der Karte) blendet die Städte deiner Freunde ein – je
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde Person in einer eigenen Farbe, einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden
Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen. (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 **⤓ Karte herunterladen** speichert die eigenen Städte und besuchten Länder als Bild; Ausschnitt
„Meine Orte“ (auf die Städte gezoomt) oder ganze Welt: „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 ### Kanban
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Projekte sind privat:
haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop oder im Dialog. 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 Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
und löschen (löschen: wer angelegt hat und Administratoren). 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/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/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
app/auth.py Konten, Anmeldung, Benutzerverwaltung app/auth.py Konten, Anmeldung, Benutzerverwaltung
app/freunde.py Freunde (Anfragen), Grundlage fürs Teilen
app/projects.py Projekte/Artikel, Bild-Uploads app/projects.py Projekte/Artikel, Bild-Uploads
app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder) app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder)
app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen
+3
View File
@@ -169,6 +169,9 @@ def setup(request: Request, response: Response, data: dict = Body(...)):
# Bisherige Projekte ohne Autor diesem Konto zuordnen # 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 created_by = ? WHERE created_by IS NULL", (user_id,))
conn.execute("UPDATE projects SET updated_by = ? WHERE updated_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 # Bisherige Links (links.json bzw. config.json) übernehmen
legacy = get_meta(conn, "legacy_links") legacy = get_meta(conn, "legacy_links")
links = json.loads(legacy) if legacy else CONFIG_LINKS links = json.loads(legacy) if legacy else CONFIG_LINKS
+19
View File
@@ -73,6 +73,18 @@ CREATE TABLE IF NOT EXISTS sessions (
expires_at REAL NOT NULL 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 ( CREATE TABLE IF NOT EXISTS user_links (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL, 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 -- Kanban: Projekte (je ein Board) → Aufgaben; gemeinsam für alle Konten
CREATE TABLE IF NOT EXISTS kanban_projects {KANBAN_PROJECTS_TABLE}; 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" -- Verknüpfung Kanban-Projekt ↔ Artikel aus "P42 Projekte"
CREATE TABLE IF NOT EXISTS kanban_project_links ( CREATE TABLE IF NOT EXISTS kanban_project_links (
project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE,
+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"])
+19 -20
View File
@@ -1,8 +1,8 @@
"""Vorrat und Einkauf in mehreren Listen sowie Produktkatalog für einheitliche Namen. """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. 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 Listen sind privat: Sehen dürfen sie der Ersteller und die Freunde, mit denen er sie teilt (Mitglieder).
(Name, Sichtbarkeit) und löschen dürfen der Ersteller und Administratoren. Jedes Produkt steht pro Liste und Einheit Ä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 (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". 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 .auth import current_user
from .common import ValidationError, clean_text, now_iso, user_list from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction from .db import transaction
from .freunde import check_shareable, shareable
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
router = APIRouter() 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 ---------- # ---------- Listen und wer sie sieht ----------
# Sichtbar: für alle freigegeben, selbst angelegt oder als Mitglied eingetragen. # Sichtbar: selbst angelegt oder als Mitglied eingetragen; Administratoren sehen alle Listen.
# Listen eines gelöschten Kontos ohne Mitglieder sehen Administratoren, damit sie nicht verwaist bleiben. _VISIBLE = """(:admin OR l.created_by = :uid
_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 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))"""
def _who(user: dict) -> dict: def _who(user: dict) -> dict:
@@ -185,7 +184,6 @@ def _shopping_list(conn, row, user: dict) -> dict:
return { return {
"id": row["id"], "id": row["id"],
"name": row["name"], "name": row["name"],
"everyone": bool(row["everyone"]),
"members": members, "members": members,
"createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None, "createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
"canManage": _can_manage(row, user), "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] return order + [k for k in CATEGORY_KEYS if k not in order]
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple: def _list_fields(conn, data: dict, user: dict, owner_id, current=()) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern.""" """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) 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", []) members = data.get("members", [])
if not isinstance(members, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in 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.") 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))} known = {r["id"] for r in conn.execute(f"SELECT id FROM users WHERE id IN ({','.join('?' * len(members))})", list(members))}
if known != members: if known != members:
raise ValidationError("Ein ausgewähltes Konto gibt es nicht mehr.") 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: def _set_members(conn, list_id: int, members: list) -> None:
@@ -253,7 +250,8 @@ def _state(conn, user: dict) -> dict:
return { return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], "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], "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], "products": [r["name"] for r in products],
"productCategories": {r["name"]: r["category"] or guess(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], "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) @router.post("/api/haushalt/listen", status_code=201)
def create_list(data: dict = Body(...), user: dict = Depends(current_user)): def create_list(data: dict = Body(...), user: dict = Depends(current_user)):
with transaction() as conn: 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: 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.") 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] position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM shopping_lists").fetchone()[0]
list_id = conn.execute( list_id = conn.execute(
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, ?, ?, ?, ?)", "INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, 0, ?, ?, ?)",
(name, int(everyone), position, now_iso(), user["id"]), (name, position, now_iso(), user["id"]),
).lastrowid ).lastrowid
_set_members(conn, list_id, members) _set_members(conn, list_id, members)
return {**_state(conn, user), "created": list_id} 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) row = _get_list(conn, list_id, user)
if not _can_manage(row, user): if not _can_manage(row, user):
raise HTTPException(403, "Nur wer die Liste angelegt hat (oder ein Administrator) kann sie ändern.") 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"]) current = [r["user_id"] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (list_id,))]
conn.execute("UPDATE shopping_lists SET name = ?, everyone = ? WHERE id = ?", (name, int(everyone), 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) _set_members(conn, list_id, members)
return _state(conn, user) return _state(conn, user)
+133 -54
View File
@@ -1,6 +1,8 @@
"""Kanban: Projekte (je ein Board) → Aufgaben mit Status, Bearbeiter und Beschreibung. """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. Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte. `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"). 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 .auth import current_user
from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list
from .db import transaction from .db import transaction
from .freunde import check_shareable, shareable
router = APIRouter() 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 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: def _may_delete(user: dict, created_by) -> bool:
return user["isAdmin"] or created_by == user["id"] return user["isAdmin"] or created_by == user["id"]
@@ -97,13 +158,12 @@ def _status(value) -> str:
return value return value
def _assignee(conn, value): def _assignee(conn, value, project):
"""Bearbeiter muss am Projekt arbeiten (Ersteller oder Mitglied)."""
if value is None: if value is None:
return None return None
if isinstance(value, bool) or not isinstance(value, int) or not conn.execute( if isinstance(value, bool) or not isinstance(value, int) or value not in {u["id"] for u in _people(conn, project)}:
"SELECT 1 FROM users WHERE id = ?", (value,) raise ValidationError("Bearbeiter muss an diesem Projekt mitarbeiten.")
).fetchone():
raise ValidationError("Unbekannter Bearbeiter.")
return value return value
@@ -113,7 +173,9 @@ def _position(value) -> float:
return float(value) 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: if value is None:
return [] return []
if not isinstance(value, list) or len(value) > MAX_LINKS or not all(isinstance(v, str) for v in value): 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 ids else set()
if len(found) != len(ids): if len(found) != len(ids):
raise ValidationError("Ein verknüpfter Artikel existiert nicht mehr.") 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 return ids
@@ -146,15 +210,14 @@ def _project_links(conn) -> dict:
return links return links
def _projects(conn, project_id=None) -> list: def _projects(conn, user: dict, project_id=None) -> list:
rows = conn.execute( rows = conn.execute(
"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name f"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
FROM kanban_projects k FROM kanban_projects k
LEFT JOIN users cu ON cu.id = k.created_by LEFT JOIN users cu ON cu.id = k.created_by
LEFT JOIN users uu ON uu.id = k.updated_by""" LEFT JOIN users uu ON uu.id = k.updated_by
+ (" WHERE k.id = ?" if project_id is not None else "") WHERE {_VISIBLE}""" + (" AND k.id = :id" if project_id is not None else "") + " ORDER BY k.position",
+ " ORDER BY k.position", {"id": project_id, **_who(user)},
(project_id,) if project_id is not None else (),
).fetchall() ).fetchall()
counts = {} counts = {}
for r in conn.execute("SELECT project_id, status, COUNT(*) AS n FROM kanban_tasks GROUP BY project_id, status"): 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"]), "createdBy": _person(r["created_by"], r["created_by_name"]),
"updatedAt": r["updated_at"], "updatedAt": r["updated_at"],
"updatedBy": _person(r["updated_by"], r["updated_by_name"]), "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 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) cleanup(conn)
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
return { return {
"projects": _projects(conn), "projects": _projects(conn, user),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles], "articles": _articles(conn, user),
"users": user_list(conn), "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)) raise ValidationError("Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: " + ", ".join(f"„{t}“" for t in missing))
def _board(conn, project_id: int) -> dict: def _board(conn, project_id: int, user: dict) -> dict:
projects = _projects(conn, project_id) projects = _projects(conn, user, project_id)
if not projects: if not projects:
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.") raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
project = projects[0] 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""", WHERE t.project_id = ? ORDER BY t.status, t.priority, t.position""",
(project_id,), (project_id,),
).fetchall() ).fetchall()
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
archive_before = iso_ago(ARCHIVE_AFTER) archive_before = iso_ago(ARCHIVE_AFTER)
archived = sorted( archived = sorted(
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before), (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": [ "archived": [
{**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived {**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived
], ],
"users": user_list(conn), "users": user_list(conn), # Namen (auch ehemaliger Mitglieder) für die Anzeige
"articles": [{"id": a["id"], "title": a["title"]} for a in articles], "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") @router.get("/api/kanban")
def get_overview(_: dict = Depends(current_user)): def get_overview(user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
return _overview(conn) return _overview(conn, user)
# ---------- Projekte ---------- # ---------- Projekte ----------
@@ -357,7 +431,8 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
name = _name(data.get("name")) name = _name(data.get("name"))
description = _description(data.get("description")) description = _description(data.get("description"))
with transaction() as conn: 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: if conn.execute("SELECT COUNT(*) FROM kanban_projects").fetchone()[0] >= MAX_PROJECTS:
raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.") raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.")
now = now_iso() 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"]), (name, description, _next(conn, "kanban_projects"), now, user["id"], now, user["id"]),
).lastrowid ).lastrowid
_save_links(conn, project_id, links) _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}") @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: with transaction() as conn:
return _board(conn, project_id) return _board(conn, project_id, user)
@router.patch("/api/kanban/projekte/{project_id}") @router.patch("/api/kanban/projekte/{project_id}")
def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)): def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): project = _get_project(conn, project_id, user)
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
values = {} values = {}
if "name" in data: if "name" in data:
values["name"] = _name(data["name"]) 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.") raise ValidationError("Ungültiger Wert für abgeschlossen.")
values["completed_at"] = now_iso() if data["completed"] else None values["completed_at"] = now_iso() if data["completed"] else None
if "links" in data: 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"]) 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 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)) 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}") @router.delete("/api/kanban/projekte/{project_id}")
def delete_project(project_id: int, user: dict = Depends(current_user)): def delete_project(project_id: int, user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
row = conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone() row = _get_project(conn, project_id, user)
if not row:
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
if not _may_delete(user, row["created_by"]): if not _may_delete(user, row["created_by"]):
raise HTTPException(403, "Nur wer das Projekt angelegt hat oder ein Administrator darf es löschen.") 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 conn.execute("DELETE FROM kanban_projects WHERE id = ?", (project_id,)) # Aufgaben & Links per CASCADE
return _overview(conn) return _overview(conn, user)
# ---------- Aufgaben ---------- # ---------- 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() row = conn.execute("SELECT * FROM kanban_tasks WHERE id = ?", (task_id,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.") raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.")
_get_project(conn, row["project_id"], user)
return row 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")) priority = _priority(data.get("priority", "mittel"))
checklist = _checklist(data.get("checklist", [])) checklist = _checklist(data.get("checklist", []))
with transaction() as conn: with transaction() as conn:
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone(): project = _get_project(conn, project_id, user)
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
requires = _requires(conn, data.get("requires", []), project_id) 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: 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.") 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, """INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
done_at, created_at, created_by, updated_at, updated_by) done_at, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", 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"]), _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] task_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
_save_checklist(conn, task_id, checklist) _save_checklist(conn, task_id, checklist)
_save_requires(conn, task_id, requires) _save_requires(conn, task_id, requires)
_ensure_unlocked(conn, task_id, status) _ensure_unlocked(conn, task_id, status)
return _board(conn, project_id) return _board(conn, project_id, user)
@router.patch("/api/kanban/aufgaben/{task_id}") @router.patch("/api/kanban/aufgaben/{task_id}")
def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(current_user)): 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.""" """Felder einzeln ändern. Neuer Status oder neue Priorität ohne Position: ans Ende der Stufe."""
with transaction() as conn: with transaction() as conn:
row = _get_task(conn, task_id) row = _get_task(conn, task_id, user)
values = {} values = {}
if "title" in data: if "title" in data:
values["title"] = _name(data["title"], "Titel", MAX_TITLE) values["title"] = _name(data["title"], "Titel", MAX_TITLE)
if "description" in data: if "description" in data:
values["description"] = _description(data["description"]) values["description"] = _description(data["description"])
if "assignee" in data: 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: if "status" in data:
values["status"] = _status(data["status"]) values["status"] = _status(data["status"])
if values["status"] != row["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"]) 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 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)) 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") @router.post("/api/kanban/aufgaben/{task_id}/zurueckholen")
def restore_task(task_id: int, user: dict = Depends(current_user)): 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.""" """Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu."""
with transaction() as conn: with transaction() as conn:
row = _get_task(conn, task_id) row = _get_task(conn, task_id, user)
now = now_iso() now = now_iso()
conn.execute( conn.execute(
"UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?", "UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(now, now, user["id"], task_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}") @router.delete("/api/kanban/aufgaben/{task_id}")
def delete_task(task_id: int, user: dict = Depends(current_user)): def delete_task(task_id: int, user: dict = Depends(current_user)):
with transaction() as conn: 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"]): if not _may_delete(user, row["created_by"]):
raise HTTPException(403, "Nur wer die Aufgabe angelegt hat oder ein Administrator darf sie löschen.") 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,)) 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 ---------- # ---------- 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( rows = conn.execute(
"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_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 = ? ORDER BY k.name COLLATE NOCASE""", WHERE l.article_id = :a AND {_VISIBLE} ORDER BY k.name COLLATE NOCASE""",
(article_id,), {"a": article_id, **_who(user)},
).fetchall() ).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows] return [{"id": r["id"], "name": r["name"]} for r in rows]
+2 -1
View File
@@ -13,7 +13,7 @@ from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles 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 .auth import current_user
from .common import ValidationError from .common import ValidationError
from .config import FRONTEND_DIST from .config import FRONTEND_DIST
@@ -61,6 +61,7 @@ async def validation_error(_: Request, exc: ValidationError):
# ---------- API ---------- # ---------- API ----------
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(freunde.router)
app.include_router(links.router) app.include_router(links.router)
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(monitoring.router) app.include_router(monitoring.router)
+25 -2
View File
@@ -22,8 +22,8 @@ log = logging.getLogger(__name__)
def start(conn) -> None: def start(conn) -> None:
"""Neue, leere Datenbank: erste Liste anlegen und gleich auf den neuesten Stand setzen.""" """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', 1, 1, ?)", (now_iso(),)) 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)}") conn.execute(f"PRAGMA user_version = {len(STEPS)}")
@@ -270,6 +270,28 @@ def _v5_wohnorte(conn) -> None:
_add_column(conn, "travel_visits", "continued", "INTEGER") _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. # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [ STEPS = [
_v1_altbestand, _v1_altbestand,
@@ -277,4 +299,5 @@ STEPS = [
_v3_kategorien, _v3_kategorien,
_v4_wunschliste, _v4_wunschliste,
_v5_wohnorte, _v5_wohnorte,
_v6_privat,
] ]
+15 -7
View File
@@ -12,7 +12,7 @@ from .config import DATA_DIR
from .common import ValidationError, clean_text, now_iso from .common import ValidationError, clean_text, now_iso
from .db import transaction from .db import transaction
from .htmlclean import sanitize from .htmlclean import sanitize
from .kanban import boards_for_article from .kanban import boards_for_article, can_edit_article
router = APIRouter() 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() row = conn.execute(f"{_SELECT} WHERE p.id = ?", (project_id,)).fetchone()
if not row: if not row:
return None return None
# Verknüpfte Kanban-Boards für die Seitenleiste # 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(): def _not_found():
@@ -169,13 +175,13 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
now, user["id"], now, user["id"], now, user["id"], now, user["id"],
), ),
) )
return _get(conn, project_id) return _get(conn, project_id, user)
@router.get("/api/projects/{project_id}") @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: 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}") @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() old = conn.execute("SELECT cover, content FROM projects WHERE id = ?", (project_id,)).fetchone()
if not old: if not old:
raise _not_found() 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) assignments = ", ".join(f"{k} = ?" for k in fields)
conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id)) conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id))
_remove_unused(conn, [old["cover"], *_content_images(old["content"])]) _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}") @router.delete("/api/projects/{project_id}")
+22 -17
View File
@@ -22,6 +22,7 @@ from .auth import current_user
from .config import WIKIPEDIA_CONTACT from .config import WIKIPEDIA_CONTACT
from .common import ValidationError, clean_text, now_iso from .common import ValidationError, clean_text, now_iso
from .db import transaction from .db import transaction
from .freunde import friend_ids
from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused
router = APIRouter() router = APIRouter()
@@ -51,15 +52,18 @@ def _city(row) -> dict:
} }
def _others(conn, user_id: int) -> list: def _others(conn, user: dict) -> list:
"""Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto.""" """Städte von Freunden (nur zum Anzeigen auf der Karte), gruppiert nach Konto; Administratoren sehen alle."""
rows = conn.execute( rows = conn.execute(
"""SELECT c.*, u.display_name FROM travel_cities c JOIN users u ON u.id = c.user_id """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""", WHERE c.user_id != ? AND c.wish = 0 ORDER BY u.display_name COLLATE NOCASE, u.id, c.name""",
(user_id,), (user["id"],),
).fetchall() ).fetchall()
allowed = None if user["isAdmin"] else friend_ids(conn, user["id"])
people = {} people = {}
for r in rows: 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 = people.setdefault(r["user_id"], {"id": r["user_id"], "name": r["display_name"], "cities": []})
person["cities"].append(_city(r)) person["cities"].append(_city(r))
return list(people.values()) 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() 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() countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
homes = conn.execute( homes = conn.execute(
@@ -103,7 +108,7 @@ def _state(conn, user_id: int) -> dict:
for h in homes for h in homes
], ],
"visits": _visits(conn, user_id), "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") @router.get("/api/reisen")
def get_travel(user: dict = Depends(current_user)): def get_travel(user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
return _state(conn, user["id"]) return _state(conn, user)
def _number(value, field: str, low: float, high: float) -> float: 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
values, values,
) )
return _state(conn, user["id"]) return _state(conn, user)
@router.delete("/api/reisen/staedte/{city_id}") @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: if not cur.rowcount:
raise HTTPException(404, "Stadt nicht gefunden.") raise HTTPException(404, "Stadt nicht gefunden.")
remove_unused(conn, photos) remove_unused(conn, photos)
return _state(conn, user["id"]) return _state(conn, user)
@router.patch("/api/reisen/staedte/{city_id}") @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(): 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.") raise ValidationError("Für diese Stadt sind schon Besuche eingetragen.")
conn.execute("UPDATE travel_cities SET wish = ? WHERE id = ?", (int(data["wish"]), city_id)) 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) ---------- # ---------- 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) since = _date(data.get("since"), "Seit", False)
with transaction() as conn: with transaction() as conn:
_add_home(conn, user["id"], city, since) _add_home(conn, user["id"], city, since)
return _state(conn, user["id"]) return _state(conn, user)
@router.delete("/api/reisen/wohnorte/{home_id}") @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"])) cur = conn.execute("DELETE FROM travel_homes WHERE id = ? AND user_id = ?", (home_id, user["id"]))
if not cur.rowcount: if not cur.rowcount:
raise HTTPException(404, "Wohnort nicht gefunden.") raise HTTPException(404, "Wohnort nicht gefunden.")
return _state(conn, user["id"]) return _state(conn, user)
@router.post("/api/reisen/besuche/{visit_id}/gewohnt") @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"]) _add_home(conn, user["id"], dict(city), visit["date_from"])
if before and not later and before["geoname_id"] != city["geoname_id"]: if before and not later and before["geoname_id"] != city["geoname_id"]:
_add_home(conn, user["id"], dict(before), after) _add_home(conn, user["id"], dict(before), after)
return _state(conn, user["id"]) return _state(conn, user)
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ---------- # ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
@@ -345,7 +350,7 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current
).lastrowid ).lastrowid
_save_photos(conn, visit_id, photos) _save_photos(conn, visit_id, photos)
conn.execute("UPDATE travel_cities SET wish = 0 WHERE id = ?", (city_id,)) 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}") @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), (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)) 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}") @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,))] 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,)) conn.execute("DELETE FROM travel_visits WHERE id = ?", (visit_id,))
remove_unused(conn, photos) remove_unused(conn, photos)
return _state(conn, user["id"]) return _state(conn, user)
def _country_code(code: str) -> str: def _country_code(code: str) -> str:
@@ -384,14 +389,14 @@ def mark_country(code: str, user: dict = Depends(current_user)):
conn.execute( conn.execute(
"INSERT OR IGNORE INTO travel_countries (user_id, code) VALUES (?, ?)", (user["id"], _country_code(code)) "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}") @router.delete("/api/reisen/laender/{code}")
def unmark_country(code: str, user: dict = Depends(current_user)): def unmark_country(code: str, user: dict = Depends(current_user)):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM travel_countries WHERE user_id = ? AND code = ?", (user["id"], _country_code(code))) 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 ---------- # ---------- Städtesuche ----------
+9
View File
@@ -48,3 +48,12 @@ def login():
client.user = {"id": user_id, "name": username.capitalize()} client.user = {"id": user_id, "name": username.capitalize()}
return client return client
return make 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
+9 -4
View File
@@ -1,6 +1,8 @@
"""Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku.""" """Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku."""
import io import io
from test_haushalt import new_list
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
@@ -68,8 +70,9 @@ def private_liste(anna):
return lid 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") anna, ben = login("anna"), login("ben")
befriend(anna, ben)
lid = private_liste(anna) lid = private_liste(anna)
assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"] assert "Schwangerschaftstest" in anna.get("/api/haushalt").json()["products"]
assert "Schwangerschaftstest" not in ben.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" 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") 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) private_liste(anna)
anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Schwangerschaftstest", "listId": home}) 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 # 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): def test_umstellung_bestehender_daten(conn, login):
anna = login("anna") anna = login("anna")
home = anna.get("/api/haushalt").json()["lists"][0]["id"] home = new_list(anna, "Zuhause")
with conn: with conn:
# Stand vor Schritt 2: Katalog ohne Listen-Zuordnung, Artikel mit fremdem Bild, Foto mit GPS # 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"],)) 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 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("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("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) conn.execute("""INSERT INTO projects (id, title, status, content, created_at, updated_at)
VALUES ('a1', 'T', 'aktiv', '<p>x</p><img src="https://tracker.example/p.gif">', 'x', 'x')""") VALUES ('a1', 'T', 'aktiv', '<p>x</p><img src="https://tracker.example/p.gif">', 'x', 'x')""")
conn.execute("PRAGMA user_version = 1") conn.execute("PRAGMA user_version = 1")
+27
View File
@@ -0,0 +1,27 @@
def test_anfrage_annehmen_entfernen(login):
anna, ben = login("anna"), login("ben")
assert anna.post(f"/api/freunde/{ben.user['id']}").json()["outgoing"][0]["name"] == "Ben"
state = ben.get("/api/freunde").json()
assert [p["name"] for p in state["incoming"]] == ["Anna"] and state["friends"] == []
assert [p["name"] for p in ben.post(f"/api/freunde/{anna.user['id']}").json()["friends"]] == ["Anna"]
assert [p["name"] for p in anna.get("/api/freunde").json()["friends"]] == ["Ben"]
assert ben.delete(f"/api/freunde/{anna.user['id']}").json()["friends"] == []
assert anna.get("/api/freunde").json()["friends"] == []
def test_eigene_anfrage_nimmt_nicht_selbst_an(login):
anna, ben = login("anna"), login("ben")
anna.post(f"/api/freunde/{ben.user['id']}")
assert anna.post(f"/api/freunde/{ben.user['id']}").json()["friends"] == []
assert anna.post(f"/api/freunde/{anna.user['id']}").status_code == 400
assert anna.post("/api/freunde/9999").status_code == 404
def test_suche(login):
anna, ben = login("anna"), login("ben")
login("bernd")
anna.post(f"/api/freunde/{ben.user['id']}")
res = anna.get("/api/freunde/suche", params={"q": "be"}).json()
assert [(r["name"], r["status"]) for r in res] == [("Ben", "angefragt"), ("Bernd", None)]
assert anna.get("/api/freunde/suche", params={"q": "a"}).json() == [] # zu kurz
assert [r["name"] for r in anna.get("/api/freunde/suche", params={"q": "%"}).json()] == []
+66 -45
View File
@@ -10,51 +10,72 @@ def items(data, list_id, area=None):
return [i for i in data["items"] if i["listId"] == list_id and (area is None or i["list"] == area)] return [i for i in data["items"] if i["listId"] == list_id and (area is None or i["list"] == area)]
def new_list(client, name, everyone=True, members=()): def new_list(client, name, members=()):
res = client.post("/api/haushalt/listen", json={"name": name, "everyone": everyone, "members": list(members)}) res = client.post("/api/haushalt/listen", json={"name": name, "members": list(members)})
assert res.status_code == 201, res.text assert res.status_code == 201, res.text
return res.json()["created"] return res.json()["created"]
def test_neue_db_hat_liste_zuhause(login): def home(client):
data = state(login("anna")) """Erste eigene Liste – Listen sind privat, jedes Konto legt seine eigene an."""
assert [l["name"] for l in data["lists"]] == ["Zuhause"] lists = [l for l in state(client)["lists"] if l["createdBy"] and l["createdBy"]["id"] == client.user["id"]]
assert data["lists"][0]["everyone"] is True return lists[0]["id"] if lists else new_list(client, "Zuhause")
def test_neue_liste_ist_privat(login):
anna, ben = login("anna"), login("ben")
home(anna)
assert [l["name"] for l in state(anna)["lists"]] == ["Zuhause"]
assert state(ben)["lists"] == []
def test_erstes_konto_bekommt_startliste():
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
client.post("/api/auth/setup", json={"username": "erste", "password": "passwort123"})
assert [l["name"] for l in client.get("/api/haushalt").json()["lists"]] == ["Zuhause"]
def test_hinzufuegen_fuehrt_zusammen_und_vereinheitlicht_namen(login): def test_hinzufuegen_fuehrt_zusammen_und_vereinheitlicht_namen(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
add(anna, home, "milch", quantity=2) add(anna, home_id, "milch", quantity=2)
data = add(anna, home, "Milch", quantity=1).json() data = add(anna, home_id, "Milch", quantity=1).json()
assert [(i["name"], i["quantity"]) for i in items(data, home, "einkauf")] == [("Milch", 3)] assert [(i["name"], i["quantity"]) for i in items(data, home_id, "einkauf")] == [("Milch", 3)]
# Andere Einheit = eigene Zeile # Andere Einheit = eigene Zeile
data = add(anna, home, "Milch", quantity=1.5, unit="l").json() data = add(anna, home_id, "Milch", quantity=1.5, unit="l").json()
assert len(items(data, home, "einkauf")) == 2 assert len(items(data, home_id, "einkauf")) == 2
def test_listen_haben_eigenen_vorrat(login): def test_listen_haben_eigenen_vorrat(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
urlaub = new_list(anna, "Urlaub") urlaub = new_list(anna, "Urlaub")
add(anna, urlaub, "Brot", area="vorrat") add(anna, urlaub, "Brot", area="vorrat")
data = state(anna) data = state(anna)
assert [i["name"] for i in items(data, urlaub, "vorrat")] == ["Brot"] assert [i["name"] for i in items(data, urlaub, "vorrat")] == ["Brot"]
assert items(data, home, "vorrat") == [] assert items(data, home_id, "vorrat") == []
def test_private_liste_nur_fuer_mitglieder(login): def test_private_liste_nur_fuer_mitglieder(login, befriend):
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True) anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
geheim = new_list(anna, "Geheim", everyone=False) geheim = new_list(anna, "Geheim")
item = add(anna, geheim, "Kuchen").json() item = add(anna, geheim, "Kuchen").json()
item_id = items(item, geheim)[0]["id"] item_id = items(item, geheim)[0]["id"]
for other in (ben, chef): # auch Admins sehen fremde private Listen nicht assert all(l["id"] != geheim for l in state(ben)["lists"])
assert all(l["id"] != geheim for l in state(other)["lists"]) assert add(ben, geheim, "x").status_code == 404
assert add(other, geheim, "x").status_code == 404 assert ben.patch(f"/api/haushalt/items/{item_id}", json={"quantity": 5}).status_code == 404
assert other.patch(f"/api/haushalt/items/{item_id}", json={"quantity": 5}).status_code == 404 # Administratoren sehen und bearbeiten alles
assert any(l["id"] == geheim and l["canManage"] for l in state(chef)["lists"])
assert chef.patch(f"/api/haushalt/items/{item_id}", json={"quantity": 5}).status_code == 200
anna.patch(f"/api/haushalt/listen/{geheim}", json={"name": "Geheim", "everyone": False, "members": [ben.user["id"]]}) # Teilen geht nur mit Freunden
res = anna.patch(f"/api/haushalt/listen/{geheim}", json={"name": "Geheim", "members": [ben.user["id"]]})
assert res.status_code == 400
befriend(anna, ben)
anna.patch(f"/api/haushalt/listen/{geheim}", json={"name": "Geheim", "members": [ben.user["id"]]})
lst = next(l for l in state(ben)["lists"] if l["id"] == geheim) lst = next(l for l in state(ben)["lists"] if l["id"] == geheim)
assert lst["canManage"] is False assert lst["canManage"] is False
assert ben.patch(f"/api/haushalt/listen/{geheim}", json={"name": "x"}).status_code == 403 assert ben.patch(f"/api/haushalt/listen/{geheim}", json={"name": "x"}).status_code == 403
@@ -71,53 +92,53 @@ def test_liste_loeschen_entfernt_eintraege(login, conn):
def test_ungueltiges_mitglied(login): def test_ungueltiges_mitglied(login):
res = login("anna").post("/api/haushalt/listen", json={"name": "x", "everyone": False, "members": [9999]}) res = login("anna").post("/api/haushalt/listen", json={"name": "x", "members": [9999]})
assert res.status_code == 400 assert res.status_code == 400
def test_abhaken_und_einkauf_beenden(login): def test_abhaken_und_einkauf_beenden(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
add(anna, home, "Milch", quantity=2) add(anna, home_id, "Milch", quantity=2)
add(anna, home, "Brot") add(anna, home_id, "Brot")
add(anna, home, "Milch", area="vorrat", quantity=1) add(anna, home_id, "Milch", area="vorrat", quantity=1)
milch = next(i for i in items(state(anna), home, "einkauf") if i["name"] == "Milch") milch = next(i for i in items(state(anna), home_id, "einkauf") if i["name"] == "Milch")
data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}).json() data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}).json()
# Abgehakt bleibt auf der Einkaufsliste # Abgehakt bleibt auf der Einkaufsliste
assert {i["name"]: i["checked"] for i in items(data, home, "einkauf")} == {"Milch": True, "Brot": False} assert {i["name"]: i["checked"] for i in items(data, home_id, "einkauf")} == {"Milch": True, "Brot": False}
res = anna.post(f"/api/haushalt/listen/{home}/einkauf-beenden") res = anna.post(f"/api/haushalt/listen/{home_id}/einkauf-beenden")
assert res.json()["moved"] == 1 assert res.json()["moved"] == 1
data = res.json() data = res.json()
assert [i["name"] for i in items(data, home, "einkauf")] == ["Brot"] assert [i["name"] for i in items(data, home_id, "einkauf")] == ["Brot"]
assert [(i["name"], i["quantity"]) for i in items(data, home, "vorrat")] == [("Milch", 3)] assert [(i["name"], i["quantity"]) for i in items(data, home_id, "vorrat")] == [("Milch", 3)]
# Nichts mehr abgehakt # Nichts mehr abgehakt
assert anna.post(f"/api/haushalt/listen/{home}/einkauf-beenden").status_code == 400 assert anna.post(f"/api/haushalt/listen/{home_id}/einkauf-beenden").status_code == 400
def test_vorrat_laesst_sich_nicht_abhaken(login): def test_vorrat_laesst_sich_nicht_abhaken(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
vorrat = items(add(anna, home, "Reis", area="vorrat").json(), home, "vorrat")[0] vorrat = items(add(anna, home_id, "Reis", area="vorrat").json(), home_id, "vorrat")[0]
assert anna.patch(f"/api/haushalt/items/{vorrat['id']}", json={"checked": True}).status_code == 400 assert anna.patch(f"/api/haushalt/items/{vorrat['id']}", json={"checked": True}).status_code == 400
def test_erneut_hinzufuegen_hebt_haken_auf(login): def test_erneut_hinzufuegen_hebt_haken_auf(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
milch = items(add(anna, home, "Milch").json(), home)[0] milch = items(add(anna, home_id, "Milch").json(), home_id)[0]
anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}) anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True})
data = add(anna, home, "Milch").json() data = add(anna, home_id, "Milch").json()
assert items(data, home)[0]["checked"] is False assert items(data, home_id)[0]["checked"] is False
def test_aufgebraucht_kommt_mit_menge_1_auf_einkauf(login): def test_aufgebraucht_kommt_mit_menge_1_auf_einkauf(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
reis = items(add(anna, home, "Reis", area="vorrat", quantity=4).json(), home)[0] reis = items(add(anna, home_id, "Reis", area="vorrat", quantity=4).json(), home_id)[0]
data = anna.post(f"/api/haushalt/items/{reis['id']}/move").json() data = anna.post(f"/api/haushalt/items/{reis['id']}/move").json()
assert [(i["list"], i["quantity"]) for i in items(data, home)] == [("einkauf", 1)] assert [(i["list"], i["quantity"]) for i in items(data, home_id)] == [("einkauf", 1)]
def test_rezept_uebernehmen_in_gewaehlte_liste(login): def test_rezept_uebernehmen_in_gewaehlte_liste(login):
@@ -132,9 +153,9 @@ def test_rezept_uebernehmen_in_gewaehlte_liste(login):
def test_einheitenwechsel_fuehrt_zusammen(login): def test_einheitenwechsel_fuehrt_zusammen(login):
anna = login("anna") anna = login("anna")
home = state(anna)["lists"][0]["id"] home_id = home(anna)
add(anna, home, "Mehl", quantity=1, unit="kg") add(anna, home_id, "Mehl", quantity=1, unit="kg")
stk = items(add(anna, home, "Mehl", quantity=2).json(), home) stk = items(add(anna, home_id, "Mehl", quantity=2).json(), home_id)
stk = next(i for i in stk if i["unit"] == "stk") stk = next(i for i in stk if i["unit"] == "stk")
data = anna.patch(f"/api/haushalt/items/{stk['id']}", json={"unit": "kg"}).json() data = anna.patch(f"/api/haushalt/items/{stk['id']}", json={"unit": "kg"}).json()
assert [(i["quantity"], i["unit"]) for i in items(data, home)] == [(3, "kg")] assert [(i["quantity"], i["unit"]) for i in items(data, home_id)] == [(3, "kg")]
+3 -2
View File
@@ -56,9 +56,10 @@ def test_zurueckholen_startet_fristen_neu(login, conn):
assert [x["title"] for x in data["tasks"]] == ["alt"] assert [x["title"] for x in data["tasks"]] == ["alt"]
def test_loeschen_nur_ersteller_oder_admin(login): def test_loeschen_nur_ersteller_oder_admin(login, befriend):
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True) anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
pid = make_board(anna) befriend(anna, ben)
pid = anna.post("/api/kanban/projekte", json={"name": "Test", "members": [ben.user["id"]]}).json()["id"]
t1, t2 = task(anna, pid, "eins"), task(anna, pid, "zwei") t1, t2 = task(anna, pid, "eins"), task(anna, pid, "zwei")
assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403 assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403
assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200 assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200
+5 -2
View File
@@ -2,9 +2,11 @@ BERLIN = {"geonameId": 2950159, "name": "Berlin", "country": "DE", "lat": 52.52,
LEIDEN = {"geonameId": 2751773, "name": "Leiden", "country": "NL", "lat": 52.16, "lon": 4.49} LEIDEN = {"geonameId": 2751773, "name": "Leiden", "country": "NL", "lat": 52.16, "lon": 4.49}
def test_staedte_anderer_nur_lesend(login): def test_staedte_anderer_nur_lesend(login, befriend):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
anna.post("/api/reisen/staedte", json=BERLIN) anna.post("/api/reisen/staedte", json=BERLIN)
assert ben.get("/api/reisen").json()["others"] == [] # erst nach Freundschaft sichtbar
befriend(anna, ben)
data = ben.get("/api/reisen").json() data = ben.get("/api/reisen").json()
assert data["cities"] == [] assert data["cities"] == []
assert [(p["name"], [c["name"] for c in p["cities"]]) for p in data["others"]] == [("Anna", ["Berlin"])] assert [(p["name"], [c["name"] for c in p["cities"]]) for p in data["others"]] == [("Anna", ["Berlin"])]
@@ -51,8 +53,9 @@ def foto(client) -> str:
return client.post("/api/uploads", content=buf.getvalue()).json()["file"] return client.post("/api/uploads", content=buf.getvalue()).json()["file"]
def test_wunschliste(login): def test_wunschliste(login, befriend):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
befriend(anna, ben)
data = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json() data = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json()
prag = data["cities"][0] prag = data["cities"][0]
assert prag["wish"] is True assert prag["wish"] is True
+7 -11
View File
@@ -1,11 +1,7 @@
"""Mindestbestand, Laden-Kategorien und Rezeptsammlung.""" """Mindestbestand, Laden-Kategorien und Rezeptsammlung."""
from app.kategorien import KEYS from app.kategorien import KEYS
from test_haushalt import add, items, new_list, state from test_haushalt import add, home, items, new_list, state
def home(client):
return state(client)["lists"][0]["id"]
def amounts(data, list_id, name): def amounts(data, list_id, name):
@@ -49,7 +45,7 @@ def test_mindestbestand_aufgebraucht_und_sofort(login):
def test_mindestbestand_nur_mit_listenzugriff(login): def test_mindestbestand_nur_mit_listenzugriff(login):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False) privat = new_list(anna, "Privat")
res = ben.put(f"/api/haushalt/listen/{privat}/mindestbestand", json={"name": "x", "unit": "stk", "minimum": 1}) res = ben.put(f"/api/haushalt/listen/{privat}/mindestbestand", json={"name": "x", "unit": "stk", "minimum": 1})
assert res.status_code == 404 assert res.status_code == 404
@@ -70,7 +66,7 @@ def test_kategorie_geraten_und_aenderbar(login):
def test_kategorie_privater_produkte_nicht_aenderbar(login): def test_kategorie_privater_produkte_nicht_aenderbar(login):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False) privat = new_list(anna, "Privat")
add(anna, privat, "Geheimzutat") add(anna, privat, "Geheimzutat")
assert ben.put("/api/haushalt/produkte/kategorie", json={"name": "Geheimzutat", "category": "obst"}).status_code == 404 assert ben.put("/api/haushalt/produkte/kategorie", json={"name": "Geheimzutat", "category": "obst"}).status_code == 404
@@ -78,10 +74,10 @@ def test_kategorie_privater_produkte_nicht_aenderbar(login):
def test_laden_reihenfolge(login): def test_laden_reihenfolge(login):
anna = login("anna") anna = login("anna")
lid = home(anna) lid = home(anna)
assert state(anna)["lists"][0]["categoryOrder"] == KEYS assert next(l for l in state(anna)["lists"] if l["id"] == lid)["categoryOrder"] == KEYS
order = list(reversed(KEYS)) order = list(reversed(KEYS))
data = anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": order}).json() data = anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": order}).json()
assert data["lists"][0]["categoryOrder"] == order assert next(l for l in data["lists"] if l["id"] == lid)["categoryOrder"] == order
assert anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": KEYS[:3]}).status_code == 400 assert anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": KEYS[:3]}).status_code == 400
@@ -126,7 +122,7 @@ def test_rezept_loeschen_nur_ersteller_oder_admin(login):
def test_rezept_vorschlag_verraet_keine_privaten_produkte(login): def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False) privat = new_list(anna, "Privat")
add(anna, privat, "Kondome", area="vorrat") add(anna, privat, "Kondome", area="vorrat")
save(ben, "Test", ["2 Kondom"]) save(ben, "Test", ["2 Kondom"])
res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json() res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json()
@@ -189,7 +185,7 @@ def test_im_laden_hinzufuegen(login):
def test_laden_fremder_liste_nicht_nutzbar(login): def test_laden_fremder_liste_nicht_nutzbar(login):
anna, ben = login("anna"), login("ben") anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False) privat = new_list(anna, "Privat")
fremd = new_store(anna, privat, "Geheimladen") fremd = new_store(anna, privat, "Geheimladen")
lid = home(ben) lid = home(ben)
assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404 assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404
+95
View File
@@ -0,0 +1,95 @@
"""Privat-Modell: Kanban-Projekte nur für Mitglieder, Artikel lesen alle / bearbeiten Beteiligte, Admins dürfen alles."""
from app import db
def projekt(client, name="P", members=(), links=()):
res = client.post("/api/kanban/projekte", json={"name": name, "members": list(members), "links": list(links)})
assert res.status_code == 201, res.text
return res.json()["id"]
def artikel(client, title="Doku"):
res = client.post("/api/projects", json={"title": title})
assert res.status_code == 201, res.text
return res.json()["id"]
def test_kanban_privat(login, befriend):
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
pid = projekt(anna)
assert ben.get("/api/kanban").json()["projects"] == []
assert ben.get(f"/api/kanban/projekte/{pid}").status_code == 404
assert ben.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "x"}).status_code == 404
assert chef.get(f"/api/kanban/projekte/{pid}").status_code == 200 # Admin sieht alles
# Mitglied nur als Freund
assert anna.patch(f"/api/kanban/projekte/{pid}", json={"members": [ben.user["id"]]}).status_code == 400
befriend(anna, ben)
data = anna.patch(f"/api/kanban/projekte/{pid}", json={"members": [ben.user["id"]]}).json()
assert data["project"]["members"] == [ben.user["id"]]
assert ben.get(f"/api/kanban/projekte/{pid}").status_code == 200
# Mitglieder verwalten nur Ersteller/Admin
assert ben.patch(f"/api/kanban/projekte/{pid}", json={"members": []}).status_code == 403
def test_bearbeiter_nur_beteiligte(login, befriend):
anna, ben, carl = login("anna"), login("ben"), login("carl")
befriend(anna, ben)
pid = projekt(anna, members=[ben.user["id"]])
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "A", "assignee": ben.user["id"]}).json()
task = data["tasks"][0]
assert [p["name"] for p in data["people"]] == ["Anna", "Ben"]
assert anna.patch(f"/api/kanban/aufgaben/{task['id']}", json={"assignee": carl.user["id"]}).status_code == 400
# Ben fliegt raus → seine Aufgaben sind niemandem mehr zugeteilt
data = anna.patch(f"/api/kanban/projekte/{pid}", json={"members": []}).json()
assert data["tasks"][0]["assignee"] is None
def test_artikel_lesen_alle_bearbeiten_beteiligte(login, befriend):
anna, ben, carl, chef = login("anna"), login("ben"), login("carl"), login("chef", admin=True)
aid = artikel(anna)
assert ben.get(f"/api/projects/{aid}").json()["canEdit"] is False
assert ben.patch(f"/api/projects/{aid}", json={"title": "x"}).status_code == 403
# Ben arbeitet an einem verknüpften Kanban-Projekt mit → darf bearbeiten
befriend(anna, ben)
projekt(anna, members=[ben.user["id"]], links=[aid])
assert ben.get(f"/api/projects/{aid}").json()["canEdit"] is True
assert ben.patch(f"/api/projects/{aid}", json={"title": "Neu"}).status_code == 200
assert carl.get(f"/api/projects/{aid}").status_code == 200 # lesen dürfen alle
assert carl.patch(f"/api/projects/{aid}", json={"title": "x"}).status_code == 403
assert chef.patch(f"/api/projects/{aid}", json={"title": "Admin"}).status_code == 200
# Löschen bleibt Autor/Admin
assert ben.delete(f"/api/projects/{aid}").status_code == 403
def test_verknuepfen_nur_bearbeitbare_artikel(login):
"""Sonst könnte man sich durch ein eigenes Kanban-Projekt Bearbeitungsrechte verschaffen."""
anna, carl = login("anna"), login("carl")
aid = artikel(anna)
res = carl.post("/api/kanban/projekte", json={"name": "Trick", "links": [aid]})
assert res.status_code == 400
assert carl.patch(f"/api/projects/{aid}", json={"title": "x"}).status_code == 403
def test_board_auf_artikelseite_nur_sichtbare(login):
anna, ben = login("anna"), login("ben")
aid = artikel(anna)
projekt(anna, name="Intern", links=[aid])
assert [b["name"] for b in anna.get(f"/api/projects/{aid}").json()["boards"]] == ["Intern"]
assert ben.get(f"/api/projects/{aid}").json()["boards"] == []
def test_umstellung_privat(conn, login):
"""Schritt 6: bisher gemeinsame Listen/Projekte bekommen alle Konten als Mitglieder, ohne Besitzer → erster Admin."""
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
with conn:
conn.execute("INSERT INTO shopping_lists (id, name, everyone, position, created_at, created_by) VALUES (50, 'Alt', 1, 1, 'x', ?)", (anna.user["id"],))
conn.execute("INSERT INTO shopping_lists (id, name, everyone, position, created_at) VALUES (51, 'Ohne', 1, 2, 'x')")
conn.execute("INSERT INTO kanban_projects (id, name, position, created_at, created_by, updated_at) VALUES (60, 'K', 1, 'x', ?, 'x')", (anna.user["id"],))
conn.execute("PRAGMA user_version = 5")
db.init()
members = lambda lid: sorted(r[0] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (lid,)))
assert members(50) == sorted([ben.user["id"], chef.user["id"]])
assert conn.execute("SELECT created_by FROM shopping_lists WHERE id = 51").fetchone()[0] == chef.user["id"]
assert sorted(r[0] for r in conn.execute("SELECT user_id FROM kanban_project_members WHERE project_id = 60")) == sorted([ben.user["id"], chef.user["id"]])
assert conn.execute("SELECT COUNT(*) FROM shopping_lists WHERE everyone = 1").fetchone()[0] == 0
assert any(l["id"] == 50 for l in ben.get("/api/haushalt").json()["lists"])
+1
View File
@@ -113,6 +113,7 @@ function Menu() {
Angemeldet als <strong>{user.displayName}</strong> Angemeldet als <strong>{user.displayName}</strong>
</div> </div>
<NavLink to="/konto">Mein Konto</NavLink> <NavLink to="/konto">Mein Konto</NavLink>
<NavLink to="/freunde">Freunde</NavLink>
{user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>} {user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>}
<button type="button" className="menu-item" onClick={logout}>Abmelden</button> <button type="button" className="menu-item" onClick={logout}>Abmelden</button>
</nav> </nav>
+23 -39
View File
@@ -1,27 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { personName } from '../../api.js'; import { personName } from '../../api.js';
import { Link } from 'react-router-dom';
import { useAuth } from '../../auth.jsx'; import { useAuth } from '../../auth.jsx';
// Liste anlegen oder bearbeiten: Name und wer sie sehen kann; löschen nur beim Bearbeiten // Liste anlegen oder bearbeiten: Name und wer sie sehen kann; löschen nur beim Bearbeiten
export default function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) { // shareable: Freunde (Admins: alle); users: alle Namen, damit auch schon geteilte Nicht-Freunde angezeigt werden
export default function ListDialog({ ref, list, users, shareable = [], onSave, onDelete, onClosed }) {
const { user } = useAuth(); const { user } = useAuth();
const ownerId = list ? list.createdBy?.id : user?.id; const ownerId = list ? list.createdBy?.id : user?.id;
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
name: list?.name || '', name: list?.name || '',
everyone: list ? list.everyone : true,
members: list?.members || [], members: list?.members || [],
})); }));
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl // Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
const choosable = users.filter((u) => u.id !== ownerId); const choosable = users.filter((u) => u.id !== ownerId && (shareable.some((f) => f.id === u.id) || (list?.members || []).includes(u.id)));
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] })); const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
async function submit(e) { async function submit(e) {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
setError(null); setError(null);
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members }); const err = await onSave({ name: form.name, members: form.members });
setBusy(false); setBusy(false);
if (err) setError(err); if (err) setError(err);
else ref.current.close(); else ref.current.close();
@@ -44,41 +45,24 @@ export default function ListDialog({ ref, list, users, onSave, onDelete, onClose
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" /> <input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
</label> </label>
<fieldset className="link-picker"> <fieldset className="link-picker">
<legend>Wer kann die Liste sehen?</legend> <legend>Teilen mit</legend>
<ul> <p className="hint">
<li> {list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer.
<label className="checkbox"> Teilen kannst du mit deinen Freunden.
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} /> </p>
Alle Konten {choosable.length === 0 ? (
</label> <p className="hint">Noch keine Freunde – unter <Link to="/freunde">Freunde</Link> hinzufügen.</p>
</li> ) : (
<li> <ul>
<label className="checkbox"> {choosable.map((u) => (
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} /> <li key={u.id}>
Nur ausgewählte Konten <label className="checkbox">
</label> <input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
</li> {u.name}
</ul> </label>
{!form.everyone && ( </li>
<> ))}
<p className="hint"> </ul>
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
</p>
{choosable.length === 0 ? (
<p className="hint">Es gibt keine weiteren Konten.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</>
)} )}
</fieldset> </fieldset>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
@@ -1,7 +1,6 @@
// Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht // Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht
function visibleFor(list, users) { function visibleFor(list, users) {
if (list.everyone) return 'alle Konten';
const ids = new Set([list.createdBy?.id, ...list.members]); const ids = new Set([list.createdBy?.id, ...list.members]);
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name); const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
return names.length ? names.join(', ') : 'niemanden'; return names.length ? names.join(', ') : 'niemanden';
@@ -21,7 +20,7 @@ export default function ListSwitcher({ lists, current, users, onChoose, onNew, o
onClick={() => onChoose(l.id)} onClick={() => onChoose(l.id)}
title={`Sichtbar für ${visibleFor(l, users)}`} title={`Sichtbar für ${visibleFor(l, users)}`}
> >
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>} {l.members.length === 0 && <span aria-label="nur für dich">🔒 </span>}
{l.name} {l.name}
</button> </button>
</li> </li>
@@ -1,15 +1,19 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
/** /**
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte". * Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung, Mitglieder (nur Freunde) und verknüpfte Artikel.
* initial: { name, description, links: [articleId] } oder null (Dialog geschlossen). * 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 // Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt aus "initial" vorbelegen
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
name: initial?.name || '', name: initial?.name || '',
description: initial?.description || '', description: initial?.description || '',
links: initial?.links || [], links: initial?.links || [],
members: initial?.members || [],
})); }));
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [error, setError] = useState(null); 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 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 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'); const q = search.trim().toLocaleLowerCase('de-DE');
// Verknüpfte zuerst, dann nach Suche gefiltert // Verknüpfte zuerst, dann nach Suche gefiltert
const shown = [...articles] const shown = [...articles]
.filter((a) => a.editable || (initial?.links || []).includes(a.id))
.filter((a) => !q || a.title.toLocaleLowerCase('de-DE').includes(q)) .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))); .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, name: form.name,
description: form.description, description: form.description,
links: form.links, links: form.links,
...(canManage ? { members: form.members } : {}),
}); });
setBusy(false); setBusy(false);
if (err) setError(err); if (err) setError(err);
@@ -49,10 +58,29 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
Beschreibung <small>(optional)</small> Beschreibung <small>(optional)</small>
<textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" /> <textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" />
</label> </label>
<fieldset className="link-picker">
<legend>Mitarbeit mit <small>({form.members.length} gewählt)</small></legend>
{!canManage ? (
<p className="hint">Mitglieder verwalten kann, wer das Projekt angelegt hat.</p>
) : choosable.length === 0 ? (
<p className="hint">Noch keine Freunde – unter <Link to="/freunde">Freunde</Link> hinzufügen. Ohne Mitglieder ist das Projekt nur für dich.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggleMember(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</fieldset>
<fieldset className="link-picker"> <fieldset className="link-picker">
<legend>Verknüpfte Artikel aus „P42 Projekte“ <small>({form.links.length} gewählt)</small></legend> <legend>Verknüpfte Artikel aus „P42 Projekte“ <small>({form.links.length} gewählt)</small></legend>
{articles.length === 0 ? ( {articles.length === 0 ? (
<p className="hint">Noch keine Artikel vorhanden.</p> <p className="hint">Keine Artikel, die du bearbeiten darfst.</p>
) : ( ) : (
<> <>
{articles.length > 6 && ( {articles.length > 6 && (
+13 -1
View File
@@ -32,7 +32,8 @@ function mainPart(feature) {
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset(). * Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt. * offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
*/ */
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, selected, onSelect }) { // routes: Teilstrecken ({ a: [lon, lat], b: [lon, lat], kind }) – als Bogen der kürzesten Strecke auf der Erdkugel
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, routes = [], selected, onSelect }) {
const box = useRef(null); const box = useRef(null);
const svgRef = useRef(null); const svgRef = useRef(null);
const layer = useRef(null); const layer = useRef(null);
@@ -133,6 +134,17 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
<title>{c.name}</title> <title>{c.name}</title>
</path> </path>
))} ))}
{routes.length > 0 && (
<g className="map-routes" aria-hidden="true">
{routes.map((r, i) => (
<path
key={i}
className={`map-route ${r.kind === 'Umzug' ? 'move' : r.kind.startsWith('Ausflug') ? 'trip' : ''}`}
d={path({ type: 'LineString', coordinates: [r.a, r.b] })}
/>
))}
</g>
)}
{home && (() => { {home && (() => {
const p = projection([home.lon, home.lat]); const p = projection([home.lon, home.lat]);
return p && ( return p && (
+1 -1
View File
@@ -50,7 +50,7 @@ export function travelRoute(visits, cities, homes = []) {
const legs = []; const legs = [];
const leg = (from, to, kind) => { const leg = (from, to, kind) => {
if (!from || !to || samePlace(from, to)) return; if (!from || !to || samePlace(from, to)) return;
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind }); legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind, a: [from.lon, from.lat], b: [to.lon, to.lat] });
}; };
let home = homes[0] || null; // aktueller Wohnort let home = homes[0] || null; // aktueller Wohnort
+2
View File
@@ -29,6 +29,7 @@ import Monitoring from './pages/Monitoring.jsx';
import NotFound from './pages/NotFound.jsx'; import NotFound from './pages/NotFound.jsx';
import Projects from './pages/Projects.jsx'; import Projects from './pages/Projects.jsx';
import Users from './pages/Users.jsx'; import Users from './pages/Users.jsx';
import Freunde from './pages/Freunde.jsx';
import './theme.js'; import './theme.js';
import './styles/index.css'; import './styles/index.css';
@@ -54,6 +55,7 @@ const router = createBrowserRouter([
{ path: 'monitoring', element: <Monitoring /> }, { path: 'monitoring', element: <Monitoring /> },
{ path: 'konto', element: <Account /> }, { path: 'konto', element: <Account /> },
{ path: 'benutzer', element: <Users /> }, { path: 'benutzer', element: <Users /> },
{ path: 'freunde', element: <Freunde /> },
{ path: '*', element: <NotFound /> }, { path: '*', element: <NotFound /> },
], ],
}, },
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import useLiveData from '../useLiveData.js';
// Freunde: Grundlage fürs Teilen von Listen und Kanban-Projekten und für fremde Punkte auf der Reise-Karte
export default function Freunde() {
const { data, error, busy, run } = useLiveData('/api/freunde', { refresh: 30 });
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const timer = useRef(null);
useEffect(() => {
clearTimeout(timer.current);
if (q.trim().length < 2) { setResults([]); return undefined; }
timer.current = setTimeout(() => {
api('GET', `/api/freunde/suche?q=${encodeURIComponent(q.trim())}`).then(setResults).catch(() => setResults([]));
}, 250);
return () => clearTimeout(timer.current);
}, [q, data]);
const act = (method, person) => run(method, `/api/freunde/${person.id}`);
const label = { freund: '✓ Freund', angefragt: 'Angefragt', eingeladen: 'Hat dich angefragt' };
return (
<>
<div className="page-head">
<div>
<h1>Freunde</h1>
<p className="lead">Mit Freunden teilst du Vorratslisten und Kanban-Projekte und siehst ihre Reise-Punkte. Eine Freundschaft gilt erst, wenn beide zugestimmt haben.</p>
</div>
</div>
{error && <p className="error" role="alert">{error}</p>}
<section className="card friends-search" aria-labelledby="freunde-suche">
<header className="household-head"><h2 id="freunde-suche">Freund hinzufügen</h2></header>
<label className="sr-only" htmlFor="friend-q">Name oder Benutzername</label>
<input id="friend-q" className="friends-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder Benutzername …" autoComplete="off" />
{results.length > 0 && (
<ul className="friends-list">
{results.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
{p.status === 'freund' || p.status === 'angefragt'
? <span className="hint">{label[p.status]}</span>
: <button className="btn small" type="button" disabled={busy} onClick={() => act('POST', p)}>{p.status === 'eingeladen' ? 'Annehmen' : 'Anfragen'}</button>}
</li>
))}
</ul>
)}
{q.trim().length >= 2 && results.length === 0 && <p className="hint">Kein Konto gefunden.</p>}
</section>
{data?.incoming.length > 0 && (
<section className="friends-section" aria-labelledby="freunde-anfragen">
<h2 className="section-title" id="freunde-anfragen">Anfragen an dich</h2>
<ul className="friends-list">
{data.incoming.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<span className="friends-actions">
<button className="btn small" type="button" disabled={busy} onClick={() => act('POST', p)}>Annehmen</button>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => act('DELETE', p)}>Ablehnen</button>
</span>
</li>
))}
</ul>
</section>
)}
<section className="friends-section" aria-labelledby="freunde-liste">
<h2 className="section-title" id="freunde-liste">Deine Freunde</h2>
{data && !data.friends.length && <p className="hint">Noch keine Freunde. Oben suchen und anfragen.</p>}
<ul className="friends-list">
{data?.friends.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<button className="link-btn danger" type="button" disabled={busy}
onClick={() => window.confirm(`Freundschaft mit ${p.name} beenden? Geteilte Listen und Projekte bleiben geteilt, bis du sie dort entfernst.`) && act('DELETE', p)}>
Entfernen
</button>
</li>
))}
</ul>
</section>
{data?.outgoing.length > 0 && (
<section className="friends-section" aria-labelledby="freunde-offen">
<h2 className="section-title" id="freunde-offen">Deine offenen Anfragen</h2>
<ul className="friends-list">
{data.outgoing.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<button className="link-btn" type="button" disabled={busy} onClick={() => act('DELETE', p)}>Zurückziehen</button>
</li>
))}
</ul>
</section>
)}
</>
);
}
+1
View File
@@ -246,6 +246,7 @@ export default function Haushalt() {
ref={listDialogRef} ref={listDialogRef}
list={listDialog.list} list={listDialog.list}
users={data?.users || []} users={data?.users || []}
shareable={data?.shareable || []}
onSave={saveList} onSave={saveList}
onDelete={deleteList} onDelete={deleteList}
onClosed={() => setListDialog(null)} onClosed={() => setListDialog(null)}
+3
View File
@@ -129,6 +129,9 @@ export default function Kanban() {
initial={projectForm} initial={projectForm}
isNew isNew
articles={data?.articles || []} articles={data?.articles || []}
users={data?.users || []}
shareable={data?.shareable || []}
ownerId={user.id}
onSave={createProject} onSave={createProject}
/> />
</> </>
+11 -4
View File
@@ -51,7 +51,10 @@ export default function KanbanBoard() {
const project = data?.project; const project = data?.project;
function editProject() { function editProject() {
setProjectForm({ name: project.name, description: project.description, links: project.links.map((l) => l.id), opened: Date.now() }); setProjectForm({
name: project.name, description: project.description, links: project.links.map((l) => l.id),
members: project.members, opened: Date.now(),
});
} }
useEffect(() => { useEffect(() => {
if (projectForm) projectDialog.current?.showModal(); if (projectForm) projectDialog.current?.showModal();
@@ -66,7 +69,7 @@ export default function KanbanBoard() {
} }
} }
const mayDelete = user.isAdmin || project?.createdBy?.id === user.id; const mayDelete = project?.canManage;
async function setCompleted(completed) { async function setCompleted(completed) {
await saveProject({ completed }).then((err) => err && setError(err)); await saveProject({ completed }).then((err) => err && setError(err));
@@ -166,7 +169,7 @@ export default function KanbanBoard() {
<option value="alle">Alle</option> <option value="alle">Alle</option>
<option value="meine">Mir zugeteilt</option> <option value="meine">Mir zugeteilt</option>
<option value="niemand">Niemandem zugeteilt</option> <option value="niemand">Niemandem zugeteilt</option>
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)} {data.people.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select> </select>
</label> </label>
<button <button
@@ -265,7 +268,7 @@ export default function KanbanBoard() {
key={`aufgabe-${editing?.opened ?? 'zu'}`} key={`aufgabe-${editing?.opened ?? 'zu'}`}
ref={dialog} ref={dialog}
target={editing} target={editing}
users={users} users={data.people}
tasks={allTasks} tasks={allTasks}
me={user} me={user}
onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)} onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)}
@@ -276,6 +279,10 @@ export default function KanbanBoard() {
ref={projectDialog} ref={projectDialog}
initial={projectForm} initial={projectForm}
articles={data.articles} articles={data.articles}
users={data.users}
shareable={data.shareable}
ownerId={project.createdBy?.id}
canManage={project.canManage}
onSave={saveProject} onSave={saveProject}
/> />
</> </>
+8 -7
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js'; import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js';
import { useAuth } from '../auth.jsx';
import Editor from '../components/projekte/Editor.jsx'; import Editor from '../components/projekte/Editor.jsx';
import Lightbox from '../components/projekte/Lightbox.jsx'; import Lightbox from '../components/projekte/Lightbox.jsx';
import StatusBadge from '../components/projekte/StatusBadge.jsx'; import StatusBadge from '../components/projekte/StatusBadge.jsx';
@@ -10,7 +9,6 @@ export default function Project() {
const { id } = useParams(); const { id } = useParams();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
@@ -30,7 +28,7 @@ export default function Project() {
api('GET', `/api/projects/${encodeURIComponent(id)}`) api('GET', `/api/projects/${encodeURIComponent(id)}`)
.then((p) => { .then((p) => {
setProject(p); setProject(p);
if (params.has('bearbeiten')) startEditing(p); if (params.has('bearbeiten') && p.canEdit) startEditing(p);
}) })
.catch((e) => setLoadError(e.message === 'Nicht gefunden.' ? 'Projekt nicht gefunden.' : e.message)); .catch((e) => setLoadError(e.message === 'Nicht gefunden.' ? 'Projekt nicht gefunden.' : e.message));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -134,8 +132,8 @@ export default function Project() {
if (!project) return <><BackLink /><p className="hint">Lade Projekt …</p></>; if (!project) return <><BackLink /><p className="hint">Lade Projekt …</p></>;
const cover = editing ? draft.cover : project.cover; const cover = editing ? draft.cover : project.cover;
// Löschen dürfen nur der Autor und Administratoren // Lesen dürfen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Admins; löschen Autor und Admins
const canDelete = user.isAdmin || project.createdBy?.id === user.id; const canDelete = project.canDelete;
return ( return (
<div className={`article-page${editing ? ' editing' : ''}`}> <div className={`article-page${editing ? ' editing' : ''}`}>
@@ -228,14 +226,17 @@ export default function Project() {
<dt>Kanban-Boards</dt> <dt>Kanban-Boards</dt>
<dd className="aside-boards"> <dd className="aside-boards">
{project.boards?.map((b) => <Link key={b.id} to={`/kanban/${b.id}`}>▦ {b.name}</Link>)} {project.boards?.map((b) => <Link key={b.id} to={`/kanban/${b.id}`}>▦ {b.name}</Link>)}
<Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link> {project.canEdit && <Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link>}
</dd> </dd>
</div> </div>
</dl> </dl>
{!editing && ( {!editing && project.canEdit && (
<button className="btn full" type="button" onClick={() => startEditing()}>Bearbeiten</button> <button className="btn full" type="button" onClick={() => startEditing()}>Bearbeiten</button>
)} )}
{!project.canEdit && (
<p className="hint">Bearbeiten können der Autor und wer an einem verknüpften Kanban-Projekt mitarbeitet.</p>
)}
{editing && ( {editing && (
<div className="edit-actions"> <div className="edit-actions">
+26 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import CitySearch from '../components/reisen/CitySearch.jsx'; import CitySearch from '../components/reisen/CitySearch.jsx';
import ExportDialog from '../components/reisen/ExportDialog.jsx'; import ExportDialog from '../components/reisen/ExportDialog.jsx';
@@ -7,7 +8,7 @@ import InfoCard from '../components/reisen/InfoCard.jsx';
import { BY_NUM, countryName } from '../components/reisen/laender.js'; import { BY_NUM, countryName } from '../components/reisen/laender.js';
import Timeline from '../components/reisen/Timeline.jsx'; import Timeline from '../components/reisen/Timeline.jsx';
import TravelStats from '../components/reisen/TravelStats.jsx'; import TravelStats from '../components/reisen/TravelStats.jsx';
import { homeAt } from '../components/reisen/route.js'; import { homeAt, travelRoute } from '../components/reisen/route.js';
import VisitDialog from '../components/reisen/VisitDialog.jsx'; import VisitDialog from '../components/reisen/VisitDialog.jsx';
import VisitedList from '../components/reisen/VisitedList.jsx'; import VisitedList from '../components/reisen/VisitedList.jsx';
import WorldMap from '../components/reisen/WorldMap.jsx'; import WorldMap from '../components/reisen/WorldMap.jsx';
@@ -24,6 +25,7 @@ const SPECIAL = {
}; };
const TAB_KEY = 'p42.reisenAnsicht'; const TAB_KEY = 'p42.reisenAnsicht';
const ROUTES_KEY = 'p42.reisenRouten';
const OTHERS_KEY = 'p42.reisenAndere'; const OTHERS_KEY = 'p42.reisenAndere';
const HIDDEN_KEY = 'p42.reisenAndereAus'; const HIDDEN_KEY = 'p42.reisenAndereAus';
const OTHER_COLORS = 6; const OTHER_COLORS = 6;
@@ -42,6 +44,7 @@ export default function Reisen() {
const [visitDialog, setVisitDialog] = useState(null); // { city, visit, opened } const [visitDialog, setVisitDialog] = useState(null); // { city, visit, opened }
const [tab, setTab] = useState(() => { try { return localStorage.getItem(TAB_KEY) || 'orte'; } catch { return 'orte'; } }); const [tab, setTab] = useState(() => { try { return localStorage.getItem(TAB_KEY) || 'orte'; } catch { return 'orte'; } });
const [zoom, setZoom] = useState(null); const [zoom, setZoom] = useState(null);
const [showRoutes, setShowRoutes] = useState(() => loadFlag(ROUTES_KEY));
useEffect(() => { useEffect(() => {
fetch('/maps/countries-50m.json') fetch('/maps/countries-50m.json')
@@ -88,6 +91,17 @@ export default function Reisen() {
); );
const allCities = useMemo(() => [...own, ...otherCities], [own, otherCities]); const allCities = useMemo(() => [...own, ...otherCities], [own, otherCities]);
// Reiserouten aus derselben Rechnung wie die Kilometer-Statistik
const routes = useMemo(
() => (showRoutes && data ? travelRoute(data.visits || [], own, data.homes || []).legs : []),
[showRoutes, data, own],
);
function toggleRoutes() {
setShowRoutes(!showRoutes);
saveFlag(ROUTES_KEY, !showRoutes);
}
function toggleOthers() { function toggleOthers() {
const next = !showOthers; const next = !showOthers;
setShowOthers(next); setShowOthers(next);
@@ -167,11 +181,18 @@ export default function Reisen() {
{data && ( {data && (
<div className="travel-others" role="group" aria-label="Städte anderer Konten"> <div className="travel-others" role="group" aria-label="Städte anderer Konten">
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}> <button type="button" className={`chip${showRoutes ? ' active' : ''}`} aria-pressed={showRoutes} onClick={toggleRoutes}
{showOthers ? '✓ ' : ''}Städte anderer zeigen title="Strecken aus der Kilometer-Statistik: Reisen, Ausflüge (gepunktet), Umzüge (gestrichelt)">
{showRoutes ? '✓ ' : ''}Reiserouten zeigen
</button> </button>
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
{showOthers ? '✓ ' : ''}Städte von Freunden zeigen
</button>
{showRoutes && data && !(data.visits || []).length && (
<span className="hint travel-legend">Für Routen Besuche mit Datum eintragen.</span>
)}
{showOthers && others.length === 0 && ( {showOthers && others.length === 0 && (
<span className="hint travel-legend">Noch kein anderes Konto hat Städte eingetragen.</span> <span className="hint travel-legend">Noch keine Freunde mit Städten – unter <Link to="/freunde">Freunde</Link> hinzufügen.</span>
)} )}
{showOthers && others.length > 0 && ( {showOthers && others.length > 0 && (
<> <>
@@ -207,6 +228,7 @@ export default function Reisen() {
cities={own} cities={own}
otherCities={otherCities} otherCities={otherCities}
home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))} home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))}
routes={routes}
selected={selected} selected={selected}
onSelect={(sel) => select(sel)} onSelect={(sel) => select(sel)}
/> />
+9
View File
@@ -0,0 +1,9 @@
/* ---------- Freunde ---------- */
.friends-search { display: grid; gap: 12px; max-width: 760px; }
.friends-input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--line-strong); border-radius: var(--radius-s); min-height: 46px; padding: 0 12px; }
.friends-input:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
.friends-section { margin-top: 28px; max-width: 760px; }
.friends-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 2px; }
.friends-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
.friends-list small { color: var(--muted); }
.friends-actions { display: inline-flex; align-items: center; gap: 14px; }
+1
View File
@@ -10,6 +10,7 @@
@import './reisen.css'; @import './reisen.css';
@import './kanban.css'; @import './kanban.css';
@import './konto.css'; @import './konto.css';
@import './freunde.css';
@import './responsive.css'; @import './responsive.css';
@import './grundschicht.css'; @import './grundschicht.css';
@import './designs/gemeinsam.css'; @import './designs/gemeinsam.css';
+5
View File
@@ -257,3 +257,8 @@
.stat-home-since { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.85rem; } .stat-home-since { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.85rem; }
.stat-home-since input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--border); border-radius: var(--radius-s); min-height: 50px; padding: 0 10px; } .stat-home-since input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--border); border-radius: var(--radius-s); min-height: 50px; padding: 0 10px; }
.visit-lived { margin: 0; } .visit-lived { margin: 0; }
/* Reiserouten */
.map-route { fill: none; stroke: var(--bright); stroke-width: 1.6; vector-effect: non-scaling-stroke; opacity: 0.75; stroke-linecap: round; }
.map-route.move { stroke-dasharray: 6 4; }
.map-route.trip { stroke-dasharray: 1 4; }