- Implement SmsService class for sending SMS via CPSMS API. - Add SMS sending functionality in the frontend with validation and user feedback. - Create database migrations for SMS message storage and telephony features. - Introduce telephony settings and user-specific configurations for click-to-call functionality. - Enhance user experience with toast notifications for incoming calls and actions.
342 lines
11 KiB
Python
342 lines
11 KiB
Python
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",
|
|
)
|