bmc_hub/app/modules/calendar/backend/router.py
Christian 4822637466 feat: add time multiplier presets management and extend worklog model
- Added a new section in the settings frontend for managing time multiplier presets, including UI for adding, displaying, and saving presets.
- Introduced a new function to normalize and load time multiplier presets from settings.
- Updated the worklog submission process to include a selected multiplier preset and its corresponding rate multiplier.
- Enhanced the worklog model to support additional fields: extra_billed_hours, extended_support_flag, rate_multiplier, manual_hourly_rate, manual_rounded_hours, and rounding_override_reason.
- Implemented backend validation to ensure that time entries linked to prepaid cards cannot be edited if the card is in a locked status.
- Added database migration to introduce new columns and constraints for the worklog and time entry tables.
2026-06-21 10:26:07 +02:00

439 lines
15 KiB
Python

import logging
import hmac
import hashlib
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from app.core.auth_dependencies import get_current_user
from app.core.config import settings
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
def _calendar_token_secret() -> bytes:
return (settings.SECRET_KEY or settings.JWT_SECRET_KEY or "calendar-dev-secret").encode("utf-8")
def _create_calendar_feed_token(user_id: int, expires_at: datetime) -> str:
exp_ts = int(expires_at.timestamp())
payload = f"{int(user_id)}:{exp_ts}".encode("utf-8")
digest = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
return f"{int(user_id)}.{exp_ts}.{digest}"
def _verify_calendar_feed_token(token: str) -> int:
parts = str(token or "").split(".")
if len(parts) != 3:
raise HTTPException(status_code=401, detail="Invalid calendar token")
user_part, exp_part, sig_part = parts
try:
user_id = int(user_part)
exp_ts = int(exp_part)
except ValueError as exc:
raise HTTPException(status_code=401, detail="Invalid calendar token") from exc
payload = f"{user_id}:{exp_ts}".encode("utf-8")
expected = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig_part):
raise HTTPException(status_code=401, detail="Invalid calendar token")
if datetime.utcnow().timestamp() > exp_ts:
raise HTTPException(status_code=401, detail="Calendar token expired")
return user_id
def _build_ical_response(events: list[dict], now: datetime) -> Response:
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
"REFRESH-INTERVAL;VALUE=DURATION:PT15M",
"X-PUBLISHED-TTL:PT15M",
]
for event in events:
start_value = datetime.fromisoformat(event.get("start"))
summary = _escape_ical(event.get("title", ""))
description_parts = []
if event.get("customer_name"):
description_parts.append(f"Kunde: {event.get('customer_name')}")
if event.get("event_type"):
description_parts.append(f"Type: {event.get('event_type')}")
if event.get("url"):
description_parts.append(f"Link: {event.get('url')}")
description = _escape_ical("\n".join(description_parts))
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
return Response(
content="\r\n".join(lines),
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'inline; filename="bmc-hub-calendar.ics"',
"Cache-Control": "public, max-age=300",
},
)
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
if not value:
return fallback
try:
return datetime.fromisoformat(value)
except ValueError:
return fallback
def _get_user_id(request: Request, user_id: int | None, only_mine: bool) -> int | None:
if user_id is not None:
return user_id
state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None:
return int(state_user_id)
if only_mine:
raise HTTPException(status_code=401, detail="User not authenticated")
return None
def _build_event(event_type: str, title: str, start_dt: datetime, url: str, extra: dict) -> dict:
payload = {
"id": f"{event_type}:{extra.get('reference_id')}",
"title": title,
"start": start_dt.isoformat(),
"url": url,
"event_type": event_type,
}
payload.update(extra)
return payload
def _escape_ical(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _format_ical_dt(value: datetime) -> str:
return value.strftime("%Y%m%dT%H%M%S")
def _get_calendar_events(
request: Request,
start_dt: datetime,
end_dt: datetime,
only_mine: bool,
user_id: int | None,
customer_id: int | None,
types: str | None,
) -> list[dict]:
allowed_types = {
"case_deadline",
"case_deferred",
"case_reminder",
"deadline",
"deferred",
"reminder",
"meeting",
"technician_visit",
"obs",
}
requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip()
}
if requested_types and not requested_types.issubset(allowed_types):
raise HTTPException(status_code=400, detail="Invalid event types")
type_map = {
"case_deadline": "deadline",
"case_deferred": "deferred",
"case_reminder": "reminder",
}
normalized_types = {type_map.get(t, t) for t in requested_types}
reminder_kinds = {"reminder", "meeting", "technician_visit", "obs", "deadline"}
include_deadline = not normalized_types or "deadline" in normalized_types
include_deferred = not normalized_types or "deferred" in normalized_types
include_reminder = not normalized_types or bool(reminder_kinds.intersection(normalized_types))
resolved_user_id = _get_user_id(request, user_id, only_mine)
events: list[dict] = []
if include_deadline:
query = """
SELECT s.id, s.titel, s.deadline, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN %s AND %s
"""
params: list = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deadline")
if not start_value:
continue
title = f"Deadline: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deadline",
title,
start_value,
f"/sag/{row.get('id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deadline",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deadline",
},
)
)
if include_deferred:
query = """
SELECT s.id, s.titel, s.deferred_until, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deferred_until IS NOT NULL
AND s.deferred_until BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deferred_until")
if not start_value:
continue
title = f"Defer: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deferred",
title,
start_value,
f"/sag/{row.get('id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deferred",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deferred",
},
)
)
if include_reminder:
query = """
SELECT r.id, r.title, r.message, r.priority, r.event_type, r.next_check_at, r.scheduled_at,
r.sag_id, s.titel as sag_title, s.customer_id, c.name as customer_name,
r.recipient_user_ids, r.created_by_user_id
FROM sag_reminders r
JOIN sag_sager s ON s.id = r.sag_id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE r.deleted_at IS NULL
AND r.is_active = true
AND s.deleted_at IS NULL
AND COALESCE(r.next_check_at, r.scheduled_at) BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
requested_reminder_types = sorted(reminder_kinds.intersection(normalized_types))
if requested_reminder_types:
query += " AND r.event_type = ANY(%s)"
params.append(requested_reminder_types)
if only_mine and resolved_user_id is not None:
query += " AND ((r.recipient_user_ids IS NOT NULL AND %s = ANY(r.recipient_user_ids)) OR r.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
title = f"Reminder: {row.get('title', 'Reminder')}"
case_title = row.get("sag_title")
if case_title:
title = f"{title} · {case_title}"
events.append(
_build_event(
"case_reminder",
title,
start_value,
f"/sag/{row.get('sag_id')}/v3",
{
"reference_id": row.get("id"),
"reference_type": "reminder",
"case_id": row.get("sag_id"),
"event_kind": row.get("event_type") or "reminder",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"event_type": row.get("event_type"),
"priority": row.get("priority"),
},
)
)
events.sort(key=lambda item: item.get("start") or "")
return events
@router.get("/calendar/events")
async def get_calendar_events(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Aggregate calendar events from sag deadlines, deferred dates, and reminders."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return {"events": events}
@router.get("/calendar/ical")
async def get_calendar_ical(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Serve calendar events as an iCal feed."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return _build_ical_response(events, now)
@router.get("/calendar/ical/subscribe")
async def get_calendar_ical_subscribe_link(
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
expires_at = datetime.utcnow() + timedelta(days=3650)
token = _create_calendar_feed_token(int(current_user.get("id")), expires_at)
base = str(request.base_url).rstrip("/")
http_url = f"{base}/api/v1/calendar/ical/feed?token={token}"
webcal_url = http_url.replace("https://", "webcals://", 1).replace("http://", "webcal://", 1)
return {
"http_url": http_url,
"webcal_url": webcal_url,
"expires_at": expires_at.isoformat() + "Z",
}
@router.get("/calendar/ical/feed")
async def get_calendar_ical_feed(
request: Request,
token: str = Query(...),
start: str = Query(None),
end: str = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Token-based iCal feed endpoint intended for external calendar subscriptions."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
user_id = _verify_calendar_feed_token(token)
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=True,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return _build_ical_response(events, now)