Update configuration and enhance newsletter generation features
- Change WIKI_API_URL in .env.example and config to point to the new API endpoint. - Set COOKIE_SECURE to false for local development in .env.example and config. - Improve the newsletter generation logic to handle new and edited articles separately. - Add display options for showing date, user, and category in the newsletter output. - Enhance error handling for Wiki API requests and update templates for better user feedback. - Update CSS for new UI elements and improve overall layout in dashboard and login pages.
This commit is contained in:
@@ -7,8 +7,9 @@ HOST_PORT=8080
|
|||||||
DATABASE_URL=sqlite:///./data/newsletter.db
|
DATABASE_URL=sqlite:///./data/newsletter.db
|
||||||
# Beispiel PostgreSQL:
|
# Beispiel PostgreSQL:
|
||||||
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
|
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
|
||||||
WIKI_API_URL=https://www.thomas-krenn.com/de/wiki/api.php
|
WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php
|
||||||
ALLOWED_HOSTS=*
|
ALLOWED_HOSTS=*
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=false
|
||||||
|
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy)
|
||||||
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
|
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
|
||||||
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
|
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
|
|||||||
- Zeitraum in Tagen
|
- Zeitraum in Tagen
|
||||||
- Nur bearbeitete Artikel
|
- Nur bearbeitete Artikel
|
||||||
- Kategorie
|
- Kategorie
|
||||||
- Datenquelle: MediaWiki API (`recentchanges`)
|
- Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`)
|
||||||
- Export:
|
- Export:
|
||||||
- Plain Text (für Outlook)
|
- Plain Text (für Outlook)
|
||||||
- Raw HTML
|
- Raw HTML
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ class Settings(BaseSettings):
|
|||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 120
|
access_token_expire_minutes: int = 120
|
||||||
database_url: str = "sqlite:///./data/newsletter.db"
|
database_url: str = "sqlite:///./data/newsletter.db"
|
||||||
wiki_api_url: str = "https://www.thomas-krenn.com/de/wiki/api.php"
|
wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php"
|
||||||
allowed_hosts: str = "*"
|
allowed_hosts: str = "*"
|
||||||
cookie_secure: bool = True
|
cookie_secure: bool = False
|
||||||
admin_bootstrap_email: str = "admin@internal.local"
|
admin_bootstrap_email: str = "admin@internal.local"
|
||||||
admin_bootstrap_password: str = "ChangeMe123!"
|
admin_bootstrap_password: str = "ChangeMe123!"
|
||||||
|
|
||||||
|
|||||||
13
app/core/cookies.py
Normal file
13
app/core/cookies.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_secure(request: Request) -> bool:
|
||||||
|
"""Secure-Flag nur setzen, wenn aktiviert und die Anfrage wirklich per HTTPS läuft."""
|
||||||
|
if not settings.cookie_secure:
|
||||||
|
return False
|
||||||
|
forwarded = request.headers.get("x-forwarded-proto", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip().lower() == "https"
|
||||||
|
return request.url.scheme == "https"
|
||||||
119
app/main.py
119
app/main.py
@@ -9,6 +9,7 @@ from sqlalchemy import inspect, text
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.cookies import cookie_secure
|
||||||
from app.core.security import create_access_token, hash_password, verify_password
|
from app.core.security import create_access_token, hash_password, verify_password
|
||||||
from app.db.session import Base, engine, get_db
|
from app.db.session import Base, engine, get_db
|
||||||
from app.models.system import SendLog
|
from app.models.system import SendLog
|
||||||
@@ -17,9 +18,18 @@ from app.schemas.user import UserCreate
|
|||||||
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
|
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
|
||||||
from app.services.config_store import get_config, set_config
|
from app.services.config_store import get_config, set_config
|
||||||
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
|
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.newsletter import (
|
||||||
|
DEFAULT_DISPLAY,
|
||||||
|
article_url,
|
||||||
|
create_html,
|
||||||
|
create_plain_text,
|
||||||
|
create_subject,
|
||||||
|
filter_articles,
|
||||||
|
parse_display_options,
|
||||||
|
split_articles_by_type,
|
||||||
|
)
|
||||||
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
|
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
|
||||||
from app.services.wiki import WikiService
|
from app.services.wiki import WikiFetchError, WikiService
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()]
|
allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()]
|
||||||
@@ -27,6 +37,7 @@ if allowed_hosts:
|
|||||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
templates.env.globals["wiki_article_url"] = article_url
|
||||||
wiki_service = WikiService()
|
wiki_service = WikiService()
|
||||||
|
|
||||||
|
|
||||||
@@ -54,9 +65,9 @@ async def add_security_headers(request: Request, call_next):
|
|||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "same-origin"
|
response.headers["Referrer-Policy"] = "same-origin"
|
||||||
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self';"
|
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
|
||||||
if not request.cookies.get(CSRF_COOKIE_NAME):
|
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")
|
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +131,10 @@ def login_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
return _render(
|
return _render(
|
||||||
request,
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)},
|
{
|
||||||
|
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
||||||
|
"error_message": None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,13 +146,30 @@ def login(
|
|||||||
password: str = Form(...),
|
password: str = Form(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
try:
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
except HTTPException:
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
{
|
||||||
|
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
||||||
|
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||||
|
},
|
||||||
|
)
|
||||||
user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first()
|
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):
|
if not user or not verify_password(password, user.password_hash):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültige Zugangsdaten.")
|
return _render(
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
{
|
||||||
|
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
||||||
|
"error_message": "Ungültige Zugangsdaten.",
|
||||||
|
},
|
||||||
|
)
|
||||||
token = create_access_token(user.id)
|
token = create_access_token(user.id)
|
||||||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||||||
response.set_cookie("access_token", token, httponly=True, secure=settings.cookie_secure, samesite="strict")
|
response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +184,20 @@ def logout(request: Request, csrf_token: str = Form(...)):
|
|||||||
@app.get("/dashboard", response_class=HTMLResponse)
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
context = _base_context(request, current_user, db)
|
context = _base_context(request, current_user, db)
|
||||||
context.update({"articles": [], "raw_html": "", "plain_text": "", "filters": {"days": 30, "only_edited": False, "category": ""}})
|
context.update({
|
||||||
|
"articles": [],
|
||||||
|
"articles_new": [],
|
||||||
|
"articles_edited": [],
|
||||||
|
"raw_html": "",
|
||||||
|
"plain_text": "",
|
||||||
|
"filters": {
|
||||||
|
"days": 30,
|
||||||
|
"only_edited": False,
|
||||||
|
"category": "",
|
||||||
|
"display": DEFAULT_DISPLAY,
|
||||||
|
},
|
||||||
|
"wiki_error": None,
|
||||||
|
})
|
||||||
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
|
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
|
||||||
context["send_logs"] = logs
|
context["send_logs"] = logs
|
||||||
return _render(request, "dashboard.html", context)
|
return _render(request, "dashboard.html", context)
|
||||||
@@ -164,27 +208,50 @@ async def dashboard_generate(
|
|||||||
request: Request,
|
request: Request,
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
days: int = Form(30),
|
days: int = Form(30),
|
||||||
only_edited: bool = Form(False),
|
only_edited: str | None = Form(None),
|
||||||
category: str = Form(""),
|
category: str = Form(""),
|
||||||
|
show_date: str | None = Form(None),
|
||||||
|
show_user: str | None = Form(None),
|
||||||
|
show_category: str | None = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_editor_or_admin),
|
current_user: User = Depends(require_editor_or_admin),
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
days = max(1, min(days, 90))
|
days = max(1, min(days, 90))
|
||||||
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited)
|
only_edited_flag = only_edited == "true"
|
||||||
filtered = filter_articles(articles, category_filter=category or None)
|
display = parse_display_options(show_date, show_user, show_category)
|
||||||
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
|
wiki_error = None
|
||||||
plain_text = create_plain_text(filtered, title)
|
filtered: list = []
|
||||||
raw_html = create_html(filtered, title)
|
articles_new: list = []
|
||||||
run_scheduled_send_if_due(db, title, raw_html, plain_text)
|
articles_edited: list = []
|
||||||
|
plain_text = ""
|
||||||
|
raw_html = ""
|
||||||
|
try:
|
||||||
|
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag)
|
||||||
|
filtered = filter_articles(articles, category_filter=category or None)
|
||||||
|
articles_new, articles_edited = split_articles_by_type(filtered)
|
||||||
|
title = create_subject(days, category)
|
||||||
|
plain_text = create_plain_text(filtered, days, display, category)
|
||||||
|
raw_html = create_html(filtered, days, display, category)
|
||||||
|
run_scheduled_send_if_due(db, title, raw_html, plain_text)
|
||||||
|
except WikiFetchError as exc:
|
||||||
|
wiki_error = exc.message
|
||||||
context = _base_context(request, current_user, db)
|
context = _base_context(request, current_user, db)
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
"articles": filtered,
|
"articles": filtered,
|
||||||
|
"articles_new": articles_new,
|
||||||
|
"articles_edited": articles_edited,
|
||||||
"raw_html": raw_html,
|
"raw_html": raw_html,
|
||||||
"plain_text": plain_text,
|
"plain_text": plain_text,
|
||||||
"filters": {"days": days, "only_edited": only_edited, "category": category},
|
"filters": {
|
||||||
|
"days": days,
|
||||||
|
"only_edited": only_edited_flag,
|
||||||
|
"category": category,
|
||||||
|
"display": display,
|
||||||
|
},
|
||||||
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
|
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
|
||||||
|
"wiki_error": wiki_error,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return _render(request, "dashboard.html", context)
|
return _render(request, "dashboard.html", context)
|
||||||
@@ -195,17 +262,25 @@ async def send_now(
|
|||||||
request: Request,
|
request: Request,
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
days: int = Form(30),
|
days: int = Form(30),
|
||||||
only_edited: bool = Form(False),
|
only_edited: str | None = Form(None),
|
||||||
category: str = Form(""),
|
category: str = Form(""),
|
||||||
|
show_date: str | None = Form(None),
|
||||||
|
show_user: str | None = Form(None),
|
||||||
|
show_category: str | None = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_editor_or_admin),
|
current_user: User = Depends(require_editor_or_admin),
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited)
|
display = parse_display_options(show_date, show_user, show_category)
|
||||||
|
only_edited_flag = only_edited == "true"
|
||||||
|
try:
|
||||||
|
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag)
|
||||||
|
except WikiFetchError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=exc.message) from exc
|
||||||
filtered = filter_articles(articles, category_filter=category or None)
|
filtered = filter_articles(articles, category_filter=category or None)
|
||||||
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
|
title = create_subject(max(1, min(days, 90)), category)
|
||||||
plain_text = create_plain_text(filtered, title)
|
plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category)
|
||||||
raw_html = create_html(filtered, title)
|
raw_html = create_html(filtered, max(1, min(days, 90)), display, category)
|
||||||
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
|
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)
|
send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False)
|
||||||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Any
|
from typing import Any, TypedDict
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/"
|
||||||
|
WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite"
|
||||||
|
ORG_NAME = "Thomas-Krenn.AG"
|
||||||
|
LINE = "=" * 78
|
||||||
|
SUBLINE = "-" * 78
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayOptions(TypedDict):
|
||||||
|
show_date: bool
|
||||||
|
show_user: bool
|
||||||
|
show_category: bool
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DISPLAY: DisplayOptions = {
|
||||||
|
"show_date": True,
|
||||||
|
"show_user": True,
|
||||||
|
"show_category": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_display_options(
|
||||||
|
show_date: str | None = None,
|
||||||
|
show_user: str | None = None,
|
||||||
|
show_category: str | None = None,
|
||||||
|
) -> DisplayOptions:
|
||||||
|
return {
|
||||||
|
"show_date": show_date == "true",
|
||||||
|
"show_user": show_user == "true",
|
||||||
|
"show_category": show_category == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def filter_articles(
|
def filter_articles(
|
||||||
@@ -17,60 +49,280 @@ def filter_articles(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def create_plain_text(articles: list[dict[str, Any]], title: str) -> str:
|
def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
lines = [title, "=" * len(title), ""]
|
sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
|
||||||
if not articles:
|
new_articles: list[dict[str, Any]] = []
|
||||||
lines.append("Keine Artikel im gewählten Zeitraum gefunden.")
|
edited_articles: list[dict[str, Any]] = []
|
||||||
return "\n".join(lines)
|
new_titles: set[str] = set()
|
||||||
|
|
||||||
for idx, item in enumerate(articles, start=1):
|
for item in sorted_articles:
|
||||||
stamp = _format_ts(item.get("timestamp"))
|
title = item.get("title", "")
|
||||||
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
if not title or item.get("type") != "new":
|
||||||
comment = item.get("comment") or "-"
|
continue
|
||||||
|
if title not in new_titles:
|
||||||
|
new_articles.append(item)
|
||||||
|
new_titles.add(title)
|
||||||
|
|
||||||
|
seen_edited: set[str] = set()
|
||||||
|
for item in sorted_articles:
|
||||||
|
title = item.get("title", "")
|
||||||
|
if not title or title in new_titles or title in seen_edited:
|
||||||
|
continue
|
||||||
|
edited_articles.append(item)
|
||||||
|
seen_edited.add(title)
|
||||||
|
|
||||||
|
return new_articles, edited_articles
|
||||||
|
|
||||||
|
|
||||||
|
def create_subject(days: int, category: str = "") -> str:
|
||||||
|
base = f"Interner Wiki-Newsletter – {ORG_NAME} ({days} Tage)"
|
||||||
|
if category.strip():
|
||||||
|
return f"{base} | Kategorie: {category.strip()}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def create_plain_text(
|
||||||
|
articles: list[dict[str, Any]],
|
||||||
|
days: int,
|
||||||
|
display: DisplayOptions | None = None,
|
||||||
|
category: str = "",
|
||||||
|
) -> str:
|
||||||
|
display = display or DEFAULT_DISPLAY
|
||||||
|
new_articles, edited_articles = split_articles_by_type(articles)
|
||||||
|
period_start, period_end = _period_range(days)
|
||||||
|
created_at = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
LINE,
|
||||||
|
f"{ORG_NAME.upper()} – INTERNER WIKI-NEWSLETTER",
|
||||||
|
LINE,
|
||||||
|
f"Zeitraum: {period_start} bis {period_end} ({days} Tage)",
|
||||||
|
f"Erstellt am: {created_at}",
|
||||||
|
]
|
||||||
|
if category.strip():
|
||||||
|
lines.append(f"Kategorie: {category.strip()}")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Liebe Kolleginnen und Kollegen,",
|
||||||
|
"",
|
||||||
|
"im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel",
|
||||||
|
"neu veröffentlicht bzw. aktualisiert:",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_articles and not edited_articles:
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
f"{idx}. {item.get('title', 'Ohne Titel')}",
|
SUBLINE,
|
||||||
f" Zeit: {stamp}",
|
"HINWEIS",
|
||||||
f" Benutzer: {item.get('user', '-')}",
|
SUBLINE,
|
||||||
f" Kategorien: {cat}",
|
"Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
|
||||||
f" Kommentar: {comment}",
|
|
||||||
"",
|
"",
|
||||||
|
_plain_footer(new_articles, edited_articles),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum."))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(
|
||||||
|
_plain_section(
|
||||||
|
"BEARBEITETE ARTIKEL",
|
||||||
|
edited_articles,
|
||||||
|
display,
|
||||||
|
empty_text="Keine bearbeiteten Artikel im Zeitraum.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(_plain_footer(new_articles, edited_articles))
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
def create_html(articles: list[dict[str, Any]], title: str) -> str:
|
def create_html(
|
||||||
if not articles:
|
articles: list[dict[str, Any]],
|
||||||
return f"<h2>{escape(title)}</h2><p>Keine Artikel im gewählten Zeitraum gefunden.</p>"
|
days: int,
|
||||||
|
display: DisplayOptions | None = None,
|
||||||
|
category: str = "",
|
||||||
|
) -> str:
|
||||||
|
display = display or DEFAULT_DISPLAY
|
||||||
|
new_articles, edited_articles = split_articles_by_type(articles)
|
||||||
|
period_start, period_end = _period_range(days)
|
||||||
|
created_at = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
subject = escape(create_subject(days, category))
|
||||||
|
|
||||||
blocks = [f"<h2>{escape(title)}</h2>", "<ul>"]
|
category_row = ""
|
||||||
for item in articles:
|
if category.strip():
|
||||||
article_title = escape(item.get("title", "Ohne Titel"))
|
category_row = f'<p style="margin:4px 0;color:#334155;"><strong>Kategorie:</strong> {escape(category.strip())}</p>'
|
||||||
stamp = escape(_format_ts(item.get("timestamp")))
|
|
||||||
user = escape(item.get("user", "-"))
|
if not new_articles and not edited_articles:
|
||||||
cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie")
|
body = '<p style="margin:16px 0;">Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.</p>'
|
||||||
comment = escape(item.get("comment") or "-")
|
else:
|
||||||
blocks.append(
|
body = "\n".join(
|
||||||
(
|
[
|
||||||
"<li>"
|
_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."),
|
||||||
f"<strong>{article_title}</strong><br>"
|
_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum."),
|
||||||
f"Zeit: {stamp}<br>"
|
]
|
||||||
f"Benutzer: {user}<br>"
|
|
||||||
f"Kategorien: {cat}<br>"
|
|
||||||
f"Kommentar: {comment}"
|
|
||||||
"</li>"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
blocks.append("</ul>")
|
|
||||||
|
summary = _html_summary(new_articles, edited_articles)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="utf-8"><title>{subject}</title></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f6fa;font-family:Arial,Helvetica,sans-serif;color:#1f2430;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6fa;padding:24px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="640" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #dde3ef;border-radius:8px;overflow:hidden;">
|
||||||
|
<tr><td style="background:#0c2c5a;color:#ffffff;padding:20px 24px;">
|
||||||
|
<h1 style="margin:0;font-size:20px;">Interner Wiki-Newsletter</h1>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;">{escape(ORG_NAME)}</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:24px;">
|
||||||
|
<p style="margin:0 0 8px;color:#334155;"><strong>Zeitraum:</strong> {period_start} bis {period_end} ({days} Tage)</p>
|
||||||
|
<p style="margin:0 0 8px;color:#334155;"><strong>Erstellt am:</strong> {created_at}</p>
|
||||||
|
{category_row}
|
||||||
|
<p style="margin:20px 0 8px;line-height:1.5;">Liebe Kolleginnen und Kollegen,</p>
|
||||||
|
<p style="margin:0 0 20px;line-height:1.5;">im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel neu veröffentlicht bzw. aktualisiert:</p>
|
||||||
|
{body}
|
||||||
|
{summary}
|
||||||
|
<p style="margin:24px 0 0;"><a href="{WIKI_HOME_URL}" style="color:#1b5dbf;">Zum Thomas-Krenn-Wiki</a></p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#f8fafc;padding:16px 24px;border-top:1px solid #e6ebf4;font-size:12px;color:#64748b;line-height:1.5;">
|
||||||
|
Dieser Newsletter wurde automatisch erstellt und ist nur für den internen Gebrauch bei {escape(ORG_NAME)} bestimmt.
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_section(
|
||||||
|
heading: str,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
display: DisplayOptions,
|
||||||
|
empty_text: str,
|
||||||
|
) -> list[str]:
|
||||||
|
lines = [SUBLINE, f"{heading} ({len(items)})", SUBLINE, ""]
|
||||||
|
if not items:
|
||||||
|
lines.append(f" {empty_text}")
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
for idx, item in enumerate(items, start=1):
|
||||||
|
lines.extend(_plain_item(idx, item, display))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list[str]:
|
||||||
|
title = item.get("title", "Ohne Titel")
|
||||||
|
url = _article_url(item.get("title", ""))
|
||||||
|
meta_parts: list[str] = []
|
||||||
|
if display["show_date"]:
|
||||||
|
meta_parts.append(_format_ts(item.get("timestamp")))
|
||||||
|
if display["show_user"]:
|
||||||
|
meta_parts.append(str(item.get("user", "-")))
|
||||||
|
if display["show_category"]:
|
||||||
|
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
||||||
|
meta_parts.append(cat)
|
||||||
|
|
||||||
|
lines = [f" {idx}. {title}", f" {url}"]
|
||||||
|
if meta_parts:
|
||||||
|
lines.append(f" ({' | '.join(meta_parts)})")
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
|
||||||
|
total = len(new_articles) + len(edited_articles)
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
SUBLINE,
|
||||||
|
"ZUSAMMENFASSUNG",
|
||||||
|
SUBLINE,
|
||||||
|
f" Neue Artikel: {len(new_articles)}",
|
||||||
|
f" Bearbeitete Artikel: {len(edited_articles)}",
|
||||||
|
f" Gesamt: {total}",
|
||||||
|
"",
|
||||||
|
f"Wiki: {WIKI_HOME_URL}",
|
||||||
|
"",
|
||||||
|
SUBLINE,
|
||||||
|
f"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
|
||||||
|
f"Gebrauch bei {ORG_NAME} bestimmt.",
|
||||||
|
LINE,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_section(heading: str, items: list[dict[str, Any]], display: DisplayOptions, empty_text: str) -> str:
|
||||||
|
blocks = [
|
||||||
|
f'<h2 style="margin:24px 0 8px;font-size:16px;color:#0c2c5a;border-bottom:2px solid #dde3ef;padding-bottom:6px;">{escape(heading)} ({len(items)})</h2>'
|
||||||
|
]
|
||||||
|
if not items:
|
||||||
|
blocks.append(f'<p style="margin:8px 0 0;color:#64748b;">{escape(empty_text)}</p>')
|
||||||
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
blocks.append('<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:8px;">')
|
||||||
|
for idx, item in enumerate(items, start=1):
|
||||||
|
blocks.append(_html_item_row(idx, item, display))
|
||||||
|
blocks.append("</table>")
|
||||||
return "\n".join(blocks)
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions) -> str:
|
||||||
|
title = escape(item.get("title", "Ohne Titel"))
|
||||||
|
url = escape(_article_url(item.get("title", "")))
|
||||||
|
meta_parts: list[str] = []
|
||||||
|
if display["show_date"]:
|
||||||
|
meta_parts.append(f"<strong>Datum:</strong> {escape(_format_ts(item.get('timestamp')))}")
|
||||||
|
if display["show_user"]:
|
||||||
|
meta_parts.append(f"<strong>Benutzer:</strong> {escape(str(item.get('user', '-')))}")
|
||||||
|
if display["show_category"]:
|
||||||
|
cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie")
|
||||||
|
meta_parts.append(f"<strong>Kategorie:</strong> {cat}")
|
||||||
|
meta_html = f'<div style="font-size:12px;color:#64748b;margin-top:4px;">{" | ".join(meta_parts)}</div>' if meta_parts else ""
|
||||||
|
|
||||||
|
return f"""<tr>
|
||||||
|
<td style="padding:10px 0;border-bottom:1px solid #e6ebf4;vertical-align:top;width:28px;color:#64748b;">{idx}.</td>
|
||||||
|
<td style="padding:10px 0;border-bottom:1px solid #e6ebf4;vertical-align:top;">
|
||||||
|
<a href="{url}" style="color:#1b5dbf;font-weight:bold;text-decoration:none;">{title}</a>
|
||||||
|
{meta_html}
|
||||||
|
</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
|
||||||
|
total = len(new_articles) + len(edited_articles)
|
||||||
|
return f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:24px;background:#f8fafc;border:1px solid #e6ebf4;border-radius:6px;">
|
||||||
|
<tr><td style="padding:14px 16px;font-size:14px;line-height:1.6;">
|
||||||
|
<strong>Zusammenfassung</strong><br>
|
||||||
|
Neue Artikel: {len(new_articles)}<br>
|
||||||
|
Bearbeitete Artikel: {len(edited_articles)}<br>
|
||||||
|
Gesamt: {total}
|
||||||
|
</td></tr>
|
||||||
|
</table>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _article_url(title: str) -> str:
|
||||||
|
return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}"
|
||||||
|
|
||||||
|
|
||||||
|
def article_url(title: str) -> str:
|
||||||
|
return _article_url(title)
|
||||||
|
|
||||||
|
|
||||||
|
def _period_range(days: int) -> tuple[str, str]:
|
||||||
|
end = datetime.now()
|
||||||
|
start = end - timedelta(days=days)
|
||||||
|
return start.strftime("%d.%m.%Y"), end.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
def _format_ts(value: str | None) -> str:
|
def _format_ts(value: str | None) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return "-"
|
return "-"
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
return dt.strftime("%d.%m.%Y %H:%M UTC")
|
return dt.strftime("%d.%m.%Y %H:%M")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ def send_newsletter(
|
|||||||
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled)
|
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled)
|
||||||
return
|
return
|
||||||
|
|
||||||
msg = MIMEText(html_content, "html", "utf-8")
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||||
|
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["From"] = settings["from_email"]
|
msg["From"] = settings["from_email"]
|
||||||
msg["To"] = ", ".join(recipients)
|
msg["To"] = ", ".join(recipients)
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import httpx
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
USER_AGENT = "TK-Newsletter-Admin/1.0 (internal; contact@thomas-krenn.com)"
|
||||||
|
|
||||||
|
|
||||||
|
class WikiFetchError(Exception):
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
self.message = message
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class WikiService:
|
class WikiService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -17,7 +25,7 @@ class WikiService:
|
|||||||
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
params = {
|
params: dict[str, str] = {
|
||||||
"action": "query",
|
"action": "query",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"list": "recentchanges",
|
"list": "recentchanges",
|
||||||
@@ -34,18 +42,31 @@ class WikiService:
|
|||||||
|
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
cont: dict[str, Any] = {}
|
cont: dict[str, Any] = {}
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
headers = {"User-Agent": USER_AGENT}
|
||||||
while True:
|
try:
|
||||||
response = await client.get(self.api_url, params={**params, **cont})
|
async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
|
||||||
response.raise_for_status()
|
while True:
|
||||||
payload = response.json()
|
response = await client.get(self.api_url, params={**params, **cont})
|
||||||
rows.extend(payload.get("query", {}).get("recentchanges", []))
|
if response.status_code >= 400:
|
||||||
if "continue" not in payload:
|
raise WikiFetchError(
|
||||||
break
|
f"Wiki-API nicht erreichbar (HTTP {response.status_code}). "
|
||||||
cont = payload["continue"]
|
f"Prüfe WIKI_API_URL in der Konfiguration."
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
if "error" in payload:
|
||||||
|
code = payload["error"].get("code", "unknown")
|
||||||
|
info = payload["error"].get("info", "Unbekannter API-Fehler")
|
||||||
|
raise WikiFetchError(f"Wiki-API-Fehler ({code}): {info}")
|
||||||
|
|
||||||
titles = sorted({item["title"] for item in rows if item.get("title")})
|
rows.extend(payload.get("query", {}).get("recentchanges", []))
|
||||||
categories = await self._get_categories_for_titles(client, titles)
|
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)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise WikiFetchError(f"Wiki-API-Anfrage fehlgeschlagen: {exc}") from exc
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row["categories"] = categories.get(row.get("title", ""), [])
|
row["categories"] = categories.get(row.get("title", ""), [])
|
||||||
@@ -67,12 +88,15 @@ class WikiService:
|
|||||||
"titles": "|".join(chunk),
|
"titles": "|".join(chunk),
|
||||||
}
|
}
|
||||||
response = await client.get(self.api_url, params=params)
|
response = await client.get(self.api_url, params=params)
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
continue
|
||||||
pages = response.json().get("query", {}).get("pages", {})
|
pages = response.json().get("query", {}).get("pages", {})
|
||||||
for page in pages.values():
|
for page in pages.values():
|
||||||
title = page.get("title")
|
title = page.get("title")
|
||||||
if not title:
|
if not title:
|
||||||
continue
|
continue
|
||||||
raw_categories = page.get("categories", [])
|
raw_categories = page.get("categories", [])
|
||||||
result[title] = [c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")]
|
result[title] = [
|
||||||
|
c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")
|
||||||
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ label { display: grid; gap: 0.3rem; font-weight: 600; }
|
|||||||
input, button, textarea { font: inherit; padding: 0.55rem; }
|
input, button, textarea { font: inherit; padding: 0.55rem; }
|
||||||
textarea { width: 100%; resize: vertical; }
|
textarea { width: 100%; resize: vertical; }
|
||||||
button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
|
button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
|
||||||
|
.btn-secondary { background: #334155; }
|
||||||
|
.copy-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
|
||||||
|
.copy-status { font-size: 0.92rem; color: #64748b; }
|
||||||
|
.copy-status.success { color: #027a48; }
|
||||||
|
.copy-status.error { color: #b42318; }
|
||||||
|
.newsletter-preview-frame { width: 100%; min-height: 520px; border: 1px solid #dde3ef; border-radius: 6px; background: #fff; }
|
||||||
|
.hidden-source { display: none; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; }
|
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; }
|
.checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; }
|
||||||
|
.display-options { border: 1px solid #dde3ef; border-radius: 6px; padding: 0.75rem; display: grid; gap: 0.45rem; }
|
||||||
|
.display-options legend { font-weight: 600; padding: 0 0.25rem; }
|
||||||
|
.error { color: #b42318; background: #fef3f2; border: 1px solid #fecdca; padding: 0.6rem; border-radius: 4px; margin: 0 0 0.75rem; }
|
||||||
|
.hint { color: #64748b; font-size: 0.92rem; margin: 0 0 0.6rem; }
|
||||||
|
|||||||
83
app/static/js/newsletter.js
Normal file
83
app/static/js/newsletter.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function setCopyStatus(message, isError) {
|
||||||
|
const status = document.getElementById("copy-status");
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = isError ? "copy-status error" : "copy-status success";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyForOutlook() {
|
||||||
|
const htmlSource = document.getElementById("newsletter-html-source");
|
||||||
|
const plainSource = document.getElementById("newsletter-plain-source");
|
||||||
|
if (!htmlSource || !htmlSource.value.trim()) {
|
||||||
|
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = htmlSource.value;
|
||||||
|
const plain = plainSource ? plainSource.value : "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.ClipboardItem) {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
"text/html": new Blob([html], { type: "text/html" }),
|
||||||
|
"text/plain": new Blob([plain], { type: "text/plain" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setCopyStatus("Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Clipboard API failed, using fallback.", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.style.position = "fixed";
|
||||||
|
container.style.left = "-9999px";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(container);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
} finally {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
document.body.removeChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopyStatus(
|
||||||
|
ok ? "Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen." : "Kopieren fehlgeschlagen.",
|
||||||
|
!ok
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyHtmlSource() {
|
||||||
|
const htmlSource = document.getElementById("newsletter-html-source");
|
||||||
|
if (!htmlSource || !htmlSource.value.trim()) {
|
||||||
|
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(htmlSource.value);
|
||||||
|
setCopyStatus("HTML-Quelltext kopiert.");
|
||||||
|
} catch (err) {
|
||||||
|
setCopyStatus("Kopieren fehlgeschlagen.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const htmlSource = document.getElementById("newsletter-html-source");
|
||||||
|
const frame = document.getElementById("newsletter-preview-frame");
|
||||||
|
if (htmlSource && frame && htmlSource.value.trim()) {
|
||||||
|
frame.srcdoc = htmlSource.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("copy-outlook-btn")?.addEventListener("click", copyForOutlook);
|
||||||
|
document.getElementById("copy-html-source-btn")?.addEventListener("click", copyHtmlSource);
|
||||||
|
});
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
<main class="container">
|
<main class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,6 +14,21 @@
|
|||||||
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}>
|
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}>
|
||||||
Nur bearbeitete Artikel (keine neu erstellten)
|
Nur bearbeitete Artikel (keine neu erstellten)
|
||||||
</label>
|
</label>
|
||||||
|
<fieldset class="display-options">
|
||||||
|
<legend>Anzeige im Output</legend>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_date" value="true" {% if filters.display.show_date %}checked{% endif %}>
|
||||||
|
Datum anzeigen
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_user" value="true" {% if filters.display.show_user %}checked{% endif %}>
|
||||||
|
Benutzer anzeigen
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_category" value="true" {% if filters.display.show_category %}checked{% endif %}>
|
||||||
|
Kategorie anzeigen
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
{% if user.role in ["admin", "editor"] %}
|
{% if user.role in ["admin", "editor"] %}
|
||||||
<button type="submit">Newsletter generieren</button>
|
<button type="submit">Newsletter generieren</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -21,6 +36,9 @@
|
|||||||
{% if user.role == "admin" %}
|
{% if user.role == "admin" %}
|
||||||
<p><a href="/admin/users">Zur Benutzerverwaltung</a></p>
|
<p><a href="/admin/users">Zur Benutzerverwaltung</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if wiki_error %}
|
||||||
|
<p class="error">{{ wiki_error }}</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if user.role in ["admin", "editor"] %}
|
{% if user.role in ["admin", "editor"] %}
|
||||||
@@ -31,6 +49,9 @@
|
|||||||
<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="only_edited" value="{{ 'true' if filters.only_edited else 'false' }}">
|
<input type="hidden" name="only_edited" value="{{ 'true' if filters.only_edited 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_category" value="{{ 'true' if filters.display.show_category else 'false' }}">
|
||||||
<button type="submit">Newsletter jetzt senden</button>
|
<button type="submit">Newsletter jetzt senden</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -76,29 +97,76 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Output: Outlook Text</h3>
|
<h3>Newsletter-Vorschau (mit Hyperlinks)</h3>
|
||||||
<textarea rows="14" readonly>{{ plain_text }}</textarea>
|
<p class="hint">Für Outlook den Button „Für Outlook kopieren“ nutzen – dann bleiben Links als klickbare Hyperlinks erhalten.</p>
|
||||||
|
<div class="copy-actions">
|
||||||
|
<button type="button" id="copy-outlook-btn" class="btn-secondary">Für Outlook kopieren</button>
|
||||||
|
<button type="button" id="copy-html-source-btn" class="btn-secondary">HTML-Quelltext kopieren</button>
|
||||||
|
<span id="copy-status" class="copy-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<iframe id="newsletter-preview-frame" class="newsletter-preview-frame" title="Newsletter Vorschau"></iframe>
|
||||||
|
<textarea id="newsletter-html-source" class="hidden-source" hidden readonly>{{ raw_html }}</textarea>
|
||||||
|
<textarea id="newsletter-plain-source" class="hidden-source" hidden readonly>{{ plain_text }}</textarea>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Output: Raw HTML</h3>
|
<h3>Output: Nur-Text (Fallback)</h3>
|
||||||
<textarea rows="14" readonly>{{ raw_html }}</textarea>
|
<p class="hint">Enthält URLs als Text. Für klickbare Links bitte die Vorschau bzw. „Für Outlook kopieren“ verwenden.</p>
|
||||||
|
<textarea rows="16" readonly>{{ plain_text }}</textarea>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Gefundene Artikel ({{ articles|length }})</h3>
|
<h3>Output: HTML-Quelltext</h3>
|
||||||
|
<textarea rows="12" readonly>{{ raw_html }}</textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>Neue Artikel ({{ articles_new|length }})</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Titel</th><th>Zeit</th><th>Benutzer</th><th>Kategorien</th></tr>
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
{% if filters.display.show_date %}<th>Zeit</th>{% endif %}
|
||||||
|
{% if filters.display.show_user %}<th>Benutzer</th>{% endif %}
|
||||||
|
{% if filters.display.show_category %}<th>Kategorien</th>{% endif %}
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in articles %}
|
{% for item in articles_new %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.title }}</td>
|
<td><a href="{{ wiki_article_url(item.title) }}" target="_blank" rel="noopener">{{ item.title }}</a></td>
|
||||||
<td>{{ item.timestamp }}</td>
|
{% if filters.display.show_date %}<td>{{ item.timestamp }}</td>{% endif %}
|
||||||
<td>{{ item.user }}</td>
|
{% if filters.display.show_user %}<td>{{ item.user }}</td>{% endif %}
|
||||||
<td>{{ item.categories|join(", ") }}</td>
|
{% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4">Keine neuen Artikel im gewählten Zeitraum.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>Bearbeitete Artikel ({{ articles_edited|length }})</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
{% if filters.display.show_date %}<th>Zeit</th>{% endif %}
|
||||||
|
{% if filters.display.show_user %}<th>Benutzer</th>{% endif %}
|
||||||
|
{% if filters.display.show_category %}<th>Kategorien</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in articles_edited %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ wiki_article_url(item.title) }}" target="_blank" rel="noopener">{{ item.title }}</a></td>
|
||||||
|
{% if filters.display.show_date %}<td>{{ item.timestamp }}</td>{% endif %}
|
||||||
|
{% if filters.display.show_user %}<td>{{ item.user }}</td>{% endif %}
|
||||||
|
{% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4">Keine bearbeiteten Artikel im gewählten Zeitraum.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -122,3 +190,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/newsletter.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card narrow">
|
<section class="card narrow">
|
||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
|
{% if error_message %}
|
||||||
|
<p class="error">{{ error_message }}</p>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/login" class="form-grid">
|
<form method="post" action="/login" class="form-grid">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<label>E-Mail
|
<label>E-Mail
|
||||||
|
|||||||
Reference in New Issue
Block a user