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
@@ -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 ----------
|
||||
|
||||
Reference in new issue
Block a user