Profil bearbeiten
+{{ error_message }}
+ {% endif %} + +Passwort ändern
+{{ pw_error_message }}
+ {% endif %} + +diff --git a/README.md b/README.md index 4ab6db9..f8e2888 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au - Optionaler SMTP-Versand: - UI-konfigurierbar - Verteilerlisten - - tägliche Zeitplanung + - flexibler Zeitplan (täglich / wöchentlich an bestimmten Wochentagen / monatlich an einem Tag) über einen Hintergrund-Scheduler + - eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie) + - manueller Versand nur per Button – „Generieren“ verschickt nie automatisch - Versandprotokoll - Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag) diff --git a/app/main.py b/app/main.py index 4c86553..69b10d0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,6 @@ +import asyncio +import logging +from datetime import datetime from pathlib import Path from pydantic import ValidationError @@ -16,7 +19,7 @@ from app.core.security import create_access_token, hash_password, verify_passwor 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.schemas.user import PasswordChange, ProfileUpdate, 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, generate_csrf_token, validate_csrf @@ -33,7 +36,7 @@ from app.services.newsletter import ( resolve_period, split_articles_by_type, ) -from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter +from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter from app.services.wiki import WikiFetchError, WikiService app = FastAPI(title=settings.app_name) @@ -45,6 +48,10 @@ 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: @@ -52,6 +59,59 @@ def on_startup() -> None: 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=[], + ) + 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 + + recipients = [r.strip() for r in settings_snapshot.get("recipients", "").split(",") if r.strip()] + 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: @@ -123,6 +183,8 @@ def _base_context(request: Request, user: User, db: Session) -> dict: 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), @@ -216,8 +278,8 @@ def logout(request: Request, csrf_token: str = Form(...)): def _empty_filters() -> dict: return { "days": 30, - "period": "days", - "article_type": "all", + "period": "last_month", + "article_type": "new", "category": "", "display": DEFAULT_DISPLAY, "editor_tip": "", @@ -259,10 +321,10 @@ async def _generate_newsletter( "articles": filtered, "articles_new": new_articles, "articles_edited": edited_articles, - "subject": create_subject(p["days"], category, p["label"]), + "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), - "raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), - "outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + "raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"]), + "outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"]), } ) except WikiFetchError as exc: @@ -322,8 +384,6 @@ async def dashboard_generate( editor_tip=editor_tip, highlight_list=highlight_list, ) - if not gen["wiki_error"]: - run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"]) context = _base_context(request, current_user, db) context.update( @@ -434,7 +494,15 @@ def update_smtp_settings( from_email: 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"), + gen_period: str = Form("days"), + gen_days: str = Form("30"), + gen_article_type: str = Form("all"), + gen_category: str = Form(""), db: Session = Depends(get_db), admin: User = Depends(get_admin_user), ): @@ -447,7 +515,32 @@ def update_smtp_settings( 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_enabled", "true" if schedule_enabled == "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()) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) @@ -533,3 +626,110 @@ def create_user( 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) + db.commit() + return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND) diff --git a/app/schemas/user.py b/app/schemas/user.py index e98152b..1ee9f9d 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -8,6 +8,15 @@ class UserCreate(BaseModel): role: str = Field(default="reader", pattern="^(admin|editor|reader)$") +class ProfileUpdate(BaseModel): + email: EmailStr + full_name: str = Field(min_length=2, max_length=255) + + +class PasswordChange(BaseModel): + new_password: str = Field(min_length=10, max_length=255) + + class UserOut(BaseModel): id: int email: EmailStr diff --git a/app/services/newsletter.py b/app/services/newsletter.py index 5bb9873..3cf481b 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -101,12 +101,9 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st return new_articles, edited_articles -def create_subject(days: int, category: str = "", period_label: str | None = None) -> str: - label = period_label or f"{days} Tage" - base = f"Interner Wiki-Newsletter – {ORG_NAME} ({label})" - if category.strip(): - return f"{base} | Kategorie: {category.strip()}" - return base +def create_subject(month_label: str | None = None, days: int = 30) -> str: + label = month_label or f"{days} Tage" + return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion" _MONTHS_DE = [ @@ -138,6 +135,7 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]: "start_str": start.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"), "label": _month_label(start), + "month_label": _month_label(start), "days": max(1, (end - start).days), } days = max(1, min(days, 90)) @@ -150,6 +148,7 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]: "start_str": start.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"), "label": f"{days} Tage", + "month_label": _month_label(end), "days": days, } @@ -296,6 +295,7 @@ def create_html( period_label: str | None = None, period: tuple[str, str] | None = None, article_type: str = "all", + month_label: str | None = None, ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) @@ -303,7 +303,7 @@ def create_html( show_edited = article_type in ("all", "edited") period_start, period_end = period if period else _period_range(days) total = len(new_articles) + len(edited_articles) - subject = escape(create_subject(days, category, period_label)) + subject = escape(create_subject(month_label or period_label, days)) preheader = escape(_summary_sentence(total)) period_label = escape(f"{period_start} – {period_end}") summary_text = escape(_summary_sentence(total)) @@ -678,6 +678,7 @@ def create_outlook_html( period_label: str | None = None, period: tuple[str, str] | None = None, article_type: str = "all", + month_label: str | None = None, ) -> str: """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" display = display or DEFAULT_DISPLAY @@ -686,7 +687,7 @@ def create_outlook_html( show_edited = article_type in ("all", "edited") period_start, period_end = period if period else _period_range(days) total = len(new_articles) + len(edited_articles) - subject = escape(create_subject(days, category, period_label)) + subject = escape(create_subject(month_label or period_label, days)) period_label = f"{period_start} – {period_end}" summary_text = _summary_sentence(total) diff --git a/app/services/smtp_sender.py b/app/services/smtp_sender.py index 6f88341..585656a 100644 --- a/app/services/smtp_sender.py +++ b/app/services/smtp_sender.py @@ -20,11 +20,53 @@ def get_smtp_settings(db: Session) -> dict[str, str]: "from_email": get_config(db, "smtp.from_email", ""), "use_tls": get_config(db, "smtp.use_tls", "true"), "recipients": get_config(db, "smtp.recipients", ""), + # Zeitplan + "schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"), + "schedule_frequency": get_config(db, "smtp.schedule_frequency", "monthly"), "schedule_time": get_config(db, "smtp.schedule_time", "08:00"), + "schedule_weekdays": get_config(db, "smtp.schedule_weekdays", "0"), + "schedule_dom": get_config(db, "smtp.schedule_dom", "1"), "last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""), + # Generierungs-Vorgaben für den geplanten Versand + "gen_period": get_config(db, "smtp.gen_period", "last_month"), + "gen_days": get_config(db, "smtp.gen_days", "30"), + "gen_article_type": get_config(db, "smtp.gen_article_type", "new"), + "gen_category": get_config(db, "smtp.gen_category", ""), } +def schedule_is_due(settings: dict[str, str], now: datetime) -> bool: + """Prüft, ob laut Zeitplan jetzt ein Versand fällig ist (mit Tages-Dedupe).""" + if settings["enabled"].lower() != "true": + return False + if (settings.get("schedule_enabled") or "false").lower() != "true": + return False + freq = (settings.get("schedule_frequency") or "off").lower() + if freq == "off": + return False + + schedule_time = settings.get("schedule_time") or "08:00" + if now.strftime("%H:%M") < schedule_time: + return False + + today = now.strftime("%Y-%m-%d") + if settings.get("last_scheduled_date") == today: + return False + + if freq == "daily": + return True + if freq == "weekly": + weekdays = {d.strip() for d in (settings.get("schedule_weekdays") or "").split(",") if d.strip() != ""} + return str(now.weekday()) in weekdays + if freq == "monthly": + try: + dom = int(settings.get("schedule_dom") or "1") + except ValueError: + dom = 1 + return now.day == max(1, min(dom, 28)) + return False + + def send_newsletter( db: Session, subject: str, @@ -67,22 +109,10 @@ def send_newsletter( return "failed", detail -def run_scheduled_send_if_due(db: Session, subject: str, html_content: str, text_content: str) -> None: - settings = get_smtp_settings(db) - if settings["enabled"].lower() != "true": - return - now = datetime.now() - schedule_time = settings["schedule_time"] or "08:00" - if now.strftime("%H:%M") < schedule_time: - return - today = now.strftime("%Y-%m-%d") - if settings["last_scheduled_date"] == today: - return - recipients = [r.strip() for r in settings["recipients"].split(",") if r.strip()] - send_newsletter(db, subject, html_content, text_content, recipients, scheduled=True) +def mark_scheduled_sent(db: Session, now: datetime) -> None: from app.services.config_store import set_config - set_config(db, "smtp.last_scheduled_date", today) + set_config(db, "smtp.last_scheduled_date", now.strftime("%Y-%m-%d")) def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None: diff --git a/app/static/css/main.css b/app/static/css/main.css index 77cca53..30c3fdb 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -115,6 +115,15 @@ a:hover { color: var(--tk-orange-dark); text-decoration: underline; } color: #CCCCCC; } +a.user-chip-link { + text-decoration: none; + cursor: pointer; +} + +a.user-chip-link:hover { + color: #FFFFFF; +} + .topbar .btn-logout { background: transparent; border: 1px solid rgba(255, 255, 255, 0.35); @@ -698,6 +707,39 @@ body.login-page .container { flex-wrap: wrap; } +/* ── Zeitplan ── */ +.schedule-box { + border: 1px solid var(--tk-border); + border-radius: 8px; + padding: 0.85rem 1rem 1rem; + margin: 0.25rem 0; +} + +.schedule-box legend { + font-weight: 600; + padding: 0 0.4rem; + color: var(--tk-orange-dark, #c15200); +} + +.weekday-row { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin: 0.25rem 0 0.75rem; +} + +.weekday-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + border: 1px solid var(--tk-border); + border-radius: 6px; + padding: 0.3rem 0.55rem; + font-size: 0.85rem; + cursor: pointer; + user-select: none; +} + /* ── Footer ── */ .site-footer { text-align: center; diff --git a/app/templates/base.html b/app/templates/base.html index 7e310f8..1a152ac 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,7 +4,7 @@
{{ success_message }}
+{% endif %} + +{{ error_message }}
+ {% endif %} + +{{ pw_error_message }}
+ {% endif %} + +