web dashboard

This commit is contained in:
2026-08-05 05:56:28 +02:00
parent af922f23ba
commit cf3563124f
7 changed files with 363 additions and 10 deletions
+63 -4
View File
@@ -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)
+1 -1
View File
@@ -182,7 +182,7 @@
</head>
<body>
<div class="container">
<a href="/{% if search %}?search={{ search }}{% endif %}" class="back-link">← Zurück zur Übersicht</a>
<a href="/search{% if search %}?search={{ search }}{% endif %}" class="back-link">← Zurück zur Übersicht</a>
<div class="contact-card">
<div class="contact-header">
+251
View File
@@ -0,0 +1,251 @@
<!-- Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) -->
<!-- Licensed under the MIT License. See LICENSE file in project root for details. -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Dashboard {{ account_name }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
font-size: 1.5rem;
color: #222;
}
.nav-links {
display: flex;
gap: 0.75rem;
}
.nav-links a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #0066cc;
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 0.875rem;
transition: background 0.15s;
}
.nav-links a:hover {
background: #0055aa;
}
.meta {
color: #666;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 600px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.box {
background: #fff;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.box-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
margin-bottom: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #222;
}
.stat-label {
font-size: 0.875rem;
color: #666;
margin-top: 0.25rem;
}
.sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.success {
background: #28a745;
}
.status-dot.failed {
background: #dc3545;
}
.status-dot.running {
background: #ffc107;
}
.sync-detail {
font-size: 0.875rem;
color: #666;
}
.sync-detail strong {
color: #333;
}
.birthday-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.birthday-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f8f9fa;
border-radius: 6px;
}
.birthday-name {
font-size: 0.875rem;
color: #333;
}
.birthday-date {
font-size: 0.8rem;
color: #888;
white-space: nowrap;
}
.empty-state {
color: #888;
font-size: 0.875rem;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Dashboard</h1>
<div class="nav-links">
<a href="/search">Suche</a>
</div>
</div>
<div class="meta">
Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %}
</div>
<div class="dashboard-grid">
<div class="box">
<div class="box-title">Kontakte</div>
<div class="stat-value">{{ contact_count }}</div>
<div class="stat-label">gespeicherte Kontakte</div>
</div>
<div class="box">
<div class="box-title">Letzter Sync</div>
{% if last_sync %}
<div class="sync-status">
<div class="status-dot {{ last_sync.status }}"></div>
<div class="sync-detail">
<strong>{{ last_sync.status }}</strong> · {{ last_sync.sync_type }}
</div>
</div>
<div class="sync-detail">
{{ 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 %}
</div>
{% if last_sync.error_message %}
<div class="sync-detail" style="color: #dc3545; margin-top: 0.5rem;">
{{ last_sync.error_message }}
</div>
{% endif %}
{% else %}
<div class="empty-state">Noch kein Sync durchgeführt.</div>
{% endif %}
</div>
</div>
<div class="box">
<div class="box-title">Geburtstage nächste 7 Tage</div>
{% if upcoming_birthdays %}
<div class="birthday-list">
{% for b in upcoming_birthdays %}
<div class="birthday-item">
<div class="birthday-name">
<a href="/contacts/{{ b.id }}" style="color: #0066cc; text-decoration: none;">{{ b.full_name or '(Kein Name)' }}</a>
{% if b.organization %}
<span style="color: #888; font-size: 0.8rem;"> · {{ b.organization }}</span>
{% endif %}
</div>
<div class="birthday-date">{{ b.birthday }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">Keine Geburtstage in den nächsten 7 Tagen.</div>
{% endif %}
</div>
</div>
</body>
</html>
+2 -2
View File
@@ -173,11 +173,11 @@
· {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div>
<form class="search-form" method="get" action="/">
<form class="search-form" method="get" action="/search">
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Name oder Organisation...">
<button type="submit">Suchen</button>
{% if search %}
<a href="/">Zurücksetzen</a>
<a href="/search">Zurücksetzen</a>
{% endif %}
</form>
+37
View File
@@ -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 = {}