chore: sync public repository
CI / checks (push) Has been cancelled

This commit is contained in:
maddin
2026-03-22 15:36:47 +00:00
parent 6fbd1bb3c2
commit 847f20c9d7
16 changed files with 402 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
1.5.12
1.6.1
+75 -4
View File
@@ -145,6 +145,12 @@ SUPPORT_TICKET_RATE_LIMIT_WINDOW = timedelta(minutes=30)
SUPPORT_TICKET_RATE_LIMIT_MAX_PER_IP = 3
SUPPORT_TICKET_RATE_LIMIT_MAX_PER_EMAIL = 5
SUPPORT_TICKET_MIN_FORM_SECONDS = 3
THEME_PREFERENCE_AUTO = "auto"
THEME_PREFERENCE_DARK = "dark"
THEME_PREFERENCE_LIGHT = "light"
THEME_PREFERENCES = {THEME_PREFERENCE_AUTO, THEME_PREFERENCE_DARK, THEME_PREFERENCE_LIGHT}
THEME_COLOR_DARK = "#2c2d2f"
THEME_COLOR_LIGHT = "#f3f4f6"
DAY_STATUS_QUERY_LABELS = {
DAY_STATUS_QUERY_VACATION: "Urlaub",
SPECIAL_DAY_STATUS_HOLIDAY: "Feiertag",
@@ -400,6 +406,16 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"app_title": settings.resolved_app_title,
"app_version": settings.app_version,
"today_date": date.today(),
"theme_preference": (
user.theme_preference
if user and user.theme_preference in THEME_PREFERENCES
else THEME_PREFERENCE_AUTO
),
"theme_color": (
THEME_COLOR_LIGHT
if user and user.theme_preference == THEME_PREFERENCE_LIGHT
else THEME_COLOR_DARK
),
}
context.update(extra)
return context
@@ -463,6 +479,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"automatic_break_rules_enabled": user.automatic_break_rules_enabled,
"default_break_minutes": user.default_break_minutes,
"preferred_home_view": user.preferred_home_view,
"theme_preference": user.theme_preference,
"preferred_month_view_mode": user.preferred_month_view_mode,
"entry_mode": user.entry_mode,
"overtime_start_date": user.overtime_start_date.isoformat() if user.overtime_start_date else None,
@@ -1897,6 +1914,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
"settings": {
"weekly_target_minutes": user.weekly_target_minutes,
"preferred_home_view": user.preferred_home_view,
"theme_preference": user.theme_preference,
"preferred_month_view_mode": user.preferred_month_view_mode,
"entry_mode": user.entry_mode,
"working_days": sorted(get_user_working_days(user)),
@@ -2027,7 +2045,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
def render_legal_page(request: Request, *, db: Session, key: str, title: str, subtitle: str | None = None) -> HTMLResponse:
markdown_text = get_site_content_markdown(db, key)
html_content = render_safe_markdown(markdown_text)
html_content = render_safe_markdown(markdown_text, obfuscate_emails=True)
user = get_current_user(request, db)
return templates.TemplateResponse(
"pages/legal_page.html",
@@ -2041,6 +2059,14 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
),
)
def require_verified_ticket_user(request: Request, db: Session) -> User | RedirectResponse:
user = get_current_user(request, db)
if not user or not user.is_active:
return RedirectResponse(url="/login?msg=contact_requires_login", status_code=status.HTTP_303_SEE_OTHER)
if not user.email_verified:
return RedirectResponse(url="/verify-email/resend", status_code=status.HTTP_303_SEE_OTHER)
return user
@app.get("/impressum", response_class=HTMLResponse)
async def impressum_page(request: Request, db: Session = Depends(get_db)):
return render_legal_page(
@@ -2063,7 +2089,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
@app.get("/kontakt", response_class=HTMLResponse)
async def contact_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
required_user = require_verified_ticket_user(request, db)
if isinstance(required_user, RedirectResponse):
return required_user
user = required_user
return templates.TemplateResponse(
"pages/contact.html",
build_context(
@@ -2096,10 +2125,13 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
user = get_current_user(request, db)
required_user = require_verified_ticket_user(request, db)
if isinstance(required_user, RedirectResponse):
return required_user
user = required_user
normalized_name = name.strip()
normalized_email = email.strip().lower()
normalized_email = user.email.strip().lower()
normalized_subject = subject.strip()
normalized_message = message.strip()
started_at_expected = request.session.get("contact_form_started_at")
@@ -2495,6 +2527,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
success_message = "Bitte bestätige zuerst deine E-Mail-Adresse über den Link in der E-Mail."
elif msg == "email_verified":
success_message = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden."
elif msg == "contact_requires_login":
error_message = "Für Kontaktanfragen musst du zuerst eingeloggt sein."
elif msg == "contact_requires_verification":
error_message = "Bitte bestätige zuerst deine E-Mail-Adresse, bevor du das Ticketsystem nutzt."
elif msg == "email_verification_send_failed":
error_message = (
"Konto wurde erstellt, aber die Bestätigungs-E-Mail konnte nicht versendet werden. "
@@ -4367,6 +4403,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
async def settings_update_preferences(
request: Request,
preferred_home_view: str = Form(...),
theme_preference: str = Form(default=""),
preferred_month_view_mode: str = Form(...),
entry_mode: str = Form(default=""),
csrf_token: str = Form(...),
@@ -4385,6 +4422,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
error="Ungueltige Standardansicht.",
status_code=status.HTTP_400_BAD_REQUEST,
)
selected_theme_preference = (theme_preference or user.theme_preference or THEME_PREFERENCE_AUTO).strip()
if selected_theme_preference not in THEME_PREFERENCES:
return render_settings_form(
request,
db=db,
user=user,
error="Ungueltiges Darstellungs-Theme.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if preferred_month_view_mode not in {"flat", "weeks"}:
return render_settings_form(
request,
@@ -4403,6 +4449,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
)
user.preferred_home_view = preferred_home_view
user.theme_preference = selected_theme_preference
user.preferred_month_view_mode = preferred_month_view_mode
new_entry_mode = entry_mode or user.entry_mode
switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY
@@ -4529,6 +4576,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
async def settings_update_weekly_target(
request: Request,
weekly_target_hours: float = Form(...),
entry_mode: str = Form(default=""),
automatic_break_rules_enabled: str | None = Form(default=None),
default_break_minutes_value: str = Form(default="", alias="default_break_minutes"),
csrf_token: str = Form(...),
@@ -4549,6 +4597,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
status_code=status.HTTP_400_BAD_REQUEST,
)
if entry_mode and entry_mode not in {ENTRY_MODE_MANUAL, ENTRY_MODE_AUTO_UNTIL_TODAY}:
return render_settings_form(
request,
db=db,
user=user,
error="Ungueltiger Erfassungsmodus.",
status_code=status.HTTP_400_BAD_REQUEST,
)
break_rules_enabled = automatic_break_rules_enabled == "on"
default_break_minutes = user.default_break_minutes if break_rules_enabled else 0
if default_break_minutes_value.strip():
@@ -4580,9 +4637,23 @@ def create_app(settings_override: Settings | None = None) -> FastAPI:
new_target_minutes=new_target_minutes,
scope="all_weeks",
)
new_entry_mode = entry_mode or user.entry_mode
switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY
user.weekly_target_minutes = new_target_minutes
user.entry_mode = new_entry_mode
user.automatic_break_rules_enabled = break_rules_enabled
user.default_break_minutes = default_break_minutes
delete_future_auto_entries(db=db, user_id=user.id, after_date=date.today())
if switched_to_auto_until_today:
ensure_user_has_default_target_rule(db, user)
autofill_entries_for_range(
db=db,
user=user,
range_start=user.created_at.date(),
range_end=date.today(),
)
db.commit()
return RedirectResponse(url="/settings?msg=weekly_target_updated", status_code=status.HTTP_303_SEE_OTHER)
+1
View File
@@ -20,6 +20,7 @@ class User(Base):
role: Mapped[str] = mapped_column(String(32), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferred_home_view: Mapped[str] = mapped_column(String(16), default="week", nullable=False)
theme_preference: Mapped[str] = mapped_column(String(16), default="auto", nullable=False)
preferred_month_view_mode: Mapped[str] = mapped_column(String(16), default="flat", nullable=False)
entry_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
working_days_csv: Mapped[str] = mapped_column(String(32), default="0,1,2,3,4", nullable=False)
+6
View File
@@ -133,6 +133,10 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
if preferred_home_view not in PREFERRED_HOME_VIEWS:
preferred_home_view = "week"
theme_preference = settings_data.get("theme_preference", "auto")
if theme_preference not in {"auto", "dark", "light"}:
theme_preference = "auto"
preferred_month_view_mode = settings_data.get("preferred_month_view_mode", "flat")
if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS:
preferred_month_view_mode = "flat"
@@ -170,6 +174,7 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
return {
"weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1),
"preferred_home_view": preferred_home_view,
"theme_preference": theme_preference,
"preferred_month_view_mode": preferred_month_view_mode,
"entry_mode": entry_mode,
"working_days": sorted(working_days),
@@ -524,6 +529,7 @@ def parse_preview_payload(preview: ImportPreview) -> dict[str, Any]:
def _apply_settings_from_backup(*, user: User, settings_data: dict[str, Any]) -> None:
user.weekly_target_minutes = settings_data["weekly_target_minutes"]
user.preferred_home_view = settings_data["preferred_home_view"]
user.theme_preference = settings_data["theme_preference"]
user.preferred_month_view_mode = settings_data["preferred_month_view_mode"]
user.entry_mode = settings_data["entry_mode"]
user.working_days_csv = serialize_working_days(settings_data["working_days"])
+29 -1
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import html
import re
import markdown as markdown_lib
import bleach
@@ -194,13 +197,36 @@ _ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'rel', 'target'],
}
_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
_EMAIL_RE = re.compile(r'(?P<email>[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})', re.IGNORECASE)
_MAILTO_LINK_RE = re.compile(
r'<a\b[^>]*href="mailto:(?P<href>[^"]+)"[^>]*>(?P<label>.*?)</a>',
re.IGNORECASE | re.DOTALL,
)
def default_site_content_markdown(key: str) -> str:
return DEFAULT_SITE_CONTENT_MARKDOWN.get(key, '')
def render_safe_markdown(markdown_text: str) -> str:
def obfuscate_email_address(email: str) -> str:
local_part, _, domain = (email or '').partition('@')
if not local_part or not domain:
return email
return f"{local_part} [at] {domain.replace('.', ' [dot] ')}"
def obfuscate_emails_in_html(html_text: str) -> str:
def replace_mailto_link(match: re.Match[str]) -> str:
href_email = html.unescape(match.group('href')).strip()
label = html.unescape(match.group('label')).strip()
email = href_email or label
return obfuscate_email_address(email)
html_text = _MAILTO_LINK_RE.sub(replace_mailto_link, html_text)
return _EMAIL_RE.sub(lambda match: obfuscate_email_address(match.group('email')), html_text)
def render_safe_markdown(markdown_text: str, *, obfuscate_emails: bool = False) -> str:
raw_html = markdown_lib.markdown(
markdown_text or '',
extensions=['extra', 'sane_lists'],
@@ -213,6 +239,8 @@ def render_safe_markdown(markdown_text: str) -> str:
protocols=_ALLOWED_PROTOCOLS,
strip=True,
)
if obfuscate_emails:
return obfuscate_emails_in_html(cleaned)
return bleach.linkify(cleaned)
+2
View File
@@ -17,6 +17,8 @@ def run_startup_migrations(engine: Engine) -> None:
statements: list[str] = []
if "preferred_home_view" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN preferred_home_view VARCHAR(16) NOT NULL DEFAULT 'week'")
if "theme_preference" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN theme_preference VARCHAR(16) NOT NULL DEFAULT 'auto'")
if "preferred_month_view_mode" not in user_columns:
statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'")
if "entry_mode" not in user_columns:
+35
View File
@@ -12,6 +12,7 @@
}
.app-logo {
filter: var(--icon-filter);
height: var(--logo-size);
width: var(--logo-size);
}
@@ -194,6 +195,7 @@
}
.dash-icon {
filter: var(--icon-filter);
height: var(--header-icon-size);
width: var(--header-icon-size);
}
@@ -1383,6 +1385,39 @@ select:disabled {
justify-self: end;
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .period-header,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .kpi-bar__item,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .week-group-header {
border-color: var(--color-border);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .period-header {
background: var(--color-surface-list-alt);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .kpi-bar__item,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .ui-chip,
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .week-group-header {
background: var(--color-surface);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .ui-chip {
border-color: var(--color-border);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row {
background: var(--color-surface-list);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row--weekend {
background: var(--color-surface-list-alt);
}
:is(html[data-theme="light"], html[data-theme="auto"][data-theme-resolved="light"]) .day-row--today {
background: var(--color-surface-list-today);
}
.week-group-card-mobile .day-list {
gap: var(--stack-1);
padding-inline-start: var(--space-2);
+2
View File
@@ -173,6 +173,8 @@
.site-footer-inner {
align-items: flex-start;
flex-direction: column;
padding-bottom: var(--space-7);
padding-bottom: calc(var(--space-7) + env(safe-area-inset-bottom));
}
}
+99 -1
View File
@@ -23,7 +23,7 @@
--color-warning-bg: #6d5500;
--color-chip-bg: #202124;
--color-weekend: #22252b;
--color-day-today: #1a3f4a;
--color-day-today: #214331;
--color-accent: #9e7700;
--color-badge-bg: #f3f3f3;
--color-badge-text: #222326;
@@ -31,6 +31,10 @@
--color-header-badge-bg: #1d1d1f;
--color-header-badge-label: #f5f5f5;
--color-header-badge-text: #f5f5f5;
--color-surface-list: #2a2b2e;
--color-surface-list-alt: #24262a;
--color-surface-list-today: #214331;
--icon-filter: none;
--font-family-base: "Atkinson Hyperlegible", "Segoe UI", sans-serif;
--font-size-xs: 0.75rem;
@@ -99,3 +103,97 @@
--bp-md: 51.25em;
--bp-sm: 32.5em;
}
html {
color-scheme: dark;
}
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-surface-2: #edf1f5;
--color-surface-3: #f7f8fa;
--color-surface-4: #e7ebf0;
--color-border: #d4dae2;
--color-border-soft: #e1e6ec;
--color-text: #17191d;
--color-text-muted: #5f6772;
--color-link: #17191d;
--color-primary: #dde3ea;
--color-primary-hover: #d1d8e1;
--color-button-primary: #4d8a55;
--color-button-primary-hover: #43784a;
--color-button-primary-border: #5c9a64;
--color-danger: #c95a5a;
--color-danger-strong: #d62828;
--color-success: #2f7d33;
--color-success-strong: #2cd600;
--color-success-bg: #e7f6e9;
--color-warning: #9f7200;
--color-warning-bg: #fff0c4;
--color-chip-bg: #e8ebf0;
--color-weekend: #ebeef2;
--color-day-today: #d7f1dc;
--color-accent: #9e7700;
--color-badge-bg: #2c2d2f;
--color-badge-text: #f5f5f5;
--color-workhours: #7f53d9;
--color-header-badge-bg: #e5e9ef;
--color-header-badge-label: #17191d;
--color-header-badge-text: #17191d;
--color-surface-list: #dde4ec;
--color-surface-list-alt: #cfd8e2;
--color-surface-list-today: #d1ebd6;
--icon-filter: brightness(0) saturate(100%) invert(8%) sepia(8%) saturate(834%) hue-rotate(182deg) brightness(92%) contrast(92%);
}
html[data-theme="auto"] {
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
html[data-theme="auto"] {
color-scheme: light;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-surface-2: #edf1f5;
--color-surface-3: #f7f8fa;
--color-surface-4: #e7ebf0;
--color-border: #d4dae2;
--color-border-soft: #e1e6ec;
--color-text: #17191d;
--color-text-muted: #5f6772;
--color-link: #17191d;
--color-primary: #dde3ea;
--color-primary-hover: #d1d8e1;
--color-button-primary: #4d8a55;
--color-button-primary-hover: #43784a;
--color-button-primary-border: #5c9a64;
--color-danger: #c95a5a;
--color-danger-strong: #d62828;
--color-success: #2f7d33;
--color-success-strong: #2cd600;
--color-success-bg: #e7f6e9;
--color-warning: #9f7200;
--color-warning-bg: #fff0c4;
--color-chip-bg: #e8ebf0;
--color-weekend: #ebeef2;
--color-day-today: #d7f1dc;
--color-accent: #9e7700;
--color-badge-bg: #2c2d2f;
--color-badge-text: #f5f5f5;
--color-workhours: #7f53d9;
--color-header-badge-bg: #e5e9ef;
--color-header-badge-label: #17191d;
--color-header-badge-text: #17191d;
--color-surface-list: #dde4ec;
--color-surface-list-alt: #cfd8e2;
--color-surface-list-today: #d1ebd6;
--icon-filter: brightness(0) saturate(100%) invert(8%) sepia(8%) saturate(834%) hue-rotate(182deg) brightness(92%) contrast(92%);
}
}
+2
View File
@@ -4,8 +4,10 @@ import { initForms } from './components/forms.js?v=20260322a';
import { initModal } from './components/modal.js';
import { initDashboard } from './components/dashboard.js';
import { initSettingsSections } from './components/settings-sections.js';
import { initTheme } from './components/theme.js';
function initApp() {
initTheme();
initCsrf();
initFlash();
initForms();
+42
View File
@@ -0,0 +1,42 @@
function resolveTheme(root) {
const preference = root.dataset.theme || 'auto';
if (preference === 'light' || preference === 'dark') {
return preference;
}
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function applyThemeState(root) {
const resolved = resolveTheme(root);
root.dataset.themeResolved = resolved;
const themeMeta = document.querySelector('meta[name="theme-color"]');
if (themeMeta) {
const background = getComputedStyle(root).getPropertyValue('--color-bg').trim();
if (background) {
themeMeta.setAttribute('content', background);
}
}
}
export function initTheme() {
const root = document.documentElement;
if (!root) {
return;
}
applyThemeState(root);
const media = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = () => {
if ((root.dataset.theme || 'auto') === 'auto') {
applyThemeState(root);
}
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', handleChange);
} else if (typeof media.addListener === 'function') {
media.addListener(handleChange);
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<html lang="de" data-theme="{{ theme_preference }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2c2d2f" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style"
+5 -2
View File
@@ -25,8 +25,8 @@
<input type="text" name="name" value="{{ contact_name }}" maxlength="120" />
</label>
<label>
E-Mail-Adresse
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" required />
Konto-E-Mail
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" readonly />
</label>
</div>
@@ -68,6 +68,9 @@
<p class="muted">
Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.
</p>
<p class="muted">
Das Ticketsystem steht nur registrierten und bestätigten Nutzerkonten zur Verfügung. Die Rückmeldung wird mit der E-Mail-Adresse deines Kontos verknüpft.
</p>
<p class="muted">
Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>.
</p>
+17 -9
View File
@@ -99,6 +99,15 @@
<p class="muted">Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).</p>
<form method="post" action="/settings/weekly-target" class="stack" data-component="break-settings-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Erfassungsmodus
<select name="entry_mode" required>
<option value="manual"
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option>
<option value="auto_until_today"
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option>
</select>
</label>
<label>
Wochenstunden
<input type="number"
@@ -145,17 +154,16 @@
</select>
</label>
<label>
Erfassungsmodus
<select name="entry_mode" required>
<option value="manual"
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option>
<option value="auto_until_today"
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option>
Theme
<select name="theme_preference" required>
<option value="auto"
{% if user.theme_preference == 'auto' %}selected{% endif %}>Automatisch</option>
<option value="dark"
{% if user.theme_preference == 'dark' %}selected{% endif %}>Dark</option>
<option value="light"
{% if user.theme_preference == 'light' %}selected{% endif %}>Light</option>
</select>
</label>
<p class="muted">
Im automatischen Modus werden fehlende Einträge für deine Arbeitstage bis einschließlich heute automatisch angelegt. Abweichungen kannst du danach einzeln anpassen.
</p>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
+75 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.database import get_engine
from app.main import create_app
from app.models import SupportTicket
from app.models import SupportTicket, User
def _csrf_from_html(html: str) -> str:
@@ -38,6 +38,18 @@ def _build_admin_app(db_url: str) -> object:
)
def _register_and_verify_user(client: TestClient, email: str, password: str = "verystrongPass123") -> None:
register = client.post(
"/auth/register",
json={"email": email, "password": password},
)
assert register.status_code == 200
with Session(get_engine()) as db:
user = db.execute(select(User).where(User.email == email)).scalar_one()
user.email_verified = True
db.commit()
def test_public_footer_and_legal_pages_render(app):
with TestClient(app) as client:
response = client.get("/login")
@@ -54,6 +66,44 @@ def test_public_footer_and_legal_pages_render(app):
assert privacy.status_code == 200
assert "Datenschutz" in privacy.text
contact = client.get("/kontakt", follow_redirects=False)
assert contact.status_code == 303
assert contact.headers["location"] == "/login?msg=contact_requires_login"
def test_legal_pages_obfuscate_email_addresses(tmp_path):
db_path = tmp_path / "legal-obfuscation.db"
app = _build_admin_app(f"sqlite:///{db_path}")
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "admin@example.com", "password": "verystrongPass123"},
)
assert register.status_code == 200
csrf = register.json()["csrf_token"]
update_legal = client.post(
"/settings/admin/site-content",
data={
"csrf_token": csrf,
"impressum_markdown": "# Impressum\n\nE-Mail: [kontakt@example.com](mailto:kontakt@example.com)",
"privacy_markdown": "# Datenschutz\n\nE-Mail: kontakt@example.com",
},
follow_redirects=False,
)
assert update_legal.status_code == 303
impressum = client.get("/impressum")
assert impressum.status_code == 200
assert "kontakt [at] example [dot] com" in impressum.text
assert "mailto:" not in impressum.text
privacy = client.get("/datenschutz")
assert privacy.status_code == 200
assert "kontakt [at] example [dot] com" in privacy.text
assert "mailto:" not in privacy.text
def test_contact_form_creates_ticket(monkeypatch, app):
import app.main as main_module
@@ -62,6 +112,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client:
_register_and_verify_user(client, "max@example.com")
form = client.get("/kontakt")
assert form.status_code == 200
csrf = _csrf_from_html(form.text)
@@ -76,7 +127,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
"website": "",
"category": "feature",
"name": "Max Beispiel",
"email": "max@example.com",
"email": "ignored@example.com",
"subject": "Bitte Monatsfilter erweitern",
"message": "Ich wünsche mir eine bessere Filterung in der Monatsansicht.",
},
@@ -88,6 +139,7 @@ def test_contact_form_creates_ticket(monkeypatch, app):
with Session(get_engine()) as db:
tickets = db.execute(select(SupportTicket)).scalars().all()
assert len(tickets) == 1
assert tickets[0].email == "max@example.com"
assert tickets[0].category == "feature"
assert tickets[0].status == "open"
assert tickets[0].subject == "Bitte Monatsfilter erweitern"
@@ -100,6 +152,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app):
monkeypatch.setattr(main_module, "utc_now", lambda: base_time)
with TestClient(app) as client:
_register_and_verify_user(client, "spam@example.com")
form = client.get("/kontakt")
csrf = _csrf_from_html(form.text)
started_at = _started_at_from_html(form.text)
@@ -113,7 +166,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app):
"website": "spam",
"category": "problem",
"name": "",
"email": "spam@example.com",
"email": "ignored@example.com",
"subject": "Spamversuch",
"message": "Das sollte blockiert werden.",
},
@@ -212,3 +265,22 @@ def test_admin_can_manage_legal_content_and_tickets(tmp_path, monkeypatch):
assert ticket.status == "closed"
assert ticket.admin_notes == "Geschlossen im Test"
assert ticket.closed_at is not None
def test_contact_requires_verified_user(tmp_path):
app = _build_admin_app(f"sqlite:///{tmp_path / 'contact-verified.db'}")
with TestClient(app) as client:
register = client.post(
"/auth/register",
json={"email": "user@example.com", "password": "verystrongPass123"},
)
assert register.status_code == 200
with Session(get_engine()) as db:
user = db.execute(select(User).where(User.email == "user@example.com")).scalar_one()
user.email_verified = False
db.commit()
contact = client.get("/kontakt", follow_redirects=False)
assert contact.status_code == 303
assert contact.headers["location"] == "/verify-email/resend"
+9
View File
@@ -1013,6 +1013,7 @@ def test_register_onboarding_applies_optional_settings(app):
assert payload["vacation_days_total"] == 22
assert payload["vacation_show_in_header"] is True
assert payload["preferred_home_view"] == "month"
assert payload["theme_preference"] == "auto"
assert payload["entry_mode"] == "auto_until_today"
assert payload["overtime_start_date"] == "2026-02-02"
assert payload["overtime_expiry_days"] == 90
@@ -1070,6 +1071,7 @@ def test_settings_export_all_supports_backup_and_existing_formats(app):
assert "application/json" in export_backup.headers["content-type"]
payload = export_backup.json()
assert payload["backup_version"] == 2
assert payload["settings"]["theme_preference"] == "auto"
assert "user" not in payload
assert payload["settings"]["weekly_target_minutes"] == 1500
assert len(payload["time_entries"]) == 1
@@ -1169,6 +1171,7 @@ def test_register_can_import_backup_during_signup(app):
"/settings/preferences",
data={
"preferred_home_view": "month",
"theme_preference": "light",
"preferred_month_view_mode": "weeks",
"entry_mode": "auto_until_today",
"csrf_token": source_csrf,
@@ -1237,6 +1240,7 @@ def test_register_can_import_backup_during_signup(app):
assert me.status_code == 200
payload = me.json()
assert payload["preferred_home_view"] == "month"
assert payload["theme_preference"] == "light"
assert payload["entry_mode"] == "auto_until_today"
assert payload["working_days"] == [0, 1, 2, 3]
assert payload["count_vacation_as_worktime"] is True
@@ -1333,6 +1337,7 @@ def test_settings_default_view_redirect(app):
"/settings/preferences",
data={
"preferred_home_view": "month",
"theme_preference": "light",
"preferred_month_view_mode": "weeks",
"csrf_token": csrf,
},
@@ -1348,6 +1353,10 @@ def test_settings_default_view_redirect(app):
assert dashboard_redirect.status_code == 303
assert dashboard_redirect.headers["location"].startswith("/month?view=weeks")
month_page = client.get("/month")
assert month_page.status_code == 200
assert 'data-theme="light"' in month_page.text
def test_main_navigation_uses_explicit_period_links(app):
with TestClient(app) as client: