2026-04-02 00:43:40 +02:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
"""API routes for Fristen-Tracking (Issue #17)."""
|
|
|
|
|
|
|
2026-04-02 23:16:53 +02:00
|
|
|
|
from datetime import date, datetime, timedelta
|
2026-04-02 00:43:40 +02:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
2026-04-02 23:16:53 +02:00
|
|
|
|
from fastapi.responses import Response
|
2026-04-02 00:43:40 +02:00
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
from tracker.db.session import get_connection
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/fristen", tags=["fristen"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _db():
|
|
|
|
|
|
conn = get_connection()
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield conn
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FristCreate(BaseModel):
|
|
|
|
|
|
kette_id: int
|
|
|
|
|
|
vorlage_id: Optional[int] = None
|
|
|
|
|
|
typ: str # überarbeitung, bericht, prüfung, umsetzung, sonstiges
|
|
|
|
|
|
datum: str # YYYY-MM-DD
|
|
|
|
|
|
beschreibung: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FristUpdate(BaseModel):
|
|
|
|
|
|
status: Optional[str] = None
|
|
|
|
|
|
datum: Optional[str] = None
|
|
|
|
|
|
beschreibung: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("")
|
|
|
|
|
|
def list_fristen(
|
|
|
|
|
|
kette_id: Optional[int] = None,
|
|
|
|
|
|
status: Optional[str] = Query(None, description="offen, überfällig, erfüllt, alle"),
|
|
|
|
|
|
typ: Optional[str] = None,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: int = 50,
|
|
|
|
|
|
conn=Depends(_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Fristen auflisten mit Joins auf ketten + vorlagen."""
|
|
|
|
|
|
where_clauses = []
|
|
|
|
|
|
params: list = []
|
|
|
|
|
|
|
|
|
|
|
|
if kette_id is not None:
|
|
|
|
|
|
where_clauses.append("f.kette_id = ?")
|
|
|
|
|
|
params.append(kette_id)
|
|
|
|
|
|
|
|
|
|
|
|
if status and status != "alle":
|
|
|
|
|
|
if status == "überfällig":
|
|
|
|
|
|
where_clauses.append("(f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')))")
|
|
|
|
|
|
else:
|
|
|
|
|
|
where_clauses.append("f.status = ?")
|
|
|
|
|
|
params.append(status)
|
|
|
|
|
|
|
|
|
|
|
|
if typ:
|
|
|
|
|
|
where_clauses.append("f.typ = ?")
|
|
|
|
|
|
params.append(typ)
|
|
|
|
|
|
|
|
|
|
|
|
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
|
|
|
|
|
|
|
|
|
|
|
# Count
|
|
|
|
|
|
total = conn.execute(f"SELECT COUNT(*) as cnt FROM fristen f{where_sql}", params).fetchone()["cnt"]
|
|
|
|
|
|
|
|
|
|
|
|
# Query with joins
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
|
f"""SELECT f.*,
|
|
|
|
|
|
k.status AS kette_status,
|
|
|
|
|
|
v_ursprung.aktenzeichen AS kette_aktenzeichen,
|
|
|
|
|
|
v_ursprung.betreff AS kette_betreff,
|
|
|
|
|
|
v_frist.aktenzeichen AS vorlage_aktenzeichen
|
|
|
|
|
|
FROM fristen f
|
|
|
|
|
|
LEFT JOIN ketten k ON f.kette_id = k.id
|
|
|
|
|
|
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
|
|
|
|
|
|
LEFT JOIN vorlagen v_frist ON f.vorlage_id = v_frist.id
|
|
|
|
|
|
{where_sql}
|
|
|
|
|
|
ORDER BY
|
|
|
|
|
|
CASE WHEN f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')) THEN 0
|
|
|
|
|
|
WHEN f.status = 'offen' THEN 1
|
|
|
|
|
|
ELSE 2 END,
|
|
|
|
|
|
f.datum ASC
|
|
|
|
|
|
LIMIT ? OFFSET ?""",
|
|
|
|
|
|
params + [page_size, offset],
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
item = dict(r)
|
|
|
|
|
|
# Mark overdue in response
|
|
|
|
|
|
if item["status"] == "offen" and item["datum"] and item["datum"] < date.today().isoformat():
|
|
|
|
|
|
item["status"] = "überfällig"
|
|
|
|
|
|
items.append(item)
|
|
|
|
|
|
|
|
|
|
|
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("")
|
|
|
|
|
|
def create_frist(body: FristCreate, conn=Depends(_db)):
|
|
|
|
|
|
"""Neue Frist manuell anlegen."""
|
|
|
|
|
|
# Validate kette exists
|
|
|
|
|
|
kette = conn.execute("SELECT id FROM ketten WHERE id = ?", (body.kette_id,)).fetchone()
|
|
|
|
|
|
if not kette:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Kette nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
if body.vorlage_id:
|
|
|
|
|
|
vorlage = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (body.vorlage_id,)).fetchone()
|
|
|
|
|
|
if not vorlage:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
cursor = conn.execute(
|
|
|
|
|
|
"""INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, 'manuell', 'offen', ?)""",
|
|
|
|
|
|
(body.kette_id, body.vorlage_id, body.typ, body.datum, body.beschreibung, datetime.now().isoformat()),
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
frist_id = cursor.lastrowid
|
|
|
|
|
|
|
|
|
|
|
|
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
|
|
|
|
|
return dict(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{frist_id}")
|
|
|
|
|
|
def update_frist(frist_id: int, body: FristUpdate, conn=Depends(_db)):
|
|
|
|
|
|
"""Frist aktualisieren (Status, Datum, Beschreibung)."""
|
|
|
|
|
|
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
|
|
|
|
|
if not existing:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
updates = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if body.status is not None:
|
|
|
|
|
|
updates.append("status = ?")
|
|
|
|
|
|
params.append(body.status)
|
|
|
|
|
|
if body.datum is not None:
|
|
|
|
|
|
updates.append("datum = ?")
|
|
|
|
|
|
params.append(body.datum)
|
|
|
|
|
|
if body.beschreibung is not None:
|
|
|
|
|
|
updates.append("beschreibung = ?")
|
|
|
|
|
|
params.append(body.beschreibung)
|
|
|
|
|
|
|
|
|
|
|
|
if not updates:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
|
|
|
|
|
|
|
|
|
|
|
updates.append("aktualisiert_at = ?")
|
|
|
|
|
|
params.append(datetime.now().isoformat())
|
|
|
|
|
|
params.append(frist_id)
|
|
|
|
|
|
|
|
|
|
|
|
conn.execute(f"UPDATE fristen SET {', '.join(updates)} WHERE id = ?", params)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
|
|
|
|
|
return dict(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{frist_id}")
|
|
|
|
|
|
def delete_frist(frist_id: int, conn=Depends(_db)):
|
|
|
|
|
|
"""Frist löschen."""
|
|
|
|
|
|
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
|
|
|
|
|
if not existing:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
conn.execute("DELETE FROM fristen WHERE id = ?", (frist_id,))
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return {"ok": True, "deleted": frist_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-02 23:16:53 +02:00
|
|
|
|
@router.get("/kalender.ics")
|
|
|
|
|
|
def fristen_kalender(conn=Depends(_db)):
|
|
|
|
|
|
"""ICS-Kalender-Feed für offene und überfällige Fristen."""
|
|
|
|
|
|
today = date.today()
|
|
|
|
|
|
|
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
|
"""SELECT f.id, f.typ, f.datum, f.beschreibung, f.status,
|
|
|
|
|
|
k.status AS kette_status,
|
|
|
|
|
|
v_ursprung.aktenzeichen AS kette_aktenzeichen,
|
|
|
|
|
|
v_ursprung.betreff AS kette_betreff
|
|
|
|
|
|
FROM fristen f
|
|
|
|
|
|
LEFT JOIN ketten k ON f.kette_id = k.id
|
|
|
|
|
|
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
|
|
|
|
|
|
WHERE f.status IN ('offen', 'überfällig')
|
|
|
|
|
|
OR (f.status = 'offen' AND f.datum < date('now'))
|
|
|
|
|
|
ORDER BY f.datum ASC""",
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
events = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
row = dict(r)
|
|
|
|
|
|
frist_datum = date.fromisoformat(row["datum"]) if row["datum"] else None
|
|
|
|
|
|
if not frist_datum:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
frist_id = row["id"]
|
|
|
|
|
|
typ = row["typ"] or "Frist"
|
|
|
|
|
|
beschreibung = row["beschreibung"] or ""
|
|
|
|
|
|
aktenzeichen = row["kette_aktenzeichen"] or "–"
|
|
|
|
|
|
kette_betreff = row["kette_betreff"] or ""
|
|
|
|
|
|
status = row["status"]
|
|
|
|
|
|
|
|
|
|
|
|
# Detect overdue
|
|
|
|
|
|
is_overdue = status == "überfällig" or (status == "offen" and frist_datum < today)
|
|
|
|
|
|
if is_overdue:
|
|
|
|
|
|
status = "überfällig"
|
|
|
|
|
|
|
|
|
|
|
|
# Build SUMMARY
|
|
|
|
|
|
summary_prefix = "\u26a0\ufe0f " if is_overdue else ""
|
|
|
|
|
|
summary = f"{summary_prefix}{typ}: {beschreibung} ({aktenzeichen})"
|
|
|
|
|
|
|
|
|
|
|
|
# Build DESCRIPTION
|
|
|
|
|
|
desc_parts = [beschreibung]
|
|
|
|
|
|
if is_overdue:
|
|
|
|
|
|
days_overdue = (today - frist_datum).days
|
|
|
|
|
|
desc_parts.append(f"\u00dcBERF\u00c4LLIG seit {days_overdue} Tagen")
|
|
|
|
|
|
desc_parts.append(f"Kette: {kette_betreff}")
|
|
|
|
|
|
desc_parts.append(f"Status: {status}")
|
|
|
|
|
|
desc_parts.append("")
|
|
|
|
|
|
desc_parts.append("https://antraege.toppyr.de/explorer")
|
|
|
|
|
|
description = "\\n".join(desc_parts)
|
|
|
|
|
|
|
|
|
|
|
|
# CATEGORIES + STATUS mapping
|
|
|
|
|
|
if is_overdue:
|
|
|
|
|
|
categories = "überfällig"
|
|
|
|
|
|
ics_status = "CONFIRMED"
|
|
|
|
|
|
else:
|
|
|
|
|
|
categories = "offen"
|
|
|
|
|
|
ics_status = "TENTATIVE"
|
|
|
|
|
|
|
|
|
|
|
|
dtstart = frist_datum.strftime("%Y%m%d")
|
|
|
|
|
|
dtend = (frist_datum + timedelta(days=1)).strftime("%Y%m%d")
|
|
|
|
|
|
dtstamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
|
|
|
|
|
|
event_lines = [
|
|
|
|
|
|
"BEGIN:VEVENT",
|
|
|
|
|
|
f"UID:frist-{frist_id}@antraege.toppyr.de",
|
|
|
|
|
|
f"DTSTAMP:{dtstamp}",
|
|
|
|
|
|
f"DTSTART;VALUE=DATE:{dtstart}",
|
|
|
|
|
|
f"DTEND;VALUE=DATE:{dtend}",
|
|
|
|
|
|
f"SUMMARY:{_ics_escape(summary)}",
|
|
|
|
|
|
f"DESCRIPTION:{_ics_escape(description)}",
|
|
|
|
|
|
"URL:https://antraege.toppyr.de/explorer",
|
|
|
|
|
|
f"CATEGORIES:{_ics_escape(categories)}",
|
|
|
|
|
|
f"STATUS:{ics_status}",
|
|
|
|
|
|
"BEGIN:VALARM",
|
|
|
|
|
|
"TRIGGER:-P7D",
|
|
|
|
|
|
"ACTION:DISPLAY",
|
|
|
|
|
|
f"DESCRIPTION:Frist in 7 Tagen: {_ics_escape(beschreibung)}",
|
|
|
|
|
|
"END:VALARM",
|
|
|
|
|
|
"END:VEVENT",
|
|
|
|
|
|
]
|
|
|
|
|
|
events.append("\r\n".join(event_lines))
|
|
|
|
|
|
|
|
|
|
|
|
cal_lines = [
|
|
|
|
|
|
"BEGIN:VCALENDAR",
|
|
|
|
|
|
"VERSION:2.0",
|
|
|
|
|
|
"PRODID:-//Antragstracker Hagen//Fristen//DE",
|
|
|
|
|
|
"CALSCALE:GREGORIAN",
|
|
|
|
|
|
"METHOD:PUBLISH",
|
|
|
|
|
|
"X-WR-CALNAME:Antragstracker Fristen",
|
|
|
|
|
|
"X-WR-TIMEZONE:Europe/Berlin",
|
|
|
|
|
|
"BEGIN:VTIMEZONE",
|
|
|
|
|
|
"TZID:Europe/Berlin",
|
|
|
|
|
|
"BEGIN:DAYLIGHT",
|
|
|
|
|
|
"TZNAME:CEST",
|
|
|
|
|
|
"TZOFFSETFROM:+0100",
|
|
|
|
|
|
"TZOFFSETTO:+0200",
|
|
|
|
|
|
"DTSTART:19700329T020000",
|
|
|
|
|
|
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
|
|
|
|
|
|
"END:DAYLIGHT",
|
|
|
|
|
|
"BEGIN:STANDARD",
|
|
|
|
|
|
"TZNAME:CET",
|
|
|
|
|
|
"TZOFFSETFROM:+0200",
|
|
|
|
|
|
"TZOFFSETTO:+0100",
|
|
|
|
|
|
"DTSTART:19701025T030000",
|
|
|
|
|
|
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
|
|
|
|
|
|
"END:STANDARD",
|
|
|
|
|
|
"END:VTIMEZONE",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for event in events:
|
|
|
|
|
|
cal_lines.append(event)
|
|
|
|
|
|
|
|
|
|
|
|
cal_lines.append("END:VCALENDAR")
|
|
|
|
|
|
|
|
|
|
|
|
ics_body = "\r\n".join(cal_lines) + "\r\n"
|
|
|
|
|
|
|
|
|
|
|
|
# Apply line folding (max 75 octets per line)
|
|
|
|
|
|
ics_body = _ics_fold(ics_body)
|
|
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=ics_body,
|
|
|
|
|
|
media_type="text/calendar; charset=utf-8",
|
|
|
|
|
|
headers={"Cache-Control": "public, max-age=3600"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ics_escape(text: str) -> str:
|
|
|
|
|
|
"""Escape text for ICS content lines (RFC 5545 §3.3.11)."""
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
text = text.replace("\\", "\\\\")
|
|
|
|
|
|
text = text.replace(";", "\\;")
|
|
|
|
|
|
text = text.replace(",", "\\,")
|
|
|
|
|
|
# Preserve intentional \\n sequences (literal newlines in ICS)
|
|
|
|
|
|
# but escape any actual newlines
|
|
|
|
|
|
text = text.replace("\n", "\\n")
|
|
|
|
|
|
text = text.replace("\r", "")
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ics_fold(text: str) -> str:
|
|
|
|
|
|
"""Fold lines longer than 75 octets per RFC 5545 §3.1."""
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for line in text.split("\r\n"):
|
|
|
|
|
|
encoded = line.encode("utf-8")
|
|
|
|
|
|
if len(encoded) <= 75:
|
|
|
|
|
|
result.append(line)
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Fold: first line 75 octets, continuations 74 (space prefix)
|
|
|
|
|
|
chunks = []
|
|
|
|
|
|
while len(encoded) > 75:
|
|
|
|
|
|
# Cut at 75 octets for first chunk, 74 for subsequent
|
|
|
|
|
|
cut = 75 if not chunks else 74
|
|
|
|
|
|
# Don't break in middle of multi-byte UTF-8
|
|
|
|
|
|
split_at = cut
|
|
|
|
|
|
while split_at > 0 and (encoded[split_at] & 0xC0) == 0x80:
|
|
|
|
|
|
split_at -= 1
|
|
|
|
|
|
chunk = encoded[:split_at].decode("utf-8")
|
|
|
|
|
|
encoded = encoded[split_at:]
|
|
|
|
|
|
if chunks:
|
|
|
|
|
|
chunks.append(" " + chunk)
|
|
|
|
|
|
else:
|
|
|
|
|
|
chunks.append(chunk)
|
|
|
|
|
|
# Remaining
|
|
|
|
|
|
remaining = encoded.decode("utf-8")
|
|
|
|
|
|
if remaining:
|
|
|
|
|
|
if chunks:
|
|
|
|
|
|
chunks.append(" " + remaining)
|
|
|
|
|
|
else:
|
|
|
|
|
|
chunks.append(remaining)
|
|
|
|
|
|
result.append("\r\n".join(chunks))
|
|
|
|
|
|
return "\r\n".join(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-02 00:43:40 +02:00
|
|
|
|
@router.get("/ueberfaellig")
|
|
|
|
|
|
def get_ueberfaellige(conn=Depends(_db)):
|
|
|
|
|
|
"""Überfällige Fristen abrufen und automatisch Status setzen."""
|
|
|
|
|
|
today = date.today().isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
# Update status for overdue fristen
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
"UPDATE fristen SET status = 'überfällig', aktualisiert_at = ? WHERE status = 'offen' AND datum < ?",
|
|
|
|
|
|
(datetime.now().isoformat(), today),
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
|
"""SELECT f.*,
|
|
|
|
|
|
k.status AS kette_status,
|
|
|
|
|
|
v_ursprung.aktenzeichen AS kette_aktenzeichen,
|
|
|
|
|
|
v_ursprung.betreff AS kette_betreff
|
|
|
|
|
|
FROM fristen f
|
|
|
|
|
|
LEFT JOIN ketten k ON f.kette_id = k.id
|
|
|
|
|
|
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
|
|
|
|
|
|
WHERE f.status = 'überfällig'
|
|
|
|
|
|
ORDER BY f.datum ASC""",
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
return {"items": [dict(r) for r in rows], "total": len(rows)}
|