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:
1 parent
f0e17b06b5
commit
dc4ce18593
9 files changed
+489
-12
No files matched your search
+68
-4
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user