bmc_hub/app/modules/calendar/backend/router.py

342 lines
11 KiB
Python
Raw Normal View History

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",
)