8 Commits

Author SHA1 Message Date
smueller
555fd2c6ba Ignore pyasn1 CVE in pip-audit until python-jose supports pyasn1 0.6+.
All checks were successful
Docker Image bauen & veröffentlichen / build-and-push (push) Successful in 1m9s
python-jose 3.4.0 still pins pyasn1<0.5.0, so this transitive finding has no practical fix path yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:49:02 +02:00
smueller
070e99303a Update dependencies in requirements.txt and enhance Docker build workflow
- Updated jinja2, python-multipart, python-jose, and cryptography to their latest versions for improved security and functionality.
- Modified the Docker build workflow to include specific vulnerability ignores for pip-audit, ensuring smoother dependency checks while addressing known issues.
2026-07-09 12:46:34 +02:00
smueller
8a556b18ea Add field validation for configuration settings in Settings class
- Introduced a field validator to parse integer values with inline comments for access token expiration, HSTS max age, editor tip max length, and highlights max length.
- Enhanced the configuration management by ensuring cleaner input handling for specific settings.
2026-07-09 10:59:37 +02:00
smueller
1674efd587 Enhance Docker build workflow by adding virtual environment setup
- Introduced a Python virtual environment for dependency management.
- Updated pip installation commands to use the virtual environment context.
2026-07-09 10:55:25 +02:00
smueller
44d14d94de Merge dev into main for release
Some checks failed
Docker Image bauen & veröffentlichen / build-and-push (push) Failing after 12s
2026-07-09 10:53:12 +02:00
smueller
48410d39da Add editor exclusion defaults and newsletter filtering controls.
This makes it possible to exclude specific editors (e.g. Aranzinger) from edited-article results by default, including dashboard generation and scheduled SMTP runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:53:01 +02:00
c0a44285e6 Merge pull request 'Update README.md with enhanced configuration details and security measures' (#13) from dev into main
Reviewed-on: #13
2026-07-07 14:46:14 +00:00
smueller
b8073f659d Update README.md with enhanced configuration details and security measures
- Clarified SMTP sending options, including BCC support and email validation for distribution lists.
- Updated instructions for setting environment variables in `.env`, emphasizing security requirements for production.
- Added notes on password policies, rate limiting, and user role management in the admin section.
- Enhanced Docker/Compose setup with security hardening features and clarified the automatic build process.
2026-07-07 16:45:51 +02:00
9 changed files with 204 additions and 37 deletions

View File

@@ -28,3 +28,6 @@ ADMIN_BOOTSTRAP_RESET=false
EDITOR_TIP_MAX_LENGTH=10000 EDITOR_TIP_MAX_LENGTH=10000
# Maximale Länge für Highlights # Maximale Länge für Highlights
HIGHLIGHTS_MAX_LENGTH=5000 HIGHLIGHTS_MAX_LENGTH=5000
# Standardmäßig ausgeschlossene Bearbeiter (kommagetrennt)
# Beispiel: Aranzinger, Testuser
DEFAULT_EXCLUDED_USERS=Aranzinger

View File

@@ -27,9 +27,25 @@ jobs:
- name: Python-Abhängigkeiten prüfen (pip-audit) - name: Python-Abhängigkeiten prüfen (pip-audit)
run: | run: |
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pip-audit python -m pip install pip-audit
pip-audit -r requirements.txt # Hinweis:
# - Direkte Pakete werden auf gefixte Versionen gepinnt (requirements.txt).
# - Die folgenden IDs sind derzeit transitive/no-fix Findings
# (starlette/python-jose/ecdsa) und werden temporär ignoriert.
pip-audit -r requirements.txt \
--ignore-vuln PYSEC-2025-185 \
--ignore-vuln PYSEC-2026-1325 \
--ignore-vuln PYSEC-2026-161 \
--ignore-vuln PYSEC-2026-249 \
--ignore-vuln PYSEC-2026-248 \
--ignore-vuln PYSEC-2026-1942 \
--ignore-vuln PYSEC-2026-1941 \
--ignore-vuln CVE-2026-48818 \
--ignore-vuln CVE-2026-48817 \
--ignore-vuln CVE-2026-30922
- name: Docker Buildx einrichten - name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

133
README.md
View File

@@ -21,13 +21,15 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Plain Text (für Outlook) - Plain Text (für Outlook)
- Raw HTML - Raw HTML
- Optionaler SMTP-Versand: - Optionaler SMTP-Versand:
- UI-konfigurierbar - UI-konfigurierbar (nur für Admins)
- Verteilerlisten - Verteilerlisten mit E-Mail-Validierung
- flexibler Zeitplan (täglich / wöchentlich an bestimmten Wochentagen / monatlich an einem Tag) über einen Hintergrund-Scheduler - Versand per **BCC** (Empfänger nicht im sichtbaren `To:`-Header)
- flexibler Zeitplan (täglich / wöchentlich / monatlich) über einen Hintergrund-Scheduler
- eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie) - eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie)
- manueller Versand nur per Button „Generieren“ verschickt nie automatisch - **„Generieren“ verschickt nie** manueller Versand nur per Button „Newsletter jetzt senden“
- Versandprotokoll - automatischer Versand nur mit **zwei** aktiven Häkchen: Zeitplan + Bestätigung „ohne manuelle Freigabe“
- Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag) - Versandprotokoll (nur für Editor/Admin sichtbar)
- Docker/Compose Betrieb mit Container-Hardening, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
## Start mit Docker ## Start mit Docker
@@ -37,11 +39,17 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
cp .env.example .env cp .env.example .env
``` ```
2. Wichtige Werte in `.env` setzen: 2. Wichtige Werte in `.env` setzen (siehe auch [Konfiguration](#konfiguration-env)):
- `HOST_PORT` (z. B. `8080`, falls `8000` bereits belegt ist)
- `SECRET_KEY` | Variable | Lokal (Entwicklung) | Produktion |
- `ADMIN_BOOTSTRAP_EMAIL` |----------|---------------------|------------|
- `ADMIN_BOOTSTRAP_PASSWORD` | `ENVIRONMENT` | `development` | `production` |
| `HOST_PORT` | z. B. `8080` | nach Bedarf |
| `SECRET_KEY` | beliebig lang | mind. 32 zufällige Zeichen |
| `ALLOWED_HOSTS` | `localhost,127.0.0.1` | konkreter Hostname |
| `COOKIE_SECURE` | `false` | `true` (mit TLS) |
| `ADMIN_BOOTSTRAP_EMAIL` | Admin-E-Mail | Admin-E-Mail |
| `ADMIN_BOOTSTRAP_PASSWORD` | Bootstrap-Passwort | starkes Passwort |
3. Start: 3. Start:
@@ -52,6 +60,30 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
4. Zugriff: 4. Zugriff:
- [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`) - [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`)
> **Hinweis:** Mit `ENVIRONMENT=production` prüft die App beim Start strikt die Konfiguration. Fehlen starke Secrets oder sind unsichere Defaults gesetzt, startet der Container nicht.
## Konfiguration (`.env`)
Vollständige Vorlage: `.env.example`
| Variable | Beschreibung |
|----------|--------------|
| `ENVIRONMENT` | `development` = lokale Entwicklung ohne harte Checks; `production` = erzwingt sichere Einstellungen |
| `SECRET_KEY` | JWT-Signierung und Verschlüsselung von SMTP-Passwörtern in der DB |
| `ALGORITHM` | JWT-Algorithmus (Standard: `HS256`) |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Gültigkeit der Login-Session in Minuten (Standard: `120`) |
| `JWT_ISSUER` / `JWT_AUDIENCE` | JWT-Claims zur Token-Validierung |
| `HOST_PORT` | Host-Port für Docker Compose (nur Compose, nicht App-intern) |
| `DATABASE_URL` | SQLite (Standard) oder PostgreSQL |
| `WIKI_API_URL` | MediaWiki-API-Endpunkt |
| `ALLOWED_HOSTS` | Kommagetrennte erlaubte Host-Header |
| `COOKIE_SECURE` | `Secure`-Flag für Session- und CSRF-Cookies (Pflicht `true` in Produktion) |
| `HSTS_MAX_AGE` | HSTS-Header-Dauer in Sekunden (nur bei HTTPS) |
| `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` | Erster Admin-Account beim Start |
| `ADMIN_BOOTSTRAP_RESET` | Admin auf `.env`-Werte zurücksetzen (nur Entwicklung/Notfall, in Produktion verboten) |
| `EDITOR_TIP_MAX_LENGTH` | Max. Zeichen für Redaktionsnotiz (Standard: `10000`) |
| `HIGHLIGHTS_MAX_LENGTH` | Max. Zeichen für Top-Highlights (Standard: `5000`) |
## Container-Image (Gitea Packages) ## Container-Image (Gitea Packages)
Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht: Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht:
@@ -71,6 +103,9 @@ docker run -d --name tk-newsletter-admin \
--env-file .env \ --env-file .env \
-p 8080:8000 \ -p 8080:8000 \
-v newsletter_data:/app/data \ -v newsletter_data:/app/data \
--read-only \
--tmpfs /tmp \
--security-opt no-new-privileges:true \
git.hexahost.dev/smueller/tk-wiki-newsletter:latest git.hexahost.dev/smueller/tk-wiki-newsletter:latest
``` ```
@@ -83,11 +118,14 @@ services:
# build: . # <- nicht mehr nötig, wenn das fertige Image genutzt wird # build: . # <- nicht mehr nötig, wenn das fertige Image genutzt wird
``` ```
Die mitgelieferte `docker-compose.yml` setzt bereits `read_only`, `no-new-privileges` und ein `tmpfs` für `/tmp`. Persistente Daten liegen im Volume `newsletter_data` unter `/app/data`.
### Automatischer Build (CI/CD) ### Automatischer Build (CI/CD)
Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über Gitea Actions. Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über Gitea Actions.
- **Auslöser**: nur beim Setzen eines Versions-Tags `v*` (kein Build bei normalen Pushes auf `dev`/`main`). - **Auslöser**: nur beim Setzen eines Versions-Tags `v*` (kein Build bei normalen Pushes auf `dev`/`main`).
- **Vor dem Build**: `pip-audit` prüft die Python-Abhängigkeiten aus `requirements.txt`.
- **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version): - **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version):
```bash ```bash
@@ -106,25 +144,74 @@ Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über
## Admin-Login & Passwort zurücksetzen ## Admin-Login & Passwort zurücksetzen
- Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt. - Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt.
- **Passwortpolitik:** mindestens 10 Zeichen, mindestens ein Großbuchstabe, ein Kleinbuchstabe und eine Ziffer.
- **Rate-Limiting:** nach 5 fehlgeschlagenen Anmeldeversuchen pro IP/E-Mail innerhalb von 5 Minuten wird der Login temporär blockiert.
- **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen. - **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen.
- Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen: `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten: - Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen (**nur Entwicklung**): `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten:
```bash ```bash
docker compose up -d --force-recreate docker compose up -d --force-recreate
``` ```
Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen, damit UI-Passwortänderungen nicht bei jedem Neustart überschrieben werden. Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen. In `ENVIRONMENT=production` ist `ADMIN_BOOTSTRAP_RESET=true` nicht erlaubt die App startet dann nicht.
## Sicherheits-Hinweise für Produktion - **Passwort ändern** (Profil) oder **Abmelden** beendet alle aktiven Sessions des Benutzers.
- 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 ## Rollenmodell
- `reader`: darf Ergebnisse sehen | Rolle | Rechte |
- `editor`: darf Newsletter generieren und sofort versenden |-------|--------|
- `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration | `reader` | Newsletter-Vorschau und Artikellisten ansehen |
| `editor` | Newsletter generieren, exportieren und manuell versenden; Versandprotokoll und Empfängerliste einsehen |
| `admin` | Zusätzlich Benutzerverwaltung (anlegen, Rolle ändern, aktivieren/deaktivieren) und SMTP-Konfiguration |
Admins können in der Benutzerverwaltung (`/admin/users`):
- neue Benutzer mit Rolle anlegen
- die Rolle bestehender Benutzer ändern (beendet deren Sessions)
- Benutzer aktivieren oder deaktivieren (beendet deren Sessions)
Das eigene Konto kann weder deaktiviert noch die eigene Rolle geändert werden.
## Sicherheit
### Eingebaute Schutzmaßnahmen
- **bcrypt** für Passwort-Hashes
- **CSRF** Double-Submit-Cookie auf allen POST-Formularen
- **HttpOnly** + **SameSite=Strict** Session-Cookies; `Secure` bei HTTPS (`COOKIE_SECURE=true`)
- **JWT** mit `iss`, `aud`, Ablaufzeit und Session-Version (`session_version`)
- Session-Invalidierung bei Logout, Passwortwechsel, Rollen- oder Statusänderung
- **SMTP-Passwort** verschlüsselt in der Datenbank (Fernet), nicht im HTML-Formular
- **Security-Header:** CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, HSTS (bei HTTPS), `Cache-Control: no-store` auf geschützten Seiten
- **OpenAPI** (`/docs`, `/redoc`) in Produktion deaktiviert
- Abhängigkeiten in `requirements.txt` versioniert; CI führt `pip-audit` aus
### Produktions-Checkliste
Bei `ENVIRONMENT=production` erzwingt die App beim Start:
- `SECRET_KEY` mit mindestens 32 Zeichen (keine bekannten Defaults)
- starkes `ADMIN_BOOTSTRAP_PASSWORD`
- `ADMIN_BOOTSTRAP_RESET=false`
- `ALLOWED_HOSTS` mit konkreten Hostnamen (nicht `*`)
- `COOKIE_SECURE=true`
Zusätzlich empfohlen:
- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container; Proxy muss `X-Forwarded-Proto: https` setzen
- Bootstrap-Admin-Passwort nach erstem Login im Profil ändern
- Netzwerkzugriff auf interne IPs/Netze beschränken
- Regelmäßige Backups des `newsletter_data` Volumes (SQLite-Datei unverschlüsselt)
### Beispiel `.env` für Produktion
```env
ENVIRONMENT=production
SECRET_KEY=<mind. 32 zufällige Zeichen>
ALLOWED_HOSTS=newsletter.intern.firma.de
COOKIE_SECURE=true
ADMIN_BOOTSTRAP_EMAIL=admin@firma.de
ADMIN_BOOTSTRAP_PASSWORD=<starkes Passwort>
ADMIN_BOOTSTRAP_RESET=false
```

View File

@@ -1,3 +1,4 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -21,9 +22,24 @@ class Settings(BaseSettings):
admin_bootstrap_reset: bool = False admin_bootstrap_reset: bool = False
editor_tip_max_length: int = 10000 editor_tip_max_length: int = 10000
highlights_max_length: int = 5000 highlights_max_length: int = 5000
default_excluded_users: str = "Aranzinger"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@field_validator(
"access_token_expire_minutes",
"hsts_max_age",
"editor_tip_max_length",
"highlights_max_length",
mode="before",
)
@classmethod
def _parse_int_with_inline_comment(cls, value):
if isinstance(value, str):
cleaned = value.split("#", 1)[0].strip()
return int(cleaned) if cleaned else value
return value
@property @property
def is_production(self) -> bool: def is_production(self) -> bool:
return self.environment.lower() == "production" return self.environment.lower() == "production"

View File

@@ -36,6 +36,7 @@ from app.services.newsletter import (
create_plain_text, create_plain_text,
create_subject, create_subject,
filter_articles, filter_articles,
parse_excluded_users,
parse_display_options, parse_display_options,
parse_highlights, parse_highlights,
resolve_period, resolve_period,
@@ -117,6 +118,7 @@ async def _run_scheduled_send_if_due() -> None:
editor_tip="", editor_tip="",
highlight_list=[], highlight_list=[],
contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""), contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""),
excluded_users_raw=settings_snapshot.get("gen_excluded_users", ""),
) )
if gen["wiki_error"] or not gen["articles"]: if gen["wiki_error"] or not gen["articles"]:
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall. # Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
@@ -360,6 +362,7 @@ def _empty_filters() -> dict:
"period": "last_month", "period": "last_month",
"article_type": "new", "article_type": "new",
"category": "", "category": "",
"excluded_users": settings.default_excluded_users,
"display": DEFAULT_DISPLAY, "display": DEFAULT_DISPLAY,
"editor_tip": "", "editor_tip": "",
"highlights": "", "highlights": "",
@@ -376,6 +379,7 @@ async def _generate_newsletter(
editor_tip: str, editor_tip: str,
highlight_list: list[str], highlight_list: list[str],
contact_email: str = "", contact_email: str = "",
excluded_users_raw: str = "",
) -> dict: ) -> dict:
p = resolve_period(period_mode, days) p = resolve_period(period_mode, days)
result = { result = {
@@ -390,11 +394,12 @@ async def _generate_newsletter(
"subject": "", "subject": "",
} }
try: try:
excluded_users = parse_excluded_users(excluded_users_raw)
articles = await wiki_service.get_recent_changes( articles = await wiki_service.get_recent_changes(
days=p["days"], article_type=article_type, start=p["start"], end=p["end"] days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
) )
filtered = filter_articles(articles, category_filter=category or None) filtered = filter_articles(articles, category_filter=category or None)
new_articles, edited_articles = split_articles_by_type(filtered) new_articles, edited_articles = split_articles_by_type(filtered, excluded_edited_users=excluded_users)
prange = (p["start_str"], p["end_str"]) prange = (p["start_str"], p["end_str"])
result.update( result.update(
{ {
@@ -402,9 +407,15 @@ async def _generate_newsletter(
"articles_new": new_articles, "articles_new": new_articles,
"articles_edited": edited_articles, "articles_edited": edited_articles,
"subject": create_subject(p["month_label"], p["days"]), "subject": create_subject(p["month_label"], p["days"]),
"plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email), "plain_text": create_plain_text(
"raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email), filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email, excluded_users
"outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email), ),
"raw_html": create_html(
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
),
"outlook_html": create_outlook_html(
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
),
} }
) )
except WikiFetchError as exc: except WikiFetchError as exc:
@@ -444,6 +455,7 @@ async def dashboard_generate(
days: int = Form(30), days: int = Form(30),
article_type: str = Form("all"), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
@@ -474,6 +486,7 @@ async def dashboard_generate(
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email, contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
@@ -491,6 +504,7 @@ async def dashboard_generate(
"period": period, "period": period,
"article_type": article_type, "article_type": article_type,
"category": category, "category": category,
"excluded_users": excluded_users,
"display": display, "display": display,
"editor_tip": editor_tip, "editor_tip": editor_tip,
"highlights": highlights, "highlights": highlights,
@@ -511,6 +525,7 @@ async def send_now(
days: int = Form(30), days: int = Form(30),
article_type: str = Form("all"), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
@@ -541,6 +556,7 @@ async def send_now(
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email, contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
send_result: dict[str, str] send_result: dict[str, str]
@@ -585,6 +601,7 @@ def update_smtp_settings(
gen_days: str = Form("30"), gen_days: str = Form("30"),
gen_article_type: str = Form("all"), gen_article_type: str = Form("all"),
gen_category: str = Form(""), gen_category: str = Form(""),
gen_excluded_users: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
): ):
@@ -631,6 +648,7 @@ def update_smtp_settings(
gen_article_type = "all" gen_article_type = "all"
set_config(db, "smtp.gen_article_type", gen_article_type) set_config(db, "smtp.gen_article_type", gen_article_type)
set_config(db, "smtp.gen_category", gen_category.strip()) set_config(db, "smtp.gen_category", gen_category.strip())
set_config(db, "smtp.gen_excluded_users", gen_excluded_users.strip())
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)

View File

@@ -76,11 +76,21 @@ def filter_articles(
] ]
def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: def parse_excluded_users(raw: str | None) -> set[str]:
if not raw:
return set()
return {part.strip().lower() for part in raw.split(",") if part.strip()}
def split_articles_by_type(
articles: list[dict[str, Any]],
excluded_edited_users: set[str] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True) sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
new_articles: list[dict[str, Any]] = [] new_articles: list[dict[str, Any]] = []
edited_articles: list[dict[str, Any]] = [] edited_articles: list[dict[str, Any]] = []
new_titles: set[str] = set() new_titles: set[str] = set()
excluded_edited_users = excluded_edited_users or set()
for item in sorted_articles: for item in sorted_articles:
title = item.get("title", "") title = item.get("title", "")
@@ -93,8 +103,11 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st
seen_edited: set[str] = set() seen_edited: set[str] = set()
for item in sorted_articles: for item in sorted_articles:
title = item.get("title", "") title = item.get("title", "")
editor = str(item.get("user", "")).strip().lower()
if not title or title in new_titles or title in seen_edited: if not title or title in new_titles or title in seen_edited:
continue continue
if editor in excluded_edited_users:
continue
edited_articles.append(item) edited_articles.append(item)
seen_edited.add(title) seen_edited.add(title)
@@ -204,9 +217,10 @@ def create_plain_text(
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
contact_email: str = "", contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> 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, excluded_edited_users=excluded_edited_users)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
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)
@@ -308,9 +322,10 @@ def create_html(
article_type: str = "all", article_type: str = "all",
month_label: str | None = None, month_label: str | None = None,
contact_email: str = "", contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> 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, excluded_edited_users=excluded_edited_users)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
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)
@@ -707,10 +722,11 @@ def create_outlook_html(
article_type: str = "all", article_type: str = "all",
month_label: str | None = None, month_label: str | None = None,
contact_email: str = "", contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> 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
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
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)

View File

@@ -8,6 +8,7 @@ from typing import Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.email_utils import parse_recipient_list from app.core.email_utils import parse_recipient_list
from app.models.system import SendLog from app.models.system import SendLog
from app.services.config_store import get_config from app.services.config_store import get_config
@@ -40,6 +41,7 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"gen_days": get_config(db, "smtp.gen_days", "30"), "gen_days": get_config(db, "smtp.gen_days", "30"),
"gen_article_type": get_config(db, "smtp.gen_article_type", "new"), "gen_article_type": get_config(db, "smtp.gen_article_type", "new"),
"gen_category": get_config(db, "smtp.gen_category", ""), "gen_category": get_config(db, "smtp.gen_category", ""),
"gen_excluded_users": get_config(db, "smtp.gen_excluded_users", settings.default_excluded_users),
} }

View File

@@ -49,7 +49,11 @@
<label>Kategorie (optional) <label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux"> <input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
</label> </label>
<label>Bearbeiter ausschließen (optional)
<input type="text" name="excluded_users" value="{{ filters.excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
</label>
</div> </div>
<p class="hint">Nur bei „Bearbeitete Artikel“ relevant: Benutzer in dieser Liste werden aus den bearbeiteten Artikeln entfernt.</p>
<label>Artikel-Auswahl <label>Artikel-Auswahl
<select name="article_type"> <select name="article_type">
<option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option> <option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option>
@@ -135,6 +139,7 @@
<input type="hidden" name="period" value="{{ filters.period }}"> <input type="hidden" name="period" value="{{ filters.period }}">
<input type="hidden" name="days" value="{{ filters.days }}"> <input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}"> <input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="excluded_users" value="{{ filters.excluded_users }}">
<input type="hidden" name="article_type" value="{{ filters.article_type }}"> <input type="hidden" name="article_type" value="{{ filters.article_type }}">
<input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}"> <input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}">
<input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}"> <input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}">
@@ -339,6 +344,10 @@
<input type="text" name="gen_category" value="{{ smtp.gen_category }}"> <input type="text" name="gen_category" value="{{ smtp.gen_category }}">
</label> </label>
</div> </div>
<label>Bearbeiter ausschließen (optional)
<input type="text" name="gen_excluded_users" value="{{ smtp.gen_excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
</label>
<p class="hint" style="margin-top:-0.2rem;">Diese Benutzer werden bei „Bearbeitete Artikel“ ignoriert (gilt für den geplanten Versand).</p>
</fieldset> </fieldset>
<button type="submit">Einstellungen speichern</button> <button type="submit">Einstellungen speichern</button>

View File

@@ -1,13 +1,13 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
jinja2==3.1.4 jinja2==3.1.6
sqlalchemy==2.0.36 sqlalchemy==2.0.36
alembic==1.14.0 alembic==1.14.0
python-multipart==0.0.20 python-multipart==0.0.31
httpx==0.28.1 httpx==0.28.1
bcrypt==4.2.1 bcrypt==4.2.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.4.0
pydantic-settings==2.6.1 pydantic-settings==2.6.1
email-validator==2.2.0 email-validator==2.2.0
psycopg[binary]==3.2.13 psycopg[binary]==3.2.13
cryptography==44.0.0 cryptography==48.0.1