This makes it possible to exclude specific editors (e.g. Aranzinger) from edited-article results by default, including dashboard generation and scheduled SMTP runs. Co-authored-by: Cursor <cursoragent@cursor.com>
889 lines
33 KiB
Python
889 lines
33 KiB
Python
import asyncio
|
||
import logging
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from urllib.parse import urlencode
|
||
|
||
from pydantic import ValidationError
|
||
|
||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
|
||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from sqlalchemy import inspect, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.cookies import cookie_secure
|
||
from app.core.email_utils import parse_recipient_list
|
||
from app.core.rate_limit import check_login_rate_limit, clear_login_attempts, record_failed_login
|
||
from app.core.security import create_access_token, hash_password, verify_password
|
||
from app.core.startup_checks import validate_startup_config
|
||
from app.db.session import Base, engine, get_db
|
||
from app.models.system import SendLog
|
||
from app.models.user import User
|
||
from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate
|
||
from app.services.auth import can_view_sensitive_logs, get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
|
||
from app.services.config_store import get_config, has_secret_config, set_config
|
||
from app.services.session_tokens import invalidate_user_sessions
|
||
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
|
||
from app.services.newsletter import (
|
||
DEFAULT_DISPLAY,
|
||
article_url,
|
||
create_html,
|
||
create_outlook_html,
|
||
create_plain_text,
|
||
create_subject,
|
||
filter_articles,
|
||
parse_excluded_users,
|
||
parse_display_options,
|
||
parse_highlights,
|
||
resolve_period,
|
||
split_articles_by_type,
|
||
)
|
||
from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter
|
||
from app.services.wiki import WikiFetchError, WikiService
|
||
|
||
validate_startup_config()
|
||
|
||
_openapi_url = None if settings.is_production else "/openapi.json"
|
||
_docs_url = None if settings.is_production else "/docs"
|
||
_redoc_url = None if settings.is_production else "/redoc"
|
||
app = FastAPI(
|
||
title=settings.app_name,
|
||
docs_url=_docs_url,
|
||
redoc_url=_redoc_url,
|
||
openapi_url=_openapi_url,
|
||
)
|
||
allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()]
|
||
if allowed_hosts:
|
||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||
templates = Jinja2Templates(directory="app/templates")
|
||
templates.env.globals["wiki_article_url"] = article_url
|
||
wiki_service = WikiService()
|
||
|
||
logger = logging.getLogger("newsletter.scheduler")
|
||
SCHEDULER_INTERVAL_SECONDS = 60
|
||
_scheduler_task: asyncio.Task | None = None
|
||
|
||
|
||
@app.on_event("startup")
|
||
def on_startup() -> None:
|
||
Path("data").mkdir(parents=True, exist_ok=True)
|
||
Base.metadata.create_all(bind=engine)
|
||
_ensure_schema_upgrades()
|
||
_bootstrap_admin()
|
||
global _scheduler_task
|
||
_scheduler_task = asyncio.create_task(_scheduler_loop())
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
async def on_shutdown() -> None:
|
||
if _scheduler_task is not None:
|
||
_scheduler_task.cancel()
|
||
|
||
|
||
async def _scheduler_loop() -> None:
|
||
"""Prüft periodisch, ob laut Zeitplan ein Newsletter-Versand fällig ist."""
|
||
while True:
|
||
try:
|
||
await asyncio.sleep(SCHEDULER_INTERVAL_SECONDS)
|
||
await _run_scheduled_send_if_due()
|
||
except asyncio.CancelledError:
|
||
break
|
||
except Exception:
|
||
logger.exception("Fehler im Scheduler-Loop")
|
||
|
||
|
||
async def _run_scheduled_send_if_due() -> None:
|
||
db = next(get_db())
|
||
try:
|
||
settings_snapshot = get_smtp_settings(db)
|
||
now = datetime.now()
|
||
if not schedule_is_due(settings_snapshot, now):
|
||
return
|
||
|
||
try:
|
||
days = int(settings_snapshot.get("gen_days") or "30")
|
||
except ValueError:
|
||
days = 30
|
||
gen = await _generate_newsletter(
|
||
period_mode=settings_snapshot.get("gen_period", "days"),
|
||
days=max(1, min(days, 90)),
|
||
article_type=settings_snapshot.get("gen_article_type", "all"),
|
||
category=settings_snapshot.get("gen_category", ""),
|
||
display=DEFAULT_DISPLAY,
|
||
editor_tip="",
|
||
highlight_list=[],
|
||
contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""),
|
||
excluded_users_raw=settings_snapshot.get("gen_excluded_users", ""),
|
||
)
|
||
if gen["wiki_error"] or not gen["articles"]:
|
||
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
|
||
logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).")
|
||
return
|
||
|
||
try:
|
||
recipients = parse_recipient_list(settings_snapshot.get("recipients", ""))
|
||
except ValueError:
|
||
logger.warning("Geplanter Versand übersprungen (ungültige Empfängerliste).")
|
||
return
|
||
|
||
send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=True)
|
||
mark_scheduled_sent(db, now)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _ensure_schema_upgrades() -> None:
|
||
inspector = inspect(engine)
|
||
with engine.begin() as conn:
|
||
if "users" in inspector.get_table_names():
|
||
cols = {c["name"] for c in inspector.get_columns("users")}
|
||
if "role" not in cols:
|
||
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'reader'"))
|
||
conn.execute(text("UPDATE users SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'reader' END"))
|
||
if "session_version" not in cols:
|
||
conn.execute(text("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0"))
|
||
|
||
|
||
@app.middleware("http")
|
||
async def add_security_headers(request: Request, call_next):
|
||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||
request.state.csrf_token = cookie_token or generate_csrf_token()
|
||
|
||
response: Response = await call_next(request)
|
||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||
response.headers["X-Frame-Options"] = "DENY"
|
||
response.headers["Referrer-Policy"] = "same-origin"
|
||
# Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP.
|
||
# E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host.
|
||
path = request.url.path
|
||
if path.startswith("/dashboard"):
|
||
csp = (
|
||
"default-src 'self'; "
|
||
"img-src 'self' https: data:; "
|
||
"style-src 'self' 'unsafe-inline'; "
|
||
"script-src 'self'; "
|
||
"frame-src 'self';"
|
||
)
|
||
else:
|
||
csp = (
|
||
"default-src 'self'; "
|
||
"img-src 'self' https: data:; "
|
||
"style-src 'self'; "
|
||
"script-src 'self';"
|
||
)
|
||
response.headers["Content-Security-Policy"] = csp
|
||
if settings.is_production and cookie_secure(request):
|
||
response.headers["Strict-Transport-Security"] = f"max-age={settings.hsts_max_age}; includeSubDomains"
|
||
if path not in {"/login", "/static"} and not path.startswith("/static/"):
|
||
response.headers["Cache-Control"] = "no-store"
|
||
response.headers["Pragma"] = "no-cache"
|
||
if not cookie_token:
|
||
response.set_cookie(
|
||
CSRF_COOKIE_NAME,
|
||
request.state.csrf_token,
|
||
httponly=True,
|
||
secure=cookie_secure(request),
|
||
samesite="strict",
|
||
)
|
||
return response
|
||
|
||
|
||
def _bootstrap_admin() -> None:
|
||
db = next(get_db())
|
||
try:
|
||
existing = db.query(User).filter(User.email == settings.admin_bootstrap_email).first()
|
||
if existing:
|
||
if settings.admin_bootstrap_reset:
|
||
existing.password_hash = hash_password(settings.admin_bootstrap_password)
|
||
existing.is_admin = True
|
||
existing.role = "admin"
|
||
existing.is_active = True
|
||
db.commit()
|
||
return
|
||
user = User(
|
||
email=settings.admin_bootstrap_email,
|
||
full_name="Initial Admin",
|
||
password_hash=hash_password(settings.admin_bootstrap_password),
|
||
is_admin=True,
|
||
role="admin",
|
||
is_active=True,
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _smtp_context(db: Session, *, include_recipients: bool) -> dict[str, str]:
|
||
smtp = get_smtp_settings(db)
|
||
safe = {key: value for key, value in smtp.items() if key != "password"}
|
||
safe["password_configured"] = "true" if has_secret_config(db, "smtp.password") or smtp.get("password") else "false"
|
||
if not include_recipients:
|
||
safe["recipients"] = ""
|
||
return safe
|
||
|
||
|
||
def _base_context(request: Request, user: User, db: Session) -> dict:
|
||
path = request.url.path
|
||
active_nav = "dashboard"
|
||
if path.startswith("/admin/users"):
|
||
active_nav = "users"
|
||
elif path.startswith("/profil"):
|
||
active_nav = "profile"
|
||
return {
|
||
"user": user,
|
||
"csrf_token": ensure_csrf_cookie(request),
|
||
"smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)),
|
||
"can_view_sensitive": can_view_sensitive_logs(user),
|
||
"active_nav": active_nav,
|
||
"editor_tip_max_length": settings.editor_tip_max_length,
|
||
"highlights_max_length": settings.highlights_max_length,
|
||
}
|
||
|
||
|
||
def _render(request: Request, name: str, context: dict) -> HTMLResponse:
|
||
return templates.TemplateResponse(request=request, name=name, context=context)
|
||
|
||
|
||
def _wants_html(request: Request) -> bool:
|
||
accept = request.headers.get("accept", "")
|
||
return "text/html" in accept or "*/*" in accept or accept == ""
|
||
|
||
|
||
@app.exception_handler(HTTPException)
|
||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||
if exc.status_code == status.HTTP_401_UNAUTHORIZED and _wants_html(request):
|
||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||
if exc.status_code == status.HTTP_403_FORBIDDEN and _wants_html(request):
|
||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def index(request: Request, db: Session = Depends(get_db)):
|
||
user = get_optional_user(request, db)
|
||
target = "/dashboard" if user else "/login"
|
||
return RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||
if get_optional_user(request, db):
|
||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||
return _render(
|
||
request,
|
||
"login.html",
|
||
{
|
||
"csrf_token": ensure_csrf_cookie(request),
|
||
"error_message": None,
|
||
},
|
||
)
|
||
|
||
|
||
@app.post("/login")
|
||
def login(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
email: str = Form(...),
|
||
password: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
try:
|
||
validate_csrf(request, csrf_token)
|
||
except HTTPException:
|
||
return _render(
|
||
request,
|
||
"login.html",
|
||
{
|
||
"csrf_token": ensure_csrf_cookie(request),
|
||
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||
},
|
||
)
|
||
try:
|
||
check_login_rate_limit(request, email)
|
||
except HTTPException:
|
||
return _render(
|
||
request,
|
||
"login.html",
|
||
{
|
||
"csrf_token": ensure_csrf_cookie(request),
|
||
"error_message": "Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.",
|
||
},
|
||
)
|
||
user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first()
|
||
if not user or not verify_password(password, user.password_hash):
|
||
record_failed_login(request, email)
|
||
return _render(
|
||
request,
|
||
"login.html",
|
||
{
|
||
"csrf_token": ensure_csrf_cookie(request),
|
||
"error_message": "Ungültige Zugangsdaten.",
|
||
},
|
||
)
|
||
clear_login_attempts(request, email)
|
||
token = create_access_token(user.id, session_version=user.session_version or 0)
|
||
max_age = settings.access_token_expire_minutes * 60
|
||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||
response.set_cookie(
|
||
"access_token",
|
||
token,
|
||
httponly=True,
|
||
secure=cookie_secure(request),
|
||
samesite="strict",
|
||
max_age=max_age,
|
||
)
|
||
return response
|
||
|
||
|
||
@app.post("/logout")
|
||
def logout(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
current_user = get_optional_user(request, db)
|
||
if current_user:
|
||
invalidate_user_sessions(current_user, db)
|
||
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||
response.delete_cookie("access_token")
|
||
return response
|
||
|
||
|
||
def _empty_filters() -> dict:
|
||
return {
|
||
"days": 30,
|
||
"period": "last_month",
|
||
"article_type": "new",
|
||
"category": "",
|
||
"excluded_users": settings.default_excluded_users,
|
||
"display": DEFAULT_DISPLAY,
|
||
"editor_tip": "",
|
||
"highlights": "",
|
||
}
|
||
|
||
|
||
async def _generate_newsletter(
|
||
*,
|
||
period_mode: str,
|
||
days: int,
|
||
article_type: str,
|
||
category: str,
|
||
display,
|
||
editor_tip: str,
|
||
highlight_list: list[str],
|
||
contact_email: str = "",
|
||
excluded_users_raw: str = "",
|
||
) -> dict:
|
||
p = resolve_period(period_mode, days)
|
||
result = {
|
||
"period": p,
|
||
"wiki_error": None,
|
||
"articles": [],
|
||
"articles_new": [],
|
||
"articles_edited": [],
|
||
"plain_text": "",
|
||
"raw_html": "",
|
||
"outlook_html": "",
|
||
"subject": "",
|
||
}
|
||
try:
|
||
excluded_users = parse_excluded_users(excluded_users_raw)
|
||
articles = await wiki_service.get_recent_changes(
|
||
days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
|
||
)
|
||
filtered = filter_articles(articles, category_filter=category or None)
|
||
new_articles, edited_articles = split_articles_by_type(filtered, excluded_edited_users=excluded_users)
|
||
prange = (p["start_str"], p["end_str"])
|
||
result.update(
|
||
{
|
||
"articles": filtered,
|
||
"articles_new": new_articles,
|
||
"articles_edited": edited_articles,
|
||
"subject": create_subject(p["month_label"], p["days"]),
|
||
"plain_text": create_plain_text(
|
||
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email, excluded_users
|
||
),
|
||
"raw_html": create_html(
|
||
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
|
||
),
|
||
"outlook_html": create_outlook_html(
|
||
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
|
||
),
|
||
}
|
||
)
|
||
except WikiFetchError as exc:
|
||
result["wiki_error"] = exc.message
|
||
return result
|
||
|
||
|
||
@app.get("/dashboard", response_class=HTMLResponse)
|
||
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||
context = _base_context(request, current_user, db)
|
||
send_status = request.query_params.get("send_status")
|
||
send_result = None
|
||
if send_status:
|
||
send_result = {"status": send_status, "message": request.query_params.get("send_msg", "")}
|
||
context.update({
|
||
"articles": [],
|
||
"articles_new": [],
|
||
"articles_edited": [],
|
||
"raw_html": "",
|
||
"outlook_html": "",
|
||
"plain_text": "",
|
||
"subject": "",
|
||
"filters": _empty_filters(),
|
||
"wiki_error": None,
|
||
"send_result": send_result,
|
||
})
|
||
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
|
||
context["send_logs"] = logs
|
||
return _render(request, "dashboard.html", context)
|
||
|
||
|
||
@app.post("/dashboard", response_class=HTMLResponse)
|
||
async def dashboard_generate(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
period: str = Form("days"),
|
||
days: int = Form(30),
|
||
article_type: str = Form("all"),
|
||
category: str = Form(""),
|
||
excluded_users: str = Form(""),
|
||
show_date: str | None = Form(None),
|
||
show_user: str | None = Form(None),
|
||
show_category: str | None = Form(None),
|
||
editor_tip: str = Form(""),
|
||
highlights: str = Form(""),
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_editor_or_admin),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
if len(editor_tip) > settings.editor_tip_max_length:
|
||
editor_tip = editor_tip[: settings.editor_tip_max_length]
|
||
if len(highlights) > settings.highlights_max_length:
|
||
highlights = highlights[: settings.highlights_max_length]
|
||
days = max(1, min(days, 90))
|
||
if article_type not in ("all", "new", "edited"):
|
||
article_type = "all"
|
||
display = parse_display_options(show_date, show_user, show_category)
|
||
highlight_list = parse_highlights(highlights)
|
||
smtp_cfg = get_smtp_settings(db)
|
||
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
|
||
|
||
gen = await _generate_newsletter(
|
||
period_mode=period,
|
||
days=days,
|
||
article_type=article_type,
|
||
category=category,
|
||
display=display,
|
||
editor_tip=editor_tip,
|
||
highlight_list=highlight_list,
|
||
contact_email=contact_email,
|
||
excluded_users_raw=excluded_users,
|
||
)
|
||
|
||
context = _base_context(request, current_user, db)
|
||
context.update(
|
||
{
|
||
"articles": gen["articles"],
|
||
"articles_new": gen["articles_new"],
|
||
"articles_edited": gen["articles_edited"],
|
||
"raw_html": gen["raw_html"],
|
||
"outlook_html": gen["outlook_html"],
|
||
"plain_text": gen["plain_text"],
|
||
"subject": gen["subject"],
|
||
"filters": {
|
||
"days": days,
|
||
"period": period,
|
||
"article_type": article_type,
|
||
"category": category,
|
||
"excluded_users": excluded_users,
|
||
"display": display,
|
||
"editor_tip": editor_tip,
|
||
"highlights": highlights,
|
||
},
|
||
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
|
||
"wiki_error": gen["wiki_error"],
|
||
"send_result": None,
|
||
}
|
||
)
|
||
return _render(request, "dashboard.html", context)
|
||
|
||
|
||
@app.post("/newsletter/send-now", response_class=HTMLResponse)
|
||
async def send_now(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
period: str = Form("days"),
|
||
days: int = Form(30),
|
||
article_type: str = Form("all"),
|
||
category: str = Form(""),
|
||
excluded_users: str = Form(""),
|
||
show_date: str | None = Form(None),
|
||
show_user: str | None = Form(None),
|
||
show_category: str | None = Form(None),
|
||
editor_tip: str = Form(""),
|
||
highlights: str = Form(""),
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_editor_or_admin),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
if len(editor_tip) > settings.editor_tip_max_length:
|
||
editor_tip = editor_tip[: settings.editor_tip_max_length]
|
||
if len(highlights) > settings.highlights_max_length:
|
||
highlights = highlights[: settings.highlights_max_length]
|
||
display = parse_display_options(show_date, show_user, show_category)
|
||
if article_type not in ("all", "new", "edited"):
|
||
article_type = "all"
|
||
highlight_list = parse_highlights(highlights)
|
||
days = max(1, min(days, 90))
|
||
smtp_cfg = get_smtp_settings(db)
|
||
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
|
||
|
||
gen = await _generate_newsletter(
|
||
period_mode=period,
|
||
days=days,
|
||
article_type=article_type,
|
||
category=category,
|
||
display=display,
|
||
editor_tip=editor_tip,
|
||
highlight_list=highlight_list,
|
||
contact_email=contact_email,
|
||
excluded_users_raw=excluded_users,
|
||
)
|
||
|
||
send_result: dict[str, str]
|
||
if gen["wiki_error"]:
|
||
send_result = {"status": "failed", "message": f"Versand abgebrochen – Wiki-Fehler: {gen['wiki_error']}"}
|
||
elif not gen["articles"]:
|
||
send_result = {"status": "failed", "message": "Versand abgebrochen – im Zeitraum wurden keine Artikel gefunden."}
|
||
else:
|
||
try:
|
||
recipients = parse_recipient_list(get_config(db, "smtp.recipients", ""))
|
||
except ValueError as exc:
|
||
send_result = {"status": "failed", "message": f"Versand abgebrochen – {exc}"}
|
||
else:
|
||
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
|
||
send_result = {"status": status_code, "message": detail}
|
||
|
||
query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]})
|
||
return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.post("/admin/smtp")
|
||
def update_smtp_settings(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
enabled: str | None = Form(None),
|
||
host: str = Form(""),
|
||
port: str = Form("587"),
|
||
username: str = Form(""),
|
||
password: str = Form(""),
|
||
from_email: str = Form(""),
|
||
from_name: str = Form(""),
|
||
reply_to: str = Form(""),
|
||
use_tls: str | None = Form(None),
|
||
recipients: str = Form(""),
|
||
schedule_enabled: str | None = Form(None),
|
||
schedule_frequency: str = Form("monthly"),
|
||
schedule_time: str = Form("08:00"),
|
||
schedule_weekdays: list[str] = Form(default=[]),
|
||
schedule_dom: str = Form("1"),
|
||
schedule_acknowledged: str | None = Form(None),
|
||
gen_period: str = Form("days"),
|
||
gen_days: str = Form("30"),
|
||
gen_article_type: str = Form("all"),
|
||
gen_category: str = Form(""),
|
||
gen_excluded_users: str = Form(""),
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(get_admin_user),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
set_config(db, "smtp.enabled", "true" if enabled == "true" else "false")
|
||
set_config(db, "smtp.host", host.strip())
|
||
set_config(db, "smtp.port", port.strip() or "587")
|
||
set_config(db, "smtp.username", username.strip())
|
||
if password.strip():
|
||
set_config(db, "smtp.password", password.strip())
|
||
set_config(db, "smtp.from_email", from_email.strip())
|
||
set_config(db, "smtp.from_name", from_name.strip())
|
||
set_config(db, "smtp.reply_to", reply_to.strip())
|
||
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false")
|
||
try:
|
||
validated_recipients = parse_recipient_list(recipients)
|
||
set_config(db, "smtp.recipients", ", ".join(validated_recipients))
|
||
except ValueError:
|
||
set_config(db, "smtp.recipients", recipients.strip())
|
||
|
||
set_config(db, "smtp.schedule_enabled", "true" if schedule_enabled == "true" else "false")
|
||
set_config(db, "smtp.schedule_acknowledged", "true" if schedule_acknowledged == "true" else "false")
|
||
if schedule_frequency not in ("daily", "weekly", "monthly"):
|
||
schedule_frequency = "monthly"
|
||
set_config(db, "smtp.schedule_frequency", schedule_frequency)
|
||
set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00")
|
||
valid_weekdays = [d for d in schedule_weekdays if d in {"0", "1", "2", "3", "4", "5", "6"}]
|
||
set_config(db, "smtp.schedule_weekdays", ",".join(valid_weekdays))
|
||
try:
|
||
dom = max(1, min(int(schedule_dom), 28))
|
||
except ValueError:
|
||
dom = 1
|
||
set_config(db, "smtp.schedule_dom", str(dom))
|
||
|
||
if gen_period not in ("days", "last_month"):
|
||
gen_period = "days"
|
||
set_config(db, "smtp.gen_period", gen_period)
|
||
try:
|
||
gdays = max(1, min(int(gen_days), 90))
|
||
except ValueError:
|
||
gdays = 30
|
||
set_config(db, "smtp.gen_days", str(gdays))
|
||
if gen_article_type not in ("all", "new", "edited"):
|
||
gen_article_type = "all"
|
||
set_config(db, "smtp.gen_article_type", gen_article_type)
|
||
set_config(db, "smtp.gen_category", gen_category.strip())
|
||
set_config(db, "smtp.gen_excluded_users", gen_excluded_users.strip())
|
||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
def _users_context(request: Request, admin: User, db: Session, **extra) -> dict:
|
||
context = _base_context(request, admin, db)
|
||
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
|
||
context.setdefault("error_message", None)
|
||
context.setdefault("success_message", None)
|
||
context.update(extra)
|
||
return context
|
||
|
||
|
||
def _format_validation_error(exc: ValidationError) -> str:
|
||
messages: list[str] = []
|
||
for err in exc.errors():
|
||
msg = err.get("msg", "Ungültige Eingabe")
|
||
if msg.startswith("Value error, "):
|
||
msg = msg[13:]
|
||
messages.append(msg)
|
||
return " ".join(messages) if messages else "Ungültige Eingabe."
|
||
|
||
|
||
@app.get("/admin/users", response_class=HTMLResponse)
|
||
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||
created = request.query_params.get("created") == "1"
|
||
return _render(
|
||
request,
|
||
"users.html",
|
||
_users_context(
|
||
request,
|
||
admin,
|
||
db,
|
||
success_message="Benutzer wurde erfolgreich angelegt." if created else None,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/admin/users", response_class=HTMLResponse)
|
||
def create_user(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
email: str = Form(...),
|
||
full_name: str = Form(...),
|
||
password: str = Form(...),
|
||
role: str = Form("reader"),
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(get_admin_user),
|
||
):
|
||
try:
|
||
validate_csrf(request, csrf_token)
|
||
except HTTPException:
|
||
return _render(
|
||
request,
|
||
"users.html",
|
||
_users_context(request, admin, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
|
||
)
|
||
|
||
try:
|
||
payload = UserCreate(email=email.strip(), full_name=full_name.strip(), password=password, role=role)
|
||
except ValidationError as exc:
|
||
return _render(
|
||
request,
|
||
"users.html",
|
||
_users_context(request, admin, db, error_message=_format_validation_error(exc)),
|
||
)
|
||
|
||
existing = db.query(User).filter(User.email == payload.email).first()
|
||
if existing:
|
||
return _render(
|
||
request,
|
||
"users.html",
|
||
_users_context(request, admin, db, error_message="Diese E-Mail-Adresse ist bereits registriert."),
|
||
)
|
||
|
||
user = User(
|
||
email=payload.email,
|
||
full_name=payload.full_name,
|
||
password_hash=hash_password(payload.password),
|
||
is_admin=payload.role == "admin",
|
||
role=payload.role,
|
||
is_active=True,
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
def _profile_context(request: Request, user: User, db: Session, **extra) -> dict:
|
||
context = _base_context(request, user, db)
|
||
context.setdefault("error_message", None)
|
||
context.setdefault("pw_error_message", None)
|
||
context.setdefault("success_message", None)
|
||
context.update(extra)
|
||
return context
|
||
|
||
|
||
@app.get("/profil", response_class=HTMLResponse)
|
||
def profile_page(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||
success = None
|
||
if request.query_params.get("updated") == "1":
|
||
success = "Profil wurde aktualisiert."
|
||
elif request.query_params.get("pw") == "1":
|
||
success = "Passwort wurde geändert."
|
||
return _render(request, "profile.html", _profile_context(request, current_user, db, success_message=success))
|
||
|
||
|
||
@app.post("/profil", response_class=HTMLResponse)
|
||
def update_profile(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
email: str = Form(...),
|
||
full_name: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
try:
|
||
validate_csrf(request, csrf_token)
|
||
except HTTPException:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
|
||
)
|
||
|
||
try:
|
||
payload = ProfileUpdate(email=email.strip(), full_name=full_name.strip())
|
||
except ValidationError as exc:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, error_message=_format_validation_error(exc)),
|
||
)
|
||
|
||
clash = db.query(User).filter(User.email == payload.email, User.id != current_user.id).first()
|
||
if clash:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, error_message="Diese E-Mail-Adresse wird bereits verwendet."),
|
||
)
|
||
|
||
current_user.email = payload.email
|
||
current_user.full_name = payload.full_name
|
||
db.commit()
|
||
return RedirectResponse(url="/profil?updated=1", status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
@app.post("/profil/passwort", response_class=HTMLResponse)
|
||
def change_password(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
current_password: str = Form(...),
|
||
new_password: str = Form(...),
|
||
confirm_password: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
try:
|
||
validate_csrf(request, csrf_token)
|
||
except HTTPException:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, pw_error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
|
||
)
|
||
|
||
if not verify_password(current_password, current_user.password_hash):
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, pw_error_message="Das aktuelle Passwort ist nicht korrekt."),
|
||
)
|
||
|
||
if new_password != confirm_password:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, pw_error_message="Die neuen Passwörter stimmen nicht überein."),
|
||
)
|
||
|
||
try:
|
||
payload = PasswordChange(new_password=new_password)
|
||
except ValidationError as exc:
|
||
return _render(
|
||
request,
|
||
"profile.html",
|
||
_profile_context(request, current_user, db, pw_error_message=_format_validation_error(exc)),
|
||
)
|
||
|
||
current_user.password_hash = hash_password(payload.new_password)
|
||
invalidate_user_sessions(current_user, db)
|
||
db.commit()
|
||
return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
@app.post("/admin/users/{user_id}/role", response_class=HTMLResponse)
|
||
def update_user_role(
|
||
request: Request,
|
||
user_id: int,
|
||
csrf_token: str = Form(...),
|
||
role: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(get_admin_user),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
target = db.query(User).filter(User.id == user_id).first()
|
||
if not target:
|
||
return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden."))
|
||
if target.id == admin.id:
|
||
return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigene Rolle kann hier nicht geändert werden."))
|
||
try:
|
||
payload = UserRoleUpdate(role=role)
|
||
except ValidationError as exc:
|
||
return _render(request, "users.html", _users_context(request, admin, db, error_message=_format_validation_error(exc)))
|
||
target.role = payload.role
|
||
target.is_admin = payload.role == "admin"
|
||
invalidate_user_sessions(target, db)
|
||
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
|
||
|
||
|
||
@app.post("/admin/users/{user_id}/toggle-active", response_class=HTMLResponse)
|
||
def toggle_user_active(
|
||
request: Request,
|
||
user_id: int,
|
||
csrf_token: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
admin: User = Depends(get_admin_user),
|
||
):
|
||
validate_csrf(request, csrf_token)
|
||
target = db.query(User).filter(User.id == user_id).first()
|
||
if not target:
|
||
return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden."))
|
||
if target.id == admin.id:
|
||
return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigenes Konto kann nicht deaktiviert werden."))
|
||
target.is_active = not target.is_active
|
||
invalidate_user_sessions(target, db)
|
||
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
|