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