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
+14
-2
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 ----------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user