Compare commits
10 Commits
3f339b3dae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555fd2c6ba | ||
|
|
070e99303a | ||
|
|
8a556b18ea | ||
|
|
1674efd587 | ||
|
|
44d14d94de | ||
|
|
48410d39da | ||
| c0a44285e6 | |||
|
|
b8073f659d | ||
| 7c340fa172 | |||
|
|
4999efdacb |
@@ -2,6 +2,7 @@ APP_NAME=TK Wiki Newsletter Admin
|
|||||||
# development = lokale Entwicklung ohne harte Produktions-Checks
|
# development = lokale Entwicklung ohne harte Produktions-Checks
|
||||||
# production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true
|
# production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
# Bitte ein langes, zufälliges Secret mit mindestens 32 Zeichen generieren und hier eintragen
|
||||||
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET_AT_LEAST_32_CHARS
|
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET_AT_LEAST_32_CHARS
|
||||||
ALGORITHM=HS256
|
ALGORITHM=HS256
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=120
|
ACCESS_TOKEN_EXPIRE_MINUTES=120
|
||||||
@@ -16,11 +17,17 @@ ALLOWED_HOSTS=localhost,127.0.0.1
|
|||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) – Pflicht in production
|
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) – Pflicht in production
|
||||||
HSTS_MAX_AGE=31536000
|
HSTS_MAX_AGE=31536000
|
||||||
|
# Admin-Bootstrap-Daten
|
||||||
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
|
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
|
||||||
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
|
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
|
||||||
# true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start
|
# true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start
|
||||||
# auf die obigen Werte zurueck (auch wenn der User schon existiert).
|
# auf die obigen Werte zurueck (auch wenn der User schon existiert).
|
||||||
# In production nicht erlaubt. Nach Erst-Setup auf false lassen.
|
# In production nicht erlaubt. Nach Erst-Setup auf false lassen.
|
||||||
ADMIN_BOOTSTRAP_RESET=false
|
ADMIN_BOOTSTRAP_RESET=false
|
||||||
|
# Maximale Länge für Editor-Tips
|
||||||
EDITOR_TIP_MAX_LENGTH=10000
|
EDITOR_TIP_MAX_LENGTH=10000
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -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
133
README.md
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
26
app/main.py
26
app/main.py
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 & bearbeitete)</option>
|
<option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue & 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user