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:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:10:10 +02:00
1 parent a1a1998d1e
commit 9df0e38f52
10 files changed
+375 -28

No files matched your search

+117 -3
View File
@@ -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))