import logging from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import Response from app.core.database import execute_query logger = logging.getLogger(__name__) router = APIRouter() 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')}", { "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')}", { "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')}", { "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, ) lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//BMC Hub//Calendar//DA", "CALSCALE:GREGORIAN", "X-WR-CALNAME:BMC Hub Kalender", ] 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", )