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

+14 -2
View File
@@ -50,6 +50,7 @@ KANBAN_TASKS_TABLE = """(
assignee INTEGER REFERENCES users(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig
position REAL NOT NULL,
done_at TEXT, -- seit wann "erledigt": nach 2 Tagen im Archiv, nach 14 Tagen gelöscht
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_at TEXT NOT NULL,
@@ -164,6 +165,7 @@ CREATE TABLE IF NOT EXISTS travel_cities (
population INTEGER,
elevation REAL,
timezone TEXT,
capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt
created_at TEXT NOT NULL,
UNIQUE (user_id, geoname_id)
);
@@ -303,9 +305,9 @@ def _migrate_checked(conn) -> None:
def _migrate_travel(conn) -> None:
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone/Hauptstadt: Spalten ergänzen (Hauptstadt füllt reisen.py nach)
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")):
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT"), ("capital", "INTEGER")):
if column not in columns:
conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}")
@@ -352,6 +354,15 @@ def _migrate_kanban_priority(conn) -> None:
)
def _migrate_kanban_done(conn) -> None:
"""Aufgaben ohne "erledigt seit": Spalte ergänzen. Schon erledigte Aufgaben zählen ab jetzt,
damit beim Update nichts sofort archiviert oder gelöscht wird."""
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
if "done_at" not in columns:
conn.execute("ALTER TABLE kanban_tasks ADD COLUMN done_at TEXT")
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE status = 'erledigt'", (now_iso(),))
def _migrate_monitor_token(conn) -> None:
# Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen
columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")}
@@ -402,6 +413,7 @@ def init() -> None:
_migrate_kanban_completed(conn)
_migrate_kanban(conn)
_migrate_kanban_priority(conn)
_migrate_kanban_done(conn)
_import_legacy(conn)
+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:
+6
View File
@@ -22,8 +22,14 @@ from .tracking import track
async def lifespan(_: FastAPI):
# Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln
sampler = asyncio.create_task(monitoring.sample_loop())
# Erledigte Kanban-Aufgaben nach 14 Tagen löschen
cleaner = asyncio.create_task(kanban.cleanup_loop())
# Hauptstadt-Kennzeichen älterer Reise-Städte nachtragen (für den Karten-Export)
capitals = asyncio.create_task(reisen.capitals_loop())
yield
sampler.cancel()
cleaner.cancel()
capitals.cancel()
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
+43 -3
View File
@@ -6,7 +6,9 @@ Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames,
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
"""
import asyncio
import json
import logging
import math
import re
import time
@@ -31,7 +33,10 @@ CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
CAPITALS_SECONDS = 6 * 3600
_search_cache: dict[str, list] = {}
log = logging.getLogger(__name__)
def _city(row) -> dict:
@@ -39,6 +44,7 @@ def _city(row) -> dict:
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
"capital": None if row["capital"] is None else bool(row["capital"]),
}
@@ -107,18 +113,20 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
timezone = data.get("timezone")
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
timezone = None
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
values = (
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
population, elevation, timezone, now_iso(),
population, elevation, timezone, capital, now_iso(),
)
with transaction() as conn:
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
conn.execute(
"""INSERT INTO travel_cities
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
values,
)
return _state(conn, user["id"])
@@ -180,6 +188,7 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
"capital": r.get("feature_code") == "PPLC",
}
for r in res.json().get("results", [])
if r.get("country_code") and r.get("id")
@@ -190,6 +199,37 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
return results
# ---------- Hauptstadt-Kennzeichen für ältere Einträge nachtragen ----------
async def fill_capitals() -> None:
"""Städte ohne Hauptstadt-Angabe bei Open-Meteo nachschlagen (GeoNames-Merkmal PPLC)."""
with transaction() as conn:
ids = [r[0] for r in conn.execute("SELECT DISTINCT geoname_id FROM travel_cities WHERE capital IS NULL")]
if not ids:
return
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
for geoname_id in ids:
try:
res = await client.get("https://geocoding-api.open-meteo.com/v1/get", params={"id": geoname_id})
res.raise_for_status()
capital = int(res.json().get("feature_code") == "PPLC")
except (httpx.HTTPError, ValueError):
continue # beim nächsten Durchlauf erneut versuchen
with transaction() as conn:
conn.execute("UPDATE travel_cities SET capital = ? WHERE geoname_id = ? AND capital IS NULL", (capital, geoname_id))
await asyncio.sleep(0.2) # Open-Meteo schonen
async def capitals_loop() -> None:
while True:
try:
await fill_capitals()
except Exception: # nie die Schleife beenden
log.exception("Hauptstädte nachtragen fehlgeschlagen")
await asyncio.sleep(CAPITALS_SECONDS)
# ---------- Infos aus Wikipedia / Wikidata ----------