initial commit

This commit is contained in:
smueller
2026-07-03 11:35:25 +02:00
commit 146d398ecb
24 changed files with 1040 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
APP_NAME=TK Wiki Newsletter Admin
ENVIRONMENT=production
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120
DATABASE_URL=sqlite:///./data/newsletter.db
# Beispiel PostgreSQL:
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
WIKI_API_URL=https://www.thomas-krenn.com/de/wiki/api.php
ALLOWED_HOSTS=*
COOKIE_SECURE=true
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
.pytest_cache/
.venv/
.env
data/
*.pyc

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# TK Wiki Newsletter Admin
Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern auf Basis der letzten Wiki-Änderungen.
## Funktionen
- Login und Benutzerverwaltung mit RBAC (`admin`, `editor`, `reader`)
- Verpflichtender CSRF-Schutz auf allen POST-Formularen
- Filter in der Web-UI:
- Zeitraum in Tagen
- Nur bearbeitete Artikel
- Kategorie
- Datenquelle: MediaWiki API (`recentchanges`)
- Export:
- Plain Text (für Outlook)
- Raw HTML
- Optionaler SMTP-Versand:
- UI-konfigurierbar
- Verteilerlisten
- tägliche Zeitplanung
- Versandprotokoll
- Docker/Compose Betrieb
## Start mit Docker
1. Konfiguration anlegen:
```bash
cp .env.example .env
```
2. Wichtige Werte in `.env` setzen:
- `SECRET_KEY`
- `ADMIN_BOOTSTRAP_EMAIL`
- `ADMIN_BOOTSTRAP_PASSWORD`
3. Start:
```bash
docker compose up --build -d
```
4. Zugriff:
- [http://localhost:8000/login](http://localhost:8000/login)
## Sicherheits-Hinweise für Produktion
- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container setzen.
- `SECRET_KEY` lang und zufällig setzen.
- Bootstrap-Admin-Passwort nach erstem Login ändern.
- Netzwerkzugriff auf interne IPs/Netze beschränken.
- Regelmäßige Backups des `data` Volumes.
## Rollenmodell
- `reader`: darf Ergebnisse sehen
- `editor`: darf Newsletter generieren und sofort versenden
- `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration

20
app/core/config.py Normal file
View File

@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "TK Wiki Newsletter Admin"
environment: str = "production"
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 120
database_url: str = "sqlite:///./data/newsletter.db"
wiki_api_url: str = "https://www.thomas-krenn.com/de/wiki/api.php"
allowed_hosts: str = "*"
cookie_secure: bool = True
admin_bootstrap_email: str = "admin@internal.local"
admin_bootstrap_password: str = "ChangeMe123!"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()

24
app/core/security.py Normal file
View File

@@ -0,0 +1,24 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: Any) -> str:
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "sub": str(subject)}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)

18
app/db/session.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.core.config import settings
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

251
app/main.py Normal file
View File

@@ -0,0 +1,251 @@
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, 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.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, 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 create_html, create_plain_text, filter_articles
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
from app.services.wiki import 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")
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';"
if not request.cookies.get(CSRF_COOKIE_NAME):
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=settings.cookie_secure, 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:
return {
"request": request,
"user": user,
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"smtp": get_smtp_settings(db),
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)})
@app.post("/login")
def login(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
validate_csrf(request, csrf_token)
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):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="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=settings.cookie_secure, 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": [], "raw_html": "", "plain_text": "", "filters": {"days": 30, "only_edited": False, "category": ""}})
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs
return templates.TemplateResponse("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: bool = Form(False),
category: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
days = max(1, min(days, 90))
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited)
filtered = filter_articles(articles, category_filter=category or None)
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
plain_text = create_plain_text(filtered, title)
raw_html = create_html(filtered, title)
run_scheduled_send_if_due(db, title, raw_html, plain_text)
context = _base_context(request, current_user, db)
context.update(
{
"articles": filtered,
"raw_html": raw_html,
"plain_text": plain_text,
"filters": {"days": days, "only_edited": only_edited, "category": category},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
}
)
return templates.TemplateResponse("dashboard.html", context)
@app.post("/newsletter/send-now")
async def send_now(
request: Request,
csrf_token: str = Form(...),
days: int = Form(30),
only_edited: bool = Form(False),
category: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited)
filtered = filter_articles(articles, category_filter=category or None)
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
plain_text = create_plain_text(filtered, title)
raw_html = create_html(filtered, title)
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 templates.TemplateResponse("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)

23
app/models/system.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, func
from app.db.session import Base
class AppConfig(Base):
__tablename__ = "app_config"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
class SendLog(Base):
__tablename__ = "send_logs"
id = Column(Integer, primary_key=True, index=True)
subject = Column(String(255), nullable=False)
recipients = Column(Text, nullable=False)
status = Column(String(32), nullable=False)
detail = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
scheduled = Column(Boolean, default=False, nullable=False)

16
app/models/user.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String, func
from app.db.session import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
full_name = Column(String(255), nullable=False)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
role = Column(String(32), default="reader", nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

19
app/schemas/user.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
full_name: str = Field(min_length=2, max_length=255)
password: str = Field(min_length=10, max_length=255)
role: str = Field(default="reader", pattern="^(admin|editor|reader)$")
class UserOut(BaseModel):
id: int
email: EmailStr
full_name: str
role: str
is_active: bool
class Config:
from_attributes = True

41
app/services/auth.py Normal file
View File

@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, Request, status
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger Token.") from exc
user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzer nicht gefunden.")
return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user
def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in {"admin", "editor"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Editor- oder Admin-Rechte erforderlich.")
return current_user
def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in {"admin", "editor", "reader"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user

View File

@@ -0,0 +1,17 @@
from sqlalchemy.orm import Session
from app.models.system import AppConfig
def get_config(db: Session, key: str, default: str = "") -> str:
row = db.query(AppConfig).filter(AppConfig.key == key).first()
return row.value if row else default
def set_config(db: Session, key: str, value: str) -> None:
row = db.query(AppConfig).filter(AppConfig.key == key).first()
if row:
row.value = value
else:
db.add(AppConfig(key=key, value=value))
db.commit()

20
app/services/csrf.py Normal file
View File

@@ -0,0 +1,20 @@
import secrets
from fastapi import HTTPException, Request, status
CSRF_COOKIE_NAME = "csrf_token"
CSRF_FORM_FIELD = "csrf_token"
def ensure_csrf_cookie(request: Request) -> str:
token = request.cookies.get(CSRF_COOKIE_NAME)
if token:
return token
return secrets.token_urlsafe(32)
def validate_csrf(request: Request, form_token: str) -> None:
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF-Validierung fehlgeschlagen.")

View File

@@ -0,0 +1,76 @@
from datetime import datetime
from html import escape
from typing import Any
def filter_articles(
articles: list[dict[str, Any]],
category_filter: str | None = None,
) -> list[dict[str, Any]]:
if not category_filter:
return articles
wanted = category_filter.strip().lower()
return [
a
for a in articles
if any(c.lower() == wanted for c in a.get("categories", []))
]
def create_plain_text(articles: list[dict[str, Any]], title: str) -> str:
lines = [title, "=" * len(title), ""]
if not articles:
lines.append("Keine Artikel im gewählten Zeitraum gefunden.")
return "\n".join(lines)
for idx, item in enumerate(articles, start=1):
stamp = _format_ts(item.get("timestamp"))
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
comment = item.get("comment") or "-"
lines.extend(
[
f"{idx}. {item.get('title', 'Ohne Titel')}",
f" Zeit: {stamp}",
f" Benutzer: {item.get('user', '-')}",
f" Kategorien: {cat}",
f" Kommentar: {comment}",
"",
]
)
return "\n".join(lines).strip()
def create_html(articles: list[dict[str, Any]], title: str) -> str:
if not articles:
return f"<h2>{escape(title)}</h2><p>Keine Artikel im gewählten Zeitraum gefunden.</p>"
blocks = [f"<h2>{escape(title)}</h2>", "<ul>"]
for item in articles:
article_title = escape(item.get("title", "Ohne Titel"))
stamp = escape(_format_ts(item.get("timestamp")))
user = escape(item.get("user", "-"))
cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie")
comment = escape(item.get("comment") or "-")
blocks.append(
(
"<li>"
f"<strong>{article_title}</strong><br>"
f"Zeit: {stamp}<br>"
f"Benutzer: {user}<br>"
f"Kategorien: {cat}<br>"
f"Kommentar: {comment}"
"</li>"
)
)
blocks.append("</ul>")
return "\n".join(blocks)
def _format_ts(value: str | None) -> str:
if not value:
return "-"
try:
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%d.%m.%Y %H:%M UTC")
except ValueError:
return value

View File

@@ -0,0 +1,89 @@
import smtplib
from datetime import datetime
from email.mime.text import MIMEText
from typing import Sequence
from sqlalchemy.orm import Session
from app.models.system import SendLog
from app.services.config_store import get_config
def get_smtp_settings(db: Session) -> dict[str, str]:
return {
"enabled": get_config(db, "smtp.enabled", "false"),
"host": get_config(db, "smtp.host", ""),
"port": get_config(db, "smtp.port", "587"),
"username": get_config(db, "smtp.username", ""),
"password": get_config(db, "smtp.password", ""),
"from_email": get_config(db, "smtp.from_email", ""),
"use_tls": get_config(db, "smtp.use_tls", "true"),
"recipients": get_config(db, "smtp.recipients", ""),
"schedule_time": get_config(db, "smtp.schedule_time", "08:00"),
"last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""),
}
def send_newsletter(
db: Session,
subject: str,
html_content: str,
text_content: str,
recipients: Sequence[str],
scheduled: bool = False,
) -> None:
settings = get_smtp_settings(db)
if settings["enabled"].lower() != "true":
_log(db, subject, recipients, "skipped", "SMTP ist deaktiviert.", scheduled)
return
if not settings["host"] or not settings["from_email"] or not recipients:
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled)
return
msg = MIMEText(html_content, "html", "utf-8")
msg["Subject"] = subject
msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients)
try:
port = int(settings["port"])
with smtplib.SMTP(settings["host"], port, timeout=20) as server:
if settings["use_tls"].lower() == "true":
server.starttls()
if settings["username"]:
server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], list(recipients), msg.as_string())
_log(db, subject, recipients, "sent", "Versand erfolgreich.", scheduled)
except Exception as exc:
_log(db, subject, recipients, "failed", f"Versandfehler: {exc}", scheduled)
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)
from app.services.config_store import set_config
set_config(db, "smtp.last_scheduled_date", today)
def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None:
db.add(
SendLog(
subject=subject,
recipients=", ".join(recipients) if recipients else "-",
status=status,
detail=detail,
scheduled=scheduled,
)
)
db.commit()

78
app/services/wiki.py Normal file
View File

@@ -0,0 +1,78 @@
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.config import settings
class WikiService:
def __init__(self) -> None:
self.api_url = settings.wiki_api_url
async def get_recent_changes(self, days: int = 30, only_edited: bool = False) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ")
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ")
params = {
"action": "query",
"format": "json",
"list": "recentchanges",
"rcprop": "title|timestamp|user|comment|flags|sizes|loginfo",
"rclimit": "200",
"rcstart": rcstart,
"rcend": rcend,
"rcnamespace": "0",
}
if only_edited:
params["rctype"] = "edit"
else:
params["rctype"] = "new|edit"
rows: list[dict[str, Any]] = []
cont: dict[str, Any] = {}
async with httpx.AsyncClient(timeout=20.0) as client:
while True:
response = await client.get(self.api_url, params={**params, **cont})
response.raise_for_status()
payload = response.json()
rows.extend(payload.get("query", {}).get("recentchanges", []))
if "continue" not in payload:
break
cont = payload["continue"]
titles = sorted({item["title"] for item in rows if item.get("title")})
categories = await self._get_categories_for_titles(client, titles)
for row in rows:
row["categories"] = categories.get(row.get("title", ""), [])
return rows
async def _get_categories_for_titles(self, client: httpx.AsyncClient, titles: list[str]) -> dict[str, list[str]]:
result: dict[str, list[str]] = defaultdict(list)
if not titles:
return {}
chunk_size = 25
for i in range(0, len(titles), chunk_size):
chunk = titles[i : i + chunk_size]
params = {
"action": "query",
"format": "json",
"prop": "categories",
"cllimit": "max",
"titles": "|".join(chunk),
}
response = await client.get(self.api_url, params=params)
response.raise_for_status()
pages = response.json().get("query", {}).get("pages", {})
for page in pages.values():
title = page.get("title")
if not title:
continue
raw_categories = page.get("categories", [])
result[title] = [c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")]
return result

14
app/static/css/main.css Normal file
View File

@@ -0,0 +1,14 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, sans-serif; background: #f5f7fb; color: #1f2430; }
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: #0c2c5a; color: #fff; }
.container { max-width: 1100px; margin: 1rem auto; padding: 0 1rem; }
.card { background: #fff; border: 1px solid #dde3ef; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.narrow { max-width: 420px; margin: 2rem auto; }
.form-grid { display: grid; gap: 0.75rem; }
label { display: grid; gap: 0.3rem; font-weight: 600; }
input, button, textarea { font: inherit; padding: 0.55rem; }
textarea { width: 100%; resize: vertical; }
button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; }
.checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; }

23
app/templates/base.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title if title else "TK Wiki Newsletter Admin" }}</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<header class="topbar">
<h1>TK Wiki Newsletter Admin</h1>
{% if user %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Logout</button>
</form>
{% endif %}
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h2>Newsletter erstellen</h2>
<form method="post" action="/dashboard" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Zeitraum (Tage)
<input type="number" name="days" min="1" max="90" value="{{ filters.days }}">
</label>
<label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}">
</label>
<label class="checkbox">
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}>
Nur bearbeitete Artikel (keine neu erstellten)
</label>
{% if user.role in ["admin", "editor"] %}
<button type="submit">Newsletter generieren</button>
{% endif %}
</form>
{% if user.role == "admin" %}
<p><a href="/admin/users">Zur Benutzerverwaltung</a></p>
{% endif %}
</section>
{% if user.role in ["admin", "editor"] %}
<section class="card">
<h3>Optionaler SMTP-Versand</h3>
<form method="post" action="/newsletter/send-now" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="only_edited" value="{{ 'true' if filters.only_edited else 'false' }}">
<button type="submit">Newsletter jetzt senden</button>
</form>
</section>
{% endif %}
{% if user.role == "admin" %}
<section class="card">
<h3>SMTP-Konfiguration (optional)</h3>
<form method="post" action="/admin/smtp" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {% if smtp.enabled == "true" %}checked{% endif %}>
SMTP Versand aktivieren
</label>
<label>SMTP Host
<input type="text" name="host" value="{{ smtp.host }}">
</label>
<label>SMTP Port
<input type="number" name="port" value="{{ smtp.port }}">
</label>
<label>Benutzername
<input type="text" name="username" value="{{ smtp.username }}">
</label>
<label>Passwort
<input type="password" name="password" value="{{ smtp.password }}">
</label>
<label>Absender E-Mail
<input type="email" name="from_email" value="{{ smtp.from_email }}">
</label>
<label class="checkbox">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden
</label>
<label>Verteilerliste (Komma-separiert)
<textarea rows="3" name="recipients">{{ smtp.recipients }}</textarea>
</label>
<label>Geplanter täglicher Versand (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
<button type="submit">SMTP Einstellungen speichern</button>
</form>
</section>
{% endif %}
<section class="card">
<h3>Output: Outlook Text</h3>
<textarea rows="14" readonly>{{ plain_text }}</textarea>
</section>
<section class="card">
<h3>Output: Raw HTML</h3>
<textarea rows="14" readonly>{{ raw_html }}</textarea>
</section>
<section class="card">
<h3>Gefundene Artikel ({{ articles|length }})</h3>
<table>
<thead>
<tr><th>Titel</th><th>Zeit</th><th>Benutzer</th><th>Kategorien</th></tr>
</thead>
<tbody>
{% for item in articles %}
<tr>
<td>{{ item.title }}</td>
<td>{{ item.timestamp }}</td>
<td>{{ item.user }}</td>
<td>{{ item.categories|join(", ") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card">
<h3>Versandprotokoll (letzte 20)</h3>
<table>
<thead><tr><th>Zeit</th><th>Status</th><th>Betreff</th><th>Empfänger</th><th>Details</th></tr></thead>
<tbody>
{% for log in send_logs %}
<tr>
<td>{{ log.created_at }}</td>
<td>{{ log.status }}</td>
<td>{{ log.subject }}</td>
<td>{{ log.recipients }}</td>
<td>{{ log.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

16
app/templates/login.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<section class="card narrow">
<h2>Login</h2>
<form method="post" action="/login" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail
<input type="email" name="email" required>
</label>
<label>Passwort
<input type="password" name="password" required>
</label>
<button type="submit">Anmelden</button>
</form>
</section>
{% endblock %}

43
app/templates/users.html Normal file
View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h2>Benutzerverwaltung</h2>
<form method="post" action="/admin/users" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail
<input type="email" name="email" required>
</label>
<label>Vollständiger Name
<input type="text" name="full_name" required>
</label>
<label>Passwort
<input type="password" name="password" required minlength="10">
</label>
<label>Rolle
<select name="role">
<option value="reader">Reader</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</label>
<button type="submit">Benutzer erstellen</button>
</form>
</section>
<section class="card">
<h3>Bestehende Benutzer</h3>
<table>
<thead><tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Aktiv</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.email }}</td>
<td>{{ u.full_name }}</td>
<td>{{ u.role }}</td>
<td>{{ "Ja" if u.is_active else "Nein" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
newsletter-admin:
build: .
container_name: tk-newsletter-admin
ports:
- "8000:8000"
env_file:
- .env
volumes:
- newsletter_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
newsletter_data:

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi
uvicorn[standard]
jinja2
sqlalchemy
alembic
python-multipart
httpx
passlib[bcrypt]
python-jose[cryptography]
pydantic-settings
email-validator
psycopg[binary]