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

439 lines
15 KiB
Python
Raw Normal View History

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)