Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen

Kanban:
- Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button
  "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen
  automatisch gelöscht (stündlich im Hintergrund und beim Öffnen)
- Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update

Reisen:
- "Karte herunterladen": eigene Städte und besuchte Länder als PNG
  (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine
  Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine
  Orte" oder ganze Welt; Beschriftungen weichen einander aus
- Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert
  und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:05:26 +02:00
1 parent f0e17b06b5
commit dc4ce18593
9 files changed
+489 -12

No files matched your search

+68 -4
View File
@@ -3,9 +3,13 @@
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, 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").
"""
import asyncio
import logging
import math
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -22,6 +26,38 @@ MAX_DESCRIPTION = 5000
MAX_TASKS = 1000 # pro Projekt
MAX_PROJECTS = 300
MAX_LINKS = 20
ARCHIVE_AFTER = timedelta(days=2)
DELETE_AFTER = timedelta(days=14)
CLEANUP_SECONDS = 3600
log = logging.getLogger(__name__)
def _ago(delta: timedelta) -> str:
# Gleiches Format wie now_iso(), damit Textvergleich in SQLite stimmt
return (datetime.now(timezone.utc) - delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _iso_after(iso: str, delta: timedelta) -> str:
return (datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def cleanup(conn) -> int:
"""Seit 14 Tagen erledigte Aufgaben löschen."""
return conn.execute(
"DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (_ago(DELETE_AFTER),)
).rowcount
async def cleanup_loop() -> None:
"""Stündlich aufräumen, auch wenn niemand das Kanban öffnet."""
while True:
try:
with transaction() as conn:
cleanup(conn)
except Exception: # nie die Schleife beenden
log.exception("Kanban-Aufräumen fehlgeschlagen")
await asyncio.sleep(CLEANUP_SECONDS)
def _person(user_id, name) -> dict | None:
@@ -158,6 +194,7 @@ def _projects(conn, project_id=None) -> list:
def _overview(conn) -> dict:
cleanup(conn)
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
return {
"projects": _projects(conn),
@@ -176,6 +213,7 @@ def _task(row) -> dict:
"assignee": row["assignee"],
"priority": PRIORITY_NAMES[row["priority"]],
"position": row["position"],
"doneAt": row["done_at"],
"createdAt": row["created_at"],
"createdBy": _person(row["created_by"], row["created_by_name"]),
"updatedAt": row["updated_at"],
@@ -188,6 +226,7 @@ def _board(conn, project_id: int) -> dict:
if not projects:
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
project = projects[0]
cleanup(conn)
tasks = conn.execute(
"""SELECT t.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
FROM kanban_tasks t
@@ -197,9 +236,18 @@ def _board(conn, project_id: int) -> dict:
(project_id,),
).fetchall()
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
archive_before = _ago(ARCHIVE_AFTER)
archived = sorted(
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
key=lambda r: r["done_at"],
reverse=True,
)
archived_ids = {r["id"] for r in archived}
return {
"project": project,
"tasks": [_task(r) for r in tasks],
"tasks": [_task(r) 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],
"users": _users(conn),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
}
@@ -310,10 +358,10 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
now = now_iso()
conn.execute(
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
done_at, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
_end_of(conn, project_id, status), now, user["id"], now, user["id"]),
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
)
return _board(conn, project_id)
@@ -332,6 +380,9 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
values["assignee"] = _assignee(conn, data["assignee"])
if "status" in data:
values["status"] = _status(data["status"])
if values["status"] != row["status"]:
# Erledigt-Uhr starten bzw. beim Zurückholen aus "erledigt" anhalten
values["done_at"] = now_iso() if values["status"] == "erledigt" else None
if "priority" in data:
values["priority"] = _priority(data["priority"])
if "position" in data:
@@ -346,6 +397,19 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
return _board(conn, row["project_id"])
@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)
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"])
@router.delete("/api/kanban/aufgaben/{task_id}")
def delete_task(task_id: int, user: dict = Depends(current_user)):
with transaction() as conn: