6394 lines
267 KiB
Python
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,
|
|
)
|