from __future__ import annotations """API routes for Fristen-Tracking (Issue #17).""" from datetime import date, datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response 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} @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) @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)}