Files
stundenfuchs/app/main.py
T
maddin 847f20c9d7
CI / checks (push) Has been cancelled
chore: sync public repository
2026-03-22 15:36:47 +00:00

6394 lines
267 KiB
Python

from datetime import date, datetime, timedelta, timezone
import json
import logging
import re
import secrets
from urllib.parse import urlencode, urlparse
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import case, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from app.auth import (
find_user_by_email,
hash_password,
is_login_blocked,
new_csrf_token,
register_failed_attempt,
register_successful_attempt,
verify_password,
)
from app.config import Settings, get_settings
from app.database import Base, get_db, get_engine, init_engine
from app.models import (
EmailServerConfig,
ImportPreview,
OvertimeAdjustment,
PasswordResetToken,
SiteContent,
SpecialDayStatus,
SupportTicket,
TimeEntry,
User,
VacationPeriod,
WeeklyTargetRule,
)
from app.schemas import LoginRequest, MFAChallengeRequest, RegisterRequest, TimeEntryCreate, TimeEntryUpdate
from app.services.calculations import (
aggregate_week,
automatic_break_minutes,
compute_net_minutes,
iso_week_bounds,
minutes_to_hhmm,
parse_time_to_minutes,
)
from app.services.exporters import build_export_rows, create_backup_export, create_excel_export, create_pdf_export
from app.services.importers import (
BackupImportError,
IMPORT_MODE_REPLACE,
build_import_preview,
cleanup_import_previews,
create_import_preview_record,
execute_backup_import,
get_import_preview_record,
load_backup_payload_from_bytes,
parse_preview_payload,
)
from app.services.legal_content import (
SITE_CONTENT_IMPRESSUM,
SITE_CONTENT_PRIVACY,
default_site_content_markdown,
normalize_markdown_input,
render_safe_markdown,
ticket_category_label,
ticket_category_options,
ticket_status_label,
)
from app.services.migrations import run_startup_migrations
from app.services.overtime import (
compute_cumulative_overtime_minutes,
compute_cumulative_overtime_until_date,
compute_effective_span_totals,
compute_effective_week_totals,
)
from app.services.targets import (
apply_weekly_target_change,
ensure_all_users_have_default_target_rules,
ensure_user_has_default_target_rule,
monday_of,
target_for_week,
target_map_for_weeks,
list_rules_for_user,
)
from app.services.vacations import collapse_dates_to_ranges, expand_vacation_dates, list_vacations_for_user
from app.services.workdays import parse_working_days_csv, serialize_working_days
from app.services.emailing import MailServerSettings, send_email
from app.services.auto_entries import (
ENTRY_MODE_AUTO_UNTIL_TODAY,
ENTRY_MODE_MANUAL,
autofill_entries_for_range,
clear_auto_entry_skip_for_date,
clear_overtime_adjustment_for_date,
clear_special_status_for_date,
count_as_worktime_dates_for_user,
delete_future_auto_entries,
effective_non_working_dates_for_user,
get_user_working_days,
list_overtime_adjustments_for_user,
list_special_statuses_for_user,
mark_auto_entry_skip_for_date,
overtime_adjustment_map,
overtime_adjustment_minutes_map,
special_status_dates,
special_status_map,
sync_auto_entries_for_all_users,
)
from app.services.public_holidays import (
GERMAN_STATE_OPTIONS,
list_public_holiday_dates,
normalize_german_state_code,
)
from app.services.security import (
build_fernet,
build_totp_uri,
decrypt_secret,
encrypt_secret,
generate_numeric_code,
generate_reset_token,
generate_totp_secret,
hash_token,
normalize_otp_code,
utc_now,
verify_totp_code,
)
logger = logging.getLogger("stundentracker.auth")
WEEKDAY_NAMES_DE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
WEEKDAY_SHORT_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
SPECIAL_DAY_STATUS_HOLIDAY = "holiday"
SPECIAL_DAY_STATUS_SICK = "sick"
OVERTIME_ADJUSTMENT_LABEL = "Stundenausgleich"
DAY_STATUS_QUERY_VACATION = "vacation"
SPECIAL_DAY_STATUS_LABELS = {
SPECIAL_DAY_STATUS_HOLIDAY: "Feiertag",
SPECIAL_DAY_STATUS_SICK: "Krankheitstag",
}
SUPPORT_TICKET_STATUS_OPEN = "open"
SUPPORT_TICKET_STATUS_CLOSED = "closed"
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",
SPECIAL_DAY_STATUS_SICK: "Krankheit",
}
AUTO_HOLIDAY_NOTE_PREFIX = "AUTO_FEIERTAG:"
MFA_METHOD_NONE = "none"
MFA_METHOD_TOTP = "totp"
MFA_METHOD_EMAIL = "email"
MFA_METHOD_LABELS = {
MFA_METHOD_NONE: "Keine 2FA",
MFA_METHOD_TOTP: "Authenticator-App (TOTP)",
MFA_METHOD_EMAIL: "E-Mail Code",
}
def create_app(settings_override: Settings | None = None) -> FastAPI:
settings = settings_override or get_settings()
asset_version = datetime.utcnow().strftime("%Y%m%d%H%M%S")
encryption_secret_source = settings.data_encryption_key or settings.session_secret
fernet = build_fernet(encryption_secret_source)
bootstrap_admin_email = (settings.bootstrap_admin_email or "").strip().lower()
def is_bootstrap_admin_identity(email: str) -> bool:
if not bootstrap_admin_email:
return False
return email.lower().strip() == bootstrap_admin_email
def ensure_bootstrap_admin(db: Session) -> None:
if not bootstrap_admin_email:
return
candidate = find_user_by_email(db, bootstrap_admin_email)
if candidate and (candidate.role != "admin" or not candidate.is_active):
candidate.role = "admin"
candidate.is_active = True
db.add(candidate)
db.commit()
logger.info("bootstrap_admin_assigned email=%s", candidate.email)
init_engine(settings.db_url)
Base.metadata.create_all(bind=get_engine())
run_startup_migrations(get_engine())
with Session(get_engine()) as startup_db:
ensure_all_users_have_default_target_rules(startup_db)
ensure_bootstrap_admin(startup_db)
app = FastAPI(title=settings.app_name)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.mount("/img", StaticFiles(directory="img"), name="img")
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
https_only=settings.cookie_secure,
same_site=settings.cookie_samesite,
max_age=60 * 60 * 24 * 7,
)
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'",
)
if response.headers.get("content-type", "").startswith("text/html"):
response.headers.setdefault("Cache-Control", "no-store")
return response
templates = Jinja2Templates(directory="app/templates")
def weekday_name_de(day: date, *, short: bool = False) -> str:
names = WEEKDAY_SHORT_DE if short else WEEKDAY_NAMES_DE
return names[day.weekday()]
def build_header_cumulative_minutes(*, db: Session, user: User) -> int:
today = date.today()
working_days = get_user_working_days(user)
rules = list_rules_for_user(db, user.id)
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date <= today)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
vacations_stmt = (
select(VacationPeriod)
.where(VacationPeriod.user_id == user.id, VacationPeriod.start_date <= today)
.order_by(VacationPeriod.start_date.asc())
)
vacations = db.execute(vacations_stmt).scalars().all()
special_statuses = list_special_statuses_for_user(db, user.id, date(1970, 1, 1), today)
vacation_dates = expand_vacation_dates(vacations, date(1970, 1, 1), today, relevant_weekdays=working_days)
non_working_dates = effective_non_working_dates_for_user(user=user, special_statuses=special_statuses)
count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates,
special_statuses=special_statuses,
)
overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, date(1970, 1, 1), today)
return compute_cumulative_overtime_until_date(
entries=entries,
rules=rules,
weekly_target_fallback=user.weekly_target_minutes,
vacation_periods=vacations,
non_working_dates=non_working_dates,
count_as_worktime_dates=count_as_worktime_dates,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(overtime_adjustments),
as_of_date=today,
overtime_start_date=user.overtime_start_date,
overtime_expiry_days=user.overtime_expiry_days,
expire_negative_overtime=user.expire_negative_overtime,
relevant_weekdays=working_days,
)
def build_header_vacation_days(*, db: Session, user: User) -> dict[str, int]:
if not user.vacation_show_in_header:
return {
"year": date.today().year,
"total": 0,
"used": 0,
"remaining": 0,
}
current_year = date.today().year
year_start = date(current_year, 1, 1)
year_end = date(current_year, 12, 31)
working_days = get_user_working_days(user)
vacations = list_vacations_for_user(db, user.id, year_start, year_end)
vacation_dates = expand_vacation_dates(vacations, year_start, year_end, relevant_weekdays=working_days)
used_days = len([day for day in vacation_dates if day.weekday() in working_days])
total_days = max(0, user.vacation_days_total or 0)
remaining_days = max(0, total_days - used_days)
return {
"year": current_year,
"total": total_days,
"used": used_days,
"remaining": remaining_days,
}
def build_header_workhours_counter_minutes(*, db: Session, user: User) -> int | None:
if not user.workhours_counter_show_in_header:
return None
if not user.workhours_counter_enabled:
return None
if user.workhours_counter_start_date is None or user.workhours_counter_end_date is None:
return None
if user.workhours_counter_end_date < user.workhours_counter_start_date:
return None
return compute_workhours_counter_minutes(
db=db,
user=user,
from_date=user.workhours_counter_start_date,
to_date=user.workhours_counter_end_date,
)
def build_main_nav_urls(request: Request, user: User) -> tuple[str, str]:
selected_date: date | None = None
date_value = request.query_params.get("date")
if date_value:
try:
selected_date = parse_date_query(date_value)
except HTTPException:
selected_date = None
if selected_date is None:
month_value = request.query_params.get("month")
if month_value:
try:
selected_date = datetime.strptime(month_value, "%Y-%m").date()
except ValueError:
selected_date = None
if selected_date is None:
selected_date = date.today()
week_url = f"/dashboard?{urlencode({'date': date.today().isoformat()})}"
month_view_mode = request.query_params.get("view") or user.preferred_month_view_mode or "flat"
month_url = f"/month?{urlencode({'month': selected_date.strftime('%Y-%m'), 'view': month_view_mode})}"
return week_url, month_url
def build_context(
request: Request,
*,
user: User | None = None,
db: Session | None = None,
**extra: object,
) -> dict:
if user:
main_nav_week_url, main_nav_month_url = build_main_nav_urls(request, user)
extra.setdefault("main_nav_week_url", main_nav_week_url)
extra.setdefault("main_nav_month_url", main_nav_month_url)
needs_cumulative = "header_cumulative_minutes" not in extra
needs_vacation = (
"header_vacation_days_total" not in extra
or "header_vacation_days_used" not in extra
or "header_vacation_days_remaining" not in extra
or "header_vacation_year" not in extra
)
needs_workhours_counter = (
"header_workhours_counter_minutes" not in extra
or "header_workhours_counter_visible" not in extra
)
if needs_cumulative or needs_vacation or needs_workhours_counter:
if db is None:
with Session(get_engine()) as context_db:
if needs_cumulative:
extra["header_cumulative_minutes"] = build_header_cumulative_minutes(db=context_db, user=user)
if needs_vacation:
vacation_data = build_header_vacation_days(db=context_db, user=user)
extra["header_vacation_days_total"] = vacation_data["total"]
extra["header_vacation_days_used"] = vacation_data["used"]
extra["header_vacation_days_remaining"] = vacation_data["remaining"]
extra["header_vacation_year"] = vacation_data["year"]
extra["header_vacation_visible"] = user.vacation_show_in_header
if needs_workhours_counter:
extra["header_workhours_counter_minutes"] = build_header_workhours_counter_minutes(
db=context_db, user=user
)
extra["header_workhours_counter_visible"] = user.workhours_counter_show_in_header
else:
if needs_cumulative:
extra["header_cumulative_minutes"] = build_header_cumulative_minutes(db=db, user=user)
if needs_vacation:
vacation_data = build_header_vacation_days(db=db, user=user)
extra["header_vacation_days_total"] = vacation_data["total"]
extra["header_vacation_days_used"] = vacation_data["used"]
extra["header_vacation_days_remaining"] = vacation_data["remaining"]
extra["header_vacation_year"] = vacation_data["year"]
extra["header_vacation_visible"] = user.vacation_show_in_header
if needs_workhours_counter:
extra["header_workhours_counter_minutes"] = build_header_workhours_counter_minutes(db=db, user=user)
extra["header_workhours_counter_visible"] = user.workhours_counter_show_in_header
extra.setdefault("header_vacation_visible", user.vacation_show_in_header)
context = {
"request": request,
"user": user,
"csrf_token": ensure_csrf_token(request),
"minutes_to_hhmm": minutes_to_hhmm,
"weekday_name_de": weekday_name_de,
"asset_version": asset_version,
"app_name": settings.app_name,
"app_env": settings.app_env,
"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
def get_client_ip(request: Request) -> str:
if request.client and request.client.host:
return request.client.host
return "unknown"
def ensure_csrf_token(request: Request) -> str:
token = request.session.get("csrf_token")
if not token:
token = new_csrf_token()
request.session["csrf_token"] = token
return token
def verify_csrf(request: Request, token: str | None) -> None:
expected = request.session.get("csrf_token")
if not expected or not token or not secrets.compare_digest(expected, token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF validation failed")
def login_user(request: Request, user: User) -> str:
request.session.clear()
request.session["user_id"] = user.id
csrf_token = new_csrf_token()
request.session["csrf_token"] = csrf_token
return csrf_token
def get_current_user(request: Request, db: Session) -> User | None:
user_id = request.session.get("user_id")
if not user_id:
return None
stmt = select(User).where(User.id == user_id, User.is_active.is_(True))
return db.execute(stmt).scalar_one_or_none()
def require_user(request: Request, db: Session) -> User:
user = get_current_user(request, db)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user
def require_admin(request: Request, db: Session) -> User:
user = require_user(request, db)
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Adminrechte erforderlich")
return user
def user_public_payload(user: User, csrf_token: str) -> dict:
return {
"id": user.id,
"email": user.email,
"email_verified": user.email_verified,
"weekly_target_minutes": user.weekly_target_minutes,
"role": user.role,
"working_days": sorted(get_user_working_days(user)),
"count_vacation_as_worktime": user.count_vacation_as_worktime,
"count_holiday_as_worktime": user.count_holiday_as_worktime,
"count_sick_as_worktime": user.count_sick_as_worktime,
"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,
"overtime_expiry_days": user.overtime_expiry_days,
"expire_negative_overtime": user.expire_negative_overtime,
"vacation_days_total": user.vacation_days_total,
"vacation_show_in_header": user.vacation_show_in_header,
"workhours_counter_enabled": user.workhours_counter_enabled,
"workhours_counter_show_in_header": user.workhours_counter_show_in_header,
"workhours_counter_start_date": (
user.workhours_counter_start_date.isoformat() if user.workhours_counter_start_date else None
),
"workhours_counter_end_date": (
user.workhours_counter_end_date.isoformat() if user.workhours_counter_end_date else None
),
"workhours_counter_manual_offset_minutes": user.workhours_counter_manual_offset_minutes,
"workhours_counter_target_minutes": user.workhours_counter_target_minutes,
"workhours_counter_target_email_enabled": user.workhours_counter_target_email_enabled,
"federal_state": user.federal_state,
"mfa_method": user.mfa_method,
"csrf_token": csrf_token,
}
def ensure_utc_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def get_email_config(db: Session) -> EmailServerConfig | None:
stmt = select(EmailServerConfig).order_by(EmailServerConfig.id.asc())
return db.execute(stmt).scalars().first()
def resolve_mail_settings(db: Session) -> MailServerSettings | None:
config = get_email_config(db)
if not config:
return None
if not config.smtp_host.strip() or not config.from_email.strip():
return None
smtp_password = decrypt_secret(fernet, config.smtp_password_encrypted)
return MailServerSettings(
smtp_host=config.smtp_host.strip(),
smtp_port=config.smtp_port,
smtp_username=(config.smtp_username.strip() if config.smtp_username else None),
smtp_password=smtp_password,
from_email=config.from_email.strip(),
from_name=config.from_name.strip() or settings.app_name,
use_starttls=config.use_starttls,
use_ssl=config.use_ssl,
verify_tls=config.verify_tls,
timeout_seconds=settings.smtp_timeout_seconds,
)
def selected_admin_notification_recipients(db: Session) -> list[str]:
config = get_email_config(db)
selected_admin_ids = parse_admin_id_csv(
config.registration_admin_notify_admin_ids_csv if config else None
)
recipients: list[str] = []
if selected_admin_ids:
selected_admin_stmt = (
select(User.email)
.where(
User.role == "admin",
User.is_active.is_(True),
User.id.in_(selected_admin_ids),
)
.order_by(User.created_at.asc())
)
recipients = [email.strip().lower() for email in db.execute(selected_admin_stmt).scalars().all() if email]
if not recipients:
recipients = [item.strip().lower() for item in settings.registration_notify_email.split(",") if item.strip()]
return list(dict.fromkeys(recipients))
def get_site_content_record(db: Session, key: str) -> SiteContent | None:
stmt = select(SiteContent).where(SiteContent.key == key)
return db.execute(stmt).scalar_one_or_none()
def get_site_content_markdown(db: Session, key: str) -> str:
record = get_site_content_record(db, key)
if record and record.markdown_text.strip():
return record.markdown_text
return default_site_content_markdown(key)
def upsert_site_content(db: Session, *, key: str, markdown_text: str, updated_by_user_id: str) -> None:
record = get_site_content_record(db, key)
normalized = normalize_markdown_input(markdown_text)
if record is None:
record = SiteContent(key=key)
db.add(record)
record.markdown_text = normalized
record.updated_by_user_id = updated_by_user_id
def support_ticket_ip_hash(request: Request) -> str:
return hash_token(f"support-ticket:{settings.session_secret}:{get_client_ip(request)}")
def issue_contact_form_started_at(request: Request) -> str:
started_at = utc_now().isoformat()
request.session["contact_form_started_at"] = started_at
return started_at
def contact_form_started_at(request: Request) -> str:
started_at = request.session.get("contact_form_started_at")
if not started_at:
started_at = issue_contact_form_started_at(request)
return started_at
def support_ticket_rate_limited(*, db: Session, ip_hash: str, email: str) -> bool:
cutoff = utc_now() - SUPPORT_TICKET_RATE_LIMIT_WINDOW
by_ip = db.execute(
select(SupportTicket.id).where(
SupportTicket.source_ip_hash == ip_hash,
SupportTicket.created_at >= cutoff,
)
).scalars().all()
by_email = db.execute(
select(SupportTicket.id).where(
SupportTicket.email == email.lower().strip(),
SupportTicket.created_at >= cutoff,
)
).scalars().all()
return len(by_ip) >= SUPPORT_TICKET_RATE_LIMIT_MAX_PER_IP or len(by_email) >= SUPPORT_TICKET_RATE_LIMIT_MAX_PER_EMAIL
def send_support_ticket_notification(*, db: Session, ticket: SupportTicket) -> None:
mail_settings = resolve_mail_settings(db)
recipients = selected_admin_notification_recipients(db)
if not mail_settings or not recipients:
return
category_label = ticket_category_label(ticket.category)
message_body = (
"Es wurde ein neues Support-Ticket erstellt.\n\n"
f"Kategorie: {category_label}\n"
f"Status: {ticket_status_label(ticket.status)}\n"
f"Name: {ticket.name or '-'}\n"
f"E-Mail: {ticket.email}\n"
f"Betreff: {ticket.subject}\n"
f"Zeitpunkt (UTC): {ensure_utc_datetime(ticket.created_at).isoformat()}\n\n"
f"Nachricht:\n{ticket.message}\n"
)
for recipient in recipients:
try:
send_email(
settings=mail_settings,
to_email=recipient,
subject=f"Neues Ticket bei {settings.app_name}",
text_body=message_body,
)
except Exception:
logger.exception("support_ticket_notification_failed ticket=%s recipient=%s", ticket.id, recipient)
def send_registration_email_if_enabled(*, db: Session, user: User) -> None:
config = get_email_config(db)
if not config or not config.registration_mails_enabled:
return
mail_settings = resolve_mail_settings(db)
if not mail_settings:
return
try:
send_email(
settings=mail_settings,
to_email=user.email,
subject=f"Willkommen bei {settings.app_name}",
text_body=(
"Dein Konto wurde erfolgreich erstellt.\n\n"
f"Du kannst dich jetzt bei {settings.app_name} anmelden und deine Zeiten verwalten."
),
)
except Exception:
logger.exception("registration_email_failed email=%s", user.email)
def send_registration_admin_notification(*, db: Session, user: User, source: str) -> None:
config = get_email_config(db)
if config and not config.registration_admin_notify_enabled:
return
recipients = selected_admin_notification_recipients(db)
mail_settings = resolve_mail_settings(db)
if not mail_settings or not recipients:
return
for recipient in dict.fromkeys(recipients):
try:
send_email(
settings=mail_settings,
to_email=recipient,
subject=f"Neue Registrierung bei {settings.app_name}",
text_body=(
"Es wurde ein neues Konto registriert.\n\n"
f"E-Mail: {user.email}\n"
f"Rolle: {user.role}\n"
f"E-Mail bestätigt: {'ja' if user.email_verified else 'nein'}\n"
f"Quelle: {source}\n"
f"Zeitpunkt (UTC): {utc_now().isoformat()}\n"
),
)
except Exception:
logger.exception(
"registration_admin_notification_failed notify=%s email=%s source=%s",
recipient,
user.email,
source,
)
def is_email_verification_enabled(db: Session) -> bool:
if not settings.email_verification_required:
return False
return resolve_mail_settings(db) is not None
def issue_email_verification_token(user: User) -> str:
raw_token = generate_reset_token()
user.email_verification_token_hash = hash_token(raw_token)
user.email_verification_expires_at = utc_now() + timedelta(minutes=settings.email_verification_token_ttl_minutes)
user.email_verification_sent_at = utc_now()
return raw_token
def send_email_verification_link(*, request: Request, db: Session, user: User, force: bool = False) -> tuple[bool, str]:
if user.email_verified:
return True, "already_verified"
mail_settings = resolve_mail_settings(db)
if not mail_settings:
return False, "mail_not_configured"
if not force and user.email_verification_sent_at is not None:
last_sent_at = ensure_utc_datetime(user.email_verification_sent_at)
if (utc_now() - last_sent_at) < timedelta(seconds=30):
return False, "rate_limited"
raw_token = issue_email_verification_token(user)
db.add(user)
db.commit()
verify_url = f"{str(request.base_url).rstrip('/')}/verify-email?token={raw_token}"
try:
send_email(
settings=mail_settings,
to_email=user.email,
subject="Bitte E-Mail-Adresse bestätigen",
text_body=(
f"Bitte bestätige deine E-Mail-Adresse für {settings.app_name}.\n\n"
f"Link: {verify_url}\n\n"
f"Der Link ist {settings.email_verification_token_ttl_minutes} Minuten gültig."
),
)
except Exception:
logger.exception("email_verification_send_failed email=%s", user.email)
return False, "send_failed"
return True, "sent"
def get_user_by_email_verification_token(db: Session, raw_token: str) -> User | None:
token_hash_value = hash_token(raw_token)
stmt = select(User).where(User.email_verification_token_hash == token_hash_value, User.is_active.is_(True))
user = db.execute(stmt).scalar_one_or_none()
if not user:
return None
if user.email_verified:
return None
if not user.email_verification_expires_at:
return None
if utc_now() > ensure_utc_datetime(user.email_verification_expires_at):
return None
return user
def begin_pending_mfa_login(request: Request, user: User) -> str:
request.session.clear()
csrf_token = new_csrf_token()
request.session["csrf_token"] = csrf_token
request.session["mfa_pending_user_id"] = user.id
request.session["mfa_pending_method"] = user.mfa_method
request.session["mfa_pending_created_at"] = utc_now().isoformat()
request.session["mfa_pending_attempts"] = 0
return csrf_token
def clear_pending_mfa_login(request: Request) -> None:
request.session.pop("mfa_pending_user_id", None)
request.session.pop("mfa_pending_method", None)
request.session.pop("mfa_pending_created_at", None)
request.session.pop("mfa_pending_attempts", None)
def get_pending_mfa_user(request: Request, db: Session) -> tuple[User | None, str | None]:
pending_user_id = request.session.get("mfa_pending_user_id")
pending_method = request.session.get("mfa_pending_method")
pending_created_at = request.session.get("mfa_pending_created_at")
if not pending_user_id or not pending_method or not pending_created_at:
return None, None
try:
created_at = datetime.fromisoformat(str(pending_created_at))
except ValueError:
clear_pending_mfa_login(request)
return None, None
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
expires_at = created_at + timedelta(minutes=settings.mfa_pending_ttl_minutes)
if utc_now() > expires_at:
clear_pending_mfa_login(request)
return None, None
stmt = select(User).where(User.id == pending_user_id, User.is_active.is_(True))
user = db.execute(stmt).scalar_one_or_none()
if not user:
clear_pending_mfa_login(request)
return None, None
return user, pending_method
def send_email_mfa_code(*, db: Session, user: User) -> bool:
mail_settings = resolve_mail_settings(db)
if not mail_settings:
return False
code = generate_numeric_code(6)
user.mfa_email_code_hash = hash_password(code)
user.mfa_email_code_expires_at = utc_now() + timedelta(minutes=settings.mfa_code_ttl_minutes)
user.mfa_email_code_sent_at = utc_now()
db.add(user)
db.commit()
try:
send_email(
settings=mail_settings,
to_email=user.email,
subject="Dein Anmeldecode",
text_body=(
"Dein 6-stelliger Sicherheitscode lautet: "
f"{code}\n\nDer Code ist {settings.mfa_code_ttl_minutes} Minuten gueltig."
),
)
except Exception:
logger.exception("mfa_email_send_failed email=%s", user.email)
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
db.add(user)
db.commit()
return False
return True
def start_mfa_challenge(request: Request, db: Session, user: User) -> tuple[bool, str]:
if user.mfa_method == MFA_METHOD_NONE:
return True, ""
begin_pending_mfa_login(request, user)
if user.mfa_method == MFA_METHOD_EMAIL:
if not send_email_mfa_code(db=db, user=user):
clear_pending_mfa_login(request)
return False, "E-Mail-Code konnte nicht versendet werden. Bitte Admin kontaktieren."
return False, ""
def verify_pending_mfa_code(request: Request, db: Session, code: str) -> tuple[User | None, str | None]:
user, method = get_pending_mfa_user(request, db)
if not user or not method:
return None, "MFA-Session ist abgelaufen. Bitte erneut anmelden."
attempts = int(request.session.get("mfa_pending_attempts", 0)) + 1
request.session["mfa_pending_attempts"] = attempts
if attempts > 8:
clear_pending_mfa_login(request)
return None, "Zu viele Fehlversuche. Bitte erneut anmelden."
normalized_code = normalize_otp_code(code)
if method == MFA_METHOD_TOTP:
secret = decrypt_secret(fernet, user.mfa_totp_secret_encrypted)
if not secret or not verify_totp_code(secret=secret, code=normalized_code):
return None, "Ungueltiger Authenticator-Code."
elif method == MFA_METHOD_EMAIL:
if not user.mfa_email_code_hash or not user.mfa_email_code_expires_at:
return None, "Kein gueltiger E-Mail-Code vorhanden."
expires_at = ensure_utc_datetime(user.mfa_email_code_expires_at)
if utc_now() > expires_at:
return None, "E-Mail-Code ist abgelaufen. Bitte neuen Code anfordern."
if not verify_password(normalized_code, user.mfa_email_code_hash):
return None, "Ungueltiger E-Mail-Code."
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
db.add(user)
db.commit()
else:
return None, "Unbekannte MFA-Methode."
clear_pending_mfa_login(request)
return user, None
def count_admin_users(db: Session) -> int:
stmt = select(User).where(User.role == "admin", User.is_active.is_(True))
return len(db.execute(stmt).scalars().all())
def parse_admin_id_csv(value: str | None) -> list[str]:
if not value:
return []
parsed: list[str] = []
seen: set[str] = set()
for raw_item in value.split(","):
item = raw_item.strip()
if not item or item in seen:
continue
parsed.append(item)
seen.add(item)
return parsed
def parse_date_query(value: str | None, *, default: date | None = None) -> date:
if value is None:
if default is None:
raise HTTPException(status_code=400, detail="Date is required")
return default
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except ValueError as exc:
raise HTTPException(status_code=400, detail="Date must be YYYY-MM-DD") from exc
def parse_date_fallback_today(value: str) -> date:
try:
return parse_date_query(value)
except HTTPException:
return date.today()
def safe_relative_url(request: Request, value: str | None) -> str | None:
if not value:
return None
if value.startswith("/"):
return value
parsed = urlparse(value)
if parsed.scheme in {"http", "https"} and parsed.netloc == request.url.netloc:
relative = parsed.path or "/"
if parsed.query:
relative = f"{relative}?{parsed.query}"
return relative
return None
def resolve_return_to(request: Request, *, fallback: str) -> str:
return (
safe_relative_url(request, request.query_params.get("return_to"))
or safe_relative_url(request, request.headers.get("referer"))
or fallback
)
def parse_day_status_mode(value: str) -> str:
if value not in DAY_STATUS_QUERY_LABELS:
raise HTTPException(status_code=400, detail="Ungueltiger Tagesmodus")
return value
def current_day_status_key(*, is_vacation: bool, special_status: str | None) -> str | None:
if is_vacation:
return DAY_STATUS_QUERY_VACATION
if special_status in SPECIAL_DAY_STATUS_LABELS:
return special_status
return None
def serialize_entry(entry: TimeEntry) -> dict:
gross_minutes = entry.end_minutes - entry.start_minutes
net_minutes = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
return {
"id": entry.id,
"date": entry.date.isoformat(),
"start_time": minutes_to_hhmm(entry.start_minutes),
"end_time": minutes_to_hhmm(entry.end_minutes),
"break_minutes": entry.break_minutes,
"break_mode": entry.break_rule_mode,
"gross_minutes": gross_minutes,
"net_minutes": net_minutes,
"notes": entry.notes,
}
def normalize_break_mode(value: str | None, default: str = "manual") -> str:
if value in {"manual", "auto"}:
return value
return default
def auto_break_rules_enabled(user: User) -> bool:
return bool(user.automatic_break_rules_enabled)
def default_break_minutes_for_user(user: User) -> int:
return max(0, int(user.default_break_minutes or 0))
def resolve_break_settings(
*,
user: User,
start_minutes: int,
end_minutes: int,
submitted_break_minutes: int | None,
submitted_break_mode: str | None,
existing_break_mode: str | None = None,
existing_break_minutes: int | None = None,
start_or_end_changed: bool = False,
) -> tuple[int, str]:
default_mode = "auto" if auto_break_rules_enabled(user) else "manual"
break_mode = normalize_break_mode(submitted_break_mode, default=default_mode)
if auto_break_rules_enabled(user):
if break_mode == "auto":
return automatic_break_minutes(start_minutes, end_minutes), "auto"
if submitted_break_minutes is not None:
return submitted_break_minutes, "manual"
if existing_break_mode == "auto" and start_or_end_changed:
return automatic_break_minutes(start_minutes, end_minutes), "auto"
if submitted_break_minutes is not None:
return submitted_break_minutes, "manual"
if existing_break_minutes is not None:
return existing_break_minutes, normalize_break_mode(existing_break_mode, default="manual")
return default_break_minutes_for_user(user), "manual"
def user_home_url(user: User) -> str:
if user.preferred_home_view == "month":
return f"/month?{urlencode({'view': user.preferred_month_view_mode or 'flat'})}"
return "/dashboard"
def parse_weekly_target_scope(scope: str) -> str:
valid_scopes = {"current_week", "all_weeks", "from_current_week"}
if scope not in valid_scopes:
raise HTTPException(status_code=400, detail="ungueltiger Scope")
return scope
def parse_bulk_mode(mode: str) -> str:
valid_modes = {"only_missing", "upsert"}
if mode not in valid_modes:
raise HTTPException(status_code=400, detail="ungueltiger Bulk-Modus")
return mode
def parse_weekday_values(values: list[str]) -> list[int]:
normalized: list[int] = []
for value in values:
try:
day = int(value)
except ValueError as exc:
raise HTTPException(status_code=400, detail="ungueltiger Wochentag") from exc
if day < 0 or day > 6:
raise HTTPException(status_code=400, detail="ungueltiger Wochentag")
if day not in normalized:
normalized.append(day)
return sorted(normalized)
def get_user_working_days(user: User) -> set[int]:
return parse_working_days_csv(user.working_days_csv)
def parse_signed_duration_to_minutes(value: str) -> int:
normalized = value.strip()
if not normalized:
raise ValueError("Bitte Stunden im Format +HH:MM oder -HH:MM eingeben")
match = re.fullmatch(r"([+-])?\s*(\d{1,3}):([0-5]\d)", normalized)
if not match:
raise ValueError("Bitte Stunden im Format +HH:MM oder -HH:MM eingeben")
sign_part, hours_part, minutes_part = match.groups()
total = int(hours_part) * 60 + int(minutes_part)
if total <= 0:
raise ValueError("Der Stundenausgleich muss groesser als 0 sein")
return -total if sign_part == "-" else total
def full_day_adjustment_minutes(*, db: Session, user: User, selected_date: date, positive: bool) -> int:
working_days = get_user_working_days(user)
workdays_per_week = max(1, len(working_days))
rules = list_rules_for_user(db, user.id)
weekly_target_minutes = target_for_week(rules, monday_of(selected_date), user.weekly_target_minutes)
per_day_minutes = int(round(weekly_target_minutes / workdays_per_week))
if per_day_minutes <= 0:
raise ValueError("Fuer diesen Tag kann kein Tages-Stundenausgleich berechnet werden")
return per_day_minutes if positive else -per_day_minutes
def full_day_work_minutes_or_none(*, db: Session, user: User, selected_date: date) -> int | None:
try:
return full_day_adjustment_minutes(db=db, user=user, selected_date=selected_date, positive=True)
except ValueError:
return None
def is_auto_holiday_status(status: SpecialDayStatus) -> bool:
return (
status.status == SPECIAL_DAY_STATUS_HOLIDAY
and bool(status.notes)
and str(status.notes).startswith(AUTO_HOLIDAY_NOTE_PREFIX)
)
def sync_auto_holidays_for_user(
*,
db: Session,
user: User,
from_date: date,
to_date: date,
) -> dict[str, int]:
if to_date < from_date:
return {"created": 0, "removed": 0}
existing_statuses = list_special_statuses_for_user(db, user.id, from_date, to_date)
existing_status_by_date = {status.date: status for status in existing_statuses}
auto_existing_by_date = {
status.date: status for status in existing_statuses if is_auto_holiday_status(status)
}
if not user.federal_state:
removed = 0
for status in auto_existing_by_date.values():
db.delete(status)
removed += 1
return {"created": 0, "removed": removed}
holiday_dates = list_public_holiday_dates(
federal_state=user.federal_state,
from_date=from_date,
to_date=to_date,
)
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= from_date, TimeEntry.date <= to_date)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
worked_dates = {
entry.date
for entry in entries
if compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes) > 0
}
target_dates = {day for day in holiday_dates if day not in worked_dates}
created = 0
removed = 0
for day, auto_status in auto_existing_by_date.items():
if day not in target_dates:
db.delete(auto_status)
removed += 1
for day in sorted(target_dates):
existing = existing_status_by_date.get(day)
if existing:
if is_auto_holiday_status(existing):
continue
# Manueller Status bleibt unverändert (z. B. Krankheit).
continue
db.add(
SpecialDayStatus(
user_id=user.id,
date=day,
status=SPECIAL_DAY_STATUS_HOLIDAY,
notes=f"{AUTO_HOLIDAY_NOTE_PREFIX}{user.federal_state}",
)
)
created += 1
return {"created": created, "removed": removed}
def sync_auto_holidays_for_all_users(*, db: Session, from_date: date, to_date: date) -> None:
stmt = (
select(User)
.where(
User.is_active.is_(True),
User.federal_state.is_not(None),
User.federal_state != "",
)
.order_by(User.created_at.asc())
)
users = db.execute(stmt).scalars().all()
for user in users:
try:
sync_auto_holidays_for_user(db=db, user=user, from_date=from_date, to_date=to_date)
except Exception:
logger.exception("startup_holiday_sync_failed email=%s", user.email)
def day_status_for_user(*, db: Session, user: User, selected_date: date) -> tuple[bool, str | None]:
working_days = get_user_working_days(user)
vacations = list_vacations_for_user(db, user.id, selected_date, selected_date)
vacation_dates = expand_vacation_dates(
vacations,
selected_date,
selected_date,
relevant_weekdays=working_days,
)
special = list_special_statuses_for_user(db, user.id, selected_date, selected_date)
special_map = special_status_map(special)
return selected_date in vacation_dates, special_map.get(selected_date)
def get_workhours_counter_manual_offset_minutes(user: User) -> int:
value = user.workhours_counter_manual_offset_minutes or 0
return max(0, int(value))
def compute_workhours_counter_minutes(
*,
db: Session,
user: User,
from_date: date,
to_date: date,
) -> int:
if to_date < from_date:
return 0
working_days = get_user_working_days(user)
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= from_date, TimeEntry.date <= to_date)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
entry_map = {entry.date: entry for entry in entries}
vacations = list_vacations_for_user(db, user.id, from_date, to_date)
vacation_dates = expand_vacation_dates(vacations, from_date, to_date, relevant_weekdays=working_days)
special_days = list_special_statuses_for_user(db, user.id, from_date, to_date)
blocked_dates = effective_non_working_dates_for_user(user=user, special_statuses=special_days)
count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates,
special_statuses=special_days,
)
rules = list_rules_for_user(db, user.id)
workdays_per_week = max(1, len(working_days))
total = 0
cursor = from_date
while cursor <= to_date:
if cursor.weekday() not in working_days:
cursor += timedelta(days=1)
continue
if cursor in count_as_worktime_dates:
week_target = target_for_week(rules, monday_of(cursor), user.weekly_target_minutes)
total += int(round(week_target / workdays_per_week))
cursor += timedelta(days=1)
continue
if cursor in vacation_dates or cursor in blocked_dates:
cursor += timedelta(days=1)
continue
entry = entry_map.get(cursor)
if entry:
total += compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
cursor += timedelta(days=1)
return total + get_workhours_counter_manual_offset_minutes(user)
def compute_workhours_counter_forecast(
*,
db: Session,
user: User,
from_date: date,
to_date: date,
) -> dict[str, int]:
if to_date < from_date:
return {"logged_minutes": 0, "projected_minutes": 0}
working_days = get_user_working_days(user)
if not working_days:
return {"logged_minutes": 0, "projected_minutes": 0}
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= from_date, TimeEntry.date <= to_date)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
entry_map = {entry.date: entry for entry in entries}
vacations = list_vacations_for_user(db, user.id, from_date, to_date)
vacation_dates = expand_vacation_dates(vacations, from_date, to_date, relevant_weekdays=working_days)
special_days = list_special_statuses_for_user(db, user.id, from_date, to_date)
blocked_dates = effective_non_working_dates_for_user(user=user, special_statuses=special_days)
count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates,
special_statuses=special_days,
)
rules = list_rules_for_user(db, user.id)
workdays_per_week = max(1, len(working_days))
today = date.today()
logged_to = min(today, to_date)
logged_minutes = 0
cursor = from_date
while cursor <= logged_to:
if cursor.weekday() not in working_days:
cursor += timedelta(days=1)
continue
if cursor in count_as_worktime_dates:
week_target = target_for_week(rules, monday_of(cursor), user.weekly_target_minutes)
logged_minutes += int(round(week_target / workdays_per_week))
cursor += timedelta(days=1)
continue
if cursor in vacation_dates or cursor in blocked_dates:
cursor += timedelta(days=1)
continue
entry = entry_map.get(cursor)
if entry:
logged_minutes += compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
cursor += timedelta(days=1)
remaining_planned = 0.0
remaining_start = max(from_date, today + timedelta(days=1))
cursor = remaining_start
while cursor <= to_date:
if cursor.weekday() in working_days and (
cursor in count_as_worktime_dates or (cursor not in vacation_dates and cursor not in blocked_dates)
):
week_target = target_for_week(rules, monday_of(cursor), user.weekly_target_minutes)
remaining_planned += week_target / workdays_per_week
cursor += timedelta(days=1)
manual_offset = get_workhours_counter_manual_offset_minutes(user)
logged_minutes += manual_offset
projected_minutes = logged_minutes + int(round(remaining_planned))
return {
"logged_minutes": logged_minutes,
"projected_minutes": projected_minutes,
}
def build_workhours_target_warning(
*,
db: Session,
user: User,
) -> dict[str, object] | None:
if not user.workhours_counter_enabled:
return None
if user.workhours_counter_start_date is None or user.workhours_counter_end_date is None:
return None
if user.workhours_counter_end_date < user.workhours_counter_start_date:
return None
target_minutes = user.workhours_counter_target_minutes
if target_minutes is None or target_minutes <= 0:
return None
forecast = compute_workhours_counter_forecast(
db=db,
user=user,
from_date=user.workhours_counter_start_date,
to_date=user.workhours_counter_end_date,
)
logged_minutes = forecast["logged_minutes"]
projected_minutes = forecast["projected_minutes"]
at_risk = date.today() <= user.workhours_counter_end_date and projected_minutes < target_minutes
missing_minutes = max(0, target_minutes - projected_minutes)
return {
"start_date": user.workhours_counter_start_date,
"end_date": user.workhours_counter_end_date,
"logged_minutes": logged_minutes,
"projected_minutes": projected_minutes,
"target_minutes": target_minutes,
"missing_minutes": missing_minutes,
"at_risk": at_risk,
}
def maybe_send_workhours_target_warning_email(
*,
db: Session,
user: User,
warning: dict[str, object] | None,
) -> None:
if warning is None or warning.get("at_risk") is not True:
return
if not user.workhours_counter_target_email_enabled:
return
mail_settings = resolve_mail_settings(db)
if not mail_settings:
return
start_date = warning["start_date"]
end_date = warning["end_date"]
target_minutes = warning["target_minutes"]
logged_minutes = warning["logged_minutes"]
projected_minutes = warning["projected_minutes"]
missing_minutes = warning["missing_minutes"]
if not isinstance(start_date, date) or not isinstance(end_date, date):
return
if not isinstance(target_minutes, int):
return
if not isinstance(logged_minutes, int) or not isinstance(projected_minutes, int) or not isinstance(missing_minutes, int):
return
warning_key = f"{start_date.isoformat()}|{end_date.isoformat()}|{target_minutes}"
today = date.today()
if (
user.workhours_counter_warning_last_sent_on == today
and user.workhours_counter_warning_last_sent_key == warning_key
):
return
try:
send_email(
settings=mail_settings,
to_email=user.email,
subject="Warnung: Arbeitsstundenziel gefährdet",
text_body=(
"Dein gesetztes Arbeitsstundenziel im Counter-Zeitraum ist mit den aktuellen "
"Einstellungen voraussichtlich nicht erreichbar.\n\n"
f"Zeitraum: {start_date.strftime('%d.%m.%Y')} - "
f"{end_date.strftime('%d.%m.%Y')}\n"
f"Ziel: {minutes_to_hhmm(target_minutes)}\n"
f"Bisher erfasst: {minutes_to_hhmm(logged_minutes)}\n"
f"Prognose bis Periodenende: {minutes_to_hhmm(projected_minutes)}\n"
f"Voraussichtliche Lücke: {minutes_to_hhmm(missing_minutes)}\n\n"
"Bitte passe bei Bedarf Arbeitszeiten oder Einstellungen an."
),
)
except Exception:
logger.exception("workhours_target_warning_mail_failed email=%s", user.email)
return
user.workhours_counter_warning_last_sent_on = today
user.workhours_counter_warning_last_sent_key = warning_key
db.add(user)
db.commit()
def range_is_full_vacation(
start_date: date,
end_date: date,
*,
vacation_dates: set[date],
relevant_weekdays: set[int],
) -> bool:
relevant_dates = [
start_date + timedelta(days=offset)
for offset in range((end_date - start_date).days + 1)
if (start_date + timedelta(days=offset)).weekday() in relevant_weekdays
]
if not relevant_dates:
return False
return all(day in vacation_dates for day in relevant_dates)
def add_vacation_range(
*,
db: Session,
user_id: str,
start_date: date,
end_date: date,
include_weekends: bool = True,
notes: str | None = None,
) -> None:
period = VacationPeriod(
user_id=user_id,
start_date=start_date,
end_date=end_date,
include_weekends=include_weekends,
notes=notes,
)
db.add(period)
def add_vacation_for_weekdays(
*,
db: Session,
user_id: str,
start_date: date,
end_date: date,
relevant_weekdays: set[int],
notes: str | None = None,
) -> None:
days_to_add = [
start_date + timedelta(days=offset)
for offset in range((end_date - start_date).days + 1)
if (start_date + timedelta(days=offset)).weekday() in relevant_weekdays
]
if not days_to_add:
return
block_start = days_to_add[0]
previous_day = days_to_add[0]
for current_day in days_to_add[1:]:
if current_day == previous_day + timedelta(days=1):
previous_day = current_day
continue
add_vacation_range(
db=db,
user_id=user_id,
start_date=block_start,
end_date=previous_day,
include_weekends=True,
notes=notes,
)
block_start = current_day
previous_day = current_day
add_vacation_range(
db=db,
user_id=user_id,
start_date=block_start,
end_date=previous_day,
include_weekends=True,
notes=notes,
)
def build_effective_vacation_ranges(
*,
periods: list[VacationPeriod],
relevant_weekdays: set[int],
) -> list[dict]:
if not periods:
return []
from_date = min(period.start_date for period in periods)
to_date = max(period.end_date for period in periods)
effective_dates = expand_vacation_dates(
periods,
from_date,
to_date,
relevant_weekdays=relevant_weekdays,
)
ranges = collapse_dates_to_ranges(effective_dates)
return [{"start_date": start, "end_date": end} for start, end in ranges]
def remove_vacation_range(
*,
db: Session,
user_id: str,
start_date: date,
end_date: date,
) -> None:
overlapping_stmt = (
select(VacationPeriod)
.where(
VacationPeriod.user_id == user_id,
VacationPeriod.end_date >= start_date,
VacationPeriod.start_date <= end_date,
)
.order_by(VacationPeriod.start_date.asc())
)
overlapping_periods = db.execute(overlapping_stmt).scalars().all()
for period in overlapping_periods:
period_start = period.start_date
period_end = period.end_date
if start_date <= period_start and end_date >= period_end:
db.delete(period)
continue
if start_date > period_start and end_date < period_end:
left_end = start_date - timedelta(days=1)
right_start = end_date + timedelta(days=1)
period.end_date = left_end
right_period = VacationPeriod(
user_id=user_id,
start_date=right_start,
end_date=period_end,
include_weekends=period.include_weekends,
notes=period.notes,
)
db.add(right_period)
continue
if start_date <= period_start <= end_date < period_end:
period.start_date = end_date + timedelta(days=1)
continue
if period_start < start_date <= period_end <= end_date:
period.end_date = start_date - timedelta(days=1)
continue
weekday_options = [
{"value": 0, "label": "Montag"},
{"value": 1, "label": "Dienstag"},
{"value": 2, "label": "Mittwoch"},
{"value": 3, "label": "Donnerstag"},
{"value": 4, "label": "Freitag"},
{"value": 5, "label": "Samstag"},
{"value": 6, "label": "Sonntag"},
]
def render_bulk_form(
request: Request,
*,
db: Session,
user: User,
from_date_value: str,
to_date_value: str,
weekdays_selected: list[int],
bulk_mode: str,
start_time: str,
end_time: str,
break_minutes: int,
break_mode: str,
notes: str,
error: str | None = None,
success_message: str | None = None,
status_code: int = status.HTTP_200_OK,
):
return templates.TemplateResponse(
"pages/bulk_entry.html",
build_context(
request,
user=user,
db=db,
from_date=from_date_value,
to_date=to_date_value,
weekdays_selected=weekdays_selected,
bulk_mode=bulk_mode,
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
weekday_options=weekday_options,
error=error,
success_message=success_message,
),
status_code=status_code,
)
def render_settings_form(
request: Request,
*,
db: Session,
user: User,
active_tab: str = "settings",
import_preview: dict | None = None,
import_mode_selected: str = "merge",
success_message: str | None = None,
error: str | None = None,
status_code: int = status.HTTP_200_OK,
):
vacations_stmt = (
select(VacationPeriod)
.where(VacationPeriod.user_id == user.id)
.order_by(VacationPeriod.start_date.asc(), VacationPeriod.end_date.asc())
)
vacations = db.execute(vacations_stmt).scalars().all()
today = date.today()
rules = list_rules_for_user(db, user.id)
settings_weekly_target_minutes = target_for_week(rules, monday_of(today), user.weekly_target_minutes)
working_days = get_user_working_days(user)
working_days_selected = sorted(working_days)
vacation_ranges = build_effective_vacation_ranges(periods=vacations, relevant_weekdays=working_days)
overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, date(1970, 1, 1), date(2100, 12, 31))
overtime_adjustment_total_positive = sum(max(0, item.minutes) for item in overtime_adjustments)
overtime_adjustment_total_negative = sum(min(0, item.minutes) for item in overtime_adjustments)
overtime_adjustment_full_day_count = sum(
1 for item in overtime_adjustments if item.notes and "ganzer Tag" in item.notes
)
workhours_counter_minutes: int | None = None
workhours_counter_warning: dict[str, object] | None = None
if (
user.workhours_counter_enabled
and user.workhours_counter_start_date is not None
and user.workhours_counter_end_date is not None
and user.workhours_counter_end_date >= user.workhours_counter_start_date
):
workhours_counter_minutes = compute_workhours_counter_minutes(
db=db,
user=user,
from_date=user.workhours_counter_start_date,
to_date=user.workhours_counter_end_date,
)
workhours_counter_warning = build_workhours_target_warning(db=db, user=user)
mfa_setup_secret = request.session.get("mfa_setup_secret")
mfa_setup_uri = None
if mfa_setup_secret:
mfa_setup_uri = build_totp_uri(secret=mfa_setup_secret, account_name=user.email)
is_admin = user.role == "admin"
if active_tab not in {"settings", "admin"}:
active_tab = "settings"
if not is_admin:
active_tab = "settings"
managed_users: list[User] = []
admin_recipients: list[User] = []
email_server_config = get_email_config(db)
if is_admin:
users_stmt = select(User).order_by(User.created_at.asc())
managed_users = db.execute(users_stmt).scalars().all()
admin_recipients_stmt = (
select(User)
.where(User.role == "admin", User.is_active.is_(True))
.order_by(User.created_at.asc())
)
admin_recipients = db.execute(admin_recipients_stmt).scalars().all()
site_content_markdown = {
SITE_CONTENT_IMPRESSUM: get_site_content_markdown(db, SITE_CONTENT_IMPRESSUM),
SITE_CONTENT_PRIVACY: get_site_content_markdown(db, SITE_CONTENT_PRIVACY),
}
support_tickets: list[SupportTicket] = []
if is_admin:
support_tickets = db.execute(
select(SupportTicket).order_by(
case((SupportTicket.status == SUPPORT_TICKET_STATUS_OPEN, 0), else_=1),
SupportTicket.created_at.desc(),
)
).scalars().all()
selected_notify_admin_ids = parse_admin_id_csv(
email_server_config.registration_admin_notify_admin_ids_csv if email_server_config else None
)
fallback_notify_email = settings.registration_notify_email.strip()
email_server_view = {
"smtp_host": email_server_config.smtp_host if email_server_config else "",
"smtp_port": email_server_config.smtp_port if email_server_config else 587,
"smtp_username": email_server_config.smtp_username if email_server_config else "",
"from_email": email_server_config.from_email if email_server_config else "",
"from_name": email_server_config.from_name if email_server_config else settings.app_name,
"use_starttls": email_server_config.use_starttls if email_server_config else True,
"use_ssl": email_server_config.use_ssl if email_server_config else False,
"verify_tls": email_server_config.verify_tls if email_server_config else True,
"registration_mails_enabled": email_server_config.registration_mails_enabled if email_server_config else True,
"password_reset_mails_enabled": email_server_config.password_reset_mails_enabled if email_server_config else True,
"registration_admin_notify_enabled": (
email_server_config.registration_admin_notify_enabled if email_server_config else True
),
"registration_admin_notify_admin_ids": selected_notify_admin_ids,
"registration_notify_fallback_email": fallback_notify_email,
"has_password": bool(email_server_config and email_server_config.smtp_password_encrypted),
}
return templates.TemplateResponse(
"pages/settings.html",
build_context(
request,
user=user,
db=db,
vacations=vacations,
vacation_ranges=vacation_ranges,
overtime_adjustments=overtime_adjustments,
overtime_adjustment_total_positive=overtime_adjustment_total_positive,
overtime_adjustment_total_negative=overtime_adjustment_total_negative,
overtime_adjustment_full_day_count=overtime_adjustment_full_day_count,
vacation_start=today.isoformat(),
vacation_end=today.isoformat(),
settings_weekly_target_minutes=settings_weekly_target_minutes,
working_days_selected=working_days_selected,
weekday_options=weekday_options,
workhours_counter_minutes=workhours_counter_minutes,
workhours_counter_warning=workhours_counter_warning,
federal_state_options=GERMAN_STATE_OPTIONS,
mfa_method_labels=MFA_METHOD_LABELS,
mfa_setup_secret=mfa_setup_secret,
mfa_setup_uri=mfa_setup_uri,
is_admin=is_admin,
active_settings_tab=active_tab,
managed_users=managed_users,
admin_recipients=admin_recipients,
admin_user_count=count_admin_users(db),
email_server=email_server_view,
site_content_markdown=site_content_markdown,
support_tickets=support_tickets,
ticket_status_label=ticket_status_label,
ticket_category_label=ticket_category_label,
mail_settings_available=resolve_mail_settings(db) is not None,
import_preview=import_preview,
import_mode_selected=import_mode_selected,
success_message=success_message,
error=error,
),
status_code=status_code,
)
def user_export_date_bounds(db: Session, user: User) -> tuple[date, date]:
dates: list[date] = []
dates.extend(
db.execute(select(TimeEntry.date).where(TimeEntry.user_id == user.id).order_by(TimeEntry.date.asc()))
.scalars()
.all()
)
dates.extend(
db.execute(
select(VacationPeriod.start_date).where(VacationPeriod.user_id == user.id).order_by(VacationPeriod.start_date.asc())
)
.scalars()
.all()
)
dates.extend(
db.execute(
select(VacationPeriod.end_date).where(VacationPeriod.user_id == user.id).order_by(VacationPeriod.end_date.asc())
)
.scalars()
.all()
)
dates.extend(
db.execute(
select(SpecialDayStatus.date).where(SpecialDayStatus.user_id == user.id).order_by(SpecialDayStatus.date.asc())
)
.scalars()
.all()
)
dates.extend(
db.execute(
select(OvertimeAdjustment.date)
.where(OvertimeAdjustment.user_id == user.id)
.order_by(OvertimeAdjustment.date.asc())
)
.scalars()
.all()
)
dates.extend(
db.execute(
select(WeeklyTargetRule.effective_from)
.where(WeeklyTargetRule.user_id == user.id)
.order_by(WeeklyTargetRule.effective_from.asc())
)
.scalars()
.all()
)
for maybe_date in (
user.overtime_start_date,
user.workhours_counter_start_date,
user.workhours_counter_end_date,
):
if maybe_date is not None:
dates.append(maybe_date)
if not dates:
today = date.today()
return today, today
return min(dates), max(dates)
def build_export_payload_for_range(
*,
db: Session,
user: User,
from_date: date,
to_date: date,
) -> tuple[list[dict], list[dict], dict]:
auto_created = autofill_entries_for_range(db=db, user=user, range_start=from_date, range_end=to_date)
if auto_created:
db.commit()
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= from_date, TimeEntry.date <= to_date)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
entries_by_date = {entry.date: entry for entry in entries}
days: list[date] = []
cursor = from_date
while cursor <= to_date:
days.append(cursor)
cursor += timedelta(days=1)
week_starts = sorted({monday_of(day) for day in days})
if not week_starts:
week_starts = [monday_of(from_date)]
full_range_start = min(week_starts)
full_range_end = max(week_starts) + timedelta(days=6)
full_entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= full_range_start, TimeEntry.date <= full_range_end)
.order_by(TimeEntry.date.asc())
)
full_entries = db.execute(full_entries_stmt).scalars().all()
rules = list_rules_for_user(db, user.id)
working_days = get_user_working_days(user)
base_week_target_map = target_map_for_weeks(rules, week_starts, user.weekly_target_minutes)
export_vacations = list_vacations_for_user(db, user.id, full_range_start, full_range_end)
export_vacation_dates = expand_vacation_dates(
export_vacations,
full_range_start,
full_range_end,
relevant_weekdays=working_days,
)
export_special_statuses = list_special_statuses_for_user(db, user.id, full_range_start, full_range_end)
export_special_dates = effective_non_working_dates_for_user(user=user, special_statuses=export_special_statuses)
export_count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=export_vacation_dates,
special_statuses=export_special_statuses,
)
export_special_by_date = special_status_map(export_special_statuses)
export_overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, full_range_start, full_range_end)
export_overtime_adjustment_minutes = overtime_adjustment_minutes_map(export_overtime_adjustments)
week_ist_map: dict[date, int] = {}
week_target_map: dict[date, int] = {}
week_delta_map: dict[date, int] = {}
for week_start in week_starts:
totals = compute_effective_week_totals(
entries=full_entries,
week_start=week_start,
weekly_target_minutes=base_week_target_map[week_start],
vacation_dates=export_vacation_dates,
non_working_dates=export_special_dates,
count_as_worktime_dates=export_count_as_worktime_dates,
overtime_adjustment_minutes_by_date=export_overtime_adjustment_minutes,
overtime_start_date=user.overtime_start_date,
relevant_weekdays=working_days,
)
week_ist_map[week_start] = totals["weekly_ist"]
week_target_map[week_start] = totals["weekly_soll"]
week_delta_map[week_start] = totals["weekly_delta"]
rows = build_export_rows(
days,
entries_by_date,
week_target_map,
week_ist_map,
week_delta_map,
export_special_by_date,
export_overtime_adjustment_minutes,
)
week_summaries = []
total_ist = 0
total_delta = 0
for week_start in week_starts:
ist = week_ist_map[week_start]
soll = week_target_map[week_start]
delta = week_delta_map[week_start]
total_ist += ist
total_delta += delta
week_summaries.append(
{
"week_start": week_start,
"week_end": week_start + timedelta(days=6),
"ist_minutes": ist,
"soll_minutes": soll,
"delta_minutes": delta,
}
)
totals = {
"from_date": from_date,
"to_date": to_date,
"ist_minutes": total_ist,
"delta_minutes": total_delta,
}
return rows, week_summaries, totals
def build_user_backup_payload(*, db: Session, user: User) -> dict:
rules = list_rules_for_user(db, user.id)
time_entries = (
db.execute(select(TimeEntry).where(TimeEntry.user_id == user.id).order_by(TimeEntry.date.asc())).scalars().all()
)
vacation_periods = (
db.execute(select(VacationPeriod).where(VacationPeriod.user_id == user.id).order_by(VacationPeriod.start_date.asc()))
.scalars()
.all()
)
special_statuses = (
db.execute(select(SpecialDayStatus).where(SpecialDayStatus.user_id == user.id).order_by(SpecialDayStatus.date.asc()))
.scalars()
.all()
)
overtime_adjustments = (
db.execute(
select(OvertimeAdjustment).where(OvertimeAdjustment.user_id == user.id).order_by(OvertimeAdjustment.date.asc())
)
.scalars()
.all()
)
return {
"backup_version": 2,
"app_name": settings.app_name,
"app_version": settings.app_version,
"exported_at": utc_now().isoformat(),
"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)),
"count_vacation_as_worktime": user.count_vacation_as_worktime,
"count_holiday_as_worktime": user.count_holiday_as_worktime,
"count_sick_as_worktime": user.count_sick_as_worktime,
"automatic_break_rules_enabled": user.automatic_break_rules_enabled,
"default_break_minutes": user.default_break_minutes,
"overtime_start_date": user.overtime_start_date.isoformat() if user.overtime_start_date else None,
"overtime_expiry_days": user.overtime_expiry_days,
"expire_negative_overtime": user.expire_negative_overtime,
"vacation_days_total": user.vacation_days_total,
"vacation_show_in_header": user.vacation_show_in_header,
"workhours_counter_enabled": user.workhours_counter_enabled,
"workhours_counter_show_in_header": user.workhours_counter_show_in_header,
"workhours_counter_start_date": (
user.workhours_counter_start_date.isoformat() if user.workhours_counter_start_date else None
),
"workhours_counter_end_date": (
user.workhours_counter_end_date.isoformat() if user.workhours_counter_end_date else None
),
"workhours_counter_manual_offset_minutes": user.workhours_counter_manual_offset_minutes,
"workhours_counter_target_minutes": user.workhours_counter_target_minutes,
"workhours_counter_target_email_enabled": user.workhours_counter_target_email_enabled,
"federal_state": user.federal_state,
},
"weekly_target_rules": [
{
"effective_from": rule.effective_from.isoformat(),
"weekly_target_minutes": rule.weekly_target_minutes,
}
for rule in rules
],
"time_entries": [
{
"date": entry.date.isoformat(),
"start_minutes": entry.start_minutes,
"end_minutes": entry.end_minutes,
"break_minutes": entry.break_minutes,
"break_rule_mode": entry.break_rule_mode,
"notes": entry.notes,
}
for entry in time_entries
],
"vacation_periods": [
{
"start_date": period.start_date.isoformat(),
"end_date": period.end_date.isoformat(),
"include_weekends": period.include_weekends,
"notes": period.notes,
}
for period in vacation_periods
],
"special_day_statuses": [
{
"date": status.date.isoformat(),
"status": status.status,
"notes": status.notes,
}
for status in special_statuses
],
"overtime_adjustments": [
{
"date": adjustment.date.isoformat(),
"minutes": adjustment.minutes,
"notes": adjustment.notes,
}
for adjustment in overtime_adjustments
],
}
def import_preview_view_data(*, db: Session, user: User, preview: ImportPreview, payload: dict) -> dict:
summary = build_import_preview(db=db, user=user, payload=payload, mode=preview.mode)
summary["id"] = preview.id
return summary
@app.on_event("startup")
async def startup_auto_holiday_sync() -> None:
with Session(get_engine()) as db:
sync_auto_holidays_for_all_users(
db=db,
from_date=date.today().replace(month=1, day=1) - timedelta(days=366),
to_date=date.today().replace(month=12, day=31) + timedelta(days=730),
)
sync_auto_entries_for_all_users(db=db)
db.commit()
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.get("/manifest.webmanifest")
async def web_manifest() -> JSONResponse:
with open("app/static/manifest.webmanifest", "r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
icon_suffix = f"?v={asset_version}"
if settings.app_env != "production":
manifest["icons"] = [
{
"src": f"/static/icons/pwa-stage-192.png{icon_suffix}",
"sizes": "192x192",
"type": "image/png",
},
{
"src": f"/static/icons/pwa-stage-512.png{icon_suffix}",
"sizes": "512x512",
"type": "image/png",
},
]
else:
manifest["icons"] = [
{
"src": f"/static/icons/pwa-192.png{icon_suffix}",
"sizes": "192x192",
"type": "image/png",
},
{
"src": f"/static/icons/pwa-512.png{icon_suffix}",
"sizes": "512x512",
"type": "image/png",
},
]
response = JSONResponse(manifest, media_type="application/manifest+json")
response.headers["Cache-Control"] = "no-store"
return response
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, obfuscate_emails=True)
user = get_current_user(request, db)
return templates.TemplateResponse(
"pages/legal_page.html",
build_context(
request,
user=user,
db=db,
title=title,
subtitle=subtitle,
content_html=html_content,
),
)
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(
request,
db=db,
key=SITE_CONTENT_IMPRESSUM,
title="Impressum",
subtitle="Angaben zum Anbieter und Kontakt.",
)
@app.get("/datenschutz", response_class=HTMLResponse)
async def privacy_page(request: Request, db: Session = Depends(get_db)):
return render_legal_page(
request,
db=db,
key=SITE_CONTENT_PRIVACY,
title="Datenschutz",
subtitle="Informationen zur Verarbeitung personenbezogener Daten in Stundenfuchs.",
)
@app.get("/kontakt", response_class=HTMLResponse)
async def contact_form(request: Request, db: Session = Depends(get_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(
request,
user=user,
db=db,
title="Kontakt",
category_options=ticket_category_options(),
contact_started_at=contact_form_started_at(request),
contact_name="",
contact_email=user.email if user else "",
contact_subject="",
contact_message="",
contact_category="problem",
success_message="Deine Nachricht wurde gesendet." if request.query_params.get("msg") == "sent" else None,
),
)
@app.post("/kontakt", response_class=HTMLResponse)
async def contact_submit(
request: Request,
category: str = Form(default="problem"),
name: str = Form(default=""),
email: str = Form(...),
subject: str = Form(...),
message: str = Form(...),
website: str = Form(default=""),
started_at: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
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 = user.email.strip().lower()
normalized_subject = subject.strip()
normalized_message = message.strip()
started_at_expected = request.session.get("contact_form_started_at")
def render_contact_error(error_message: str, *, status_code: int = status.HTTP_400_BAD_REQUEST) -> HTMLResponse:
issue_contact_form_started_at(request)
return templates.TemplateResponse(
"pages/contact.html",
build_context(
request,
user=user,
db=db,
title="Kontakt",
category_options=ticket_category_options(),
contact_started_at=contact_form_started_at(request),
contact_name=normalized_name,
contact_email=normalized_email,
contact_subject=normalized_subject,
contact_message=normalized_message,
contact_category=category,
error=error_message,
),
status_code=status_code,
)
if website.strip():
return render_contact_error("Nachricht konnte nicht versendet werden.", status_code=status.HTTP_429_TOO_MANY_REQUESTS)
if category not in {item["value"] for item in ticket_category_options()}:
return render_contact_error("Bitte eine gültige Kategorie auswählen.")
if not normalized_email or "@" not in normalized_email:
return render_contact_error("Bitte eine gültige E-Mail-Adresse eingeben.")
if len(normalized_subject) < 4 or len(normalized_subject) > 180:
return render_contact_error("Der Betreff muss zwischen 4 und 180 Zeichen lang sein.")
if len(normalized_message) < 10 or len(normalized_message) > 5000:
return render_contact_error("Die Nachricht muss zwischen 10 und 5000 Zeichen lang sein.")
if started_at_expected != started_at:
return render_contact_error("Das Formular ist abgelaufen. Bitte erneut absenden.")
try:
started_at_value = datetime.fromisoformat(started_at)
except ValueError:
return render_contact_error("Das Formular ist abgelaufen. Bitte erneut absenden.")
if ensure_utc_datetime(started_at_value) > utc_now() - timedelta(seconds=SUPPORT_TICKET_MIN_FORM_SECONDS):
return render_contact_error(
"Die Nachricht wurde zu schnell abgesendet. Bitte kurz warten und erneut versuchen.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
ip_hash = support_ticket_ip_hash(request)
if support_ticket_rate_limited(db=db, ip_hash=ip_hash, email=normalized_email):
return render_contact_error(
"Es wurden in kurzer Zeit bereits zu viele Nachrichten gesendet. Bitte später erneut versuchen.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
ticket = SupportTicket(
user_id=user.id if user else None,
category=category,
status=SUPPORT_TICKET_STATUS_OPEN,
name=normalized_name,
email=normalized_email,
subject=normalized_subject,
message=normalized_message,
source_ip_hash=ip_hash,
source_user_agent=(request.headers.get("user-agent", "") or "")[:512],
)
db.add(ticket)
db.commit()
send_support_ticket_notification(db=db, ticket=ticket)
issue_contact_form_started_at(request)
return RedirectResponse(url="/kontakt?msg=sent", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/", response_class=HTMLResponse)
async def root(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/landing.html",
build_context(request),
)
@app.get("/register", response_class=HTMLResponse)
async def register_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/register.html",
build_context(
request,
error=None,
federal_state_options=GERMAN_STATE_OPTIONS,
weekday_options=weekday_options,
today_iso=date.today().isoformat(),
email_mfa_available=resolve_mail_settings(db) is not None,
),
)
@app.get("/hilfe", response_class=HTMLResponse)
async def help_page(request: Request, db: Session = Depends(get_db)):
user = require_user(request, db)
return templates.TemplateResponse(
"pages/help.html",
build_context(request, user=user),
)
@app.post("/register", response_class=HTMLResponse)
async def register_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
backup_file: UploadFile | None = File(default=None),
federal_state: str = Form(default=""),
vacation_days_total_value: str = Form(default="", alias="vacation_days_total"),
weekly_target_hours_value: str = Form(default="", alias="weekly_target_hours"),
vacation_show_in_header: str | None = Form(default=None),
preferred_home_view: str = Form(default=""),
entry_mode: str = Form(default="manual"),
overtime_start_date_value: str = Form(default="", alias="overtime_start_date"),
overtime_expiry_days_value: str = Form(default="", alias="overtime_expiry_days"),
expire_negative_overtime: str | None = Form(default=None),
workhours_counter_enabled: str | None = Form(default=None),
workhours_counter_show_in_header: str | None = Form(default=None),
workhours_counter_start_date_value: str = Form(default="", alias="workhours_counter_start_date"),
workhours_counter_end_date_value: str = Form(default="", alias="workhours_counter_end_date"),
workhours_counter_manual_offset_hours_value: str = Form(default="", alias="workhours_counter_manual_offset_hours"),
workhours_counter_target_hours_value: str = Form(default="", alias="workhours_counter_target_hours"),
workhours_counter_target_email_enabled: str | None = Form(default=None),
working_days_values: list[str] = Form(default=[], alias="working_days"),
count_vacation_as_worktime: str | None = Form(default=None),
count_holiday_as_worktime: str | None = Form(default=None),
count_sick_as_worktime: str | None = Form(default=None),
automatic_break_rules_enabled: str | None = Form(default=None),
mfa_preference: str = Form(default="none"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
def render_register_error(message: str, status_code: int = status.HTTP_400_BAD_REQUEST) -> HTMLResponse:
return templates.TemplateResponse(
"pages/register.html",
build_context(
request,
error=message,
federal_state_options=GERMAN_STATE_OPTIONS,
weekday_options=weekday_options,
today_iso=date.today().isoformat(),
email_mfa_available=resolve_mail_settings(db) is not None,
),
status_code=status_code,
)
try:
payload = RegisterRequest(email=email, password=password)
except Exception as exc: # pydantic validation
return render_register_error(str(exc))
backup_payload: dict | None = None
if backup_file and (backup_file.filename or "").strip():
try:
backup_payload = load_backup_payload_from_bytes(await backup_file.read())
except BackupImportError as exc:
return render_register_error(str(exc))
existing = find_user_by_email(db, payload.email)
if existing:
return render_register_error("E-Mail ist bereits registriert", status.HTTP_409_CONFLICT)
normalized_state = normalize_german_state_code(federal_state)
if federal_state.strip() and normalized_state is None:
return render_register_error("Ungültiges Bundesland ausgewählt.")
selected_working_days = [0, 1, 2, 3, 4]
if working_days_values:
try:
selected_working_days = parse_weekday_values(working_days_values)
except HTTPException as exc:
return render_register_error(exc.detail, exc.status_code)
selected_home_view = preferred_home_view.strip() or "week"
if selected_home_view not in {"week", "month"}:
return render_register_error("Ungültige Standardansicht.")
selected_entry_mode = entry_mode.strip() or ENTRY_MODE_MANUAL
if selected_entry_mode not in {ENTRY_MODE_MANUAL, ENTRY_MODE_AUTO_UNTIL_TODAY}:
return render_register_error("Ungültiger Erfassungsmodus.")
overtime_start_date = None
if overtime_start_date_value.strip():
try:
overtime_start_date = parse_date_query(overtime_start_date_value.strip())
except HTTPException as exc:
return render_register_error(exc.detail, exc.status_code)
overtime_expiry_days = None
if overtime_expiry_days_value.strip():
try:
overtime_expiry_days = int(overtime_expiry_days_value.strip())
except ValueError:
return render_register_error("Verfall muss eine ganze Zahl in Tagen sein.")
if overtime_expiry_days <= 0:
return render_register_error("Verfall muss größer als 0 sein.")
if overtime_expiry_days > 3650:
return render_register_error("Verfall ist zu groß (maximal 3650 Tage).")
vacation_days_total = 0
if vacation_days_total_value.strip():
try:
vacation_days_total = int(vacation_days_total_value.strip())
except ValueError:
return render_register_error("Gesamturlaubstage müssen eine ganze Zahl sein.")
if vacation_days_total < 0:
return render_register_error("Gesamturlaubstage dürfen nicht negativ sein.")
if vacation_days_total > 365:
return render_register_error("Gesamturlaubstage sind zu groß (maximal 365).")
weekly_target_minutes = 1500
if weekly_target_hours_value.strip():
try:
weekly_target_hours = float(weekly_target_hours_value.strip().replace(",", "."))
except ValueError:
return render_register_error("Wochenstunden müssen eine Zahl sein.")
weekly_target_minutes = int(round(weekly_target_hours * 60))
if weekly_target_minutes <= 0:
return render_register_error("Wochenstunden müssen größer als 0 sein.")
counter_enabled = workhours_counter_enabled == "on"
counter_show_in_header = workhours_counter_show_in_header == "on"
counter_target_email_enabled = workhours_counter_target_email_enabled == "on"
counter_start_date = None
counter_end_date = None
counter_manual_offset_minutes = 0
counter_target_minutes: int | None = None
if workhours_counter_start_date_value.strip():
try:
counter_start_date = parse_date_query(workhours_counter_start_date_value.strip())
except HTTPException as exc:
return render_register_error(exc.detail, exc.status_code)
if workhours_counter_end_date_value.strip():
try:
counter_end_date = parse_date_query(workhours_counter_end_date_value.strip())
except HTTPException as exc:
return render_register_error(exc.detail, exc.status_code)
if counter_enabled:
if counter_start_date is None or counter_end_date is None:
return render_register_error("Bitte Start- und Enddatum für den Arbeitsstunden-Counter setzen.")
if counter_end_date < counter_start_date:
return render_register_error("Enddatum darf nicht vor dem Startdatum liegen.")
if workhours_counter_manual_offset_hours_value.strip():
try:
counter_manual_offset_hours = float(workhours_counter_manual_offset_hours_value.strip().replace(",", "."))
except ValueError:
return render_register_error("Zusatzstunden müssen eine Zahl sein.")
counter_manual_offset_minutes = int(round(counter_manual_offset_hours * 60))
if counter_manual_offset_minutes < 0:
return render_register_error("Zusatzstunden dürfen nicht negativ sein.")
if workhours_counter_target_hours_value.strip():
try:
counter_target_hours = float(workhours_counter_target_hours_value.strip().replace(",", "."))
except ValueError:
return render_register_error("Stundenziel muss eine Zahl sein.")
counter_target_minutes = int(round(counter_target_hours * 60))
if counter_target_minutes <= 0:
return render_register_error("Stundenziel muss größer als 0 sein.")
if mfa_preference not in {MFA_METHOD_NONE, MFA_METHOD_EMAIL, MFA_METHOD_TOTP}:
return render_register_error("Ungültige 2FA-Auswahl.")
mail_settings_available = resolve_mail_settings(db) is not None
verification_enabled = settings.email_verification_required and mail_settings_available
selected_mfa_method = MFA_METHOD_NONE
if mfa_preference == MFA_METHOD_EMAIL and mail_settings_available:
selected_mfa_method = MFA_METHOD_EMAIL
if backup_payload is not None:
selected_home_view = "week"
selected_entry_mode = ENTRY_MODE_MANUAL
selected_working_days = [0, 1, 2, 3, 4]
normalized_state = None
overtime_start_date = None
overtime_expiry_days = None
vacation_days_total = 0
weekly_target_minutes = 1500
counter_enabled = False
counter_show_in_header = False
counter_start_date = None
counter_end_date = None
counter_manual_offset_minutes = 0
counter_target_minutes = None
user = User(
email=payload.email.lower(),
password_hash=hash_password(payload.password),
role="admin" if is_bootstrap_admin_identity(payload.email) else "user",
preferred_home_view=selected_home_view,
entry_mode=selected_entry_mode,
working_days_csv=serialize_working_days(selected_working_days),
count_vacation_as_worktime=count_vacation_as_worktime == "on",
count_holiday_as_worktime=count_holiday_as_worktime == "on",
count_sick_as_worktime=count_sick_as_worktime == "on",
automatic_break_rules_enabled=automatic_break_rules_enabled == "on",
overtime_start_date=overtime_start_date,
overtime_expiry_days=overtime_expiry_days,
expire_negative_overtime=expire_negative_overtime == "on",
vacation_days_total=vacation_days_total,
weekly_target_minutes=weekly_target_minutes,
vacation_show_in_header=(vacation_show_in_header == "on" if vacation_show_in_header is not None else True),
workhours_counter_enabled=counter_enabled,
workhours_counter_show_in_header=counter_show_in_header and counter_enabled,
workhours_counter_start_date=counter_start_date,
workhours_counter_end_date=counter_end_date,
workhours_counter_manual_offset_minutes=counter_manual_offset_minutes,
workhours_counter_target_minutes=counter_target_minutes,
workhours_counter_target_email_enabled=(
counter_target_email_enabled
and counter_enabled
and counter_target_minutes is not None
and mail_settings_available
),
federal_state=normalized_state,
mfa_method=selected_mfa_method,
email_verified=not verification_enabled,
)
db.add(user)
db.commit()
db.refresh(user)
send_registration_admin_notification(db=db, user=user, source="register_form")
if normalized_state:
sync_auto_holidays_for_user(
db=db,
user=user,
from_date=date.today().replace(month=1, day=1) - timedelta(days=366),
to_date=date.today().replace(month=12, day=31) + timedelta(days=730),
)
if backup_payload is not None:
execute_backup_import(db=db, user=user, payload=backup_payload, mode=IMPORT_MODE_REPLACE)
sync_auto_holidays_for_user(
db=db,
user=user,
from_date=date.today().replace(month=1, day=1) - timedelta(days=366),
to_date=date.today().replace(month=12, day=31) + timedelta(days=730),
)
autofill_entries_for_range(
db=db,
user=user,
range_start=date(1970, 1, 1),
range_end=date.today(),
)
else:
ensure_user_has_default_target_rule(db, user)
if selected_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY:
autofill_entries_for_range(
db=db,
user=user,
range_start=user.created_at.date(),
range_end=date.today(),
)
db.commit()
if verification_enabled:
sent, reason = send_email_verification_link(request=request, db=db, user=user)
logger.info("register_pending_verification email=%s sent=%s reason=%s", user.email, sent, reason)
if not sent and reason != "rate_limited":
return RedirectResponse(url="/login?msg=email_verification_send_failed", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/login?msg=email_verification_sent", status_code=status.HTTP_303_SEE_OTHER)
send_registration_email_if_enabled(db=db, user=user)
logger.info("register_success email=%s", user.email)
login_user(request, user)
if mfa_preference == MFA_METHOD_TOTP:
request.session["mfa_setup_secret"] = generate_totp_secret()
return RedirectResponse(url="/settings?msg=mfa_setup_required", status_code=status.HTTP_303_SEE_OTHER)
if mfa_preference == MFA_METHOD_EMAIL and selected_mfa_method == MFA_METHOD_NONE:
return RedirectResponse(url="/settings?msg=mfa_email_unavailable", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
@app.get("/login", response_class=HTMLResponse)
async def login_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
msg = request.query_params.get("msg")
success_message = None
error_message = None
if msg == "password_reset_done":
success_message = "Passwort wurde erfolgreich gesetzt. Bitte jetzt anmelden."
elif msg == "account_deleted":
success_message = "Dein Konto und alle zugehörigen Daten wurden gelöscht."
elif msg == "email_verification_sent":
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. "
"Bitte fordere einen neuen Link an."
)
return templates.TemplateResponse(
"pages/login.html",
build_context(request, error=error_message, success_message=success_message),
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
try:
payload = LoginRequest(email=email, password=password)
except Exception as exc:
return templates.TemplateResponse(
"pages/login.html",
build_context(request, error=str(exc)),
status_code=status.HTTP_400_BAD_REQUEST,
)
client_ip = get_client_ip(request)
blocked, retry_minutes = is_login_blocked(
db,
payload.email,
client_ip,
settings.login_rate_limit_attempts,
settings.login_rate_limit_window_minutes,
)
if blocked:
logger.warning("login_blocked email=%s ip=%s", payload.email.lower(), client_ip)
return templates.TemplateResponse(
"pages/login.html",
build_context(request, error=f"Zu viele Fehlversuche. Bitte in {retry_minutes} Minuten erneut versuchen."),
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
user = find_user_by_email(db, payload.email)
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
register_failed_attempt(db, payload.email, client_ip)
logger.warning("login_failed email=%s ip=%s", payload.email.lower(), client_ip)
return templates.TemplateResponse(
"pages/login.html",
build_context(request, error="Ungueltige Anmeldedaten"),
status_code=status.HTTP_401_UNAUTHORIZED,
)
if settings.email_verification_required and not user.email_verified:
logger.info("login_blocked_unverified email=%s ip=%s", payload.email.lower(), client_ip)
return templates.TemplateResponse(
"pages/login.html",
build_context(
request,
error=(
"Bitte zuerst deine E-Mail-Adresse bestätigen. "
"Du kannst unten einen neuen Bestätigungslink anfordern."
),
),
status_code=status.HTTP_403_FORBIDDEN,
)
register_successful_attempt(db, payload.email, client_ip)
logger.info("login_success email=%s ip=%s", payload.email.lower(), client_ip)
may_login_directly, mfa_error = start_mfa_challenge(request, db, user)
if mfa_error:
return templates.TemplateResponse(
"pages/login.html",
build_context(request, error=mfa_error),
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if may_login_directly:
login_user(request, user)
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/login/mfa", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/login/mfa", response_class=HTMLResponse)
async def login_mfa_form(request: Request, db: Session = Depends(get_db)):
current_user = get_current_user(request, db)
if current_user:
return RedirectResponse(url=user_home_url(current_user), status_code=status.HTTP_303_SEE_OTHER)
pending_user, pending_method = get_pending_mfa_user(request, db)
if not pending_user or not pending_method:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/mfa_challenge.html",
build_context(
request,
error=None,
mfa_method=pending_method,
mfa_method_label=MFA_METHOD_LABELS.get(pending_method, "2FA"),
mfa_is_email=pending_method == MFA_METHOD_EMAIL,
),
)
@app.post("/login/mfa", response_class=HTMLResponse)
async def login_mfa_submit(
request: Request,
code: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
user, error = verify_pending_mfa_code(request, db, code)
if error or not user:
pending_user, pending_method = get_pending_mfa_user(request, db)
if not pending_user or not pending_method:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/mfa_challenge.html",
build_context(
request,
error=error or "Ungueltiger Code",
mfa_method=pending_method,
mfa_method_label=MFA_METHOD_LABELS.get(pending_method, "2FA"),
mfa_is_email=pending_method == MFA_METHOD_EMAIL,
),
status_code=status.HTTP_401_UNAUTHORIZED,
)
login_user(request, user)
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
@app.post("/login/mfa/resend", response_class=HTMLResponse)
async def login_mfa_resend(
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
user, pending_method = get_pending_mfa_user(request, db)
if not user or not pending_method:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if pending_method != MFA_METHOD_EMAIL:
return RedirectResponse(url="/login/mfa", status_code=status.HTTP_303_SEE_OTHER)
if email_mfa_resend_cooldown_active(user):
return templates.TemplateResponse(
"pages/mfa_challenge.html",
build_context(
request,
error="Bitte kurz warten, bevor ein neuer Code gesendet wird.",
mfa_method=pending_method,
mfa_method_label=MFA_METHOD_LABELS.get(pending_method, "2FA"),
mfa_is_email=True,
),
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not send_email_mfa_code(db=db, user=user):
return templates.TemplateResponse(
"pages/mfa_challenge.html",
build_context(
request,
error="Neuer Code konnte nicht versendet werden.",
mfa_method=pending_method,
mfa_method_label=MFA_METHOD_LABELS.get(pending_method, "2FA"),
mfa_is_email=True,
),
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return templates.TemplateResponse(
"pages/mfa_challenge.html",
build_context(
request,
success_message="Neuer Code wurde versendet.",
error=None,
mfa_method=pending_method,
mfa_method_label=MFA_METHOD_LABELS.get(pending_method, "2FA"),
mfa_is_email=True,
),
)
@app.post("/logout")
async def logout(
request: Request,
csrf_token: str = Form(default=""),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
logger.info("logout email=%s", user.email)
request.session.clear()
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/verify-email", response_class=HTMLResponse)
async def verify_email(request: Request, token: str = Query(...), db: Session = Depends(get_db)):
user = get_user_by_email_verification_token(db, token)
if not user:
return templates.TemplateResponse(
"pages/email_verification_result.html",
build_context(
request,
success=False,
message="Der Bestätigungslink ist ungültig oder abgelaufen.",
),
status_code=status.HTTP_400_BAD_REQUEST,
)
user.email_verified = True
user.email_verification_token_hash = None
user.email_verification_expires_at = None
user.email_verification_sent_at = None
db.add(user)
db.commit()
logger.info("email_verified email=%s", user.email)
send_registration_email_if_enabled(db=db, user=user)
return RedirectResponse(url="/login?msg=email_verified", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/verify-email/resend", response_class=HTMLResponse)
async def resend_verification_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/email_verification_resend.html",
build_context(request, error=None, success_message=None),
)
@app.post("/verify-email/resend", response_class=HTMLResponse)
async def resend_verification_submit(
request: Request,
email: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
generic_message = "Wenn ein unbestätigtes Konto mit dieser E-Mail existiert, wurde ein neuer Link versendet."
try:
normalized_email = RegisterRequest(email=email, password="validplaceholder123").email.lower()
except Exception:
return templates.TemplateResponse(
"pages/email_verification_resend.html",
build_context(request, error=None, success_message=generic_message),
)
user = find_user_by_email(db, normalized_email)
if user and user.is_active and not user.email_verified and is_email_verification_enabled(db):
sent, reason = send_email_verification_link(request=request, db=db, user=user)
logger.info("email_verification_resend email=%s sent=%s reason=%s", user.email, sent, reason)
return templates.TemplateResponse(
"pages/email_verification_resend.html",
build_context(request, error=None, success_message=generic_message),
)
@app.get("/password-reset/request", response_class=HTMLResponse)
async def password_reset_request_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"pages/password_reset_request.html",
build_context(request, error=None, success_message=None),
)
@app.post("/password-reset/request", response_class=HTMLResponse)
async def password_reset_request_submit(
request: Request,
email: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
generic_message = (
"Wenn ein Konto mit dieser E-Mail existiert und Mailversand aktiv ist, wurde ein Reset-Link versendet."
)
try:
normalized_email = RegisterRequest(email=email, password="validplaceholder123").email.lower()
except Exception:
return templates.TemplateResponse(
"pages/password_reset_request.html",
build_context(request, error=generic_message, success_message=None),
)
user = find_user_by_email(db, normalized_email)
config = get_email_config(db)
if user and user.is_active and config and config.password_reset_mails_enabled:
mail_settings = resolve_mail_settings(db)
if mail_settings:
invalidate_password_reset_tokens(db=db, user_id=user.id)
raw_token = generate_reset_token()
token_hash_value = hash_token(raw_token)
reset_token = PasswordResetToken(
user_id=user.id,
token_hash=token_hash_value,
expires_at=utc_now() + timedelta(minutes=settings.password_reset_token_ttl_minutes),
requested_ip=get_client_ip(request),
)
db.add(reset_token)
db.commit()
base_url = str(request.base_url).rstrip("/")
reset_url = f"{base_url}/password-reset/confirm?token={raw_token}"
try:
send_email(
settings=mail_settings,
to_email=user.email,
subject="Passwort zuruecksetzen",
text_body=(
"Du hast eine Passwort-Zuruecksetzung angefordert.\n\n"
f"Link: {reset_url}\n\n"
f"Der Link ist {settings.password_reset_token_ttl_minutes} Minuten gueltig."
),
)
except Exception:
logger.exception("password_reset_email_failed email=%s", user.email)
return templates.TemplateResponse(
"pages/password_reset_request.html",
build_context(request, error=None, success_message=generic_message),
)
def get_valid_reset_token(db: Session, raw_token: str) -> PasswordResetToken | None:
token_hash_value = hash_token(raw_token)
stmt = select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash_value)
token_row = db.execute(stmt).scalar_one_or_none()
if not token_row:
return None
if token_row.used_at is not None:
return None
if utc_now() > ensure_utc_datetime(token_row.expires_at):
return None
return token_row
def invalidate_password_reset_tokens(
*,
db: Session,
user_id: str,
exclude_token_id: str | None = None,
) -> int:
stmt = select(PasswordResetToken).where(
PasswordResetToken.user_id == user_id,
PasswordResetToken.used_at.is_(None),
)
tokens = db.execute(stmt).scalars().all()
if not tokens:
return 0
invalidated_at = utc_now()
invalidated = 0
for token in tokens:
if exclude_token_id and token.id == exclude_token_id:
continue
token.used_at = invalidated_at
db.add(token)
invalidated += 1
return invalidated
def email_mfa_resend_cooldown_active(user: User) -> bool:
sent_at = user.mfa_email_code_sent_at
if sent_at is None:
return False
return (utc_now() - ensure_utc_datetime(sent_at)) < timedelta(seconds=30)
@app.get("/password-reset/confirm", response_class=HTMLResponse)
async def password_reset_confirm_form(
request: Request,
token: str = Query(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if user:
return RedirectResponse(url=user_home_url(user), status_code=status.HTTP_303_SEE_OTHER)
token_row = get_valid_reset_token(db, token)
if not token_row:
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token="", error="Reset-Link ist ungueltig oder abgelaufen."),
status_code=status.HTTP_400_BAD_REQUEST,
)
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token=token, error=None, success_message=None),
)
@app.post("/password-reset/confirm", response_class=HTMLResponse)
async def password_reset_confirm_submit(
request: Request,
token: str = Form(...),
new_password: str = Form(...),
new_password_repeat: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
verify_csrf(request, csrf_token)
token_row = get_valid_reset_token(db, token)
if not token_row:
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token="", error="Reset-Link ist ungueltig oder abgelaufen."),
status_code=status.HTTP_400_BAD_REQUEST,
)
if new_password != new_password_repeat:
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token=token, error="Passwoerter stimmen nicht ueberein."),
status_code=status.HTTP_400_BAD_REQUEST,
)
if len(new_password) < 10:
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token=token, error="Neues Passwort muss mindestens 10 Zeichen lang sein."),
status_code=status.HTTP_400_BAD_REQUEST,
)
user = db.execute(select(User).where(User.id == token_row.user_id)).scalar_one_or_none()
if not user:
return templates.TemplateResponse(
"pages/password_reset_confirm.html",
build_context(request, token="", error="Benutzer nicht gefunden."),
status_code=status.HTTP_404_NOT_FOUND,
)
user.password_hash = hash_password(new_password)
invalidate_password_reset_tokens(db=db, user_id=user.id, exclude_token_id=token_row.id)
token_row.used_at = utc_now()
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
db.add(user)
db.add(token_row)
db.commit()
return RedirectResponse(url="/login?msg=password_reset_done", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
request: Request,
date_value: str | None = Query(default=None, alias="date"),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if date_value is None and user.preferred_home_view == "month":
return RedirectResponse(
url=f"/month?{urlencode({'view': user.preferred_month_view_mode or 'flat'})}",
status_code=status.HTTP_303_SEE_OTHER,
)
selected_date = parse_date_query(date_value, default=date.today())
week_start, week_end = iso_week_bounds(selected_date)
working_days = get_user_working_days(user)
ensure_user_has_default_target_rule(db, user)
db.commit()
rules = list_rules_for_user(db, user.id)
selected_week_target_minutes = target_for_week(rules, week_start, user.weekly_target_minutes)
auto_created = autofill_entries_for_range(db=db, user=user, range_start=week_start, range_end=week_end)
if auto_created:
db.commit()
week_entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= week_start, TimeEntry.date <= week_end)
.order_by(TimeEntry.date.asc())
)
week_entries = db.execute(week_entries_stmt).scalars().all()
all_entries_until_week_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date <= week_end)
.order_by(TimeEntry.date.asc())
)
all_entries_until_week = db.execute(all_entries_until_week_stmt).scalars().all()
vacations_selected = list_vacations_for_user(db, user.id, week_start, week_end)
vacation_dates_selected = expand_vacation_dates(
vacations_selected,
week_start,
week_end,
relevant_weekdays=working_days,
)
special_statuses_selected = list_special_statuses_for_user(db, user.id, week_start, week_end)
special_dates_selected = effective_non_working_dates_for_user(user=user, special_statuses=special_statuses_selected)
count_as_worktime_dates_selected = count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates_selected,
special_statuses=special_statuses_selected,
)
special_status_by_date = special_status_map(special_statuses_selected)
overtime_adjustments_selected = list_overtime_adjustments_for_user(db, user.id, week_start, week_end)
overtime_adjustments_by_date = overtime_adjustment_map(overtime_adjustments_selected)
week_data = aggregate_week(week_entries, week_start, selected_week_target_minutes)
effective_week_totals = compute_effective_week_totals(
entries=week_entries,
week_start=week_start,
weekly_target_minutes=selected_week_target_minutes,
vacation_dates=vacation_dates_selected,
non_working_dates=special_dates_selected,
count_as_worktime_dates=count_as_worktime_dates_selected,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(overtime_adjustments_selected),
overtime_start_date=user.overtime_start_date,
relevant_weekdays=working_days,
)
week_data["weekly_ist"] = effective_week_totals["weekly_ist"]
week_data["weekly_soll"] = effective_week_totals["weekly_soll"]
week_data["weekly_delta"] = effective_week_totals["weekly_delta"]
for day_info in week_data["days"]:
day_info["is_vacation"] = day_info["date"] in vacation_dates_selected
day_info["special_status"] = special_status_by_date.get(day_info["date"])
day_info["overtime_adjustment_minutes"] = (
overtime_adjustments_by_date[day_info["date"]].minutes if day_info["date"] in overtime_adjustments_by_date else 0
)
vacations_until_week = list_vacations_for_user(db, user.id, date(1970, 1, 1), week_end)
special_until_week = list_special_statuses_for_user(db, user.id, date(1970, 1, 1), week_end)
vacation_dates_until_week = expand_vacation_dates(
vacations_until_week,
date(1970, 1, 1),
week_end,
relevant_weekdays=working_days,
)
overtime_adjustments_until_week = list_overtime_adjustments_for_user(db, user.id, date(1970, 1, 1), week_end)
week_data["cumulative_delta"] = compute_cumulative_overtime_minutes(
entries=all_entries_until_week,
rules=rules,
weekly_target_fallback=user.weekly_target_minutes,
vacation_periods=vacations_until_week,
non_working_dates=effective_non_working_dates_for_user(user=user, special_statuses=special_until_week),
count_as_worktime_dates=count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates_until_week,
special_statuses=special_until_week,
),
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(overtime_adjustments_until_week),
selected_week_start=week_start,
overtime_start_date=user.overtime_start_date,
overtime_expiry_days=user.overtime_expiry_days,
expire_negative_overtime=user.expire_negative_overtime,
relevant_weekdays=working_days,
)
week_data["is_vacation_week"] = range_is_full_vacation(
week_start,
week_end,
vacation_dates=vacation_dates_selected,
relevant_weekdays=working_days,
)
previous_week = week_start - timedelta(days=7)
next_week = week_start + timedelta(days=7)
workhours_target_warning = build_workhours_target_warning(db=db, user=user)
maybe_send_workhours_target_warning_email(db=db, user=user, warning=workhours_target_warning)
return templates.TemplateResponse(
"pages/dashboard.html",
build_context(
request,
user=user,
db=db,
week=week_data,
selected_date=selected_date,
previous_week=previous_week,
next_week=next_week,
workhours_target_warning=workhours_target_warning,
),
)
@app.post("/weekly-target")
async def change_weekly_target(
request: Request,
week_start_value: str = Form(..., alias="week_start"),
weekly_target_hours: float = Form(...),
scope: str = Form(...),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
parsed_scope = parse_weekly_target_scope(scope)
selected_week_start = parse_date_query(week_start_value)
new_target_minutes = int(round(weekly_target_hours * 60))
if new_target_minutes <= 0:
raise HTTPException(status_code=400, detail="Wochen-Soll muss groesser als 0 sein")
ensure_user_has_default_target_rule(db, user)
apply_weekly_target_change(
db,
user=user,
selected_week_start=selected_week_start,
new_target_minutes=new_target_minutes,
scope=parsed_scope,
)
db.commit()
logger.info(
"weekly_target_updated email=%s week_start=%s scope=%s minutes=%s",
user.email,
selected_week_start.isoformat(),
parsed_scope,
new_target_minutes,
)
destination = return_to if return_to.startswith("/") else "/dashboard"
base_path, _, existing_query = destination.partition("?")
query_params: dict[str, str] = {}
if existing_query:
for part in existing_query.split("&"):
if not part:
continue
key, sep, value = part.partition("=")
query_params[key] = value if sep else ""
if base_path.startswith("/dashboard"):
query_params.setdefault("date", selected_week_start.isoformat())
query_params["target_updated"] = "1"
url = f"{base_path}?{urlencode(query_params)}"
return RedirectResponse(url=url, status_code=status.HTTP_303_SEE_OTHER)
def get_entry_or_404(db: Session, user_id: str, entry_id: str) -> TimeEntry:
stmt = select(TimeEntry).where(TimeEntry.id == entry_id, TimeEntry.user_id == user_id)
entry = db.execute(stmt).scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden")
return entry
@app.get("/entry/new", response_class=HTMLResponse)
async def new_entry_form(
request: Request,
date_value: str | None = Query(default=None, alias="date"),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
selected_date = parse_date_query(date_value, default=date.today())
return_to = resolve_return_to(request, fallback=f"/dashboard?date={selected_date.isoformat()}")
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Tag hinzufuegen",
action_url="/entry/new",
entry={
"break_mode": "auto" if auto_break_rules_enabled(user) else "manual",
"break_minutes": automatic_break_minutes(start_minutes=8 * 60 + 30, end_minutes=15 * 60)
if auto_break_rules_enabled(user)
else default_break_minutes_for_user(user),
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=selected_date),
selected_date=selected_date,
return_to=return_to,
error=None,
),
)
@app.post("/entry/new", response_class=HTMLResponse)
async def new_entry_submit(
request: Request,
date_value: str = Form(..., alias="date"),
start_time: str = Form(...),
end_time: str = Form(...),
break_minutes: int = Form(default=0),
break_mode: str = Form(default="manual"),
notes: str = Form(default=""),
return_to: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
payload = TimeEntryCreate(
date=parse_date_query(date_value),
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes or None,
)
start_minutes = parse_time_to_minutes(payload.start_time)
end_minutes = parse_time_to_minutes(payload.end_time)
effective_break_minutes, effective_break_mode = resolve_break_settings(
user=user,
start_minutes=start_minutes,
end_minutes=end_minutes,
submitted_break_minutes=payload.break_minutes,
submitted_break_mode=payload.break_mode,
)
compute_net_minutes(start_minutes, end_minutes, effective_break_minutes)
except Exception as exc:
selected_date = parse_date_fallback_today(date_value)
safe_return_to = return_to if return_to.startswith("/") else resolve_return_to(
request, fallback=f"/dashboard?date={selected_date.isoformat()}"
)
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Tag hinzufuegen",
action_url="/entry/new",
entry={
"date": date_value,
"start_time": start_time,
"end_time": end_time,
"break_minutes": break_minutes,
"break_mode": break_mode,
"notes": notes,
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=selected_date),
selected_date=selected_date,
return_to=safe_return_to,
error=str(exc),
),
status_code=status.HTTP_400_BAD_REQUEST,
)
entry = TimeEntry(
user_id=user.id,
date=payload.date,
start_minutes=start_minutes,
end_minutes=end_minutes,
break_minutes=effective_break_minutes,
break_rule_mode=effective_break_mode,
notes=payload.notes,
)
db.add(entry)
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=payload.date)
clear_special_status_for_date(db=db, user_id=user.id, day=payload.date)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=payload.date)
try:
db.commit()
except IntegrityError:
db.rollback()
selected_date = parse_date_fallback_today(date_value)
safe_return_to = return_to if return_to.startswith("/") else resolve_return_to(
request, fallback=f"/dashboard?date={selected_date.isoformat()}"
)
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Tag hinzufuegen",
action_url="/entry/new",
entry={
"date": date_value,
"start_time": start_time,
"end_time": end_time,
"break_minutes": break_minutes,
"break_mode": break_mode,
"notes": notes,
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=selected_date),
selected_date=selected_date,
return_to=safe_return_to,
error="Es existiert bereits ein Eintrag fuer dieses Datum.",
),
status_code=status.HTTP_409_CONFLICT,
)
destination = return_to if return_to.startswith("/") else f"/dashboard?{urlencode({'date': payload.date.isoformat()})}"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/entry/{entry_id}/edit", response_class=HTMLResponse)
async def edit_entry_form(entry_id: str, request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
entry = get_entry_or_404(db, user.id, entry_id)
return_to = resolve_return_to(request, fallback=f"/dashboard?date={entry.date.isoformat()}")
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Eintrag bearbeiten",
action_url=f"/entry/{entry.id}/edit",
entry={
"date": entry.date.isoformat(),
"start_time": minutes_to_hhmm(entry.start_minutes),
"end_time": minutes_to_hhmm(entry.end_minutes),
"break_minutes": entry.break_minutes,
"break_mode": entry.break_rule_mode,
"notes": entry.notes or "",
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=entry.date),
selected_date=entry.date,
return_to=return_to,
error=None,
),
)
@app.post("/entry/{entry_id}/edit", response_class=HTMLResponse)
async def edit_entry_submit(
entry_id: str,
request: Request,
date_value: str = Form(..., alias="date"),
start_time: str = Form(...),
end_time: str = Form(...),
break_minutes: int = Form(default=0),
break_mode: str = Form(default="manual"),
notes: str = Form(default=""),
return_to: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
entry = get_entry_or_404(db, user.id, entry_id)
try:
payload = TimeEntryCreate(
date=parse_date_query(date_value),
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes or None,
)
start_minutes = parse_time_to_minutes(payload.start_time)
end_minutes = parse_time_to_minutes(payload.end_time)
effective_break_minutes, effective_break_mode = resolve_break_settings(
user=user,
start_minutes=start_minutes,
end_minutes=end_minutes,
submitted_break_minutes=payload.break_minutes,
submitted_break_mode=payload.break_mode,
existing_break_mode=entry.break_rule_mode,
existing_break_minutes=entry.break_minutes,
start_or_end_changed=(start_minutes != entry.start_minutes or end_minutes != entry.end_minutes),
)
compute_net_minutes(start_minutes, end_minutes, effective_break_minutes)
except Exception as exc:
selected_date = parse_date_fallback_today(date_value)
safe_return_to = return_to if return_to.startswith("/") else resolve_return_to(
request, fallback=f"/dashboard?date={selected_date.isoformat()}"
)
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Eintrag bearbeiten",
action_url=f"/entry/{entry.id}/edit",
entry={
"date": date_value,
"start_time": start_time,
"end_time": end_time,
"break_minutes": break_minutes,
"break_mode": break_mode,
"notes": notes,
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=selected_date),
selected_date=selected_date,
return_to=safe_return_to,
error=str(exc),
),
status_code=status.HTTP_400_BAD_REQUEST,
)
entry.date = payload.date
entry.start_minutes = start_minutes
entry.end_minutes = end_minutes
entry.break_minutes = effective_break_minutes
entry.break_rule_mode = effective_break_mode
entry.notes = payload.notes
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=payload.date)
clear_special_status_for_date(db=db, user_id=user.id, day=payload.date)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=payload.date)
try:
db.commit()
except IntegrityError:
db.rollback()
selected_date = parse_date_fallback_today(date_value)
safe_return_to = return_to if return_to.startswith("/") else resolve_return_to(
request, fallback=f"/dashboard?date={selected_date.isoformat()}"
)
return templates.TemplateResponse(
"pages/entry_form.html",
build_context(
request,
user=user,
db=db,
title="Eintrag bearbeiten",
action_url=f"/entry/{entry.id}/edit",
entry={
"date": date_value,
"start_time": start_time,
"end_time": end_time,
"break_minutes": break_minutes,
"break_mode": break_mode,
"notes": notes,
},
full_day_net_minutes=full_day_work_minutes_or_none(db=db, user=user, selected_date=selected_date),
selected_date=selected_date,
return_to=safe_return_to,
error="Es existiert bereits ein Eintrag fuer dieses Datum.",
),
status_code=status.HTTP_409_CONFLICT,
)
destination = return_to if return_to.startswith("/") else f"/dashboard?{urlencode({'date': entry.date.isoformat()})}"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.post("/entry/{entry_id}/delete")
async def delete_entry(
entry_id: str,
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
entry = get_entry_or_404(db, user.id, entry_id)
selected_date = entry.date
db.delete(entry)
if user.entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY and selected_date <= date.today():
mark_auto_entry_skip_for_date(db=db, user_id=user.id, day=selected_date)
db.commit()
dashboard_url = f"/dashboard?{urlencode({'date': selected_date.isoformat()})}"
return RedirectResponse(url=dashboard_url, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/day-status/edit", response_class=HTMLResponse)
async def edit_day_status_form(
request: Request,
date_value: str | None = Query(default=None, alias="date"),
status_value: str = Query(..., alias="status"),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
selected_date = parse_date_query(date_value, default=date.today())
status_key = parse_day_status_mode(status_value)
existing_entry_stmt = select(TimeEntry).where(TimeEntry.user_id == user.id, TimeEntry.date == selected_date)
existing_entry = db.execute(existing_entry_stmt).scalar_one_or_none()
day_is_vacation, day_special_status = day_status_for_user(db=db, user=user, selected_date=selected_date)
active_status_key = current_day_status_key(is_vacation=day_is_vacation, special_status=day_special_status)
day_adjustment = overtime_adjustment_map(
list_overtime_adjustments_for_user(db, user.id, selected_date, selected_date)
).get(selected_date)
return_to = resolve_return_to(request, fallback=f"/dashboard?date={selected_date.isoformat()}")
if status_key == DAY_STATUS_QUERY_VACATION:
action_url = "/vacation/day/toggle"
else:
action_url = "/special-day/toggle"
return templates.TemplateResponse(
"pages/day_status_form.html",
build_context(
request,
user=user,
db=db,
title=DAY_STATUS_QUERY_LABELS[status_key],
selected_date=selected_date,
status_key=status_key,
action_url=action_url,
is_active=active_status_key == status_key,
current_status_key=active_status_key,
current_status_label=DAY_STATUS_QUERY_LABELS.get(active_status_key) if active_status_key else None,
day_overtime_adjustment_minutes=day_adjustment.minutes if day_adjustment else 0,
has_entry=existing_entry is not None,
existing_entry_id=existing_entry.id if existing_entry else None,
return_to=return_to,
),
)
@app.get("/overtime-adjustment/edit", response_class=HTMLResponse)
async def edit_overtime_adjustment_form(
request: Request,
date_value: str | None = Query(default=None, alias="date"),
overtime_error: str | None = Query(default=None),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
selected_date = parse_date_query(date_value, default=date.today())
existing_entry_stmt = select(TimeEntry).where(TimeEntry.user_id == user.id, TimeEntry.date == selected_date)
existing_entry = db.execute(existing_entry_stmt).scalar_one_or_none()
day_is_vacation, day_special_status = day_status_for_user(db=db, user=user, selected_date=selected_date)
day_adjustment = overtime_adjustment_map(
list_overtime_adjustments_for_user(db, user.id, selected_date, selected_date)
).get(selected_date)
return_to = resolve_return_to(request, fallback=f"/dashboard?date={selected_date.isoformat()}")
return templates.TemplateResponse(
"pages/overtime_adjustment_form.html",
build_context(
request,
user=user,
db=db,
title=OVERTIME_ADJUSTMENT_LABEL,
selected_date=selected_date,
day_is_vacation=day_is_vacation,
day_special_status=day_special_status,
day_overtime_adjustment_minutes=day_adjustment.minutes if day_adjustment else 0,
overtime_adjustment_error=overtime_error,
has_entry=existing_entry is not None,
existing_entry_id=existing_entry.id if existing_entry else None,
return_to=return_to,
),
)
@app.get("/month", response_class=HTMLResponse)
async def month_view(
request: Request,
month: str | None = Query(default=None),
view_mode: str | None = Query(default=None, alias="view"),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ensure_user_has_default_target_rule(db, user)
db.commit()
if view_mode is None:
view_mode = user.preferred_month_view_mode or "flat"
if view_mode not in {"flat"}:
view_mode = "flat"
if month:
try:
month_date = datetime.strptime(month, "%Y-%m").date()
except ValueError as exc:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein") from exc
else:
today = date.today()
month_date = date(today.year, today.month, 1)
month_start = date(month_date.year, month_date.month, 1)
if month_start.month == 12:
next_month = date(month_start.year + 1, 1, 1)
else:
next_month = date(month_start.year, month_start.month + 1, 1)
month_end = next_month - timedelta(days=1)
working_days = get_user_working_days(user)
auto_created = autofill_entries_for_range(db=db, user=user, range_start=month_start, range_end=month_end)
if auto_created:
db.commit()
stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= month_start, TimeEntry.date <= month_end)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(stmt).scalars().all()
entries_by_date = {entry.date: entry for entry in entries}
displayed_week_starts: set[date] = set()
current = month_start
while current <= month_end:
week_start = monday_of(current)
displayed_week_starts.add(week_start)
current += timedelta(days=1)
ordered_week_starts = sorted(displayed_week_starts)
full_display_start = min(ordered_week_starts)
full_display_end = max(ordered_week_starts) + timedelta(days=6)
rules = list_rules_for_user(db, user.id)
month_vacations = list_vacations_for_user(db, user.id, month_start, month_end)
month_vacation_dates = expand_vacation_dates(
month_vacations,
month_start,
month_end,
relevant_weekdays=working_days,
)
display_vacations = list_vacations_for_user(db, user.id, full_display_start, full_display_end)
display_vacation_dates = expand_vacation_dates(
display_vacations,
full_display_start,
full_display_end,
relevant_weekdays=working_days,
)
month_special_statuses = list_special_statuses_for_user(db, user.id, month_start, month_end)
month_special_dates = effective_non_working_dates_for_user(user=user, special_statuses=month_special_statuses)
month_count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=month_vacation_dates,
special_statuses=month_special_statuses,
)
display_special_statuses = list_special_statuses_for_user(db, user.id, full_display_start, full_display_end)
display_special_status_map = special_status_map(display_special_statuses)
display_overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, full_display_start, full_display_end)
display_overtime_adjustment_map = overtime_adjustment_map(display_overtime_adjustments)
month_overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, month_start, month_end)
month_ist = 0
month_soll = 0
month_delta = 0
weeks: list[dict] = []
for week_start in ordered_week_starts:
week_end = week_start + timedelta(days=6)
visible_start = max(week_start, month_start)
visible_end = min(week_end, month_end)
weekly_target_minutes = target_for_week(rules, week_start, user.weekly_target_minutes)
week_totals = compute_effective_span_totals(
entries=entries,
range_start=visible_start,
range_end=visible_end,
weekly_target_minutes=weekly_target_minutes,
vacation_dates=month_vacation_dates,
non_working_dates=month_special_dates,
count_as_worktime_dates=month_count_as_worktime_dates,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(month_overtime_adjustments),
overtime_start_date=user.overtime_start_date,
relevant_weekdays=working_days,
)
weekly_ist = week_totals["ist_minutes"]
weekly_soll = week_totals["soll_minutes"]
weekly_delta = week_totals["delta_minutes"]
vacation_days_visible = week_totals["vacation_workdays"]
month_ist += weekly_ist
month_soll += weekly_soll
month_delta += weekly_delta
week_days = []
cursor = visible_start
while cursor <= visible_end:
entry = entries_by_date.get(cursor)
if entry:
net = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
week_days.append(
{
"date": cursor,
"entry": entry,
"net_minutes": net,
"is_weekend": cursor.weekday() >= 5,
"is_vacation": cursor in display_vacation_dates,
"special_status": display_special_status_map.get(cursor),
"overtime_adjustment_minutes": (
display_overtime_adjustment_map[cursor].minutes if cursor in display_overtime_adjustment_map else 0
),
}
)
else:
week_days.append(
{
"date": cursor,
"entry": None,
"net_minutes": 0,
"is_weekend": cursor.weekday() >= 5,
"is_vacation": cursor in display_vacation_dates,
"special_status": display_special_status_map.get(cursor),
"overtime_adjustment_minutes": (
display_overtime_adjustment_map[cursor].minutes if cursor in display_overtime_adjustment_map else 0
),
}
)
cursor += timedelta(days=1)
weeks.append(
{
"week_start": week_start,
"week_end": week_end,
"iso_week": week_start.isocalendar()[1],
"days": week_days,
"weekly_ist": weekly_ist,
"weekly_soll": weekly_soll,
"weekly_delta": weekly_delta,
"vacation_days": vacation_days_visible,
"is_vacation_week": range_is_full_vacation(
week_start,
week_end,
vacation_dates=display_vacation_dates,
relevant_weekdays=working_days,
),
}
)
previous_month = (month_start.replace(day=1) - timedelta(days=1)).replace(day=1)
next_month_value = next_month
workhours_target_warning = build_workhours_target_warning(db=db, user=user)
maybe_send_workhours_target_warning_email(db=db, user=user, warning=workhours_target_warning)
return templates.TemplateResponse(
"pages/month.html",
build_context(
request,
user=user,
db=db,
month_start=month_start,
month_end=month_end,
month_value=month_start.strftime("%Y-%m"),
view_mode=view_mode,
weeks=weeks,
month_ist=month_ist,
month_soll=month_soll,
month_delta=month_delta,
previous_month=previous_month,
next_month=next_month_value,
monthly_soll_mode="summe_anteilig_nach_monatstagen",
workhours_target_warning=workhours_target_warning,
),
)
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
msg: str | None = Query(default=None),
tab: str = Query(default="settings"),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
success_message = None
error_message = None
if msg == "profile_updated":
success_message = "Profil aktualisiert."
elif msg == "password_updated":
success_message = "Passwort aktualisiert."
elif msg == "preferences_updated":
success_message = "Einstellungen gespeichert."
elif msg == "vacation_added":
success_message = "Urlaubszeitraum hinzugefuegt."
elif msg == "vacation_deleted":
success_message = "Urlaubszeitraum entfernt."
elif msg == "overtime_updated":
success_message = "Ueberstunden-Regeln gespeichert."
elif msg == "workdays_updated":
success_message = "Arbeitstage gespeichert."
elif msg == "vacation_allowance_updated":
success_message = "Urlaubstage gespeichert."
elif msg == "workhours_counter_updated":
success_message = "Arbeitsstunden-Counter gespeichert."
elif msg == "weekly_target_updated":
success_message = "Wochenstunden gespeichert."
elif msg == "mfa_updated":
success_message = "2FA-Einstellungen gespeichert."
elif msg == "admin_user_updated":
success_message = "Benutzer aktualisiert."
elif msg == "admin_user_deleted":
success_message = "Benutzer geloescht."
elif msg == "account_deleted":
success_message = "Dein Konto und alle zugehörigen Daten wurden gelöscht."
elif msg == "smtp_updated":
success_message = "E-Mail-Server gespeichert."
elif msg == "smtp_test_sent":
success_message = "Testmail wurde versendet."
elif msg == "site_content_updated":
success_message = "Impressum und Datenschutz wurden gespeichert."
elif msg == "ticket_updated":
success_message = "Ticket aktualisiert."
elif msg == "mfa_setup_required":
success_message = "Bitte 2FA in den Sicherheitseinstellungen mit einem Setup-Code abschließen."
elif msg == "mfa_email_unavailable":
error_message = "E-Mail-2FA konnte nicht aktiviert werden, da kein Mailserver verfügbar ist."
return render_settings_form(
request,
db=db,
user=user,
active_tab=tab,
success_message=success_message,
error=error_message,
)
@app.post("/settings/profile")
async def settings_update_profile(
request: Request,
email: str = Form(...),
federal_state: str = Form(default=""),
current_password: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
if not verify_password(current_password, user.password_hash):
return render_settings_form(
request,
db=db,
user=user,
error="Aktuelles Passwort ist ungültig.",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
payload = RegisterRequest(email=email, password="validplaceholder123")
except Exception:
return render_settings_form(
request,
db=db,
user=user,
error="Ungültige E-Mail-Adresse.",
status_code=status.HTTP_400_BAD_REQUEST,
)
existing = find_user_by_email(db, payload.email)
if existing and existing.id != user.id:
return render_settings_form(
request,
db=db,
user=user,
error="E-Mail ist bereits vergeben.",
status_code=status.HTTP_409_CONFLICT,
)
normalized_state = normalize_german_state_code(federal_state)
if federal_state.strip() and normalized_state is None:
return render_settings_form(
request,
db=db,
user=user,
error="Ungültiges Bundesland ausgewählt.",
status_code=status.HTTP_400_BAD_REQUEST,
)
new_email = payload.email.lower()
email_changed = new_email != user.email.lower()
verification_required_for_new_email = email_changed and is_email_verification_enabled(db)
user.email = new_email
user.federal_state = normalized_state
if email_changed:
user.email_verification_token_hash = None
user.email_verification_expires_at = None
user.email_verification_sent_at = None
if verification_required_for_new_email:
user.email_verified = False
sync_start = date.today().replace(month=1, day=1) - timedelta(days=366)
sync_end = date.today().replace(month=12, day=31) + timedelta(days=730)
try:
sync_auto_holidays_for_user(db=db, user=user, from_date=sync_start, to_date=sync_end)
except Exception:
logger.exception("holiday_sync_failed email=%s state=%s", user.email, user.federal_state)
return render_settings_form(
request,
db=db,
user=user,
error="Feiertage konnten nicht automatisch synchronisiert werden.",
status_code=status.HTTP_502_BAD_GATEWAY,
)
db.commit()
if verification_required_for_new_email:
sent, reason = send_email_verification_link(request=request, db=db, user=user, force=True)
logger.info("profile_email_changed_requires_verification email=%s sent=%s reason=%s", user.email, sent, reason)
request.session.clear()
if not sent:
return RedirectResponse(url="/login?msg=email_verification_send_failed", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/login?msg=email_verification_sent", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/settings?msg=profile_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/password")
async def settings_update_password(
request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
new_password_repeat: str = Form(...),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
if not verify_password(current_password, user.password_hash):
return render_settings_form(
request,
db=db,
user=user,
error="Aktuelles Passwort ist ungueltig.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if new_password != new_password_repeat:
return render_settings_form(
request,
db=db,
user=user,
error="Neue Passwoerter stimmen nicht ueberein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if len(new_password) < 10:
return render_settings_form(
request,
db=db,
user=user,
error="Neues Passwort muss mindestens 10 Zeichen haben.",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.password_hash = hash_password(new_password)
invalidate_password_reset_tokens(db=db, user_id=user.id)
db.commit()
return RedirectResponse(url="/settings?msg=password_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/mfa")
async def settings_update_mfa(
request: Request,
mfa_method: str = Form(...),
current_password: str = Form(...),
setup_code: str = Form(default=""),
regenerate_totp: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
if not verify_password(current_password, user.password_hash):
return render_settings_form(
request,
db=db,
user=user,
error="Aktuelles Passwort ist ungueltig.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if mfa_method not in {MFA_METHOD_NONE, MFA_METHOD_TOTP, MFA_METHOD_EMAIL}:
return render_settings_form(
request,
db=db,
user=user,
error="Ungueltige MFA-Methode.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if mfa_method == MFA_METHOD_NONE:
user.mfa_method = MFA_METHOD_NONE
user.mfa_totp_secret_encrypted = None
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
request.session.pop("mfa_setup_secret", None)
db.add(user)
db.commit()
return RedirectResponse(url="/settings?msg=mfa_updated", status_code=status.HTTP_303_SEE_OTHER)
if mfa_method == MFA_METHOD_EMAIL:
if not resolve_mail_settings(db):
return render_settings_form(
request,
db=db,
user=user,
error="E-Mail-Server ist nicht konfiguriert. E-Mail-2FA kann nicht aktiviert werden.",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.mfa_method = MFA_METHOD_EMAIL
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
request.session.pop("mfa_setup_secret", None)
db.add(user)
db.commit()
return RedirectResponse(url="/settings?msg=mfa_updated", status_code=status.HTTP_303_SEE_OTHER)
# TOTP flow
if regenerate_totp == "on":
request.session.pop("mfa_setup_secret", None)
user.mfa_totp_secret_encrypted = None
user.mfa_method = MFA_METHOD_NONE
db.add(user)
db.commit()
setup_secret = request.session.get("mfa_setup_secret")
if not setup_secret and user.mfa_totp_secret_encrypted:
# Secret exists already: method can be toggled directly.
user.mfa_method = MFA_METHOD_TOTP
db.add(user)
db.commit()
return RedirectResponse(url="/settings?msg=mfa_updated", status_code=status.HTTP_303_SEE_OTHER)
if not setup_secret:
setup_secret = generate_totp_secret()
request.session["mfa_setup_secret"] = setup_secret
return render_settings_form(
request,
db=db,
user=user,
error="TOTP-Schluessel erstellt. Bitte in Authenticator-App hinterlegen und Code bestaetigen.",
status_code=status.HTTP_200_OK,
)
if not setup_code.strip():
return render_settings_form(
request,
db=db,
user=user,
error="Bitte den 6-stelligen Code aus der Authenticator-App eingeben.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if not verify_totp_code(secret=setup_secret, code=setup_code):
return render_settings_form(
request,
db=db,
user=user,
error="Authenticator-Code ist ungueltig.",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.mfa_totp_secret_encrypted = encrypt_secret(fernet, setup_secret)
user.mfa_method = MFA_METHOD_TOTP
user.mfa_email_code_hash = None
user.mfa_email_code_expires_at = None
request.session.pop("mfa_setup_secret", None)
db.add(user)
db.commit()
return RedirectResponse(url="/settings?msg=mfa_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/users/{target_user_id}")
async def admin_update_user(
target_user_id: str,
request: Request,
role: str = Form(...),
is_active: str | None = Form(default=None),
reset_mfa: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
if role not in {"user", "admin"}:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Ungueltige Rolle.",
status_code=status.HTTP_400_BAD_REQUEST,
)
target_user = db.execute(select(User).where(User.id == target_user_id)).scalar_one_or_none()
if not target_user:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Benutzer nicht gefunden.",
status_code=status.HTTP_404_NOT_FOUND,
)
target_is_active = is_active == "on"
current_admin_count = count_admin_users(db)
if target_user.role == "admin":
removing_admin_role = role != "admin"
deactivating_admin = not target_is_active
if (removing_admin_role or deactivating_admin) and current_admin_count <= 1:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Mindestens ein aktiver Admin muss erhalten bleiben.",
status_code=status.HTTP_400_BAD_REQUEST,
)
target_user.role = role
target_user.is_active = target_is_active
if reset_mfa == "on":
target_user.mfa_method = MFA_METHOD_NONE
target_user.mfa_totp_secret_encrypted = None
target_user.mfa_email_code_hash = None
target_user.mfa_email_code_expires_at = None
db.add(target_user)
db.commit()
return RedirectResponse(url="/settings?tab=admin&msg=admin_user_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/users/{target_user_id}/delete")
async def admin_delete_user(
target_user_id: str,
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
target_user = db.execute(select(User).where(User.id == target_user_id)).scalar_one_or_none()
if not target_user:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Benutzer nicht gefunden.",
status_code=status.HTTP_404_NOT_FOUND,
)
if target_user.id == admin_user.id:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Du kannst deinen eigenen Account nicht loeschen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
current_admin_count = count_admin_users(db)
if target_user.role == "admin" and target_user.is_active and current_admin_count <= 1:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Mindestens ein aktiver Admin muss erhalten bleiben.",
status_code=status.HTTP_400_BAD_REQUEST,
)
db.delete(target_user)
db.commit()
return RedirectResponse(url="/settings?tab=admin&msg=admin_user_deleted", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/email-server")
async def admin_update_email_server(
request: Request,
smtp_host: str = Form(default=""),
smtp_port_value: str = Form(default="587", alias="smtp_port"),
smtp_username: str = Form(default=""),
smtp_password: str = Form(default=""),
from_email: str = Form(default=""),
from_name: str = Form(default=""),
use_starttls: str | None = Form(default=None),
use_ssl: str | None = Form(default=None),
verify_tls: str | None = Form(default=None),
registration_mails_enabled: str | None = Form(default=None),
password_reset_mails_enabled: str | None = Form(default=None),
registration_admin_notify_enabled: str | None = Form(default=None),
registration_admin_notify_admin_ids: list[str] = Form(default=[], alias="registration_admin_notify_admin_ids"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
try:
smtp_port = int(smtp_port_value)
except ValueError:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="SMTP-Port muss numerisch sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if smtp_port < 1 or smtp_port > 65535:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="SMTP-Port ausserhalb des gueltigen Bereichs (1-65535).",
status_code=status.HTTP_400_BAD_REQUEST,
)
use_starttls_value = use_starttls == "on"
use_ssl_value = use_ssl == "on"
if use_starttls_value and use_ssl_value:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Bitte nur STARTTLS oder SMTP-SSL aktivieren, nicht beides gleichzeitig.",
status_code=status.HTTP_400_BAD_REQUEST,
)
host = smtp_host.strip()
sender = from_email.strip().lower()
if not host or not sender:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="SMTP-Host und Absender-E-Mail sind erforderlich.",
status_code=status.HTTP_400_BAD_REQUEST,
)
config = get_email_config(db)
if not config:
config = EmailServerConfig()
db.add(config)
db.flush()
config.smtp_host = host
config.smtp_port = smtp_port
config.smtp_username = smtp_username.strip() or None
if smtp_password.strip():
config.smtp_password_encrypted = encrypt_secret(fernet, smtp_password.strip())
config.from_email = sender
config.from_name = from_name.strip() or settings.app_name
config.use_starttls = use_starttls_value
config.use_ssl = use_ssl_value
config.verify_tls = verify_tls == "on"
config.registration_mails_enabled = registration_mails_enabled == "on"
config.password_reset_mails_enabled = password_reset_mails_enabled == "on"
config.registration_admin_notify_enabled = registration_admin_notify_enabled == "on"
selected_admin_ids = parse_admin_id_csv(",".join(registration_admin_notify_admin_ids))
if selected_admin_ids:
active_admin_ids_stmt = select(User.id).where(User.role == "admin", User.is_active.is_(True))
active_admin_ids = set(db.execute(active_admin_ids_stmt).scalars().all())
invalid_selection = [admin_id for admin_id in selected_admin_ids if admin_id not in active_admin_ids]
if invalid_selection:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Ausgewaehlte Admin-Empfaenger sind ungueltig oder nicht mehr aktiv.",
status_code=status.HTTP_400_BAD_REQUEST,
)
config.registration_admin_notify_admin_ids_csv = ",".join(selected_admin_ids) if selected_admin_ids else None
config.updated_by_user_id = admin_user.id
db.add(config)
db.commit()
return RedirectResponse(url="/settings?tab=admin&msg=smtp_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/email-server/test")
async def admin_send_test_email(
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
mail_settings = resolve_mail_settings(db)
if not mail_settings:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="E-Mail-Server ist nicht vollstaendig konfiguriert.",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
send_email(
settings=mail_settings,
to_email=admin_user.email,
subject=f"{settings.app_name} Testmail",
text_body="Diese Testmail bestaetigt die SMTP-Konfiguration.",
)
except Exception as exc:
logger.exception("smtp_test_mail_failed admin=%s", admin_user.email)
error_message = "Testmail konnte nicht versendet werden."
if "WRONG_VERSION_NUMBER" in str(exc):
error_message = (
"TLS-Modus passt nicht zum SMTP-Server. Bei Port 587 bitte STARTTLS aktivieren "
"und SMTP-SSL deaktivieren."
)
elif exc.__class__.__name__ == "SMTPAuthenticationError":
error_message = (
"SMTP-Anmeldung fehlgeschlagen. Bitte Benutzername/Passwort oder SMTP-Policy prüfen."
)
elif isinstance(exc, TimeoutError) or "timed out" in str(exc).lower():
error_message = (
"SMTP-Server nicht erreichbar (Timeout). Bitte Host/Port/Firewall/IP-Block prüfen."
)
elif exc.__class__.__name__ == "SMTPServerDisconnected":
error_message = (
"SMTP-Verbindung wurde vom Server beendet. Bitte TLS-Modus und Server-Logs prüfen."
)
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error=error_message,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return RedirectResponse(url="/settings?tab=admin&msg=smtp_test_sent", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/site-content")
async def admin_update_site_content(
request: Request,
impressum_markdown: str = Form(default=""),
privacy_markdown: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
upsert_site_content(
db=db,
key=SITE_CONTENT_IMPRESSUM,
markdown_text=impressum_markdown,
updated_by_user_id=admin_user.id,
)
upsert_site_content(
db=db,
key=SITE_CONTENT_PRIVACY,
markdown_text=privacy_markdown,
updated_by_user_id=admin_user.id,
)
db.commit()
return RedirectResponse(url="/settings?tab=admin&msg=site_content_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/admin/tickets/{ticket_id}")
async def admin_update_ticket(
request: Request,
ticket_id: str,
status_value: str = Form(..., alias="status"),
admin_notes: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
admin_user = require_admin(request, db)
verify_csrf(request, csrf_token)
if status_value not in {SUPPORT_TICKET_STATUS_OPEN, SUPPORT_TICKET_STATUS_CLOSED}:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Ungültiger Ticketstatus.",
status_code=status.HTTP_400_BAD_REQUEST,
)
ticket = db.get(SupportTicket, ticket_id)
if ticket is None:
return render_settings_form(
request,
db=db,
user=admin_user,
active_tab="admin",
error="Ticket wurde nicht gefunden.",
status_code=status.HTTP_404_NOT_FOUND,
)
normalized_notes = (admin_notes or "").strip()[:4000]
ticket.status = status_value
ticket.admin_notes = normalized_notes or None
ticket.closed_at = utc_now() if status_value == SUPPORT_TICKET_STATUS_CLOSED else None
db.add(ticket)
db.commit()
return RedirectResponse(url="/settings?tab=admin&msg=ticket_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/preferences")
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(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
if preferred_home_view not in {"week", "month"}:
return render_settings_form(
request,
db=db,
user=user,
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,
db=db,
user=user,
error="Ungueltige Monatsansicht.",
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,
)
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
user.entry_mode = new_entry_mode
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=preferences_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/workdays")
async def settings_update_workdays(
request: Request,
working_days_values: list[str] = Form(default=[], alias="working_days"),
count_vacation_as_worktime: str | None = Form(default=None),
count_holiday_as_worktime: str | None = Form(default=None),
count_sick_as_worktime: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
working_days = parse_weekday_values(working_days_values)
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
if not working_days:
return render_settings_form(
request,
db=db,
user=user,
error="Bitte mindestens einen Arbeitstag auswaehlen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.working_days_csv = serialize_working_days(working_days)
user.count_vacation_as_worktime = count_vacation_as_worktime == "on"
user.count_holiday_as_worktime = count_holiday_as_worktime == "on"
user.count_sick_as_worktime = count_sick_as_worktime == "on"
sync_start = date.today().replace(month=1, day=1) - timedelta(days=366)
sync_end = date.today().replace(month=12, day=31) + timedelta(days=730)
try:
sync_auto_holidays_for_user(db=db, user=user, from_date=sync_start, to_date=sync_end)
except Exception:
logger.exception("holiday_sync_failed_after_workdays_update email=%s", user.email)
return render_settings_form(
request,
db=db,
user=user,
error="Feiertage konnten nicht automatisch synchronisiert werden.",
status_code=status.HTTP_502_BAD_GATEWAY,
)
db.commit()
return RedirectResponse(url="/settings?msg=workdays_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/vacation-allowance")
async def settings_update_vacation_allowance(
request: Request,
vacation_days_total_value: str = Form(..., alias="vacation_days_total"),
vacation_show_in_header_present: str | None = Form(default=None),
vacation_show_in_header: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
vacation_days_total = int(vacation_days_total_value.strip())
except ValueError:
return render_settings_form(
request,
db=db,
user=user,
error="Gesamturlaubstage muessen eine ganze Zahl sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if vacation_days_total < 0:
return render_settings_form(
request,
db=db,
user=user,
error="Gesamturlaubstage duerfen nicht negativ sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if vacation_days_total > 365:
return render_settings_form(
request,
db=db,
user=user,
error="Gesamturlaubstage sind zu gross (maximal 365).",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.vacation_days_total = vacation_days_total
if vacation_show_in_header_present is not None:
user.vacation_show_in_header = vacation_show_in_header == "on"
db.commit()
return RedirectResponse(url="/settings?msg=vacation_allowance_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/weekly-target")
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(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
new_target_minutes = int(round(weekly_target_hours * 60))
if new_target_minutes <= 0:
return render_settings_form(
request,
db=db,
user=user,
error="Wochenstunden muessen groesser als 0 sein.",
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():
try:
parsed_default_break_minutes = int(default_break_minutes_value.strip())
except ValueError:
return render_settings_form(
request,
db=db,
user=user,
error="Tägliche Pause muss eine ganze Zahl in Minuten sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if parsed_default_break_minutes < 0:
return render_settings_form(
request,
db=db,
user=user,
error="Tägliche Pause darf nicht negativ sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
default_break_minutes = parsed_default_break_minutes
ensure_user_has_default_target_rule(db, user)
apply_weekly_target_change(
db,
user=user,
selected_week_start=monday_of(date.today()),
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)
@app.post("/settings/workhours-counter")
async def settings_update_workhours_counter(
request: Request,
workhours_counter_enabled: str | None = Form(default=None),
workhours_counter_show_in_header: str | None = Form(default=None),
workhours_counter_start_date_value: str = Form(default="", alias="workhours_counter_start_date"),
workhours_counter_end_date_value: str = Form(default="", alias="workhours_counter_end_date"),
workhours_counter_manual_offset_hours_value: str = Form(default="", alias="workhours_counter_manual_offset_hours"),
workhours_counter_target_hours_value: str = Form(default="", alias="workhours_counter_target_hours"),
workhours_counter_target_email_enabled: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
enabled = workhours_counter_enabled == "on"
show_in_header = workhours_counter_show_in_header == "on"
target_email_enabled = workhours_counter_target_email_enabled == "on"
start_date_value = workhours_counter_start_date_value.strip()
end_date_value = workhours_counter_end_date_value.strip()
manual_offset_hours_value = workhours_counter_manual_offset_hours_value.strip()
target_hours_value = workhours_counter_target_hours_value.strip()
start_date = None
end_date = None
manual_offset_minutes = 0
target_minutes: int | None = None
if start_date_value:
try:
start_date = parse_date_query(start_date_value)
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
if end_date_value:
try:
end_date = parse_date_query(end_date_value)
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
if enabled:
if start_date is None or end_date is None:
return render_settings_form(
request,
db=db,
user=user,
error="Bitte Start- und Enddatum fuer den Arbeitsstunden-Counter setzen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if end_date < start_date:
return render_settings_form(
request,
db=db,
user=user,
error="Enddatum darf nicht vor dem Startdatum liegen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if manual_offset_hours_value:
try:
manual_offset_hours = float(manual_offset_hours_value.replace(",", "."))
except ValueError:
return render_settings_form(
request,
db=db,
user=user,
error="Zusatzstunden müssen eine Zahl sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
manual_offset_minutes = int(round(manual_offset_hours * 60))
if manual_offset_minutes < 0:
return render_settings_form(
request,
db=db,
user=user,
error="Zusatzstunden dürfen nicht negativ sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if target_hours_value:
try:
target_hours = float(target_hours_value.replace(",", "."))
except ValueError:
return render_settings_form(
request,
db=db,
user=user,
error="Stundenziel muss eine Zahl sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
target_minutes = int(round(target_hours * 60))
if target_minutes <= 0:
return render_settings_form(
request,
db=db,
user=user,
error="Stundenziel muss größer als 0 sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if target_email_enabled and resolve_mail_settings(db) is None:
return render_settings_form(
request,
db=db,
user=user,
error="E-Mail-Warnungen sind erst verfügbar, wenn ein E-Mail-Server konfiguriert ist.",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.workhours_counter_enabled = enabled
user.workhours_counter_show_in_header = show_in_header and enabled
user.workhours_counter_start_date = start_date
user.workhours_counter_end_date = end_date
user.workhours_counter_manual_offset_minutes = manual_offset_minutes
user.workhours_counter_target_minutes = target_minutes
user.workhours_counter_target_email_enabled = target_email_enabled and target_minutes is not None and enabled
user.workhours_counter_warning_last_sent_on = None
user.workhours_counter_warning_last_sent_key = None
db.commit()
return RedirectResponse(url="/settings?msg=workhours_counter_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/overtime")
async def settings_update_overtime(
request: Request,
overtime_start_date_value: str = Form(default="", alias="overtime_start_date"),
overtime_expiry_days_value: str = Form(default="", alias="overtime_expiry_days"),
expire_negative_overtime: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
overtime_start_date = None
if overtime_start_date_value.strip():
try:
overtime_start_date = parse_date_query(overtime_start_date_value.strip())
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
overtime_expiry_days = None
if overtime_expiry_days_value.strip():
try:
overtime_expiry_days = int(overtime_expiry_days_value.strip())
except ValueError:
return render_settings_form(
request,
db=db,
user=user,
error="Verfall muss eine ganze Zahl in Tagen sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if overtime_expiry_days <= 0:
return render_settings_form(
request,
db=db,
user=user,
error="Verfall muss groesser als 0 sein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if overtime_expiry_days > 3650:
return render_settings_form(
request,
db=db,
user=user,
error="Verfall ist zu gross (maximal 3650 Tage).",
status_code=status.HTTP_400_BAD_REQUEST,
)
user.overtime_start_date = overtime_start_date
user.overtime_expiry_days = overtime_expiry_days
user.expire_negative_overtime = expire_negative_overtime == "on"
db.commit()
return RedirectResponse(url="/settings?msg=overtime_updated", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/vacations/add")
async def settings_add_vacation(
request: Request,
start_date_value: str = Form(..., alias="start_date"),
end_date_value: str = Form(..., alias="end_date"),
include_weekends: str | None = Form(default=None),
notes: str = Form(default=""),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
start_date = parse_date_query(start_date_value)
end_date = parse_date_query(end_date_value)
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
if end_date < start_date:
return render_settings_form(
request,
db=db,
user=user,
error="Enddatum darf nicht vor dem Startdatum liegen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
include_weekends_value = include_weekends == "on"
notes_value = notes.strip() or None
if include_weekends_value:
period = VacationPeriod(
user_id=user.id,
start_date=start_date,
end_date=end_date,
include_weekends=True,
notes=notes_value,
)
db.add(period)
else:
working_days = get_user_working_days(user)
add_vacation_for_weekdays(
db=db,
user_id=user.id,
start_date=start_date,
end_date=end_date,
relevant_weekdays=working_days,
notes=notes_value,
)
db.commit()
return RedirectResponse(url="/settings?msg=vacation_added", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/vacations/delete-range")
async def settings_delete_vacation_range(
request: Request,
start_date_value: str = Form(..., alias="start_date"),
end_date_value: str = Form(..., alias="end_date"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
start_date = parse_date_query(start_date_value)
end_date = parse_date_query(end_date_value)
except HTTPException as exc:
return render_settings_form(
request,
db=db,
user=user,
error=exc.detail,
status_code=exc.status_code,
)
if end_date < start_date:
return render_settings_form(
request,
db=db,
user=user,
error="Enddatum darf nicht vor dem Startdatum liegen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
remove_vacation_range(
db=db,
user_id=user.id,
start_date=start_date,
end_date=end_date,
)
db.commit()
return RedirectResponse(url="/settings?msg=vacation_deleted", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/settings/vacations/{vacation_id}/delete")
async def settings_delete_vacation(
vacation_id: str,
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
stmt = select(VacationPeriod).where(VacationPeriod.id == vacation_id, VacationPeriod.user_id == user.id)
vacation = db.execute(stmt).scalar_one_or_none()
if not vacation:
return render_settings_form(
request,
db=db,
user=user,
error="Urlaubszeitraum nicht gefunden.",
status_code=status.HTTP_404_NOT_FOUND,
)
db.delete(vacation)
db.commit()
return RedirectResponse(url="/settings?msg=vacation_deleted", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/vacation/day/toggle")
async def toggle_day_vacation(
request: Request,
date_value: str = Form(..., alias="date"),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
selected_date = parse_date_query(date_value)
working_days = get_user_working_days(user)
vacations = list_vacations_for_user(db, user.id, selected_date, selected_date)
vacation_dates = expand_vacation_dates(
vacations,
selected_date,
selected_date,
relevant_weekdays=working_days,
)
if selected_date in vacation_dates:
remove_vacation_range(db=db, user_id=user.id, start_date=selected_date, end_date=selected_date)
else:
clear_special_status_for_date(db=db, user_id=user.id, day=selected_date)
add_vacation_range(
db=db,
user_id=user.id,
start_date=selected_date,
end_date=selected_date,
include_weekends=True,
notes="Schneller Urlaubseintrag",
)
db.commit()
destination = return_to if return_to.startswith("/") else "/dashboard"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.post("/vacation/week/toggle")
async def toggle_week_vacation(
request: Request,
week_start_value: str = Form(..., alias="week_start"),
week_end_value: str = Form(..., alias="week_end"),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
week_start = parse_date_query(week_start_value)
week_end = parse_date_query(week_end_value)
if week_end < week_start:
raise HTTPException(status_code=400, detail="Ungueltiger Wochenbereich")
working_days = get_user_working_days(user)
vacations = list_vacations_for_user(db, user.id, week_start, week_end)
vacation_dates = expand_vacation_dates(
vacations,
week_start,
week_end,
relevant_weekdays=working_days,
)
already_full = range_is_full_vacation(
week_start,
week_end,
vacation_dates=vacation_dates,
relevant_weekdays=working_days,
)
if already_full:
remove_vacation_range(db=db, user_id=user.id, start_date=week_start, end_date=week_end)
else:
cursor = week_start
while cursor <= week_end:
if cursor.weekday() in working_days:
clear_special_status_for_date(db=db, user_id=user.id, day=cursor)
cursor += timedelta(days=1)
add_vacation_for_weekdays(
db=db,
user_id=user.id,
start_date=week_start,
end_date=week_end,
relevant_weekdays=working_days,
notes="Schneller Urlaubseintrag Woche",
)
db.commit()
destination = return_to if return_to.startswith("/") else "/dashboard"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.post("/special-day/toggle")
async def toggle_special_day(
request: Request,
date_value: str = Form(..., alias="date"),
status_value: str = Form(..., alias="status"),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
selected_date = parse_date_query(date_value)
if status_value not in SPECIAL_DAY_STATUS_LABELS:
raise HTTPException(status_code=400, detail="Ungueltiger Sonderstatus")
existing_entry_stmt = select(TimeEntry).where(TimeEntry.user_id == user.id, TimeEntry.date == selected_date)
existing_entry = db.execute(existing_entry_stmt).scalar_one_or_none()
if existing_entry:
destination = return_to if return_to.startswith("/") else "/dashboard"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
existing_status_stmt = select(SpecialDayStatus).where(
SpecialDayStatus.user_id == user.id,
SpecialDayStatus.date == selected_date,
)
existing_status = db.execute(existing_status_stmt).scalar_one_or_none()
if existing_status and existing_status.status == status_value:
db.delete(existing_status)
else:
remove_vacation_range(db=db, user_id=user.id, start_date=selected_date, end_date=selected_date)
if existing_status:
existing_status.status = status_value
existing_status.notes = f"Schneller Sonderstatus: {SPECIAL_DAY_STATUS_LABELS[status_value]}"
else:
db.add(
SpecialDayStatus(
user_id=user.id,
date=selected_date,
status=status_value,
notes=f"Schneller Sonderstatus: {SPECIAL_DAY_STATUS_LABELS[status_value]}",
)
)
db.commit()
destination = return_to if return_to.startswith("/") else "/dashboard"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.post("/overtime-adjustment/set")
async def set_overtime_adjustment(
request: Request,
date_value: str = Form(..., alias="date"),
adjustment_mode: str = Form(default="manual"),
adjustment_value: str = Form(default=""),
interval_start_time: str = Form(default=""),
interval_end_time: str = Form(default=""),
interval_direction: str = Form(default="negative"),
full_day_direction: str = Form(default="negative"),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
selected_date = parse_date_query(date_value)
destination = return_to if return_to.startswith("/") else f"/overtime-adjustment/edit?date={selected_date.isoformat()}"
existing_entry_stmt = select(TimeEntry).where(TimeEntry.user_id == user.id, TimeEntry.date == selected_date)
existing_entry = db.execute(existing_entry_stmt).scalar_one_or_none()
if existing_entry:
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
try:
if adjustment_mode == "full_day":
if full_day_direction not in {"positive", "negative"}:
raise ValueError("Ungueltige Richtung fuer Tages-Stundenausgleich")
minutes = full_day_adjustment_minutes(
db=db,
user=user,
selected_date=selected_date,
positive=full_day_direction == "positive",
)
note = (
f"{OVERTIME_ADJUSTMENT_LABEL}: ganzer Tag "
f"({'+' if minutes > 0 else '-'}{minutes_to_hhmm(abs(minutes))})"
)
elif adjustment_mode == "interval":
if interval_direction not in {"positive", "negative"}:
raise ValueError("Ungueltige Richtung fuer Stundenausgleich")
start_minutes = parse_time_to_minutes(interval_start_time)
end_minutes = parse_time_to_minutes(interval_end_time)
if end_minutes <= start_minutes:
raise ValueError("Die Endzeit muss nach der Startzeit liegen")
interval_minutes = end_minutes - start_minutes
minutes = interval_minutes if interval_direction == "positive" else -interval_minutes
note = (
f"{OVERTIME_ADJUSTMENT_LABEL}: Zeitraum {interval_start_time} - {interval_end_time} "
f"({'+' if minutes > 0 else '-'}{minutes_to_hhmm(abs(minutes))})"
)
else:
minutes = parse_signed_duration_to_minutes(adjustment_value)
note = f"{OVERTIME_ADJUSTMENT_LABEL}: manuell {'+' if minutes > 0 else ''}{minutes_to_hhmm(minutes)}"
except ValueError as exc:
error_params = {"date": selected_date.isoformat(), "overtime_error": str(exc)}
if return_to.startswith("/"):
error_params["return_to"] = return_to
error_query = urlencode(error_params)
return RedirectResponse(url=f"/overtime-adjustment/edit?{error_query}", status_code=status.HTTP_303_SEE_OTHER)
existing_adjustment_stmt = select(OvertimeAdjustment).where(
OvertimeAdjustment.user_id == user.id,
OvertimeAdjustment.date == selected_date,
)
existing_adjustment = db.execute(existing_adjustment_stmt).scalar_one_or_none()
if existing_adjustment:
existing_adjustment.minutes = minutes
existing_adjustment.notes = note
else:
db.add(
OvertimeAdjustment(
user_id=user.id,
date=selected_date,
minutes=minutes,
notes=note,
)
)
db.commit()
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.post("/overtime-adjustment/clear")
async def clear_overtime_adjustment(
request: Request,
date_value: str = Form(..., alias="date"),
return_to: str = Form(default="/dashboard"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
selected_date = parse_date_query(date_value)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=selected_date)
db.commit()
destination = return_to if return_to.startswith("/") else "/dashboard"
return RedirectResponse(url=destination, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/bulk-entry", response_class=HTMLResponse)
async def bulk_entry_form(
request: Request,
from_date_value: str | None = Query(default=None, alias="from"),
to_date_value: str | None = Query(default=None, alias="to"),
weekdays_value: str | None = Query(default="0,1,2,3,4", alias="weekdays"),
bulk_mode: str | None = Query(default="only_missing", alias="mode"),
created: int | None = Query(default=None),
updated: int | None = Query(default=None),
skipped: int | None = Query(default=None),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
today = date.today()
default_from = monday_of(today - timedelta(days=7))
default_to = monday_of(today) + timedelta(days=6)
parsed_from = parse_date_query(from_date_value, default=default_from).isoformat()
parsed_to = parse_date_query(to_date_value, default=default_to).isoformat()
weekday_parts = [item for item in (weekdays_value or "").split(",") if item]
try:
weekdays_selected = parse_weekday_values(weekday_parts) if weekday_parts else [0, 1, 2, 3, 4]
except HTTPException:
weekdays_selected = [0, 1, 2, 3, 4]
if bulk_mode not in {"only_missing", "upsert"}:
bulk_mode = "only_missing"
success_message = None
if created is not None and updated is not None and skipped is not None:
success_message = f"Mehrfacheingabe gespeichert: {created} angelegt, {updated} aktualisiert, {skipped} uebersprungen."
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=parsed_from,
to_date_value=parsed_to,
weekdays_selected=weekdays_selected,
bulk_mode=bulk_mode,
start_time="08:30",
end_time="15:00",
break_minutes=automatic_break_minutes(start_minutes=8 * 60 + 30, end_minutes=15 * 60)
if auto_break_rules_enabled(user)
else default_break_minutes_for_user(user),
break_mode="auto" if auto_break_rules_enabled(user) else "manual",
notes="",
success_message=success_message,
)
@app.post("/bulk-entry", response_class=HTMLResponse)
async def bulk_entry_submit(
request: Request,
from_date_value: str = Form(..., alias="from_date"),
to_date_value: str = Form(..., alias="to_date"),
weekdays_values: list[str] = Form(default=[]),
start_time: str = Form(...),
end_time: str = Form(...),
break_minutes: int = Form(default=0),
break_mode: str = Form(default="manual"),
notes: str = Form(default=""),
bulk_mode: str = Form(..., alias="mode"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
try:
parsed_mode = parse_bulk_mode(bulk_mode)
from_date = parse_date_query(from_date_value)
to_date = parse_date_query(to_date_value)
weekdays_selected = parse_weekday_values(weekdays_values)
start_minutes = parse_time_to_minutes(start_time)
end_minutes = parse_time_to_minutes(end_time)
effective_break_minutes, effective_break_mode = resolve_break_settings(
user=user,
start_minutes=start_minutes,
end_minutes=end_minutes,
submitted_break_minutes=break_minutes,
submitted_break_mode=break_mode,
)
compute_net_minutes(start_minutes, end_minutes, effective_break_minutes)
except HTTPException as exc:
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=from_date_value,
to_date_value=to_date_value,
weekdays_selected=[0, 1, 2, 3, 4],
bulk_mode=bulk_mode if bulk_mode in {"only_missing", "upsert"} else "only_missing",
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
error=exc.detail,
status_code=exc.status_code,
)
except Exception as exc:
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=from_date_value,
to_date_value=to_date_value,
weekdays_selected=[0, 1, 2, 3, 4],
bulk_mode=bulk_mode if bulk_mode in {"only_missing", "upsert"} else "only_missing",
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
)
if from_date > to_date:
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=from_date_value,
to_date_value=to_date_value,
weekdays_selected=weekdays_selected,
bulk_mode=parsed_mode,
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
error="Von-Datum darf nicht nach dem Bis-Datum liegen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if (to_date - from_date).days > 370:
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=from_date_value,
to_date_value=to_date_value,
weekdays_selected=weekdays_selected,
bulk_mode=parsed_mode,
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
error="Zeitraum ist zu gross. Bitte maximal 12 Monate auf einmal bearbeiten.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if not weekdays_selected:
return render_bulk_form(
request,
db=db,
user=user,
from_date_value=from_date_value,
to_date_value=to_date_value,
weekdays_selected=weekdays_selected,
bulk_mode=parsed_mode,
start_time=start_time,
end_time=end_time,
break_minutes=break_minutes,
break_mode=break_mode,
notes=notes,
error="Bitte mindestens einen Wochentag auswaehlen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
existing_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= from_date, TimeEntry.date <= to_date)
.order_by(TimeEntry.date.asc())
)
existing_entries = db.execute(existing_stmt).scalars().all()
existing_by_date = {entry.date: entry for entry in existing_entries}
created_count = 0
updated_count = 0
skipped_count = 0
cursor = from_date
while cursor <= to_date:
if cursor.weekday() not in weekdays_selected:
cursor += timedelta(days=1)
continue
existing_entry = existing_by_date.get(cursor)
if existing_entry and parsed_mode == "only_missing":
skipped_count += 1
cursor += timedelta(days=1)
continue
if existing_entry:
existing_entry.start_minutes = start_minutes
existing_entry.end_minutes = end_minutes
existing_entry.break_minutes = effective_break_minutes
existing_entry.break_rule_mode = effective_break_mode
existing_entry.notes = notes.strip() or None
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=cursor)
updated_count += 1
else:
new_entry = TimeEntry(
user_id=user.id,
date=cursor,
start_minutes=start_minutes,
end_minutes=end_minutes,
break_minutes=effective_break_minutes,
break_rule_mode=effective_break_mode,
notes=notes.strip() or None,
)
db.add(new_entry)
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=cursor)
created_count += 1
clear_special_status_for_date(db=db, user_id=user.id, day=cursor)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=cursor)
cursor += timedelta(days=1)
db.commit()
logger.info(
"bulk_entry email=%s from=%s to=%s mode=%s created=%s updated=%s skipped=%s",
user.email,
from_date.isoformat(),
to_date.isoformat(),
parsed_mode,
created_count,
updated_count,
skipped_count,
)
params = urlencode(
{
"from": from_date.isoformat(),
"to": to_date.isoformat(),
"mode": parsed_mode,
"weekdays": ",".join(str(day) for day in weekdays_selected),
"created": str(created_count),
"updated": str(updated_count),
"skipped": str(skipped_count),
}
)
return RedirectResponse(url=f"/bulk-entry?{params}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/export", response_class=HTMLResponse)
async def export_form(request: Request, db: Session = Depends(get_db)):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
today = date.today()
first_day_of_month = date(today.year, today.month, 1)
return templates.TemplateResponse(
"pages/export.html",
build_context(
request,
user=user,
db=db,
from_date=first_day_of_month.isoformat(),
to_date=today.isoformat(),
error=None,
),
)
@app.post("/export")
async def export_data(
request: Request,
from_date_value: str = Form(..., alias="from_date"),
to_date_value: str = Form(..., alias="to_date"),
export_format: str = Form(..., alias="format"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
ensure_user_has_default_target_rule(db, user)
db.commit()
try:
from_date = parse_date_query(from_date_value)
to_date = parse_date_query(to_date_value)
except HTTPException as exc:
return templates.TemplateResponse(
"pages/export.html",
build_context(
request,
user=user,
db=db,
from_date=from_date_value,
to_date=to_date_value,
error=exc.detail,
),
status_code=status.HTTP_400_BAD_REQUEST,
)
if from_date > to_date:
return templates.TemplateResponse(
"pages/export.html",
build_context(
request,
user=user,
db=db,
from_date=from_date_value,
to_date=to_date_value,
error="Von-Datum darf nicht nach dem Bis-Datum liegen.",
),
status_code=status.HTTP_400_BAD_REQUEST,
)
if export_format not in {"xlsx", "pdf"}:
raise HTTPException(status_code=400, detail="Ungueltiges Exportformat")
rows, week_summaries, totals = build_export_payload_for_range(
db=db,
user=user,
from_date=from_date,
to_date=to_date,
)
title = "Stundenexport"
if export_format == "xlsx":
payload = create_excel_export(rows, week_summaries, totals, title)
filename = f"stunden_{from_date.isoformat()}_{to_date.isoformat()}.xlsx"
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
payload = create_pdf_export(rows, week_summaries, totals, title)
filename = f"stunden_{from_date.isoformat()}_{to_date.isoformat()}.pdf"
media_type = "application/pdf"
response = Response(content=payload, media_type=media_type)
response.headers["Content-Disposition"] = f'attachment; filename=\"{filename}\"'
return response
@app.post("/settings/export-all")
async def settings_export_all_data(
request: Request,
export_format: str = Form(..., alias="format"),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = get_current_user(request, db)
if not user:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
verify_csrf(request, csrf_token)
ensure_user_has_default_target_rule(db, user)
db.commit()
if export_format not in {"xlsx", "pdf", "backup_json"}:
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Ungültiges Exportformat.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if export_format == "backup_json":
payload = create_backup_export(build_user_backup_payload(db=db, user=user))
filename = f"stundenfuchs_backup_{date.today().isoformat()}.json"
media_type = "application/json"
else:
from_date, to_date = user_export_date_bounds(db, user)
rows, week_summaries, totals = build_export_payload_for_range(
db=db,
user=user,
from_date=from_date,
to_date=to_date,
)
title = "Stundenexport"
if export_format == "xlsx":
payload = create_excel_export(rows, week_summaries, totals, title)
filename = f"stunden_gesamt_{from_date.isoformat()}_{to_date.isoformat()}.xlsx"
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
payload = create_pdf_export(rows, week_summaries, totals, title)
filename = f"stunden_gesamt_{from_date.isoformat()}_{to_date.isoformat()}.pdf"
media_type = "application/pdf"
response = Response(content=payload, media_type=media_type)
response.headers["Content-Disposition"] = f'attachment; filename=\"{filename}\"'
return response
@app.post("/settings/import/preview")
async def settings_import_preview(
request: Request,
import_mode: str = Form(default="merge"),
backup_file: UploadFile | None = File(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = require_user(request, db)
verify_csrf(request, csrf_token)
if import_mode not in {"merge", "replace_user_data"}:
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Ungültiger Importmodus.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if backup_file is None or not (backup_file.filename or "").strip():
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected=import_mode,
error="Bitte wähle eine Backup-Datei aus.",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
payload = load_backup_payload_from_bytes(await backup_file.read())
preview = create_import_preview_record(db=db, user=user, payload=payload, mode=import_mode)
import_preview = import_preview_view_data(db=db, user=user, preview=preview, payload=payload)
db.commit()
except BackupImportError as exc:
db.rollback()
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected=import_mode,
error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
)
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected=import_mode,
import_preview=import_preview,
success_message="Backup geprüft. Bitte kontrolliere die Vorschau vor dem Import.",
)
@app.post("/settings/import/execute")
async def settings_import_execute(
request: Request,
preview_id: str = Form(...),
confirm_replace: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = require_user(request, db)
verify_csrf(request, csrf_token)
preview = get_import_preview_record(db=db, user=user, preview_id=preview_id)
if preview is None:
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Die Importvorschau ist abgelaufen oder nicht mehr verfügbar.",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
payload = parse_preview_payload(preview)
except BackupImportError as exc:
db.delete(preview)
db.commit()
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
)
import_preview = import_preview_view_data(db=db, user=user, preview=preview, payload=payload)
if preview.mode == IMPORT_MODE_REPLACE and confirm_replace != "on":
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected=preview.mode,
import_preview=import_preview,
error="Bitte bestätige, dass deine bisherigen Daten ersetzt werden sollen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
result = execute_backup_import(db=db, user=user, payload=payload, mode=preview.mode)
sync_auto_holidays_for_user(
db=db,
user=user,
from_date=date.today().replace(month=1, day=1) - timedelta(days=366),
to_date=date.today().replace(month=12, day=31) + timedelta(days=730),
)
autofill_entries_for_range(
db=db,
user=user,
range_start=date(1970, 1, 1),
range_end=date.today(),
)
db.delete(preview)
db.commit()
except BackupImportError as exc:
db.rollback()
preview = get_import_preview_record(db=db, user=user, preview_id=preview_id)
import_preview = None
if preview is not None:
import_preview = import_preview_view_data(db=db, user=user, preview=preview, payload=payload)
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected=preview.mode if preview else "merge",
import_preview=import_preview,
error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
)
created_total = sum(result["created"].values())
skipped_total = sum(result["skipped"].values())
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
import_mode_selected="merge",
success_message=(
f"Backup importiert. {created_total} Datensätze übernommen, "
f"{skipped_total} Konflikte übersprungen."
),
)
@app.post("/settings/account/delete")
async def settings_delete_own_account(
request: Request,
current_password: str = Form(...),
confirm_email: str = Form(...),
confirm_delete: str | None = Form(default=None),
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
user = require_user(request, db)
verify_csrf(request, csrf_token)
if not verify_password(current_password, user.password_hash):
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Aktuelles Passwort ist nicht korrekt.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if confirm_email.strip().lower() != user.email.lower():
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Bitte gib zur Bestätigung genau deine E-Mail-Adresse ein.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if confirm_delete != "on":
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Bitte bestätige, dass dein Konto dauerhaft gelöscht werden soll.",
status_code=status.HTTP_400_BAD_REQUEST,
)
if user.role == "admin" and user.is_active and count_admin_users(db) <= 1:
return render_settings_form(
request,
db=db,
user=user,
active_tab="settings",
error="Der letzte aktive Admin kann seinen Account nicht selbst löschen.",
status_code=status.HTTP_400_BAD_REQUEST,
)
request.session.clear()
db.delete(user)
db.commit()
return RedirectResponse(url="/login?msg=account_deleted", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/auth/register")
async def api_register(
request: Request,
payload: RegisterRequest,
db: Session = Depends(get_db),
):
existing = find_user_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=409, detail="E-Mail ist bereits registriert")
verification_enabled = is_email_verification_enabled(db)
user = User(
email=payload.email.lower(),
password_hash=hash_password(payload.password),
role="admin" if is_bootstrap_admin_identity(payload.email) else "user",
email_verified=not verification_enabled,
)
db.add(user)
db.commit()
db.refresh(user)
ensure_user_has_default_target_rule(db, user)
db.commit()
send_registration_admin_notification(db=db, user=user, source="api_register")
if verification_enabled:
sent, reason = send_email_verification_link(request=request, db=db, user=user)
logger.info("api_register_pending_verification email=%s sent=%s reason=%s", user.email, sent, reason)
if not sent and reason != "rate_limited":
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Bestaetigungs-E-Mail konnte nicht versendet werden")
return {"ok": True, "email_verification_required": True}
send_registration_email_if_enabled(db=db, user=user)
csrf_token = login_user(request, user)
logger.info("register_success email=%s", user.email)
return user_public_payload(user, csrf_token)
@app.post("/auth/login")
async def api_login(
request: Request,
payload: LoginRequest,
db: Session = Depends(get_db),
):
client_ip = get_client_ip(request)
blocked, retry_minutes = is_login_blocked(
db,
payload.email,
client_ip,
settings.login_rate_limit_attempts,
settings.login_rate_limit_window_minutes,
)
if blocked:
logger.warning("login_blocked email=%s ip=%s", payload.email.lower(), client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Zu viele Fehlversuche. In {retry_minutes} Minuten erneut versuchen.",
)
user = find_user_by_email(db, payload.email)
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
register_failed_attempt(db, payload.email, client_ip)
logger.warning("login_failed email=%s ip=%s", payload.email.lower(), client_ip)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungueltige Anmeldedaten")
if settings.email_verification_required and not user.email_verified:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="E-Mail-Adresse ist noch nicht bestaetigt.")
register_successful_attempt(db, payload.email, client_ip)
logger.info("login_success email=%s ip=%s", payload.email.lower(), client_ip)
may_login_directly, mfa_error = start_mfa_challenge(request, db, user)
if mfa_error:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=mfa_error)
if not may_login_directly:
return {
"mfa_required": True,
"mfa_method": user.mfa_method,
"csrf_token": ensure_csrf_token(request),
}
csrf_token = login_user(request, user)
response = user_public_payload(user, csrf_token)
response["mfa_required"] = False
return response
@app.post("/auth/mfa")
async def api_login_mfa(
request: Request,
payload: MFAChallengeRequest,
db: Session = Depends(get_db),
):
assert_api_csrf(request)
user, error = verify_pending_mfa_code(request, db, payload.code)
if error or not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=error or "Ungueltiger Code")
csrf_token = login_user(request, user)
response = user_public_payload(user, csrf_token)
response["mfa_required"] = False
return response
@app.post("/auth/mfa/resend")
async def api_login_mfa_resend(request: Request, db: Session = Depends(get_db)):
assert_api_csrf(request)
user, pending_method = get_pending_mfa_user(request, db)
if not user or not pending_method:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Keine aktive MFA-Session")
if pending_method != MFA_METHOD_EMAIL:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Resend nur fuer E-Mail-MFA")
if email_mfa_resend_cooldown_active(user):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Bitte kurz warten, bevor ein neuer Code gesendet wird.",
)
if not send_email_mfa_code(db=db, user=user):
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Code konnte nicht versendet werden")
return {"ok": True}
@app.post("/auth/logout")
async def api_logout(request: Request, db: Session = Depends(get_db)):
user = require_user(request, db)
csrf_token = request.headers.get("x-csrf-token")
verify_csrf(request, csrf_token)
logger.info("logout email=%s", user.email)
request.session.clear()
return JSONResponse(status_code=200, content={"ok": True})
@app.get("/me")
async def api_me(request: Request, db: Session = Depends(get_db)):
user = require_user(request, db)
return user_public_payload(user, ensure_csrf_token(request))
def assert_api_csrf(request: Request) -> None:
csrf_token = request.headers.get("x-csrf-token")
verify_csrf(request, csrf_token)
@app.get("/time-entries")
async def list_time_entries(
request: Request,
from_date: str | None = Query(default=None, alias="from"),
to_date: str | None = Query(default=None, alias="to"),
db: Session = Depends(get_db),
):
user = require_user(request, db)
start = parse_date_query(from_date) if from_date else date.today()
end = parse_date_query(to_date) if to_date else (date.today() + timedelta(days=730))
auto_created = autofill_entries_for_range(db=db, user=user, range_start=start, range_end=end)
if auto_created:
db.commit()
stmt = select(TimeEntry).where(TimeEntry.user_id == user.id)
if from_date:
stmt = stmt.where(TimeEntry.date >= start)
if to_date:
stmt = stmt.where(TimeEntry.date <= end)
stmt = stmt.order_by(TimeEntry.date.asc())
entries = db.execute(stmt).scalars().all()
return {"items": [serialize_entry(entry) for entry in entries]}
@app.post("/time-entries")
async def create_time_entry(
request: Request,
payload: TimeEntryCreate,
db: Session = Depends(get_db),
):
user = require_user(request, db)
assert_api_csrf(request)
start_minutes = parse_time_to_minutes(payload.start_time)
end_minutes = parse_time_to_minutes(payload.end_time)
break_minutes, break_mode = resolve_break_settings(
user=user,
start_minutes=start_minutes,
end_minutes=end_minutes,
submitted_break_minutes=payload.break_minutes,
submitted_break_mode=payload.break_mode,
)
compute_net_minutes(start_minutes, end_minutes, break_minutes)
entry = TimeEntry(
user_id=user.id,
date=payload.date,
start_minutes=start_minutes,
end_minutes=end_minutes,
break_minutes=break_minutes,
break_rule_mode=break_mode,
notes=payload.notes,
)
db.add(entry)
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=payload.date)
clear_special_status_for_date(db=db, user_id=user.id, day=payload.date)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=payload.date)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Eintrag fuer dieses Datum existiert bereits")
db.refresh(entry)
return serialize_entry(entry)
@app.patch("/time-entries/{entry_id}")
async def update_time_entry(
entry_id: str,
request: Request,
payload: TimeEntryUpdate,
db: Session = Depends(get_db),
):
user = require_user(request, db)
assert_api_csrf(request)
entry = get_entry_or_404(db, user.id, entry_id)
start_minutes = entry.start_minutes
end_minutes = entry.end_minutes
break_minutes = entry.break_minutes
if payload.start_time is not None:
start_minutes = parse_time_to_minutes(payload.start_time)
if payload.end_time is not None:
end_minutes = parse_time_to_minutes(payload.end_time)
break_minutes, break_mode = resolve_break_settings(
user=user,
start_minutes=start_minutes,
end_minutes=end_minutes,
submitted_break_minutes=payload.break_minutes,
submitted_break_mode=payload.break_mode,
existing_break_mode=entry.break_rule_mode,
existing_break_minutes=entry.break_minutes,
start_or_end_changed=(
start_minutes != entry.start_minutes or end_minutes != entry.end_minutes
),
)
compute_net_minutes(start_minutes, end_minutes, break_minutes)
entry.start_minutes = start_minutes
entry.end_minutes = end_minutes
entry.break_minutes = break_minutes
entry.break_rule_mode = break_mode
if payload.notes is not None:
entry.notes = payload.notes
clear_auto_entry_skip_for_date(db=db, user_id=user.id, day=entry.date)
clear_overtime_adjustment_for_date(db=db, user_id=user.id, day=entry.date)
db.commit()
db.refresh(entry)
return serialize_entry(entry)
@app.delete("/time-entries/{entry_id}")
async def delete_time_entry_api(
entry_id: str,
request: Request,
db: Session = Depends(get_db),
):
user = require_user(request, db)
assert_api_csrf(request)
entry = get_entry_or_404(db, user.id, entry_id)
selected_date = entry.date
db.delete(entry)
if user.entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY and selected_date <= date.today():
mark_auto_entry_skip_for_date(db=db, user_id=user.id, day=selected_date)
db.commit()
return {"ok": True}
@app.get("/reports/week")
async def week_report(
request: Request,
date_value: str | None = Query(default=None, alias="date"),
db: Session = Depends(get_db),
):
user = require_user(request, db)
selected_date = parse_date_query(date_value, default=date.today())
ensure_user_has_default_target_rule(db, user)
db.commit()
rules = list_rules_for_user(db, user.id)
working_days = get_user_working_days(user)
week_start, week_end = iso_week_bounds(selected_date)
week_target_minutes = target_for_week(rules, week_start, user.weekly_target_minutes)
auto_created = autofill_entries_for_range(db=db, user=user, range_start=week_start, range_end=week_end)
if auto_created:
db.commit()
week_entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= week_start, TimeEntry.date <= week_end)
.order_by(TimeEntry.date.asc())
)
week_entries = db.execute(week_entries_stmt).scalars().all()
all_entries_until_week_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date <= week_end)
.order_by(TimeEntry.date.asc())
)
all_entries_until_week = db.execute(all_entries_until_week_stmt).scalars().all()
vacations_selected = list_vacations_for_user(db, user.id, week_start, week_end)
vacation_dates_selected = expand_vacation_dates(
vacations_selected,
week_start,
week_end,
relevant_weekdays=working_days,
)
special_selected = list_special_statuses_for_user(db, user.id, week_start, week_end)
special_dates_selected = effective_non_working_dates_for_user(user=user, special_statuses=special_selected)
count_as_worktime_dates_selected = count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates_selected,
special_statuses=special_selected,
)
special_by_date = special_status_map(special_selected)
overtime_adjustments_selected = list_overtime_adjustments_for_user(db, user.id, week_start, week_end)
overtime_adjustments_by_date = overtime_adjustment_map(overtime_adjustments_selected)
vacation_days_selected = len([day for day in vacation_dates_selected if day.weekday() in working_days])
week_data = aggregate_week(week_entries, week_start, week_target_minutes)
effective_week_totals = compute_effective_week_totals(
entries=week_entries,
week_start=week_start,
weekly_target_minutes=week_target_minutes,
vacation_dates=vacation_dates_selected,
non_working_dates=special_dates_selected,
count_as_worktime_dates=count_as_worktime_dates_selected,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(overtime_adjustments_selected),
overtime_start_date=user.overtime_start_date,
relevant_weekdays=working_days,
)
week_data["weekly_ist"] = effective_week_totals["weekly_ist"]
week_data["weekly_soll"] = effective_week_totals["weekly_soll"]
week_data["weekly_delta"] = effective_week_totals["weekly_delta"]
vacations_until_week = list_vacations_for_user(db, user.id, date(1970, 1, 1), week_end)
special_until_week = list_special_statuses_for_user(db, user.id, date(1970, 1, 1), week_end)
vacation_dates_until_week = expand_vacation_dates(
vacations_until_week,
date(1970, 1, 1),
week_end,
relevant_weekdays=working_days,
)
overtime_adjustments_until_week = list_overtime_adjustments_for_user(db, user.id, date(1970, 1, 1), week_end)
week_data["cumulative_delta"] = compute_cumulative_overtime_minutes(
entries=all_entries_until_week,
rules=rules,
weekly_target_fallback=user.weekly_target_minutes,
vacation_periods=vacations_until_week,
non_working_dates=effective_non_working_dates_for_user(user=user, special_statuses=special_until_week),
count_as_worktime_dates=count_as_worktime_dates_for_user(
user=user,
vacation_dates=vacation_dates_until_week,
special_statuses=special_until_week,
),
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(overtime_adjustments_until_week),
selected_week_start=week_start,
overtime_start_date=user.overtime_start_date,
overtime_expiry_days=user.overtime_expiry_days,
expire_negative_overtime=user.expire_negative_overtime,
relevant_weekdays=working_days,
)
return {
"week_start": week_data["week_start"].isoformat(),
"week_end": week_data["week_end"].isoformat(),
"weekly_ist_minutes": week_data["weekly_ist"],
"weekly_soll_minutes": week_data["weekly_soll"],
"weekly_delta_minutes": week_data["weekly_delta"],
"cumulative_delta_minutes": week_data["cumulative_delta"],
"vacation_days": vacation_days_selected,
"days": [
{
"date": day_info["date"].isoformat(),
"entry": serialize_entry(day_info["entry"]) if day_info["entry"] else None,
"net_minutes": day_info["net_minutes"],
"special_status": special_by_date.get(day_info["date"]),
"overtime_adjustment_minutes": (
overtime_adjustments_by_date[day_info["date"]].minutes
if day_info["date"] in overtime_adjustments_by_date
else 0
),
}
for day_info in week_data["days"]
],
}
@app.get("/reports/month")
async def month_report(
request: Request,
month: str = Query(...),
db: Session = Depends(get_db),
):
user = require_user(request, db)
ensure_user_has_default_target_rule(db, user)
db.commit()
try:
month_date = datetime.strptime(month, "%Y-%m").date()
except ValueError as exc:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein") from exc
month_start = date(month_date.year, month_date.month, 1)
if month_start.month == 12:
next_month = date(month_start.year + 1, 1, 1)
else:
next_month = date(month_start.year, month_start.month + 1, 1)
month_end = next_month - timedelta(days=1)
auto_created = autofill_entries_for_range(db=db, user=user, range_start=month_start, range_end=month_end)
if auto_created:
db.commit()
entries_stmt = (
select(TimeEntry)
.where(TimeEntry.user_id == user.id, TimeEntry.date >= month_start, TimeEntry.date <= month_end)
.order_by(TimeEntry.date.asc())
)
entries = db.execute(entries_stmt).scalars().all()
month_ist = 0
month_soll = 0
days = []
entry_map = {entry.date: entry for entry in entries}
rules = list_rules_for_user(db, user.id)
working_days = get_user_working_days(user)
cursor = month_start
displayed_week_starts = set()
month_special = list_special_statuses_for_user(db, user.id, month_start, month_end)
month_special_dates = effective_non_working_dates_for_user(user=user, special_statuses=month_special)
month_special_by_date = special_status_map(month_special)
month_overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, month_start, month_end)
month_overtime_adjustment_map = overtime_adjustment_map(month_overtime_adjustments)
while cursor <= month_end:
week_start = monday_of(cursor)
displayed_week_starts.add(week_start)
entry = entry_map.get(cursor)
if entry:
net_minutes = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
days.append(
{
"date": cursor.isoformat(),
"entry": serialize_entry(entry),
"net_minutes": net_minutes,
"special_status": month_special_by_date.get(cursor),
"overtime_adjustment_minutes": (
month_overtime_adjustment_map[cursor].minutes if cursor in month_overtime_adjustment_map else 0
),
}
)
else:
days.append(
{
"date": cursor.isoformat(),
"entry": None,
"net_minutes": 0,
"special_status": month_special_by_date.get(cursor),
"overtime_adjustment_minutes": (
month_overtime_adjustment_map[cursor].minutes if cursor in month_overtime_adjustment_map else 0
),
}
)
cursor += timedelta(days=1)
ordered_week_starts = sorted(displayed_week_starts)
month_vacations = list_vacations_for_user(db, user.id, month_start, month_end)
month_vacation_dates = expand_vacation_dates(
month_vacations,
month_start,
month_end,
relevant_weekdays=working_days,
)
month_count_as_worktime_dates = count_as_worktime_dates_for_user(
user=user,
vacation_dates=month_vacation_dates,
special_statuses=month_special,
)
month_delta = 0
weekly_breakdown = []
for week_start in ordered_week_starts:
week_end = week_start + timedelta(days=6)
visible_start = max(week_start, month_start)
visible_end = min(week_end, month_end)
weekly_target_minutes = target_for_week(rules, week_start, user.weekly_target_minutes)
week_totals = compute_effective_span_totals(
entries=entries,
range_start=visible_start,
range_end=visible_end,
weekly_target_minutes=weekly_target_minutes,
vacation_dates=month_vacation_dates,
non_working_dates=month_special_dates,
count_as_worktime_dates=month_count_as_worktime_dates,
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_map(month_overtime_adjustments),
overtime_start_date=user.overtime_start_date,
relevant_weekdays=working_days,
)
weekly_ist = week_totals["ist_minutes"]
weekly_soll = week_totals["soll_minutes"]
weekly_delta = week_totals["delta_minutes"]
vacation_days_visible = week_totals["vacation_workdays"]
month_ist += weekly_ist
month_soll += weekly_soll
month_delta += weekly_delta
weekly_breakdown.append(
{
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"ist_minutes": weekly_ist,
"soll_minutes": weekly_soll,
"delta_minutes": weekly_delta,
"vacation_days": vacation_days_visible,
"overtime_adjustment_minutes": week_totals["overtime_adjustment_minutes"],
}
)
return {
"month": month,
"month_start": month_start.isoformat(),
"month_end": month_end.isoformat(),
"month_ist_minutes": month_ist,
"month_soll_minutes": month_soll,
"month_delta_minutes": month_delta,
"delta_mode": "sum_partial_week_delta_for_month_days",
"weeks": weekly_breakdown,
"days": days,
}
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
app_settings = get_settings()
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=app_settings.port,
proxy_headers=True,
forwarded_allow_ips=app_settings.forwarded_allow_ips,
)