Files
TK-Wiki-Newsletter/app/main.py
smueller 9f42cc77cc Enhance UI and functionality for admin dashboard and user management
- Update `_base_context` to include `active_nav` for dynamic navigation highlighting.
- Revamp `base.html` to improve header layout and navigation links for better user experience.
- Redesign `dashboard.html` to include statistics for new and edited articles, enhancing visibility of content changes.
- Improve `users.html` layout for user management, adding detailed user roles and status indicators.
- Update CSS styles for a cohesive design across the application, introducing new color variables and layout adjustments.
2026-07-03 13:44:57 +02:00

362 lines
14 KiB
Python

from pathlib import Path
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.security import create_access_token, hash_password, verify_password
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 UserCreate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, set_config
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
from app.services.newsletter import (
DEFAULT_DISPLAY,
article_url,
create_html,
create_outlook_html,
create_plain_text,
create_subject,
filter_articles,
parse_display_options,
split_articles_by_type,
)
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
from app.services.wiki import WikiFetchError, WikiService
app = FastAPI(title=settings.app_name)
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()
@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()
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"))
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
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"
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
if not request.cookies.get(CSRF_COOKIE_NAME):
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), 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:
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 _base_context(request: Request, user: User, db: Session) -> dict:
path = request.url.path
active_nav = "dashboard"
if path.startswith("/admin/users"):
active_nav = "users"
return {
"user": user,
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"smtp": get_smtp_settings(db),
"active_nav": active_nav,
}
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": request.cookies.get(CSRF_COOKIE_NAME) or 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": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
},
)
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):
return _render(
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"error_message": "Ungültige Zugangsdaten.",
},
)
token = create_access_token(user.id)
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict")
return response
@app.post("/logout")
def logout(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie("access_token")
return response
@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)
context.update({
"articles": [],
"articles_new": [],
"articles_edited": [],
"raw_html": "",
"outlook_html": "",
"plain_text": "",
"filters": {
"days": 30,
"only_edited": False,
"category": "",
"display": DEFAULT_DISPLAY,
},
"wiki_error": None,
})
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(...),
days: int = Form(30),
only_edited: str | None = Form(None),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
days = max(1, min(days, 90))
only_edited_flag = only_edited == "true"
display = parse_display_options(show_date, show_user, show_category)
wiki_error = None
filtered: list = []
articles_new: list = []
articles_edited: list = []
plain_text = ""
raw_html = ""
outlook_html = ""
try:
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag)
filtered = filter_articles(articles, category_filter=category or None)
articles_new, articles_edited = split_articles_by_type(filtered)
title = create_subject(days, category)
plain_text = create_plain_text(filtered, days, display, category)
raw_html = create_html(filtered, days, display, category)
outlook_html = create_outlook_html(filtered, days, display, category)
run_scheduled_send_if_due(db, title, raw_html, plain_text)
except WikiFetchError as exc:
wiki_error = exc.message
context = _base_context(request, current_user, db)
context.update(
{
"articles": filtered,
"articles_new": articles_new,
"articles_edited": articles_edited,
"raw_html": raw_html,
"outlook_html": outlook_html,
"plain_text": plain_text,
"filters": {
"days": days,
"only_edited": only_edited_flag,
"category": category,
"display": display,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": wiki_error,
}
)
return _render(request, "dashboard.html", context)
@app.post("/newsletter/send-now")
async def send_now(
request: Request,
csrf_token: str = Form(...),
days: int = Form(30),
only_edited: str | None = Form(None),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
display = parse_display_options(show_date, show_user, show_category)
only_edited_flag = only_edited == "true"
try:
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag)
except WikiFetchError as exc:
raise HTTPException(status_code=502, detail=exc.message) from exc
filtered = filter_articles(articles, category_filter=category or None)
title = create_subject(max(1, min(days, 90)), category)
plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category)
raw_html = create_html(filtered, max(1, min(days, 90)), display, category)
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False)
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@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(""),
use_tls: str | None = Form(None),
recipients: str = Form(""),
schedule_time: str = Form("08:00"),
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())
set_config(db, "smtp.password", password)
set_config(db, "smtp.from_email", from_email.strip())
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false")
set_config(db, "smtp.recipients", recipients.strip())
set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00")
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
context = _base_context(request, admin, db)
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
return _render(request, "users.html", context)
@app.post("/admin/users")
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),
):
validate_csrf(request, csrf_token)
payload = UserCreate(email=email, full_name=full_name, password=password, role=role)
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
raise HTTPException(status_code=400, detail="E-Mail existiert bereits.")
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", status_code=status.HTTP_302_FOUND)