From 4822637466d5d89c382a5e231224ce91a78a8b93 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 21 Jun 2026 10:26:07 +0200 Subject: [PATCH] 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. --- app/modules/calendar/backend/router.py | 163 ++++++-- app/modules/calendar/templates/index.html | 46 +- app/modules/sag/templates/detail.html | 170 +++++++- app/modules/sag/templates/detail_v3.html | 170 +++++++- app/prepaid/backend/router.py | 90 +++- app/prepaid/backend/views.py | 13 +- app/prepaid/frontend/detail.html | 393 ++++++++++++++++-- app/prepaid/frontend/index.html | 32 +- app/settings/backend/router.py | 21 + app/settings/frontend/settings.html | 175 ++++++++ app/ticket/backend/models.py | 12 + app/ticket/backend/router.py | 89 +++- app/ticket/frontend/ticket_detail.html | 73 +++- app/timetracking/backend/order_service.py | 1 + app/timetracking/backend/router.py | 52 ++- .../194_time_entry_billing_extensions.sql | 113 +++++ 16 files changed, 1506 insertions(+), 107 deletions(-) create mode 100644 migrations/194_time_entry_billing_extensions.sql diff --git a/app/modules/calendar/backend/router.py b/app/modules/calendar/backend/router.py index 5a02e1e..de230d7 100644 --- a/app/modules/calendar/backend/router.py +++ b/app/modules/calendar/backend/router.py @@ -1,14 +1,98 @@ import logging +import hmac +import hashlib from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException, Query, Request +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 @@ -302,40 +386,53 @@ async def get_calendar_ical( types=types, ) - lines = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - "PRODID:-//BMC Hub//Calendar//DA", - "CALSCALE:GREGORIAN", - "X-WR-CALNAME:BMC Hub Kalender", - ] + return _build_ical_response(events, now) - 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", - ]) +@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) - lines.append("END:VCALENDAR") + return { + "http_url": http_url, + "webcal_url": webcal_url, + "expires_at": expires_at.isoformat() + "Z", + } - return Response( - content="\r\n".join(lines), - media_type="text/calendar; charset=utf-8", + +@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) diff --git a/app/modules/calendar/templates/index.html b/app/modules/calendar/templates/index.html index 71391e5..a322bd5 100644 --- a/app/modules/calendar/templates/index.html +++ b/app/modules/calendar/templates/index.html @@ -431,7 +431,15 @@
Status: Klar
- iCal: {{ request.base_url }}api/v1/calendar/ical +
Outlook iCal abonnement (opdateres lobende):
+ +
+ + +
+
Tip: Brug internetkalender-abonnement i Outlook, ikke import, for automatisk opdatering.
@@ -574,6 +582,9 @@ const rangeLabelEl = document.getElementById('rangeLabel'); const customerSelect = document.getElementById('customerSelect'); const customerSearch = document.getElementById('customerSearch'); + const icalSubscribeLink = document.getElementById('icalSubscribeLink'); + const copyIcalLinkBtn = document.getElementById('copyIcalLinkBtn'); + const copyWebcalLinkBtn = document.getElementById('copyWebcalLinkBtn'); const mineToggle = document.getElementById('mineToggle'); const allToggle = document.getElementById('allToggle'); const viewButtons = document.getElementById('viewButtons'); @@ -589,6 +600,38 @@ let onlyMine = true; + async function copyTextToClipboard(value) { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + calendarStatusEl.textContent = 'Link kopieret'; + } catch (err) { + console.warn('Clipboard write failed', err); + calendarStatusEl.textContent = 'Kunne ikke kopiere link'; + } + } + + async function loadIcalSubscriptionLink() { + if (!icalSubscribeLink) return; + try { + const response = await fetch('/api/v1/calendar/ical/subscribe'); + if (!response.ok) return; + const data = await response.json(); + if (data.http_url) { + icalSubscribeLink.href = data.http_url; + icalSubscribeLink.textContent = data.http_url; + } + if (copyIcalLinkBtn) { + copyIcalLinkBtn.onclick = () => copyTextToClipboard(data.http_url || ''); + } + if (copyWebcalLinkBtn) { + copyWebcalLinkBtn.onclick = () => copyTextToClipboard(data.webcal_url || ''); + } + } catch (err) { + console.warn('Could not load tokenized iCal subscription URL', err); + } + } + function setToggle(activeMine) { onlyMine = activeMine; mineToggle.classList.toggle('active', activeMine); @@ -717,6 +760,7 @@ calendar.render(); loadCustomers(); + loadIcalSubscriptionLink(); mineToggle.addEventListener('click', () => setToggle(true)); allToggle.addEventListener('click', () => setToggle(false)); diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 63d5e01..5a1a752 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -6248,7 +6248,7 @@
-
+
-
+
+ + +
+
@@ -10220,6 +10226,12 @@
+
+ + +
@@ -10403,6 +10415,13 @@
+
+ + +
Preset kan sætte tekst automatisk og gemme multiplier med registreringen.
+
@@ -10418,6 +10437,90 @@