diff --git a/README.md b/README.md index 9fd62fe..ed87ee5 100644 --- a/README.md +++ b/README.md @@ -204,12 +204,15 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich. | Methode | Pfad | Beschreibung | |---------|------|--------------| -| `GET` | `/` | Web-UI — zeigt Kontakte des eingeloggten Users (HTML) | -| `GET` | `/contacts/{id}` | Web-UI — Detailseite eines einzelnen Kontakts | +| `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | +| `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users | +| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts | | `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig | | `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) | | `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen | +| `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users | | `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage | +| `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung diff --git a/SPEC.md b/SPEC.md index be5cd93..9beaacd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -240,12 +240,15 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. | Endpunkt | Beschreibung | |---|---| -| `GET /` | Einfache HTML-Übersicht (Jinja2-Template), zeigt Kontakte des zugeordneten Accounts | +| `GET /` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | +| `GET /search` | HTML-Übersicht mit Suchfunktion, zeigt Kontakte des zugeordneten Accounts | | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | | `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | +| `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | +| `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) | | `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | ### 12.5 Netzwerkkontext diff --git a/src/api/main.py b/src/api/main.py index 000d14f..89bc7c1 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -11,15 +11,15 @@ import json import logging from datetime import date -from fastapi import FastAPI, Depends, Query, Request +from fastapi import Depends, FastAPI, Query, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from config import Config import db from api.auth import get_current_user, resolve_account_for_user from api.schemas import ContactListResponse, ContactOut, SyncRunOut +from config import Config logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("api") @@ -129,6 +129,25 @@ def birthdays_today(current_user: str = Depends(get_current_user)): return [_row_to_contact_out(r) for r in rows] +@app.get("/api/contacts/birthdays/upcoming") +def birthdays_upcoming( + days: int = Query(default=7, ge=1, le=90), + current_user: str = Depends(get_current_user), +): + account_name, is_admin = resolve_account_for_user(current_user) + with db.get_connection() as conn: + rows = db.get_upcoming_birthdays(conn, account_name, days) + return {"days": days, "items": rows} + + +@app.get("/api/contacts/count") +def contact_count(current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + with db.get_connection() as conn: + total = db.get_contact_count(conn, account_name) + return {"total": total} + + @app.get("/api/sync-runs", response_model=list[SyncRunOut]) def list_sync_runs(current_user: str = Depends(get_current_user)): account_name, is_admin = resolve_account_for_user(current_user) @@ -153,7 +172,47 @@ def list_sync_runs(current_user: str = Depends(get_current_user)): @app.get("/", response_class=HTMLResponse) -def web_index( +def web_dashboard( + request: Request, + current_user: str = Depends(get_current_user), +): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + contact_count = db.get_contact_count(conn, account_name) + upcoming_birthdays = db.get_upcoming_birthdays(conn, account_name, 7) + where_clause, params = _account_filter_clause(account_name) + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, account, sync_type, started_at, finished_at, status, + contacts_upserted, contacts_deleted, error_message + FROM sync_runs {where_clause} + ORDER BY started_at DESC + LIMIT 1""", + params, + ) + last_sync = cur.fetchone() + + if last_sync: + last_sync["started_at"] = str(last_sync["started_at"]) + last_sync["finished_at"] = str(last_sync["finished_at"]) if last_sync["finished_at"] else None + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "current_user": current_user, + "is_admin": is_admin, + "account_name": account_name or "alle Accounts", + "contact_count": contact_count, + "upcoming_birthdays": upcoming_birthdays, + "last_sync": last_sync, + }, + ) + + +@app.get("/search", response_class=HTMLResponse) +def web_search( request: Request, search: str | None = Query(default=None), current_user: str = Depends(get_current_user), @@ -215,7 +274,7 @@ def web_contact( if not row: from fastapi.responses import RedirectResponse - return RedirectResponse(url="/", status_code=303) + return RedirectResponse(url="/search", status_code=303) contact = _row_to_contact_out(row) diff --git a/src/api/templates/contact.html b/src/api/templates/contact.html index 724778a..64f3deb 100644 --- a/src/api/templates/contact.html +++ b/src/api/templates/contact.html @@ -182,7 +182,7 @@
- ← Zurück zur Übersicht + ← Zurück zur Übersicht
diff --git a/src/api/templates/dashboard.html b/src/api/templates/dashboard.html new file mode 100644 index 0000000..ede39c7 --- /dev/null +++ b/src/api/templates/dashboard.html @@ -0,0 +1,251 @@ + + + + + + + + + Dashboard – {{ account_name }} + + + +
+
+

Dashboard

+ +
+ +
+ Angemeldet als {{ current_user }} + {% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %} +
+ +
+
+
Kontakte
+
{{ contact_count }}
+
gespeicherte Kontakte
+
+ +
+
Letzter Sync
+ {% if last_sync %} +
+
+
+ {{ last_sync.status }} · {{ last_sync.sync_type }} +
+
+
+ {{ last_sync.started_at }} + {% if last_sync.contacts_upserted is not none %} + · {{ last_sync.contacts_upserted }} aktualisiert + {% endif %} + {% if last_sync.contacts_deleted is not none and last_sync.contacts_deleted > 0 %} + · {{ last_sync.contacts_deleted }} gelöscht + {% endif %} +
+ {% if last_sync.error_message %} +
+ {{ last_sync.error_message }} +
+ {% endif %} + {% else %} +
Noch kein Sync durchgeführt.
+ {% endif %} +
+
+ +
+
Geburtstage – nächste 7 Tage
+ {% if upcoming_birthdays %} +
+ {% for b in upcoming_birthdays %} +
+
+ {{ b.full_name or '(Kein Name)' }} + {% if b.organization %} + · {{ b.organization }} + {% endif %} +
+
{{ b.birthday }}
+
+ {% endfor %} +
+ {% else %} +
Keine Geburtstage in den nächsten 7 Tagen.
+ {% endif %} +
+
+ + diff --git a/src/api/templates/index.html b/src/api/templates/index.html index 11ea19a..f2d8fbf 100644 --- a/src/api/templates/index.html +++ b/src/api/templates/index.html @@ -173,11 +173,11 @@ · {{ contacts|length }} Kontakte (max. 200 angezeigt)
-
+ {% if search %} - Zurücksetzen + Zurücksetzen {% endif %}
diff --git a/src/db.py b/src/db.py index ee9465e..d420acb 100644 --- a/src/db.py +++ b/src/db.py @@ -5,6 +5,7 @@ import logging import os import uuid from contextlib import contextmanager +from datetime import date import pymysql from pymysql.cursors import DictCursor @@ -121,6 +122,42 @@ def delete_contacts_by_href_uids(conn, account: str, uids: list[str]): conn.commit() +def _account_filter_clause(account_name: str | None) -> tuple[str, list]: + if account_name is None: + return "", [] + return "WHERE account = %s", [account_name] + + +def get_contact_count(conn, account: str | None) -> int: + where_clause, params = _account_filter_clause(account) + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}", params) + return cur.fetchone()["total"] + + +def get_upcoming_birthdays(conn, account: str | None, days: int = 7) -> list[dict]: + where_clause, params = _account_filter_clause(account) + today = date.today() + with conn.cursor() as cur: + cur.execute( + f"""SELECT id, full_name, organization, birthday, account + FROM contacts {where_clause} + {"AND" if where_clause else "WHERE"} birthday IS NOT NULL + AND ( + (MONTH(birthday) > %s) + OR (MONTH(birthday) = %s AND DAY(birthday) >= %s) + ) + AND ( + (MONTH(birthday) < %s) + OR (MONTH(birthday) = %s AND DAY(birthday) <= %s + %s) + ) + ORDER BY MONTH(birthday), DAY(birthday)""", + params + [today.month, today.month, today.day, + today.month, today.month, today.day, days], + ) + return cur.fetchall() + + def _sanitize_contact(c: dict) -> dict: """Stellt sicher, dass alle Werte Skalare sind (kein tuple/list/dict).""" sanitized = {}