6 Commits

Author SHA1 Message Date
32f10c3197 Merge pull request 'dev' (#6) from dev into main
Reviewed-on: #6
2026-07-07 13:45:25 +00:00
smueller
0e37dac816 Update dashboard to handle newsletter send status and redirect accordingly
- Added logic to retrieve and display the send status and message on the dashboard.
- Modified the send_now function to redirect to the dashboard with query parameters for status and message.
- Removed redundant context updates in the send_now function for cleaner code.
2026-07-07 15:44:37 +02:00
smueller
3d0f5e4f04 Enhance newsletter functionality with contact email support
- Added support for a contact email in newsletter generation, allowing users to specify a reply-to address.
- Updated the SMTP settings to include a reply-to field in the configuration.
- Modified the newsletter templates to display the contact email in plain text and HTML formats.
- Improved the dashboard UI to allow users to set the reply-to address when configuring SMTP settings.
2026-07-07 15:42:12 +02:00
2c04c70e78 Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-07-07 13:37:15 +00:00
smueller
9a0871381a Implement newsletter scheduling features
- Added a background scheduler to manage newsletter dispatch based on user-defined settings.
- Enhanced the SMTP settings to include scheduling options such as frequency, time, and specific weekdays.
- Updated the dashboard UI to allow users to configure scheduling preferences for automated newsletter sending.
- Modified the newsletter generation logic to accommodate new scheduling parameters and ensure proper content delivery.
2026-07-07 15:36:48 +02:00
smueller
6b0402103a Add profile management features
- Introduced profile update and password change functionalities in main.py.
- Added new ProfileUpdate and PasswordChange schemas in user.py.
- Updated base.html to include a profile link in the navigation and modified user chip for better accessibility.
- Enhanced CSS for user chip links to improve UI interaction.
2026-07-07 15:26:29 +02:00
9 changed files with 524 additions and 72 deletions

View File

@@ -23,7 +23,9 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Optionaler SMTP-Versand: - Optionaler SMTP-Versand:
- UI-konfigurierbar - UI-konfigurierbar
- Verteilerlisten - 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 - Versandprotokoll
- Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag) - Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)

View File

@@ -1,4 +1,8 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode
from pydantic import ValidationError from pydantic import ValidationError
@@ -16,7 +20,7 @@ from app.core.security import create_access_token, hash_password, verify_passwor
from app.db.session import Base, engine, get_db from app.db.session import Base, engine, get_db
from app.models.system import SendLog from app.models.system import SendLog
from app.models.user import User 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.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.config_store import get_config, set_config
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
@@ -33,7 +37,7 @@ from app.services.newsletter import (
resolve_period, resolve_period,
split_articles_by_type, 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 from app.services.wiki import WikiFetchError, WikiService
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name)
@@ -45,6 +49,10 @@ templates = Jinja2Templates(directory="app/templates")
templates.env.globals["wiki_article_url"] = article_url templates.env.globals["wiki_article_url"] = article_url
wiki_service = WikiService() wiki_service = WikiService()
logger = logging.getLogger("newsletter.scheduler")
SCHEDULER_INTERVAL_SECONDS = 60
_scheduler_task: asyncio.Task | None = None
@app.on_event("startup") @app.on_event("startup")
def on_startup() -> None: def on_startup() -> None:
@@ -52,6 +60,60 @@ def on_startup() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_ensure_schema_upgrades() _ensure_schema_upgrades()
_bootstrap_admin() _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", ""),
)
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: def _ensure_schema_upgrades() -> None:
@@ -123,6 +185,8 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
active_nav = "dashboard" active_nav = "dashboard"
if path.startswith("/admin/users"): if path.startswith("/admin/users"):
active_nav = "users" active_nav = "users"
elif path.startswith("/profil"):
active_nav = "profile"
return { return {
"user": user, "user": user,
"csrf_token": ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
@@ -216,8 +280,8 @@ def logout(request: Request, csrf_token: str = Form(...)):
def _empty_filters() -> dict: def _empty_filters() -> dict:
return { return {
"days": 30, "days": 30,
"period": "days", "period": "last_month",
"article_type": "all", "article_type": "new",
"category": "", "category": "",
"display": DEFAULT_DISPLAY, "display": DEFAULT_DISPLAY,
"editor_tip": "", "editor_tip": "",
@@ -234,6 +298,7 @@ async def _generate_newsletter(
display, display,
editor_tip: str, editor_tip: str,
highlight_list: list[str], highlight_list: list[str],
contact_email: str = "",
) -> dict: ) -> dict:
p = resolve_period(period_mode, days) p = resolve_period(period_mode, days)
result = { result = {
@@ -259,10 +324,10 @@ async def _generate_newsletter(
"articles": filtered, "articles": filtered,
"articles_new": new_articles, "articles_new": new_articles,
"articles_edited": edited_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), "plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email),
"raw_html": create_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"], contact_email),
"outlook_html": create_outlook_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, p["month_label"], contact_email),
} }
) )
except WikiFetchError as exc: except WikiFetchError as exc:
@@ -273,6 +338,10 @@ async def _generate_newsletter(
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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 = _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({ context.update({
"articles": [], "articles": [],
"articles_new": [], "articles_new": [],
@@ -283,7 +352,7 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
"subject": "", "subject": "",
"filters": _empty_filters(), "filters": _empty_filters(),
"wiki_error": None, "wiki_error": None,
"send_result": None, "send_result": send_result,
}) })
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs context["send_logs"] = logs
@@ -312,6 +381,8 @@ async def dashboard_generate(
article_type = "all" article_type = "all"
display = parse_display_options(show_date, show_user, show_category) display = parse_display_options(show_date, show_user, show_category)
highlight_list = parse_highlights(highlights) 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( gen = await _generate_newsletter(
period_mode=period, period_mode=period,
@@ -321,9 +392,8 @@ async def dashboard_generate(
display=display, display=display,
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email,
) )
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 = _base_context(request, current_user, db)
context.update( context.update(
@@ -374,6 +444,8 @@ async def send_now(
article_type = "all" article_type = "all"
highlight_list = parse_highlights(highlights) highlight_list = parse_highlights(highlights)
days = max(1, min(days, 90)) 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( gen = await _generate_newsletter(
period_mode=period, period_mode=period,
@@ -383,6 +455,7 @@ async def send_now(
display=display, display=display,
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email,
) )
send_result: dict[str, str] send_result: dict[str, str]
@@ -395,31 +468,8 @@ async def send_now(
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False) status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail} send_result = {"status": status_code, "message": detail}
context = _base_context(request, current_user, db) query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]})
context.update( return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER)
{
"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,
"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": send_result,
}
)
return _render(request, "dashboard.html", context)
@app.post("/admin/smtp") @app.post("/admin/smtp")
@@ -432,9 +482,18 @@ def update_smtp_settings(
username: str = Form(""), username: str = Form(""),
password: str = Form(""), password: str = Form(""),
from_email: str = Form(""), from_email: str = Form(""),
reply_to: str = Form(""),
use_tls: str | None = Form(None), use_tls: str | None = Form(None),
recipients: str = Form(""), recipients: str = Form(""),
schedule_enabled: str | None = Form(None),
schedule_frequency: str = Form("monthly"),
schedule_time: str = Form("08:00"), 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), db: Session = Depends(get_db),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
): ):
@@ -445,9 +504,35 @@ def update_smtp_settings(
set_config(db, "smtp.username", username.strip()) set_config(db, "smtp.username", username.strip())
set_config(db, "smtp.password", password) set_config(db, "smtp.password", password)
set_config(db, "smtp.from_email", from_email.strip()) set_config(db, "smtp.from_email", from_email.strip())
set_config(db, "smtp.reply_to", reply_to.strip())
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false") set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false")
set_config(db, "smtp.recipients", recipients.strip()) 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") 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) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@@ -533,3 +618,110 @@ def create_user(
db.add(user) db.add(user)
db.commit() db.commit()
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND) 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)

View File

@@ -8,6 +8,15 @@ class UserCreate(BaseModel):
role: str = Field(default="reader", pattern="^(admin|editor|reader)$") 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): class UserOut(BaseModel):
id: int id: int
email: EmailStr email: EmailStr

View File

@@ -101,12 +101,9 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st
return new_articles, edited_articles return new_articles, edited_articles
def create_subject(days: int, category: str = "", period_label: str | None = None) -> str: def create_subject(month_label: str | None = None, days: int = 30) -> str:
label = period_label or f"{days} Tage" label = month_label or f"{days} Tage"
base = f"Interner Wiki-Newsletter {ORG_NAME} ({label})" return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion"
if category.strip():
return f"{base} | Kategorie: {category.strip()}"
return base
_MONTHS_DE = [ _MONTHS_DE = [
@@ -138,6 +135,7 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]:
"start_str": start.strftime("%d.%m.%Y"), "start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"),
"label": _month_label(start), "label": _month_label(start),
"month_label": _month_label(start),
"days": max(1, (end - start).days), "days": max(1, (end - start).days),
} }
days = max(1, min(days, 90)) 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"), "start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"),
"label": f"{days} Tage", "label": f"{days} Tage",
"month_label": _month_label(end),
"days": days, "days": days,
} }
@@ -204,6 +203,7 @@ def create_plain_text(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
contact_email: str = "",
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles)
@@ -251,7 +251,7 @@ def create_plain_text(
] ]
) )
lines.extend(_plain_editor_tip(editor_tip)) lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles)) lines.append(_plain_footer(new_articles, edited_articles, contact_email))
return "\n".join(lines).strip() return "\n".join(lines).strip()
if show_new: if show_new:
@@ -268,7 +268,7 @@ def create_plain_text(
) )
lines.append("") lines.append("")
lines.extend(_plain_editor_tip(editor_tip)) lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles)) lines.append(_plain_footer(new_articles, edited_articles, contact_email))
return "\n".join(lines).strip() return "\n".join(lines).strip()
@@ -296,6 +296,8 @@ def create_html(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles)
@@ -303,7 +305,7 @@ def create_html(
show_edited = article_type in ("all", "edited") show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days) period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles) 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)) preheader = escape(_summary_sentence(total))
period_label = escape(f"{period_start} {period_end}") period_label = escape(f"{period_start} {period_end}")
summary_text = escape(_summary_sentence(total)) summary_text = escape(_summary_sentence(total))
@@ -334,6 +336,15 @@ def create_html(
f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>' f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
) )
if contact_email.strip():
ce = escape(contact_email.strip())
contact_html = (
f'Fragen oder ein Themenwunsch? Meldet euch gern beim '
f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Wiki-Team</a>.'
)
else:
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team."
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
@@ -411,7 +422,7 @@ def create_html(
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};"> <td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.<br><br> {contact_html}<br><br>
Viele Grüße<br> Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team Euer Thomas-Krenn-Wiki Team
</td> </td>
@@ -678,6 +689,8 @@ def create_outlook_html(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
) -> str: ) -> str:
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
@@ -686,7 +699,7 @@ def create_outlook_html(
show_edited = article_type in ("all", "edited") show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days) period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles) 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}" period_label = f"{period_start} {period_end}"
summary_text = _summary_sentence(total) summary_text = _summary_sentence(total)
@@ -725,6 +738,19 @@ def create_outlook_html(
editor_tip_row = _outlook_editor_tip(editor_tip) editor_tip_row = _outlook_editor_tip(editor_tip)
if contact_email.strip():
contact_inner = (
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team: ", size="1")}'
f'{_outlook_link(contact_email.strip(), f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False)}'
)
contact_row = f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:12px 0 0;")}>
{contact_inner}
</td>
</tr>"""
else:
contact_row = ""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
<head> <head>
@@ -784,6 +810,7 @@ def create_outlook_html(
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")} {_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td> </td>
</tr> </tr>
{contact_row}
<tr> <tr>
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}> <td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")} {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
@@ -931,8 +958,15 @@ def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list
return lines return lines
def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: def _plain_footer(
new_articles: list[dict[str, Any]],
edited_articles: list[dict[str, Any]],
contact_email: str = "",
) -> str:
total = len(new_articles) + len(edited_articles) total = len(new_articles) + len(edited_articles)
contact_lines = []
if contact_email.strip():
contact_lines = [f"Fragen oder ein Themenwunsch? E-Mail an das Wiki-Team: {contact_email.strip()}", ""]
return "\n".join( return "\n".join(
[ [
SUBLINE, SUBLINE,
@@ -944,6 +978,7 @@ def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict
"", "",
f"Wiki: {WIKI_HOME_URL}", f"Wiki: {WIKI_HOME_URL}",
"", "",
*contact_lines,
SUBLINE, SUBLINE,
"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen", "Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
f"Gebrauch bei {ORG_NAME} bestimmt.", f"Gebrauch bei {ORG_NAME} bestimmt.",

View File

@@ -18,13 +18,56 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"username": get_config(db, "smtp.username", ""), "username": get_config(db, "smtp.username", ""),
"password": get_config(db, "smtp.password", ""), "password": get_config(db, "smtp.password", ""),
"from_email": get_config(db, "smtp.from_email", ""), "from_email": get_config(db, "smtp.from_email", ""),
"reply_to": get_config(db, "smtp.reply_to", ""),
"use_tls": get_config(db, "smtp.use_tls", "true"), "use_tls": get_config(db, "smtp.use_tls", "true"),
"recipients": get_config(db, "smtp.recipients", ""), "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_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", ""), "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( def send_newsletter(
db: Session, db: Session,
subject: str, subject: str,
@@ -49,6 +92,8 @@ def send_newsletter(
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = settings["from_email"] msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients) msg["To"] = ", ".join(recipients)
if settings.get("reply_to"):
msg["Reply-To"] = settings["reply_to"]
try: try:
port = int(settings["port"]) port = int(settings["port"])
@@ -67,22 +112,10 @@ def send_newsletter(
return "failed", detail return "failed", detail
def run_scheduled_send_if_due(db: Session, subject: str, html_content: str, text_content: str) -> None: def mark_scheduled_sent(db: Session, now: datetime) -> 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)
from app.services.config_store import set_config 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: def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None:

View File

@@ -115,6 +115,15 @@ a:hover { color: var(--tk-orange-dark); text-decoration: underline; }
color: #CCCCCC; color: #CCCCCC;
} }
a.user-chip-link {
text-decoration: none;
cursor: pointer;
}
a.user-chip-link:hover {
color: #FFFFFF;
}
.topbar .btn-logout { .topbar .btn-logout {
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgba(255, 255, 255, 0.35);
@@ -698,6 +707,39 @@ body.login-page .container {
flex-wrap: wrap; 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 ── */ /* ── Footer ── */
.site-footer { .site-footer {
text-align: center; text-align: center;

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title> <title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title>
<link rel="stylesheet" href="/static/css/main.css?v=4"> <link rel="stylesheet" href="/static/css/main.css?v=6">
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<header class="topbar"> <header class="topbar">
@@ -22,11 +22,12 @@
{% if user.role == "admin" %} {% if user.role == "admin" %}
<a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a> <a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a>
{% endif %} {% endif %}
<a href="/profil" class="{% if active_nav == 'profile' %}active{% endif %}">Profil</a>
</nav> </nav>
<div class="user-chip"> <a href="/profil" class="user-chip user-chip-link">
<span>{{ user.full_name }}</span> <span>{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }}">{{ user.role }}</span> <span class="badge badge-{{ user.role }}">{{ user.role }}</span>
</div> </a>
<form method="post" action="/logout"> <form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-logout">Abmelden</button> <button type="submit" class="btn-logout">Abmelden</button>

View File

@@ -253,9 +253,14 @@
<input type="password" name="password" value="{{ smtp.password }}"> <input type="password" name="password" value="{{ smtp.password }}">
</label> </label>
</div> </div>
<label>Absender E-Mail <div class="form-row">
<input type="email" name="from_email" value="{{ smtp.from_email }}"> <label>Absender E-Mail
</label> <input type="email" name="from_email" value="{{ smtp.from_email }}">
</label>
<label>Antwort-Adresse (Reply-To)
<input type="email" name="reply_to" value="{{ smtp.reply_to }}" placeholder="wiki-team@firma.de">
</label>
</div>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}> <input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden STARTTLS verwenden
@@ -263,9 +268,68 @@
<label>Verteilerliste (kommagetrennt) <label>Verteilerliste (kommagetrennt)
<textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea> <textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea>
</label> </label>
<label>Geplanter täglicher Versand (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}"> <fieldset class="schedule-box">
</label> <legend>Geplanter Versand</legend>
<label class="checkbox">
<input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}>
Automatischen Versand aktivieren
</label>
<p class="hint" style="margin:0 0 0.6rem;">Ausschalten, wenn du vor dem Versenden noch Redaktionsnotiz oder Top-Highlights setzen möchtest dann manuell über „Newsletter jetzt senden“.</p>
<div class="form-row">
<label>Häufigkeit
<select name="schedule_frequency">
<option value="daily" {% if smtp.schedule_frequency == 'daily' %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if smtp.schedule_frequency == 'weekly' %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if smtp.schedule_frequency == 'monthly' %}selected{% endif %}>Monatlich</option>
</select>
</label>
<label>Uhrzeit (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
</div>
{% set active_days = smtp.schedule_weekdays.split(',') %}
<label>Wochentage (nur bei „Wöchentlich“)</label>
<div class="weekday-row">
{% for value, label in [('0','Mo'), ('1','Di'), ('2','Mi'), ('3','Do'), ('4','Fr'), ('5','Sa'), ('6','So')] %}
<label class="weekday-chip">
<input type="checkbox" name="schedule_weekdays" value="{{ value }}" {% if value in active_days %}checked{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
<label>Tag im Monat (nur bei „Monatlich“, 128)
<input type="number" name="schedule_dom" min="1" max="28" value="{{ smtp.schedule_dom }}">
</label>
<p class="hint" style="margin:0.5rem 0 0.25rem;">Inhalt des geplanten Newsletters:</p>
<div class="form-row">
<label>Zeitraum
<select name="gen_period">
<option value="days" {% if smtp.gen_period != 'last_month' %}selected{% endif %}>Letzte X Tage</option>
<option value="last_month" {% if smtp.gen_period == 'last_month' %}selected{% endif %}>Letzter Monat</option>
</select>
</label>
<label>Tage (bei „Letzte X Tage“)
<input type="number" name="gen_days" min="1" max="90" value="{{ smtp.gen_days }}">
</label>
</div>
<div class="form-row">
<label>Artikel-Auswahl
<select name="gen_article_type">
<option value="all" {% if smtp.gen_article_type == 'all' %}selected{% endif %}>Alle</option>
<option value="new" {% if smtp.gen_article_type == 'new' %}selected{% endif %}>Nur neue</option>
<option value="edited" {% if smtp.gen_article_type == 'edited' %}selected{% endif %}>Nur bearbeitete</option>
</select>
</label>
<label>Kategorie (optional)
<input type="text" name="gen_category" value="{{ smtp.gen_category }}">
</label>
</div>
</fieldset>
<button type="submit">Einstellungen speichern</button> <button type="submit">Einstellungen speichern</button>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Mein Profil{% endblock %}
{% block content %}
<div class="page-header">
<h2>Mein Profil</h2>
<p class="page-lead"><a href="/dashboard">← Zurück zum Dashboard</a></p>
</div>
{% if success_message %}
<p class="hint" style="color:var(--tk-success);font-weight:600;">{{ success_message }}</p>
{% endif %}
<div class="layout-2col">
<div class="main-col">
<section class="card card-accent-orange">
<div class="card-header">
<h3>Profil bearbeiten</h3>
</div>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/profil" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<label>Vollständiger Name
<input type="text" name="full_name" required minlength="2" value="{{ user.full_name }}">
</label>
<label>E-Mail
<input type="email" name="email" required value="{{ user.email }}">
</label>
</div>
<button type="submit" class="btn-primary">Profil speichern</button>
</form>
</section>
<section class="card">
<div class="card-header">
<h3>Passwort ändern</h3>
</div>
{% if pw_error_message %}
<p class="error">{{ pw_error_message }}</p>
{% endif %}
<form method="post" action="/profil/passwort" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Aktuelles Passwort
<input type="password" name="current_password" required autocomplete="current-password">
</label>
<div class="form-row">
<label>Neues Passwort
<input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mindestens 10 Zeichen">
</label>
<label>Neues Passwort bestätigen
<input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">
</label>
</div>
<button type="submit" class="btn-primary">Passwort ändern</button>
</form>
</section>
</div>
<aside class="sidebar-col">
<div class="sidebar-card">
<h4>Konto</h4>
<ul>
<li><strong>Rolle:</strong> {{ user.role }}</li>
<li><strong>Status:</strong> {{ "Aktiv" if user.is_active else "Inaktiv" }}</li>
</ul>
<p class="hint">Die Rolle kann nur ein Administrator ändern.</p>
</div>
</aside>
</div>
{% endblock %}