Kanban: Checklisten und Voraussetzungen zwischen Aufgaben
- Checkliste je Aufgabe (Tabelle kanban_checklist): im Dialog anlegen, abhaken, entfernen; Karte zeigt den Fortschritt - "Setzt voraus" (Tabelle kanban_dependencies): Aufgabe ist gesperrt und bleibt in "Offen", bis alle Voraussetzungen erledigt sind; nur innerhalb eines Projekts, Kreise und Selbstbezug werden abgelehnt; gelöschte Voraussetzungen entsperren, archivierte zählen als erledigt - Ziehen einer gesperrten Karte aus "Offen" zeigt, worauf sie wartet - Migrationen: Tabellen-Neuaufbau über Zwischennamen, damit Fremdschlüssel anderer Tabellen nicht auf die gelöschte Alt-Tabelle umgebogen werden (Test für ganz alte Datenbanken) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1a1998d1e
commit
9df0e38f52
10 files changed
+375
-28
No files matched your search
@@ -204,6 +204,23 @@ CREATE TABLE IF NOT EXISTS kanban_project_links (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE};
|
||||
|
||||
-- Checkliste innerhalb einer Aufgabe
|
||||
CREATE TABLE IF NOT EXISTS kanban_checklist (
|
||||
id INTEGER PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Abhängigkeiten: task_id ist gesperrt, bis requires_id erledigt ist (nur innerhalb eines Projekts)
|
||||
CREATE TABLE IF NOT EXISTS kanban_dependencies (
|
||||
task_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
||||
requires_id INTEGER NOT NULL REFERENCES kanban_tasks(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, requires_id),
|
||||
CHECK (task_id != requires_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
|
||||
+117
-3
@@ -25,6 +25,9 @@ MAX_DESCRIPTION = 5000
|
||||
MAX_TASKS = 1000 # pro Projekt
|
||||
MAX_PROJECTS = 300
|
||||
MAX_LINKS = 20
|
||||
MAX_CHECKLIST = 100
|
||||
MAX_CHECK_TEXT = 200
|
||||
MAX_REQUIRES = 30
|
||||
ARCHIVE_AFTER = timedelta(days=2)
|
||||
DELETE_AFTER = timedelta(days=14)
|
||||
CLEANUP_SECONDS = 3600
|
||||
@@ -194,6 +197,9 @@ def _task(row) -> dict:
|
||||
"priority": PRIORITY_NAMES[row["priority"]],
|
||||
"position": row["position"],
|
||||
"doneAt": row["done_at"],
|
||||
"checklist": [],
|
||||
"requires": [],
|
||||
"blocked": False,
|
||||
"createdAt": row["created_at"],
|
||||
"createdBy": _person(row["created_by"], row["created_by_name"]),
|
||||
"updatedAt": row["updated_at"],
|
||||
@@ -201,6 +207,95 @@ def _task(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _task_extras(conn, project_id: int) -> dict:
|
||||
"""Checklisten, Voraussetzungen und "gesperrt" für alle Aufgaben eines Projekts."""
|
||||
extras = {}
|
||||
for r in conn.execute(
|
||||
"""SELECT c.* FROM kanban_checklist c JOIN kanban_tasks t ON t.id = c.task_id
|
||||
WHERE t.project_id = ? ORDER BY c.task_id, c.position""",
|
||||
(project_id,),
|
||||
):
|
||||
extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})["checklist"].append(
|
||||
{"id": r["id"], "text": r["text"], "done": bool(r["done"])}
|
||||
)
|
||||
for r in conn.execute(
|
||||
"""SELECT d.task_id, d.requires_id, rt.status FROM kanban_dependencies d
|
||||
JOIN kanban_tasks t ON t.id = d.task_id JOIN kanban_tasks rt ON rt.id = d.requires_id
|
||||
WHERE t.project_id = ?""",
|
||||
(project_id,),
|
||||
):
|
||||
e = extras.setdefault(r["task_id"], {"checklist": [], "requires": [], "blocked": False})
|
||||
e["requires"].append(r["requires_id"])
|
||||
e["blocked"] = e["blocked"] or r["status"] != "erledigt"
|
||||
return extras
|
||||
|
||||
|
||||
def _open_requirements(conn, task_id: int) -> list:
|
||||
"""Titel der noch nicht erledigten Voraussetzungen einer Aufgabe."""
|
||||
return [r["title"] for r in conn.execute(
|
||||
"""SELECT t.title FROM kanban_dependencies d JOIN kanban_tasks t ON t.id = d.requires_id
|
||||
WHERE d.task_id = ? AND t.status != 'erledigt' ORDER BY t.title""",
|
||||
(task_id,),
|
||||
)]
|
||||
|
||||
|
||||
def _checklist(value) -> list:
|
||||
if not isinstance(value, list) or len(value) > MAX_CHECKLIST:
|
||||
raise ValidationError(f"Ungültige Checkliste (max. {MAX_CHECKLIST} Punkte).")
|
||||
items = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("done", False), bool):
|
||||
raise ValidationError("Ungültiger Checklisten-Punkt.")
|
||||
items.append((_name(item.get("text"), "Checklisten-Punkt", MAX_CHECK_TEXT), item.get("done", False)))
|
||||
return items
|
||||
|
||||
|
||||
def _save_checklist(conn, task_id: int, items: list) -> None:
|
||||
conn.execute("DELETE FROM kanban_checklist WHERE task_id = ?", (task_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO kanban_checklist (task_id, text, done, position) VALUES (?, ?, ?, ?)",
|
||||
[(task_id, text, int(done), i) for i, (text, done) in enumerate(items)],
|
||||
)
|
||||
|
||||
|
||||
def _requires(conn, value, project_id: int, task_id=None) -> list:
|
||||
"""Voraussetzungen prüfen: Aufgaben desselben Projekts, nicht sie selbst, keine Kreise."""
|
||||
if not isinstance(value, list) or len(value) > MAX_REQUIRES or any(isinstance(v, bool) or not isinstance(v, int) for v in value):
|
||||
raise ValidationError("Ungültige Voraussetzungen.")
|
||||
ids = list(dict.fromkeys(value))
|
||||
if task_id in ids:
|
||||
raise ValidationError("Eine Aufgabe kann nicht sich selbst voraussetzen.")
|
||||
if ids:
|
||||
found = {r[0] for r in conn.execute(
|
||||
f"SELECT id FROM kanban_tasks WHERE project_id = ? AND id IN ({','.join('?' * len(ids))})", (project_id, *ids)
|
||||
)}
|
||||
if found != set(ids):
|
||||
raise ValidationError("Voraussetzungen müssen Aufgaben aus diesem Projekt sein.")
|
||||
if task_id is not None:
|
||||
# Kreis: hängt eine der Voraussetzungen (über Umwege) von dieser Aufgabe ab?
|
||||
seen, todo = set(), list(ids)
|
||||
while todo:
|
||||
current = todo.pop()
|
||||
if current == task_id:
|
||||
raise ValidationError("Das ergäbe einen Kreis: diese Aufgabe ist selbst Voraussetzung einer der gewählten.")
|
||||
if current in seen:
|
||||
continue
|
||||
seen.add(current)
|
||||
todo += [r[0] for r in conn.execute("SELECT requires_id FROM kanban_dependencies WHERE task_id = ?", (current,))]
|
||||
return ids
|
||||
|
||||
|
||||
def _save_requires(conn, task_id: int, ids: list) -> None:
|
||||
conn.execute("DELETE FROM kanban_dependencies WHERE task_id = ?", (task_id,))
|
||||
conn.executemany("INSERT INTO kanban_dependencies (task_id, requires_id) VALUES (?, ?)", [(task_id, r) for r in ids])
|
||||
|
||||
|
||||
def _ensure_unlocked(conn, task_id: int, status: str) -> None:
|
||||
"""Gesperrte Aufgaben bleiben in "Offen", bis alle Voraussetzungen erledigt sind."""
|
||||
if status != "offen" and (missing := _open_requirements(conn, task_id)):
|
||||
raise ValidationError("Gesperrt – erst erledigen: " + ", ".join(f"„{t}“" for t in missing))
|
||||
|
||||
|
||||
def _board(conn, project_id: int) -> dict:
|
||||
projects = _projects(conn, project_id)
|
||||
if not projects:
|
||||
@@ -223,11 +318,14 @@ def _board(conn, project_id: int) -> dict:
|
||||
reverse=True,
|
||||
)
|
||||
archived_ids = {r["id"] for r in archived}
|
||||
extras = _task_extras(conn, project_id)
|
||||
return {
|
||||
"project": project,
|
||||
"tasks": [_task(r) for r in tasks if r["id"] not in archived_ids],
|
||||
"tasks": [{**_task(r), **extras.get(r["id"], {})} for r in tasks if r["id"] not in archived_ids],
|
||||
# Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen
|
||||
"archived": [{**_task(r), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived],
|
||||
"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],
|
||||
}
|
||||
@@ -330,9 +428,11 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
description = _description(data.get("description"))
|
||||
status = _status(data.get("status", "offen"))
|
||||
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.")
|
||||
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.")
|
||||
now = now_iso()
|
||||
@@ -343,6 +443,10 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
||||
(project_id, title, description, status, _assignee(conn, data.get("assignee")), 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)
|
||||
|
||||
|
||||
@@ -370,7 +474,17 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
|
||||
elif values.get("status", row["status"]) != row["status"] or values.get("priority", row["priority"]) != row["priority"]:
|
||||
# Neue Spalte oder neue Priorität: ans Ende (innerhalb der Stufe, da nach Priorität sortiert wird)
|
||||
values["position"] = _end_of(conn, row["project_id"], values.get("status", row["status"]))
|
||||
if values:
|
||||
changed = bool(values)
|
||||
if "checklist" in data:
|
||||
_save_checklist(conn, task_id, _checklist(data["checklist"]))
|
||||
changed = True
|
||||
if "requires" in data:
|
||||
_save_requires(conn, task_id, _requires(conn, data["requires"], row["project_id"], task_id))
|
||||
changed = True
|
||||
# Gesperrt: darf nicht aus "Offen" heraus. War sie schon weiter, bleibt sie, wo sie ist.
|
||||
if values.get("status", row["status"]) != row["status"]:
|
||||
_ensure_unlocked(conn, task_id, values["status"])
|
||||
if changed:
|
||||
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))
|
||||
|
||||
+26
-17
@@ -32,6 +32,20 @@ def upgrade(conn) -> None:
|
||||
conn.execute(f"PRAGMA user_version = {number}")
|
||||
|
||||
|
||||
def _rebuild(conn, table: str, definition: str, columns: str, select: str, params=()) -> None:
|
||||
"""Tabelle neu aufbauen: neue Tabelle unter Zwischennamen anlegen, Daten kopieren, alte löschen, umbenennen.
|
||||
|
||||
So herum bleiben Fremdschlüssel anderer Tabellen (z. B. Checkliste → Aufgaben) auf den ursprünglichen
|
||||
Namen gerichtet. Würde man die alte Tabelle zuerst umbenennen, schriebe SQLite deren Verweise auf den
|
||||
Zwischennamen um – und nach dem Löschen zeigten sie ins Leere.
|
||||
select liest aus der alten Tabelle (unter ihrem ursprünglichen Namen).
|
||||
"""
|
||||
conn.execute(f"CREATE TABLE {table}_neu {definition}")
|
||||
conn.execute(f"INSERT INTO {table}_neu ({columns}) {select}", params)
|
||||
conn.execute(f"DROP TABLE {table}")
|
||||
conn.execute(f"ALTER TABLE {table}_neu RENAME TO {table}")
|
||||
|
||||
|
||||
# ---------- Schritt 1: Altbestand aus der Zeit vor der Versionsnummer ----------
|
||||
# Diese Umstellungen prüfen noch selbst, ob sie nötig sind – eine Datenbank ohne Nummer kann
|
||||
# auf jedem früheren Stand sein. Reihenfolge wie früher beim Start.
|
||||
@@ -71,14 +85,12 @@ def _household(conn) -> None:
|
||||
list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?"
|
||||
conn.execute("DROP INDEX IF EXISTS household_vorrat")
|
||||
conn.execute("DROP INDEX IF EXISTS household_einkauf")
|
||||
conn.execute("ALTER TABLE household_items RENAME TO household_items_alt")
|
||||
conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE)
|
||||
conn.execute(
|
||||
f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by)
|
||||
SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""",
|
||||
_rebuild(
|
||||
conn, "household_items", HOUSEHOLD_TABLE,
|
||||
"id, key, name, list, list_id, quantity, unit, updated_at, updated_by",
|
||||
f"SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items",
|
||||
(default,),
|
||||
)
|
||||
conn.execute("DROP TABLE household_items_alt")
|
||||
|
||||
|
||||
def _household_checked(conn) -> None:
|
||||
@@ -166,22 +178,19 @@ def _kanban_projects(conn) -> None:
|
||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
|
||||
if "project_id" in columns:
|
||||
return
|
||||
conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt")
|
||||
conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE)
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]:
|
||||
project = None
|
||||
if conn.execute("SELECT COUNT(*) FROM kanban_tasks").fetchone()[0]:
|
||||
now = now_iso()
|
||||
project = conn.execute(
|
||||
"INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)",
|
||||
(now, now),
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"""INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position,
|
||||
created_at, created_by, updated_at, updated_by)
|
||||
SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by
|
||||
FROM kanban_tasks_alt""",
|
||||
(project,),
|
||||
)
|
||||
conn.execute("DROP TABLE kanban_tasks_alt")
|
||||
_rebuild(
|
||||
conn, "kanban_tasks", KANBAN_TASKS_TABLE,
|
||||
"id, project_id, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by",
|
||||
"SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by FROM kanban_tasks",
|
||||
(project,),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Schritt 2: Datenschutz ----------
|
||||
|
||||
@@ -63,3 +63,65 @@ def test_loeschen_nur_ersteller_oder_admin(login):
|
||||
assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403
|
||||
assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200
|
||||
assert chef.delete(f"/api/kanban/aufgaben/{t2['id']}").status_code == 200
|
||||
|
||||
|
||||
# ---------- Checklisten und Abhängigkeiten ----------
|
||||
|
||||
|
||||
def find(data, title):
|
||||
return next(t for t in data["tasks"] + data["archived"] if t["title"] == title)
|
||||
|
||||
|
||||
def test_checkliste_speichern_und_abhaken(login):
|
||||
anna = login("anna")
|
||||
pid = make_board(anna)
|
||||
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={
|
||||
"title": "Umzug", "checklist": [{"text": "Kartons"}, {"text": "Transporter", "done": True}],
|
||||
}).json()
|
||||
t = find(data, "Umzug")
|
||||
assert [(c["text"], c["done"]) for c in t["checklist"]] == [("Kartons", False), ("Transporter", True)]
|
||||
data = anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"checklist": [{"text": "Kartons", "done": True}]}).json()
|
||||
assert [(c["text"], c["done"]) for c in find(data, "Umzug")["checklist"]] == [("Kartons", True)]
|
||||
assert anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"checklist": [{"text": " "}]}).status_code == 400
|
||||
|
||||
|
||||
def test_voraussetzung_sperrt_bis_erledigt(login):
|
||||
anna = login("anna")
|
||||
pid = make_board(anna)
|
||||
a = task(anna, pid, "Wand streichen")
|
||||
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "Bilder aufhängen", "requires": [a["id"]]}).json()
|
||||
b = find(data, "Bilder aufhängen")
|
||||
assert b["blocked"] is True and b["requires"] == [a["id"]]
|
||||
res = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"})
|
||||
assert res.status_code == 400 and "Wand streichen" in res.json()["error"]
|
||||
anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"status": "erledigt"})
|
||||
data = anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"status": "arbeit"}).json()
|
||||
assert find(data, "Bilder aufhängen")["blocked"] is False
|
||||
|
||||
|
||||
def test_gesperrt_anlegen_nur_in_offen(login):
|
||||
anna = login("anna")
|
||||
pid = make_board(anna)
|
||||
a = task(anna, pid, "A")
|
||||
res = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "B", "status": "arbeit", "requires": [a["id"]]})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_kein_kreis_und_nur_eigenes_projekt(login):
|
||||
anna = login("anna")
|
||||
pid, other = make_board(anna), make_board(anna)
|
||||
a, b = task(anna, pid, "A"), task(anna, pid, "B")
|
||||
fremd = task(anna, other, "Fremd")
|
||||
assert anna.patch(f"/api/kanban/aufgaben/{b['id']}", json={"requires": [a["id"]]}).status_code == 200
|
||||
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [b["id"]]}).status_code == 400 # Kreis
|
||||
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [a["id"]]}).status_code == 400 # selbst
|
||||
assert anna.patch(f"/api/kanban/aufgaben/{a['id']}", json={"requires": [fremd["id"]]}).status_code == 400
|
||||
|
||||
|
||||
def test_geloeschte_voraussetzung_entsperrt(login):
|
||||
anna = login("anna")
|
||||
pid = make_board(anna)
|
||||
a = task(anna, pid, "A")
|
||||
data = anna.post(f"/api/kanban/projekte/{pid}/aufgaben", json={"title": "B", "requires": [a["id"]]}).json()
|
||||
anna.delete(f"/api/kanban/aufgaben/{a['id']}")
|
||||
assert find(board(anna, pid), "B")["blocked"] is False
|
||||
@@ -158,3 +158,17 @@ def test_schritt_laeuft_nur_einmal(old_db, monkeypatch):
|
||||
db.init()
|
||||
assert calls == [1]
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS)
|
||||
|
||||
|
||||
def test_uralte_aufgaben_umbau_laesst_neue_tabellen_heil(old_db):
|
||||
"""Aufgaben ohne Projekt (ganz alter Stand): Umbau darf die Verweise von Checkliste/Abhängigkeiten nicht verbiegen."""
|
||||
conn = old_db(
|
||||
"""CREATE TABLE kanban_tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL, assignee INTEGER, position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER,
|
||||
updated_at TEXT NOT NULL, updated_by INTEGER)""",
|
||||
f"INSERT INTO kanban_tasks VALUES (1, 'alt', '', 'offen', NULL, 1, '{T}', NULL, '{T}', NULL)",
|
||||
)
|
||||
with conn:
|
||||
conn.execute("INSERT INTO kanban_checklist (task_id, text, done, position) VALUES (1, 'x', 0, 0)")
|
||||
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'kanban_checklist'").fetchone()[0]
|
||||
assert "kanban_tasks_alt" not in sql
|
||||
Reference in new issue
Block a user