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)