Compare commits
6 Commits
c0a44285e6
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555fd2c6ba | ||
|
|
070e99303a | ||
|
|
8a556b18ea | ||
|
|
1674efd587 | ||
|
|
44d14d94de | ||
|
|
48410d39da |
@@ -28,3 +28,6 @@ ADMIN_BOOTSTRAP_RESET=false
|
||||
EDITOR_TIP_MAX_LENGTH=10000
|
||||
# Maximale Länge für Highlights
|
||||
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)
|
||||
run: |
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install pip-audit
|
||||
pip-audit -r requirements.txt
|
||||
python -m pip install pip-audit
|
||||
# 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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -21,9 +22,24 @@ class Settings(BaseSettings):
|
||||
admin_bootstrap_reset: bool = False
|
||||
editor_tip_max_length: int = 10000
|
||||
highlights_max_length: int = 5000
|
||||
default_excluded_users: str = "Aranzinger"
|
||||
|
||||
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
|
||||
def is_production(self) -> bool:
|
||||
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_subject,
|
||||
filter_articles,
|
||||
parse_excluded_users,
|
||||
parse_display_options,
|
||||
parse_highlights,
|
||||
resolve_period,
|
||||
@@ -117,6 +118,7 @@ async def _run_scheduled_send_if_due() -> None:
|
||||
editor_tip="",
|
||||
highlight_list=[],
|
||||
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"]:
|
||||
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
|
||||
@@ -360,6 +362,7 @@ def _empty_filters() -> dict:
|
||||
"period": "last_month",
|
||||
"article_type": "new",
|
||||
"category": "",
|
||||
"excluded_users": settings.default_excluded_users,
|
||||
"display": DEFAULT_DISPLAY,
|
||||
"editor_tip": "",
|
||||
"highlights": "",
|
||||
@@ -376,6 +379,7 @@ async def _generate_newsletter(
|
||||
editor_tip: str,
|
||||
highlight_list: list[str],
|
||||
contact_email: str = "",
|
||||
excluded_users_raw: str = "",
|
||||
) -> dict:
|
||||
p = resolve_period(period_mode, days)
|
||||
result = {
|
||||
@@ -390,11 +394,12 @@ async def _generate_newsletter(
|
||||
"subject": "",
|
||||
}
|
||||
try:
|
||||
excluded_users = parse_excluded_users(excluded_users_raw)
|
||||
articles = await wiki_service.get_recent_changes(
|
||||
days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
|
||||
)
|
||||
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"])
|
||||
result.update(
|
||||
{
|
||||
@@ -402,9 +407,15 @@ async def _generate_newsletter(
|
||||
"articles_new": new_articles,
|
||||
"articles_edited": edited_articles,
|
||||
"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),
|
||||
"raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email),
|
||||
"outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email),
|
||||
"plain_text": create_plain_text(
|
||||
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email, excluded_users
|
||||
),
|
||||
"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:
|
||||
@@ -444,6 +455,7 @@ async def dashboard_generate(
|
||||
days: int = Form(30),
|
||||
article_type: str = Form("all"),
|
||||
category: str = Form(""),
|
||||
excluded_users: str = Form(""),
|
||||
show_date: str | None = Form(None),
|
||||
show_user: str | None = Form(None),
|
||||
show_category: str | None = Form(None),
|
||||
@@ -474,6 +486,7 @@ async def dashboard_generate(
|
||||
editor_tip=editor_tip,
|
||||
highlight_list=highlight_list,
|
||||
contact_email=contact_email,
|
||||
excluded_users_raw=excluded_users,
|
||||
)
|
||||
|
||||
context = _base_context(request, current_user, db)
|
||||
@@ -491,6 +504,7 @@ async def dashboard_generate(
|
||||
"period": period,
|
||||
"article_type": article_type,
|
||||
"category": category,
|
||||
"excluded_users": excluded_users,
|
||||
"display": display,
|
||||
"editor_tip": editor_tip,
|
||||
"highlights": highlights,
|
||||
@@ -511,6 +525,7 @@ async def send_now(
|
||||
days: int = Form(30),
|
||||
article_type: str = Form("all"),
|
||||
category: str = Form(""),
|
||||
excluded_users: str = Form(""),
|
||||
show_date: str | None = Form(None),
|
||||
show_user: str | None = Form(None),
|
||||
show_category: str | None = Form(None),
|
||||
@@ -541,6 +556,7 @@ async def send_now(
|
||||
editor_tip=editor_tip,
|
||||
highlight_list=highlight_list,
|
||||
contact_email=contact_email,
|
||||
excluded_users_raw=excluded_users,
|
||||
)
|
||||
|
||||
send_result: dict[str, str]
|
||||
@@ -585,6 +601,7 @@ def update_smtp_settings(
|
||||
gen_days: str = Form("30"),
|
||||
gen_article_type: str = Form("all"),
|
||||
gen_category: str = Form(""),
|
||||
gen_excluded_users: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_admin_user),
|
||||
):
|
||||
@@ -631,6 +648,7 @@ def update_smtp_settings(
|
||||
gen_article_type = "all"
|
||||
set_config(db, "smtp.gen_article_type", gen_article_type)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
new_articles: list[dict[str, Any]] = []
|
||||
edited_articles: list[dict[str, Any]] = []
|
||||
new_titles: set[str] = set()
|
||||
excluded_edited_users = excluded_edited_users or set()
|
||||
|
||||
for item in sorted_articles:
|
||||
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()
|
||||
for item in sorted_articles:
|
||||
title = item.get("title", "")
|
||||
editor = str(item.get("user", "")).strip().lower()
|
||||
if not title or title in new_titles or title in seen_edited:
|
||||
continue
|
||||
if editor in excluded_edited_users:
|
||||
continue
|
||||
edited_articles.append(item)
|
||||
seen_edited.add(title)
|
||||
|
||||
@@ -204,9 +217,10 @@ def create_plain_text(
|
||||
period: tuple[str, str] | None = None,
|
||||
article_type: str = "all",
|
||||
contact_email: str = "",
|
||||
excluded_edited_users: set[str] | None = None,
|
||||
) -> str:
|
||||
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_edited = article_type in ("all", "edited")
|
||||
period_start, period_end = period if period else _period_range(days)
|
||||
@@ -308,9 +322,10 @@ def create_html(
|
||||
article_type: str = "all",
|
||||
month_label: str | None = None,
|
||||
contact_email: str = "",
|
||||
excluded_edited_users: set[str] | None = None,
|
||||
) -> str:
|
||||
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_edited = article_type in ("all", "edited")
|
||||
period_start, period_end = period if period else _period_range(days)
|
||||
@@ -707,10 +722,11 @@ def create_outlook_html(
|
||||
article_type: str = "all",
|
||||
month_label: str | None = None,
|
||||
contact_email: str = "",
|
||||
excluded_edited_users: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
|
||||
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_edited = article_type in ("all", "edited")
|
||||
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 app.core.config import settings
|
||||
from app.core.email_utils import parse_recipient_list
|
||||
from app.models.system import SendLog
|
||||
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_article_type": get_config(db, "smtp.gen_article_type", "new"),
|
||||
"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)
|
||||
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
|
||||
</label>
|
||||
<label>Bearbeiter ausschließen (optional)
|
||||
<input type="text" name="excluded_users" value="{{ filters.excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Nur bei „Bearbeitete Artikel“ relevant: Benutzer in dieser Liste werden aus den bearbeiteten Artikeln entfernt.</p>
|
||||
<label>Artikel-Auswahl
|
||||
<select name="article_type">
|
||||
<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="days" value="{{ filters.days }}">
|
||||
<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="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' }}">
|
||||
@@ -339,6 +344,10 @@
|
||||
<input type="text" name="gen_category" value="{{ smtp.gen_category }}">
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<button type="submit">Einstellungen speichern</button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
jinja2==3.1.4
|
||||
jinja2==3.1.6
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
python-multipart==0.0.20
|
||||
python-multipart==0.0.31
|
||||
httpx==0.28.1
|
||||
bcrypt==4.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-jose[cryptography]==3.4.0
|
||||
pydantic-settings==2.6.1
|
||||
email-validator==2.2.0
|
||||
psycopg[binary]==3.2.13
|
||||
cryptography==44.0.0
|
||||
cryptography==48.0.1
|
||||
|
||||
Reference in New Issue
Block a user