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:
1 parent
1d7497bead
commit
0fb3521c2f
34 files changed
+833
-245
No files matched your search
@@ -81,14 +81,18 @@ Die ganze Seite ist nur nach Anmeldung erreichbar.
|
||||
- Weitere Konten legt ein Administrator unter **Menü → Benutzer verwalten** an
|
||||
(dort auch: Passwort zurücksetzen, Admin-Rechte vergeben, Konten löschen).
|
||||
- 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**
|
||||
(die Städte der anderen lassen sich auf der Karte einblenden).
|
||||
- Gemeinsam für alle Konten: **Kanban**.
|
||||
- **Vorrat & Einkauf** in Listen (z. B. Zuhause, Urlaub): wer eine Liste anlegt, bestimmt, ob alle
|
||||
Konten sie sehen oder nur ausgewählte.
|
||||
- **Artikel** sind gemeinsam: alle Konten können sie lesen und bearbeiten; angezeigt wird,
|
||||
wer ihn geschrieben und wer ihn zuletzt geändert hat. Löschen dürfen nur der Autor und
|
||||
Administratoren.
|
||||
(Städte von Freunden lassen sich auf der Karte einblenden).
|
||||
- **Vorratslisten** und **Kanban-Projekte** sind privat: Sehen und bearbeiten dürfen sie der Ersteller und
|
||||
die Freunde, mit denen er sie teilt (Mitglieder). Mitglieder verwalten und löschen: Ersteller.
|
||||
- **Artikel** („P42 Projekte“) kann jeder lesen. Bearbeiten dürfen der Autor und alle, die an einem
|
||||
verknüpften Kanban-Projekt mitarbeiten; verknüpfen lassen sich nur Artikel, die man selbst bearbeiten
|
||||
darf. Löschen dürfen der Autor und 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/`:
|
||||
|
||||
@@ -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
|
||||
und einen eigenen Einkaufszettel.
|
||||
|
||||
- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an. Dabei festlegen, wer sie
|
||||
sieht: **alle Konten** oder **nur ausgewählte**. Der Ersteller sieht seine Liste immer.
|
||||
Name, Sichtbarkeit und Löschen („✎ Liste bearbeiten“) nur für den Ersteller und Administratoren –
|
||||
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).
|
||||
- **Neue Liste:** „+ Neue Liste“ legt Vorrat und Einkauf zusammen neu an und ist privat. Unter
|
||||
„Teilen mit“ Freunde auswählen. Name, Mitglieder und Löschen („✎ Liste bearbeiten“) nur für den
|
||||
Ersteller und Administratoren. Löschen entfernt Vorrat und Einkauf der Liste.
|
||||
- **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
|
||||
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
|
||||
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,
|
||||
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
|
||||
Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
|
||||
Der Schalter **Städte von Freunden zeigen** (über der Karte) blendet die Städte deiner Freunde ein – je
|
||||
Person in einer eigenen Farbe, einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden
|
||||
(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
|
||||
„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
|
||||
|
||||
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
|
||||
haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop oder im Dialog.
|
||||
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Projekte sind privat:
|
||||
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
|
||||
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/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei)
|
||||
app/auth.py Konten, Anmeldung, Benutzerverwaltung
|
||||
app/freunde.py Freunde (Anfragen), Grundlage fürs Teilen
|
||||
app/projects.py Projekte/Artikel, Bild-Uploads
|
||||
app/htmlclean.py Artikel-HTML bereinigen (nur eigene Bilder)
|
||||
app/bilder.py Metadaten (GPS usw.) aus hochgeladenen Bildern entfernen
|
||||
|
||||
@@ -169,6 +169,9 @@ def setup(request: Request, response: Response, data: dict = Body(...)):
|
||||
# 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 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
|
||||
legacy = get_meta(conn, "legacy_links")
|
||||
links = json.loads(legacy) if legacy else CONFIG_LINKS
|
||||
|
||||
@@ -73,6 +73,18 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
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 (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
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
|
||||
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"
|
||||
CREATE TABLE IF NOT EXISTS kanban_project_links (
|
||||
project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -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
@@ -1,8 +1,8 @@
|
||||
"""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.
|
||||
Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte. Ändern
|
||||
(Name, Sichtbarkeit) und löschen dürfen der Ersteller und Administratoren. Jedes Produkt steht pro Liste und Einheit
|
||||
Listen sind privat: Sehen dürfen sie der Ersteller und die Freunde, mit denen er sie teilt (Mitglieder).
|
||||
Ä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
|
||||
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 .common import ValidationError, clean_text, now_iso, user_list
|
||||
from .db import transaction
|
||||
from .freunde import check_shareable, shareable
|
||||
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
|
||||
|
||||
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 ----------
|
||||
|
||||
# Sichtbar: für alle freigegeben, selbst angelegt oder als Mitglied eingetragen.
|
||||
# Listen eines gelöschten Kontos ohne Mitglieder sehen Administratoren, damit sie nicht verwaist bleiben.
|
||||
_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 (:admin AND l.created_by IS NULL))"""
|
||||
# Sichtbar: selbst angelegt oder als Mitglied eingetragen; Administratoren sehen alle Listen.
|
||||
_VISIBLE = """(:admin 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))"""
|
||||
|
||||
|
||||
def _who(user: dict) -> dict:
|
||||
@@ -185,7 +184,6 @@ def _shopping_list(conn, row, user: dict) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"everyone": bool(row["everyone"]),
|
||||
"members": members,
|
||||
"createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
|
||||
"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]
|
||||
|
||||
|
||||
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
|
||||
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
|
||||
def _list_fields(conn, data: dict, user: dict, owner_id, current=()) -> tuple:
|
||||
"""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)
|
||||
everyone = data.get("everyone", True)
|
||||
if not isinstance(everyone, bool):
|
||||
raise ValidationError("Ungültige Sichtbarkeit.")
|
||||
members = data.get("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.")
|
||||
@@ -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))}
|
||||
if known != members:
|
||||
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:
|
||||
@@ -253,7 +250,8 @@ def _state(conn, user: dict) -> dict:
|
||||
return {
|
||||
"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],
|
||||
"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],
|
||||
"productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products},
|
||||
"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)
|
||||
def create_list(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
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:
|
||||
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]
|
||||
list_id = conn.execute(
|
||||
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, int(everyone), position, now_iso(), user["id"]),
|
||||
"INSERT INTO shopping_lists (name, everyone, position, created_at, created_by) VALUES (?, 0, ?, ?, ?)",
|
||||
(name, position, now_iso(), user["id"]),
|
||||
).lastrowid
|
||||
_set_members(conn, list_id, members)
|
||||
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)
|
||||
if not _can_manage(row, user):
|
||||
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"])
|
||||
conn.execute("UPDATE shopping_lists SET name = ?, everyone = ? WHERE id = ?", (name, int(everyone), list_id))
|
||||
current = [r["user_id"] for r in conn.execute("SELECT user_id FROM shopping_list_members WHERE list_id = ?", (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)
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
+133
-54
@@ -1,6 +1,8 @@
|
||||
"""Kanban: Projekte (je ein Board) → Aufgaben mit Status, Bearbeiter und Beschreibung.
|
||||
|
||||
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
|
||||
Projekte sind privat: Sehen und bearbeiten dürfen sie der Ersteller und die Mitglieder (Freunde, mit denen er das
|
||||
Projekt umsetzt); Administratoren sehen alles. Mitglieder verwalten und das Projekt löschen dürfen der Ersteller
|
||||
und Administratoren, Aufgaben löschen deren Ersteller und Administratoren.
|
||||
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
|
||||
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
|
||||
Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gelöscht (gezählt ab "erledigt").
|
||||
@@ -15,6 +17,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from .auth import current_user
|
||||
from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list
|
||||
from .db import transaction
|
||||
from .freunde import check_shareable, shareable
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,6 +62,64 @@ def _person(user_id, name) -> dict | None:
|
||||
return {"id": user_id, "name": name} if user_id is not None and name else None
|
||||
|
||||
|
||||
# Sichtbar: Ersteller, Mitglieder, Administratoren
|
||||
_VISIBLE = """(:admin OR k.created_by = :uid
|
||||
OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))"""
|
||||
|
||||
|
||||
def _who(user: dict) -> dict:
|
||||
return {"uid": user["id"], "admin": int(user["isAdmin"])}
|
||||
|
||||
|
||||
def _get_project(conn, project_id: int, user: dict):
|
||||
row = conn.execute(f"SELECT k.* FROM kanban_projects k WHERE k.id = :id AND {_VISIBLE}", {"id": project_id, **_who(user)}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gelöscht oder nicht mit dir geteilt.")
|
||||
return row
|
||||
|
||||
|
||||
def _member_ids(conn, project_id: int) -> list:
|
||||
return [r["user_id"] for r in conn.execute("SELECT user_id FROM kanban_project_members WHERE project_id = ?", (project_id,))]
|
||||
|
||||
|
||||
def _people(conn, row) -> list:
|
||||
"""Wer am Projekt arbeitet (Ersteller + Mitglieder) – wählbar als Bearbeiter."""
|
||||
ids = {row["created_by"], *_member_ids(conn, row["id"])} - {None}
|
||||
return [u for u in user_list(conn) if u["id"] in ids]
|
||||
|
||||
|
||||
def _members(conn, value, user: dict, owner_id, current=()) -> list:
|
||||
if not isinstance(value, list) or any(isinstance(m, bool) or not isinstance(m, int) for m in value):
|
||||
raise ValidationError("Ungültige Mitglieder.")
|
||||
ids = sorted(set(value) - {owner_id})
|
||||
check_shareable(conn, user, ids, keep=current)
|
||||
return ids
|
||||
|
||||
|
||||
def _save_members(conn, project_id: int, ids: list) -> None:
|
||||
conn.execute("DELETE FROM kanban_project_members WHERE project_id = ?", (project_id,))
|
||||
conn.executemany("INSERT INTO kanban_project_members (project_id, user_id) VALUES (?, ?)", [(project_id, i) for i in ids])
|
||||
# Wer nicht mehr dabei ist, kann auch nicht mehr Bearbeiter sein
|
||||
people = {r["created_by"] for r in conn.execute("SELECT created_by FROM kanban_projects WHERE id = ?", (project_id,))} | set(ids)
|
||||
conn.execute(
|
||||
f"UPDATE kanban_tasks SET assignee = NULL WHERE project_id = ? AND assignee NOT IN ({','.join('?' * len(people))})",
|
||||
(project_id, *people),
|
||||
)
|
||||
|
||||
|
||||
def can_edit_article(conn, user: dict, article_id: str) -> bool:
|
||||
"""Artikel bearbeiten: Autor, wer an einem verknüpften Kanban-Projekt arbeitet, Administratoren."""
|
||||
if user["isAdmin"]:
|
||||
return True
|
||||
return conn.execute(
|
||||
"""SELECT 1 FROM projects p WHERE p.id = :a AND (p.created_by = :uid OR EXISTS (
|
||||
SELECT 1 FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = p.id AND (k.created_by = :uid
|
||||
OR EXISTS (SELECT 1 FROM kanban_project_members m WHERE m.project_id = k.id AND m.user_id = :uid))))""",
|
||||
{"a": article_id, "uid": user["id"]},
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def _may_delete(user: dict, created_by) -> bool:
|
||||
return user["isAdmin"] or created_by == user["id"]
|
||||
|
||||
@@ -97,13 +158,12 @@ def _status(value) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _assignee(conn, value):
|
||||
def _assignee(conn, value, project):
|
||||
"""Bearbeiter muss am Projekt arbeiten (Ersteller oder Mitglied)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not conn.execute(
|
||||
"SELECT 1 FROM users WHERE id = ?", (value,)
|
||||
).fetchone():
|
||||
raise ValidationError("Unbekannter Bearbeiter.")
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value not in {u["id"] for u in _people(conn, project)}:
|
||||
raise ValidationError("Bearbeiter muss an diesem Projekt mitarbeiten.")
|
||||
return value
|
||||
|
||||
|
||||
@@ -113,7 +173,9 @@ def _position(value) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _links(conn, value) -> list:
|
||||
def _links(conn, value, user: dict, current=()) -> list:
|
||||
"""Verknüpfte Artikel: nur solche, die man selbst bearbeiten darf (sonst verschaffte die Verknüpfung
|
||||
Bearbeitungsrechte); schon verknüpfte dürfen bleiben."""
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or len(value) > MAX_LINKS or not all(isinstance(v, str) for v in value):
|
||||
@@ -124,6 +186,8 @@ def _links(conn, value) -> list:
|
||||
)} if ids else set()
|
||||
if len(found) != len(ids):
|
||||
raise ValidationError("Ein verknüpfter Artikel existiert nicht mehr.")
|
||||
if any(a not in current and not can_edit_article(conn, user, a) for a in ids):
|
||||
raise ValidationError("Verknüpfen lassen sich nur Artikel, die du bearbeiten darfst.")
|
||||
return ids
|
||||
|
||||
|
||||
@@ -146,15 +210,14 @@ def _project_links(conn) -> dict:
|
||||
return links
|
||||
|
||||
|
||||
def _projects(conn, project_id=None) -> list:
|
||||
def _projects(conn, user: dict, project_id=None) -> list:
|
||||
rows = conn.execute(
|
||||
"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_projects k
|
||||
LEFT JOIN users cu ON cu.id = k.created_by
|
||||
LEFT JOIN users uu ON uu.id = k.updated_by"""
|
||||
+ (" WHERE k.id = ?" if project_id is not None else "")
|
||||
+ " ORDER BY k.position",
|
||||
(project_id,) if project_id is not None else (),
|
||||
f"""SELECT k.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||
FROM kanban_projects k
|
||||
LEFT JOIN users cu ON cu.id = k.created_by
|
||||
LEFT JOIN users uu ON uu.id = k.updated_by
|
||||
WHERE {_VISIBLE}""" + (" AND k.id = :id" if project_id is not None else "") + " ORDER BY k.position",
|
||||
{"id": project_id, **_who(user)},
|
||||
).fetchall()
|
||||
counts = {}
|
||||
for r in conn.execute("SELECT project_id, status, COUNT(*) AS n FROM kanban_tasks GROUP BY project_id, status"):
|
||||
@@ -173,18 +236,28 @@ def _projects(conn, project_id=None) -> list:
|
||||
"createdBy": _person(r["created_by"], r["created_by_name"]),
|
||||
"updatedAt": r["updated_at"],
|
||||
"updatedBy": _person(r["updated_by"], r["updated_by_name"]),
|
||||
"members": _member_ids(conn, r["id"]),
|
||||
"canManage": _may_delete(user, r["created_by"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _overview(conn) -> dict:
|
||||
def _articles(conn, user: dict) -> list:
|
||||
"""Artikel zum Verknüpfen; editable = darf man selbst verknüpfen."""
|
||||
return [
|
||||
{"id": a["id"], "title": a["title"], "editable": can_edit_article(conn, user, a["id"])}
|
||||
for a in conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE")
|
||||
]
|
||||
|
||||
|
||||
def _overview(conn, user: dict) -> dict:
|
||||
cleanup(conn)
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
return {
|
||||
"projects": _projects(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
"projects": _projects(conn, user),
|
||||
"articles": _articles(conn, user),
|
||||
"users": user_list(conn),
|
||||
"shareable": shareable(conn, user),
|
||||
}
|
||||
|
||||
|
||||
@@ -298,8 +371,8 @@ def _ensure_unlocked(conn, task_id: int, status: str) -> None:
|
||||
raise ValidationError("Gesperrt – erst auf „Wartet“ oder „Erledigt“ setzen: " + ", ".join(f"„{t}“" for t in missing))
|
||||
|
||||
|
||||
def _board(conn, project_id: int) -> dict:
|
||||
projects = _projects(conn, project_id)
|
||||
def _board(conn, project_id: int, user: dict) -> dict:
|
||||
projects = _projects(conn, user, project_id)
|
||||
if not projects:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
|
||||
project = projects[0]
|
||||
@@ -312,7 +385,6 @@ def _board(conn, project_id: int) -> dict:
|
||||
WHERE t.project_id = ? ORDER BY t.status, t.priority, t.position""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||
archive_before = iso_ago(ARCHIVE_AFTER)
|
||||
archived = sorted(
|
||||
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
|
||||
@@ -328,8 +400,10 @@ def _board(conn, project_id: int) -> dict:
|
||||
"archived": [
|
||||
{**_task(r), **extras.get(r["id"], {}), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived
|
||||
],
|
||||
"users": user_list(conn),
|
||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||
"users": user_list(conn), # Namen (auch ehemaliger Mitglieder) für die Anzeige
|
||||
"people": _people(conn, conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()),
|
||||
"shareable": shareable(conn, user),
|
||||
"articles": _articles(conn, user),
|
||||
}
|
||||
|
||||
|
||||
@@ -344,9 +418,9 @@ def _save_links(conn, project_id: int, links: list) -> None:
|
||||
|
||||
|
||||
@router.get("/api/kanban")
|
||||
def get_overview(_: dict = Depends(current_user)):
|
||||
def get_overview(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _overview(conn)
|
||||
return _overview(conn, user)
|
||||
|
||||
|
||||
# ---------- Projekte ----------
|
||||
@@ -357,7 +431,8 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
name = _name(data.get("name"))
|
||||
description = _description(data.get("description"))
|
||||
with transaction() as conn:
|
||||
links = _links(conn, data.get("links"))
|
||||
links = _links(conn, data.get("links"), user)
|
||||
members = _members(conn, data.get("members", []), user, user["id"])
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_projects").fetchone()[0] >= MAX_PROJECTS:
|
||||
raise ValidationError(f"Maximal {MAX_PROJECTS} Kanban-Projekte.")
|
||||
now = now_iso()
|
||||
@@ -367,20 +442,20 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
(name, description, _next(conn, "kanban_projects"), now, user["id"], now, user["id"]),
|
||||
).lastrowid
|
||||
_save_links(conn, project_id, links)
|
||||
return {"id": project_id, **_overview(conn)}
|
||||
_save_members(conn, project_id, members)
|
||||
return {"id": project_id, **_overview(conn, user)}
|
||||
|
||||
|
||||
@router.get("/api/kanban/projekte/{project_id}")
|
||||
def get_board(project_id: int, _: dict = Depends(current_user)):
|
||||
def get_board(project_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/projekte/{project_id}")
|
||||
def update_project(project_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
project = _get_project(conn, project_id, user)
|
||||
values = {}
|
||||
if "name" in data:
|
||||
values["name"] = _name(data["name"])
|
||||
@@ -391,32 +466,36 @@ def update_project(project_id: int, data: dict = Body(...), user: dict = Depends
|
||||
raise ValidationError("Ungültiger Wert für abgeschlossen.")
|
||||
values["completed_at"] = now_iso() if data["completed"] else None
|
||||
if "links" in data:
|
||||
_save_links(conn, project_id, _links(conn, data["links"]))
|
||||
current = [r["article_id"] for r in conn.execute("SELECT article_id FROM kanban_project_links WHERE project_id = ?", (project_id,))]
|
||||
_save_links(conn, project_id, _links(conn, data["links"], user, current))
|
||||
if "members" in data:
|
||||
if not _may_delete(user, project["created_by"]):
|
||||
raise HTTPException(403, "Mitglieder verwalten dürfen nur wer das Projekt angelegt hat und Administratoren.")
|
||||
_save_members(conn, project_id, _members(conn, data["members"], user, project["created_by"], _member_ids(conn, project_id)))
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_projects SET {columns} WHERE id = ?", (*values.values(), project_id))
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.delete("/api/kanban/projekte/{project_id}")
|
||||
def delete_project(project_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = conn.execute("SELECT * FROM kanban_projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
row = _get_project(conn, project_id, user)
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer das Projekt angelegt hat oder ein Administrator darf es löschen.")
|
||||
conn.execute("DELETE FROM kanban_projects WHERE id = ?", (project_id,)) # Aufgaben & Links per CASCADE
|
||||
return _overview(conn)
|
||||
return _overview(conn, user)
|
||||
|
||||
|
||||
# ---------- Aufgaben ----------
|
||||
|
||||
|
||||
def _get_task(conn, task_id: int):
|
||||
def _get_task(conn, task_id: int, user: dict):
|
||||
row = conn.execute("SELECT * FROM kanban_tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Aufgabe nicht gefunden – vielleicht wurde sie gerade gelöscht.")
|
||||
_get_project(conn, row["project_id"], user)
|
||||
return row
|
||||
|
||||
|
||||
@@ -432,8 +511,7 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
priority = _priority(data.get("priority", "mittel"))
|
||||
checklist = _checklist(data.get("checklist", []))
|
||||
with transaction() as conn:
|
||||
if not conn.execute("SELECT 1 FROM kanban_projects WHERE id = ?", (project_id,)).fetchone():
|
||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden.")
|
||||
project = _get_project(conn, project_id, user)
|
||||
requires = _requires(conn, data.get("requires", []), project_id)
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE project_id = ?", (project_id,)).fetchone()[0] >= MAX_TASKS:
|
||||
raise ValidationError(f"Maximal {MAX_TASKS} Aufgaben pro Projekt. Bitte erledigte Aufgaben löschen.")
|
||||
@@ -442,28 +520,28 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
|
||||
done_at, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee"), project), priority,
|
||||
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
|
||||
)
|
||||
task_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
_save_checklist(conn, task_id, checklist)
|
||||
_save_requires(conn, task_id, requires)
|
||||
_ensure_unlocked(conn, task_id, status)
|
||||
return _board(conn, project_id)
|
||||
return _board(conn, project_id, user)
|
||||
|
||||
|
||||
@router.patch("/api/kanban/aufgaben/{task_id}")
|
||||
def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
"""Felder einzeln ändern. Neuer Status oder neue Priorität ohne Position: ans Ende der Stufe."""
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
values = {}
|
||||
if "title" in data:
|
||||
values["title"] = _name(data["title"], "Titel", MAX_TITLE)
|
||||
if "description" in data:
|
||||
values["description"] = _description(data["description"])
|
||||
if "assignee" in data:
|
||||
values["assignee"] = _assignee(conn, data["assignee"])
|
||||
values["assignee"] = _assignee(conn, data["assignee"], _get_project(conn, row["project_id"], user))
|
||||
if "status" in data:
|
||||
values["status"] = _status(data["status"])
|
||||
if values["status"] != row["status"]:
|
||||
@@ -490,39 +568,40 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
values.update(updated_at=now_iso(), updated_by=user["id"])
|
||||
columns = ", ".join(f"{k} = ?" for k in values) # Schlüssel stammen nur aus der Liste oben
|
||||
conn.execute(f"UPDATE kanban_tasks SET {columns} WHERE id = ?", (*values.values(), task_id))
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
@router.post("/api/kanban/aufgaben/{task_id}/zurueckholen")
|
||||
def restore_task(task_id: int, user: dict = Depends(current_user)):
|
||||
"""Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu."""
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
|
||||
(now, now, user["id"], task_id),
|
||||
)
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
@router.delete("/api/kanban/aufgaben/{task_id}")
|
||||
def delete_task(task_id: int, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
row = _get_task(conn, task_id)
|
||||
row = _get_task(conn, task_id, user)
|
||||
if not _may_delete(user, row["created_by"]):
|
||||
raise HTTPException(403, "Nur wer die Aufgabe angelegt hat oder ein Administrator darf sie löschen.")
|
||||
conn.execute("DELETE FROM kanban_tasks WHERE id = ?", (task_id,))
|
||||
return _board(conn, row["project_id"])
|
||||
return _board(conn, row["project_id"], user)
|
||||
|
||||
|
||||
# ---------- Für die Artikelseite ----------
|
||||
|
||||
|
||||
def boards_for_article(conn, article_id: str) -> list:
|
||||
def boards_for_article(conn, article_id: str, user: dict) -> list:
|
||||
"""Verknüpfte Boards, die das Konto sehen darf."""
|
||||
rows = conn.execute(
|
||||
"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = ? ORDER BY k.name COLLATE NOCASE""",
|
||||
(article_id,),
|
||||
f"""SELECT k.id, k.name FROM kanban_project_links l JOIN kanban_projects k ON k.id = l.project_id
|
||||
WHERE l.article_id = :a AND {_VISIBLE} ORDER BY k.name COLLATE NOCASE""",
|
||||
{"a": article_id, **_who(user)},
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
+2
-1
@@ -13,7 +13,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
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 .common import ValidationError
|
||||
from .config import FRONTEND_DIST
|
||||
@@ -61,6 +61,7 @@ async def validation_error(_: Request, exc: ValidationError):
|
||||
# ---------- API ----------
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(freunde.router)
|
||||
app.include_router(links.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(monitoring.router)
|
||||
|
||||
@@ -22,8 +22,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def start(conn) -> None:
|
||||
"""Neue, leere Datenbank: erste Liste anlegen und gleich auf den neuesten Stand setzen."""
|
||||
conn.execute("INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),))
|
||||
"""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', 0, 1, ?)", (now_iso(),))
|
||||
conn.execute(f"PRAGMA user_version = {len(STEPS)}")
|
||||
|
||||
|
||||
@@ -270,6 +270,28 @@ def _v5_wohnorte(conn) -> None:
|
||||
_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.
|
||||
STEPS = [
|
||||
_v1_altbestand,
|
||||
@@ -277,4 +299,5 @@ STEPS = [
|
||||
_v3_kategorien,
|
||||
_v4_wunschliste,
|
||||
_v5_wohnorte,
|
||||
_v6_privat,
|
||||
]
|
||||
+15
-7
@@ -12,7 +12,7 @@ from .config import DATA_DIR
|
||||
from .common import ValidationError, clean_text, now_iso
|
||||
from .db import transaction
|
||||
from .htmlclean import sanitize
|
||||
from .kanban import boards_for_article
|
||||
from .kanban import boards_for_article, can_edit_article
|
||||
|
||||
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()
|
||||
if not row:
|
||||
return None
|
||||
# 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():
|
||||
@@ -169,13 +175,13 @@ def create_project(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
now, user["id"], now, user["id"],
|
||||
),
|
||||
)
|
||||
return _get(conn, project_id)
|
||||
return _get(conn, project_id, user)
|
||||
|
||||
|
||||
@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:
|
||||
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}")
|
||||
@@ -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()
|
||||
if not old:
|
||||
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)
|
||||
conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id))
|
||||
_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}")
|
||||
|
||||
+22
-17
@@ -22,6 +22,7 @@ from .auth import current_user
|
||||
from .config import WIKIPEDIA_CONTACT
|
||||
from .common import ValidationError, clean_text, now_iso
|
||||
from .db import transaction
|
||||
from .freunde import friend_ids
|
||||
from .projects import UPLOAD_DIR, UPLOAD_NAME, remove_unused
|
||||
|
||||
router = APIRouter()
|
||||
@@ -51,15 +52,18 @@ def _city(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _others(conn, user_id: int) -> list:
|
||||
"""Städte der anderen Konten (nur zum Anzeigen auf der Karte), gruppiert nach Konto."""
|
||||
def _others(conn, user: dict) -> list:
|
||||
"""Städte von Freunden (nur zum Anzeigen auf der Karte), gruppiert nach Konto; Administratoren sehen alle."""
|
||||
rows = conn.execute(
|
||||
"""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""",
|
||||
(user_id,),
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
allowed = None if user["isAdmin"] else friend_ids(conn, user["id"])
|
||||
people = {}
|
||||
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["cities"].append(_city(r))
|
||||
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()
|
||||
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
|
||||
homes = conn.execute(
|
||||
@@ -103,7 +108,7 @@ def _state(conn, user_id: int) -> dict:
|
||||
for h in homes
|
||||
],
|
||||
"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")
|
||||
def get_travel(user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@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:
|
||||
raise HTTPException(404, "Stadt nicht gefunden.")
|
||||
remove_unused(conn, photos)
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@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():
|
||||
raise ValidationError("Für diese Stadt sind schon Besuche eingetragen.")
|
||||
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) ----------
|
||||
@@ -228,7 +233,7 @@ def add_home(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||
since = _date(data.get("since"), "Seit", False)
|
||||
with transaction() as conn:
|
||||
_add_home(conn, user["id"], city, since)
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@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"]))
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Wohnort nicht gefunden.")
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
@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"])
|
||||
if before and not later and before["geoname_id"] != city["geoname_id"]:
|
||||
_add_home(conn, user["id"], dict(before), after)
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
|
||||
@@ -345,7 +350,7 @@ def add_visit(city_id: int, data: dict = Body(...), user: dict = Depends(current
|
||||
).lastrowid
|
||||
_save_photos(conn, visit_id, photos)
|
||||
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}")
|
||||
@@ -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),
|
||||
)
|
||||
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}")
|
||||
@@ -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,))]
|
||||
conn.execute("DELETE FROM travel_visits WHERE id = ?", (visit_id,))
|
||||
remove_unused(conn, photos)
|
||||
return _state(conn, user["id"])
|
||||
return _state(conn, user)
|
||||
|
||||
|
||||
def _country_code(code: str) -> str:
|
||||
@@ -384,14 +389,14 @@ def mark_country(code: str, user: dict = Depends(current_user)):
|
||||
conn.execute(
|
||||
"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}")
|
||||
def unmark_country(code: str, user: dict = Depends(current_user)):
|
||||
with transaction() as conn:
|
||||
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 ----------
|
||||
|
||||
@@ -48,3 +48,12 @@ def login():
|
||||
client.user = {"id": user_id, "name": username.capitalize()}
|
||||
return client
|
||||
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
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Datenschutz: keine Metadaten in Bildern, private Listen bleiben privat, keine fremden Bilder, keine offene API-Doku."""
|
||||
import io
|
||||
|
||||
from test_haushalt import new_list
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
@@ -68,8 +70,9 @@ def private_liste(anna):
|
||||
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")
|
||||
befriend(anna, ben)
|
||||
lid = private_liste(anna)
|
||||
assert "Schwangerschaftstest" in anna.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"
|
||||
|
||||
|
||||
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")
|
||||
home = anna.get("/api/haushalt").json()["lists"][0]["id"]
|
||||
befriend(anna, ben)
|
||||
home = new_list(anna, "Zuhause", members=[ben.user["id"]])
|
||||
private_liste(anna)
|
||||
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
|
||||
@@ -125,7 +129,7 @@ def test_api_doku_nicht_oeffentlich():
|
||||
|
||||
def test_umstellung_bestehender_daten(conn, login):
|
||||
anna = login("anna")
|
||||
home = anna.get("/api/haushalt").json()["lists"][0]["id"]
|
||||
home = new_list(anna, "Zuhause")
|
||||
with conn:
|
||||
# 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"],))
|
||||
@@ -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 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("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)
|
||||
VALUES ('a1', 'T', 'aktiv', '<p>x</p><img src="https://tracker.example/p.gif">', 'x', 'x')""")
|
||||
conn.execute("PRAGMA user_version = 1")
|
||||
|
||||
@@ -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()] == []
|
||||
@@ -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)]
|
||||
|
||||
|
||||
def new_list(client, name, everyone=True, members=()):
|
||||
res = client.post("/api/haushalt/listen", json={"name": name, "everyone": everyone, "members": list(members)})
|
||||
def new_list(client, name, members=()):
|
||||
res = client.post("/api/haushalt/listen", json={"name": name, "members": list(members)})
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()["created"]
|
||||
|
||||
|
||||
def test_neue_db_hat_liste_zuhause(login):
|
||||
data = state(login("anna"))
|
||||
assert [l["name"] for l in data["lists"]] == ["Zuhause"]
|
||||
assert data["lists"][0]["everyone"] is True
|
||||
def home(client):
|
||||
"""Erste eigene Liste – Listen sind privat, jedes Konto legt seine eigene an."""
|
||||
lists = [l for l in state(client)["lists"] if l["createdBy"] and l["createdBy"]["id"] == client.user["id"]]
|
||||
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):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
add(anna, home, "milch", quantity=2)
|
||||
data = add(anna, home, "Milch", quantity=1).json()
|
||||
assert [(i["name"], i["quantity"]) for i in items(data, home, "einkauf")] == [("Milch", 3)]
|
||||
home_id = home(anna)
|
||||
add(anna, home_id, "milch", quantity=2)
|
||||
data = add(anna, home_id, "Milch", quantity=1).json()
|
||||
assert [(i["name"], i["quantity"]) for i in items(data, home_id, "einkauf")] == [("Milch", 3)]
|
||||
# Andere Einheit = eigene Zeile
|
||||
data = add(anna, home, "Milch", quantity=1.5, unit="l").json()
|
||||
assert len(items(data, home, "einkauf")) == 2
|
||||
data = add(anna, home_id, "Milch", quantity=1.5, unit="l").json()
|
||||
assert len(items(data, home_id, "einkauf")) == 2
|
||||
|
||||
|
||||
def test_listen_haben_eigenen_vorrat(login):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
home_id = home(anna)
|
||||
urlaub = new_list(anna, "Urlaub")
|
||||
add(anna, urlaub, "Brot", area="vorrat")
|
||||
data = state(anna)
|
||||
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)
|
||||
geheim = new_list(anna, "Geheim", everyone=False)
|
||||
geheim = new_list(anna, "Geheim")
|
||||
item = add(anna, geheim, "Kuchen").json()
|
||||
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(other)["lists"])
|
||||
assert add(other, geheim, "x").status_code == 404
|
||||
assert other.patch(f"/api/haushalt/items/{item_id}", json={"quantity": 5}).status_code == 404
|
||||
assert all(l["id"] != geheim for l in state(ben)["lists"])
|
||||
assert add(ben, geheim, "x").status_code == 404
|
||||
assert ben.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)
|
||||
assert lst["canManage"] is False
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
def test_abhaken_und_einkauf_beenden(login):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
add(anna, home, "Milch", quantity=2)
|
||||
add(anna, home, "Brot")
|
||||
add(anna, home, "Milch", area="vorrat", quantity=1)
|
||||
milch = next(i for i in items(state(anna), home, "einkauf") if i["name"] == "Milch")
|
||||
home_id = home(anna)
|
||||
add(anna, home_id, "Milch", quantity=2)
|
||||
add(anna, home_id, "Brot")
|
||||
add(anna, home_id, "Milch", area="vorrat", quantity=1)
|
||||
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()
|
||||
# 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
|
||||
data = res.json()
|
||||
assert [i["name"] for i in items(data, home, "einkauf")] == ["Brot"]
|
||||
assert [(i["name"], i["quantity"]) for i in items(data, home, "vorrat")] == [("Milch", 3)]
|
||||
assert [i["name"] for i in items(data, home_id, "einkauf")] == ["Brot"]
|
||||
assert [(i["name"], i["quantity"]) for i in items(data, home_id, "vorrat")] == [("Milch", 3)]
|
||||
# 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):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
vorrat = items(add(anna, home, "Reis", area="vorrat").json(), home, "vorrat")[0]
|
||||
home_id = home(anna)
|
||||
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
|
||||
|
||||
|
||||
def test_erneut_hinzufuegen_hebt_haken_auf(login):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
milch = items(add(anna, home, "Milch").json(), home)[0]
|
||||
home_id = home(anna)
|
||||
milch = items(add(anna, home_id, "Milch").json(), home_id)[0]
|
||||
anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True})
|
||||
data = add(anna, home, "Milch").json()
|
||||
assert items(data, home)[0]["checked"] is False
|
||||
data = add(anna, home_id, "Milch").json()
|
||||
assert items(data, home_id)[0]["checked"] is False
|
||||
|
||||
|
||||
def test_aufgebraucht_kommt_mit_menge_1_auf_einkauf(login):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
reis = items(add(anna, home, "Reis", area="vorrat", quantity=4).json(), home)[0]
|
||||
home_id = home(anna)
|
||||
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()
|
||||
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):
|
||||
@@ -132,9 +153,9 @@ def test_rezept_uebernehmen_in_gewaehlte_liste(login):
|
||||
|
||||
def test_einheitenwechsel_fuehrt_zusammen(login):
|
||||
anna = login("anna")
|
||||
home = state(anna)["lists"][0]["id"]
|
||||
add(anna, home, "Mehl", quantity=1, unit="kg")
|
||||
stk = items(add(anna, home, "Mehl", quantity=2).json(), home)
|
||||
home_id = home(anna)
|
||||
add(anna, home_id, "Mehl", quantity=1, unit="kg")
|
||||
stk = items(add(anna, home_id, "Mehl", quantity=2).json(), home_id)
|
||||
stk = next(i for i in stk if i["unit"] == "stk")
|
||||
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")]
|
||||
@@ -56,9 +56,10 @@ def test_zurueckholen_startet_fristen_neu(login, conn):
|
||||
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)
|
||||
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")
|
||||
assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403
|
||||
assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
def test_staedte_anderer_nur_lesend(login):
|
||||
def test_staedte_anderer_nur_lesend(login, befriend):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
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()
|
||||
assert data["cities"] == []
|
||||
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"]
|
||||
|
||||
|
||||
def test_wunschliste(login):
|
||||
def test_wunschliste(login, befriend):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
befriend(anna, ben)
|
||||
data = anna.post("/api/reisen/staedte", json={**PRAG, "wish": True}).json()
|
||||
prag = data["cities"][0]
|
||||
assert prag["wish"] is True
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Mindestbestand, Laden-Kategorien und Rezeptsammlung."""
|
||||
from app.kategorien import KEYS
|
||||
|
||||
from test_haushalt import add, items, new_list, state
|
||||
|
||||
|
||||
def home(client):
|
||||
return state(client)["lists"][0]["id"]
|
||||
from test_haushalt import add, home, items, new_list, state
|
||||
|
||||
|
||||
def amounts(data, list_id, name):
|
||||
@@ -49,7 +45,7 @@ def test_mindestbestand_aufgebraucht_und_sofort(login):
|
||||
|
||||
def test_mindestbestand_nur_mit_listenzugriff(login):
|
||||
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})
|
||||
assert res.status_code == 404
|
||||
|
||||
@@ -70,7 +66,7 @@ def test_kategorie_geraten_und_aenderbar(login):
|
||||
|
||||
def test_kategorie_privater_produkte_nicht_aenderbar(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
privat = new_list(anna, "Privat")
|
||||
add(anna, privat, "Geheimzutat")
|
||||
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):
|
||||
anna = login("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))
|
||||
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
|
||||
|
||||
|
||||
@@ -126,7 +122,7 @@ def test_rezept_loeschen_nur_ersteller_oder_admin(login):
|
||||
|
||||
def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
privat = new_list(anna, "Privat")
|
||||
add(anna, privat, "Kondome", area="vorrat")
|
||||
save(ben, "Test", ["2 Kondom"])
|
||||
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):
|
||||
anna, ben = login("anna"), login("ben")
|
||||
privat = new_list(anna, "Privat", everyone=False)
|
||||
privat = new_list(anna, "Privat")
|
||||
fremd = new_store(anna, privat, "Geheimladen")
|
||||
lid = home(ben)
|
||||
assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404
|
||||
|
||||
@@ -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"])
|
||||
@@ -113,6 +113,7 @@ function Menu() {
|
||||
Angemeldet als <strong>{user.displayName}</strong>
|
||||
</div>
|
||||
<NavLink to="/konto">Mein Konto</NavLink>
|
||||
<NavLink to="/freunde">Freunde</NavLink>
|
||||
{user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>}
|
||||
<button type="button" className="menu-item" onClick={logout}>Abmelden</button>
|
||||
</nav>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { personName } from '../../api.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../auth.jsx';
|
||||
|
||||
// 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 ownerId = list ? list.createdBy?.id : user?.id;
|
||||
const [form, setForm] = useState(() => ({
|
||||
name: list?.name || '',
|
||||
everyone: list ? list.everyone : true,
|
||||
members: list?.members || [],
|
||||
}));
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 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] }));
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
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);
|
||||
if (err) setError(err);
|
||||
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" />
|
||||
</label>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Wer kann die Liste sehen?</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
|
||||
Alle Konten
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
|
||||
Nur ausgewählte Konten
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
{!form.everyone && (
|
||||
<>
|
||||
<p className="hint">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<legend>Teilen mit</legend>
|
||||
<p className="hint">
|
||||
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer.
|
||||
Teilen kannst du mit deinen Freunden.
|
||||
</p>
|
||||
{choosable.length === 0 ? (
|
||||
<p className="hint">Noch keine Freunde – unter <Link to="/freunde">Freunde</Link> hinzufügen.</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>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht
|
||||
|
||||
function visibleFor(list, users) {
|
||||
if (list.everyone) return 'alle Konten';
|
||||
const ids = new Set([list.createdBy?.id, ...list.members]);
|
||||
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
|
||||
return names.length ? names.join(', ') : 'niemanden';
|
||||
@@ -21,7 +20,7 @@ export default function ListSwitcher({ lists, current, users, onChoose, onNew, o
|
||||
onClick={() => onChoose(l.id)}
|
||||
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}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte".
|
||||
* initial: { name, description, links: [articleId] } oder null (Dialog geschlossen).
|
||||
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung, Mitglieder (nur Freunde) und verknüpfte Artikel.
|
||||
* 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
|
||||
const [form, setForm] = useState(() => ({
|
||||
name: initial?.name || '',
|
||||
description: initial?.description || '',
|
||||
links: initial?.links || [],
|
||||
members: initial?.members || [],
|
||||
}));
|
||||
const [search, setSearch] = useState('');
|
||||
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 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');
|
||||
// Verknüpfte zuerst, dann nach Suche gefiltert
|
||||
const shown = [...articles]
|
||||
.filter((a) => a.editable || (initial?.links || []).includes(a.id))
|
||||
.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)));
|
||||
|
||||
@@ -31,6 +39,7 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
links: form.links,
|
||||
...(canManage ? { members: form.members } : {}),
|
||||
});
|
||||
setBusy(false);
|
||||
if (err) setError(err);
|
||||
@@ -49,10 +58,29 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
|
||||
Beschreibung <small>(optional)</small>
|
||||
<textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" />
|
||||
</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">
|
||||
<legend>Verknüpfte Artikel aus „P42 Projekte“ <small>({form.links.length} gewählt)</small></legend>
|
||||
{articles.length === 0 ? (
|
||||
<p className="hint">Noch keine Artikel vorhanden.</p>
|
||||
<p className="hint">Keine Artikel, die du bearbeiten darfst.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.length > 6 && (
|
||||
|
||||
@@ -32,7 +32,8 @@ function mainPart(feature) {
|
||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||
* 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 svgRef = useRef(null);
|
||||
const layer = useRef(null);
|
||||
@@ -133,6 +134,17 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
|
||||
<title>{c.name}</title>
|
||||
</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 && (() => {
|
||||
const p = projection([home.lon, home.lat]);
|
||||
return p && (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function travelRoute(visits, cities, homes = []) {
|
||||
const legs = [];
|
||||
const leg = (from, to, kind) => {
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ import Monitoring from './pages/Monitoring.jsx';
|
||||
import NotFound from './pages/NotFound.jsx';
|
||||
import Projects from './pages/Projects.jsx';
|
||||
import Users from './pages/Users.jsx';
|
||||
import Freunde from './pages/Freunde.jsx';
|
||||
import './theme.js';
|
||||
import './styles/index.css';
|
||||
|
||||
@@ -54,6 +55,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'monitoring', element: <Monitoring /> },
|
||||
{ path: 'konto', element: <Account /> },
|
||||
{ path: 'benutzer', element: <Users /> },
|
||||
{ path: 'freunde', element: <Freunde /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -246,6 +246,7 @@ export default function Haushalt() {
|
||||
ref={listDialogRef}
|
||||
list={listDialog.list}
|
||||
users={data?.users || []}
|
||||
shareable={data?.shareable || []}
|
||||
onSave={saveList}
|
||||
onDelete={deleteList}
|
||||
onClosed={() => setListDialog(null)}
|
||||
|
||||
@@ -129,6 +129,9 @@ export default function Kanban() {
|
||||
initial={projectForm}
|
||||
isNew
|
||||
articles={data?.articles || []}
|
||||
users={data?.users || []}
|
||||
shareable={data?.shareable || []}
|
||||
ownerId={user.id}
|
||||
onSave={createProject}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -51,7 +51,10 @@ export default function KanbanBoard() {
|
||||
const project = data?.project;
|
||||
|
||||
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(() => {
|
||||
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) {
|
||||
await saveProject({ completed }).then((err) => err && setError(err));
|
||||
@@ -166,7 +169,7 @@ export default function KanbanBoard() {
|
||||
<option value="alle">Alle</option>
|
||||
<option value="meine">Mir 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>
|
||||
</label>
|
||||
<button
|
||||
@@ -265,7 +268,7 @@ export default function KanbanBoard() {
|
||||
key={`aufgabe-${editing?.opened ?? 'zu'}`}
|
||||
ref={dialog}
|
||||
target={editing}
|
||||
users={users}
|
||||
users={data.people}
|
||||
tasks={allTasks}
|
||||
me={user}
|
||||
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}
|
||||
initial={projectForm}
|
||||
articles={data.articles}
|
||||
users={data.users}
|
||||
shareable={data.shareable}
|
||||
ownerId={project.createdBy?.id}
|
||||
canManage={project.canManage}
|
||||
onSave={saveProject}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import Editor from '../components/projekte/Editor.jsx';
|
||||
import Lightbox from '../components/projekte/Lightbox.jsx';
|
||||
import StatusBadge from '../components/projekte/StatusBadge.jsx';
|
||||
@@ -10,7 +9,6 @@ export default function Project() {
|
||||
const { id } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
@@ -30,7 +28,7 @@ export default function Project() {
|
||||
api('GET', `/api/projects/${encodeURIComponent(id)}`)
|
||||
.then((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));
|
||||
// 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></>;
|
||||
|
||||
const cover = editing ? draft.cover : project.cover;
|
||||
// Löschen dürfen nur der Autor und Administratoren
|
||||
const canDelete = user.isAdmin || project.createdBy?.id === user.id;
|
||||
// Lesen dürfen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Admins; löschen Autor und Admins
|
||||
const canDelete = project.canDelete;
|
||||
|
||||
return (
|
||||
<div className={`article-page${editing ? ' editing' : ''}`}>
|
||||
@@ -228,14 +226,17 @@ export default function Project() {
|
||||
<dt>Kanban-Boards</dt>
|
||||
<dd className="aside-boards">
|
||||
{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>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{!editing && (
|
||||
{!editing && project.canEdit && (
|
||||
<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 && (
|
||||
<div className="edit-actions">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { feature } from 'topojson-client';
|
||||
import CitySearch from '../components/reisen/CitySearch.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 Timeline from '../components/reisen/Timeline.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 VisitedList from '../components/reisen/VisitedList.jsx';
|
||||
import WorldMap from '../components/reisen/WorldMap.jsx';
|
||||
@@ -24,6 +25,7 @@ const SPECIAL = {
|
||||
};
|
||||
|
||||
const TAB_KEY = 'p42.reisenAnsicht';
|
||||
const ROUTES_KEY = 'p42.reisenRouten';
|
||||
const OTHERS_KEY = 'p42.reisenAndere';
|
||||
const HIDDEN_KEY = 'p42.reisenAndereAus';
|
||||
const OTHER_COLORS = 6;
|
||||
@@ -42,6 +44,7 @@ export default function Reisen() {
|
||||
const [visitDialog, setVisitDialog] = useState(null); // { city, visit, opened }
|
||||
const [tab, setTab] = useState(() => { try { return localStorage.getItem(TAB_KEY) || 'orte'; } catch { return 'orte'; } });
|
||||
const [zoom, setZoom] = useState(null);
|
||||
const [showRoutes, setShowRoutes] = useState(() => loadFlag(ROUTES_KEY));
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/maps/countries-50m.json')
|
||||
@@ -88,6 +91,17 @@ export default function Reisen() {
|
||||
);
|
||||
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() {
|
||||
const next = !showOthers;
|
||||
setShowOthers(next);
|
||||
@@ -167,11 +181,18 @@ export default function Reisen() {
|
||||
|
||||
{data && (
|
||||
<div className="travel-others" role="group" aria-label="Städte anderer Konten">
|
||||
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
|
||||
{showOthers ? '✓ ' : ''}Städte anderer zeigen
|
||||
<button type="button" className={`chip${showRoutes ? ' active' : ''}`} aria-pressed={showRoutes} onClick={toggleRoutes}
|
||||
title="Strecken aus der Kilometer-Statistik: Reisen, Ausflüge (gepunktet), Umzüge (gestrichelt)">
|
||||
{showRoutes ? '✓ ' : ''}Reiserouten zeigen
|
||||
</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 && (
|
||||
<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 && (
|
||||
<>
|
||||
@@ -207,6 +228,7 @@ export default function Reisen() {
|
||||
cities={own}
|
||||
otherCities={otherCities}
|
||||
home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))}
|
||||
routes={routes}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
|
||||
@@ -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; }
|
||||
@@ -10,6 +10,7 @@
|
||||
@import './reisen.css';
|
||||
@import './kanban.css';
|
||||
@import './konto.css';
|
||||
@import './freunde.css';
|
||||
@import './responsive.css';
|
||||
@import './grundschicht.css';
|
||||
@import './designs/gemeinsam.css';
|
||||
|
||||
@@ -257,3 +257,8 @@
|
||||
.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; }
|
||||
.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; }
|
||||
Reference in new issue
Block a user