bmc_hub/app/modules/telefoni/backend/router.py
Christian e6b4d8fb47 feat: add alert notes functionality with inline and modal display
- Implemented alert notes JavaScript module for loading and displaying alerts for customers and contacts.
- Created HTML template for alert boxes to display alerts inline on detail pages.
- Developed modal for creating and editing alert notes with form validation and user restrictions.
- Added modal for displaying alerts with acknowledgment functionality.
- Enhanced user experience with toast notifications for successful operations.
2026-02-17 12:49:11 +01:00

688 lines
25 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
import base64
from datetime import datetime
from typing import Optional
from urllib.error import URLError, HTTPError
from urllib.request import Request as UrlRequest, urlopen
from urllib.parse import urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from app.core.auth_service import AuthService
from app.core.auth_dependencies import require_permission
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.services.sms_service import SmsService
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
from .service import TelefoniService
from .utils import (
digits_only,
extract_extension,
ip_in_whitelist,
is_outbound_call,
normalize_e164,
phone_suffix_8,
)
from .websocket import manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/sms/send")
async def send_sms(payload: SmsSendRequest, request: Request):
user_id = getattr(request.state, "user_id", None)
logger.info("📨 SMS send requested by user_id=%s", user_id)
contact_id = payload.contact_id
if not contact_id:
suffix8 = phone_suffix_8(payload.to)
contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
contact_id = int(contact["id"]) if contact and contact.get("id") else None
if not contact_id:
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
try:
result = SmsService.send_sms(payload.to, payload.message, payload.sender)
execute_query(
"""
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
contact_id,
user_id,
result.get("recipient") or payload.to,
payload.sender or settings.SMS_SENDER,
payload.message,
"sent",
json.dumps(result.get("result") or {}),
),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
execute_query(
"""
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
contact_id,
user_id,
payload.to,
payload.sender or settings.SMS_SENDER,
payload.message,
"failed",
json.dumps({"error": str(e)}),
),
)
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
logger.error("❌ SMS send failed: %s", e)
raise HTTPException(status_code=500, detail="SMS send failed")
return {
"status": "ok",
**result,
}
def _get_client_ip(request: Request) -> str:
cf_ip = request.headers.get("cf-connecting-ip")
if cf_ip:
return cf_ip.strip()
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
if not accepted_tokens and not whitelist:
logger.error("❌ Telefoni callbacks are not secured (no TELEFONI_SHARED_SECRET or TELEFONI_IP_WHITELIST set)")
raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
if token and token.strip() in accepted_tokens:
return
if whitelist:
client_ip = _get_client_ip(request)
if ip_in_whitelist(client_ip, whitelist):
return
raise HTTPException(status_code=403, detail="Forbidden")
@router.get("/telefoni/established")
async def yealink_established(
request: Request,
callid: Optional[str] = Query(None),
call_id: Optional[str] = Query(None),
caller: Optional[str] = Query(None),
callee: Optional[str] = Query(None),
remote: Optional[str] = Query(None),
local: Optional[str] = Query(None),
active_user: Optional[str] = Query(None),
called_number: Optional[str] = Query(None),
token: Optional[str] = Query(None),
):
"""Yealink Action URL: Established"""
_validate_yealink_request(request, token)
resolved_callid = (callid or call_id or "").strip()
if not resolved_callid:
raise HTTPException(status_code=422, detail="Missing callid/call_id")
def _sanitize(value: Optional[str]) -> Optional[str]:
if value is None:
return None
v = value.strip()
if not v:
return None
if v.startswith("$"):
return None
return v
def _is_external_number(value: Optional[str]) -> bool:
d = digits_only(value)
return len(d) >= 8
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
d = digits_only(value)
if not d:
return False
local_d = digits_only(local_ext)
if local_d and d.endswith(local_d):
return True
return len(d) <= 6
local_value = _sanitize(local) or _sanitize(active_user)
caller_value = _sanitize(caller) or _sanitize(remote)
callee_value = _sanitize(callee)
called_number_value = _sanitize(called_number)
local_extension = extract_extension(local_value) or local_value
is_outbound = False
if called_number_value and _is_external_number(called_number_value):
is_outbound = True
elif caller_value and local_extension:
if not _is_internal_number(caller_value, local_extension):
is_outbound = is_outbound_call(caller_value, local_extension)
direction = "outbound" if is_outbound else "inbound"
candidates = [
callee_value,
called_number_value,
_sanitize(remote),
caller_value,
] if direction == "outbound" else [
caller_value,
_sanitize(remote),
callee_value,
called_number_value,
]
ekstern_raw = None
for candidate in candidates:
if candidate and _is_external_number(candidate):
ekstern_raw = candidate
break
if not ekstern_raw:
for candidate in candidates:
if candidate:
ekstern_raw = candidate
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
suffix8 = phone_suffix_8(ekstern_raw)
user_ids = TelefoniService.find_user_by_extension(local_extension)
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
kontakt_id = kontakt.get("id") if kontakt else None
# Get extended contact details if we found a contact
contact_details = {}
if kontakt_id:
contact_details = TelefoniService.get_contact_details(kontakt_id)
payload = {
"callid": resolved_callid,
"call_id": call_id,
"caller": caller_value,
"callee": callee_value,
"remote": remote,
"local": local_value,
"active_user": active_user,
"called_number": called_number_value,
"direction": direction,
"client_ip": _get_client_ip(request),
"user_agent": request.headers.get("user-agent"),
"received_at": datetime.utcnow().isoformat(),
}
# Store call with first user_id (or None if no users)
primary_user_id = user_ids[0] if user_ids else None
row = TelefoniService.upsert_call(
callid=resolved_callid,
user_id=primary_user_id,
direction=direction,
ekstern_nummer=ekstern_value,
intern_extension=(local_extension or "")[:16] or None,
kontakt_id=kontakt_id,
raw_payload=json.dumps(payload),
started_at=datetime.utcnow(),
)
# Send websocket notification to ALL users with this extension
if user_ids:
call_data = {
"call_id": str(row.get("id") or resolved_callid),
"number": ekstern_e164 or (ekstern_raw or ""),
"direction": direction,
"contact": kontakt,
"recent_cases": contact_details.get("recent_cases", []),
"last_call": contact_details.get("last_call"),
}
for user_id in user_ids:
await manager.send_to_user(user_id, "incoming_call", call_data)
logger.info("📞 Telefoni notification sent to %d user(s) for extension=%s", len(user_ids), local_extension)
else:
logger.info("⚠️ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid)
return {"status": "ok"}
@router.get("/telefoni/terminated")
async def yealink_terminated(
request: Request,
callid: Optional[str] = Query(None),
call_id: Optional[str] = Query(None),
duration: Optional[str] = Query(None),
call_duration: Optional[str] = Query(None),
token: Optional[str] = Query(None),
):
"""Yealink Action URL: Terminated"""
_validate_yealink_request(request, token)
resolved_callid = (callid or call_id or "").strip()
if not resolved_callid:
raise HTTPException(status_code=422, detail="Missing callid/call_id")
duration_raw = (duration or call_duration or "").strip() or None
duration_value: Optional[int] = None
if duration_raw:
try:
duration_value = int(duration_raw)
except ValueError:
# Accept common timer formats from PBX/phones: mm:ss or hh:mm:ss
if ":" in duration_raw:
parts = duration_raw.split(":")
try:
nums = [int(p) for p in parts]
if len(nums) == 2:
duration_value = nums[0] * 60 + nums[1]
elif len(nums) == 3:
duration_value = nums[0] * 3600 + nums[1] * 60 + nums[2]
except ValueError:
duration_value = None
if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
return {"status": "ok"}
@router.websocket("/telefoni/ws")
async def telefoni_ws(websocket: WebSocket):
token = websocket.query_params.get("token")
auth_header = (websocket.headers.get("authorization") or "").strip()
if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(token) if token else None
if not payload:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
if token and token.strip() in accepted_tokens:
user_id_param = websocket.query_params.get("user_id")
try:
user_id = int(user_id_param) if user_id_param else None
except (TypeError, ValueError):
user_id = None
if not user_id:
logger.info("⚠️ Telefoni WS rejected: shared secret requires user_id")
await websocket.close(code=1008)
return
else:
logger.info("⚠️ Telefoni WS rejected: invalid or missing token")
await websocket.close(code=1008)
return
else:
user_id_value = payload.get("sub") or payload.get("user_id")
try:
user_id = int(user_id_value)
except (TypeError, ValueError):
logger.info("⚠️ Telefoni WS rejected: invalid user id in token")
await websocket.close(code=1008)
return
await manager.connect(user_id, websocket)
logger.info("✅ Telefoni WS connected for user_id=%s", user_id)
try:
while True:
# Keep alive / ignore client messages
await websocket.receive_text()
except WebSocketDisconnect:
logger.info(" Telefoni WS disconnected for user_id=%s", user_id)
await manager.disconnect(user_id, websocket)
except Exception:
logger.info(" Telefoni WS disconnected (exception) for user_id=%s", user_id)
await manager.disconnect(user_id, websocket)
@router.post("/telefoni/test-popup")
async def telefoni_test_popup(
request: Request,
token: Optional[str] = Query(None),
user_id: Optional[int] = Query(None),
extension: Optional[str] = Query(None),
):
"""Trigger test popup for currently authenticated user."""
target_user_id = getattr(request.state, "user_id", None)
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
token_valid = bool(token and token.strip() in accepted_tokens)
if not target_user_id:
if not token_valid:
raise HTTPException(status_code=401, detail="Not authenticated")
if user_id:
target_user_id = int(user_id)
elif extension:
# find_user_by_extension now returns list - take first match for test
user_ids = TelefoniService.find_user_by_extension(extension)
target_user_id = user_ids[0] if user_ids else None
if not target_user_id:
raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth")
conn_count = await manager.connection_count_for_user(int(target_user_id))
await manager.send_to_user(
int(target_user_id),
"incoming_call",
{
"call_id": f"test-{int(datetime.utcnow().timestamp())}",
"number": "+4511223344",
"direction": "inbound",
"contact": {
"id": None,
"name": "Test popup",
"company": "BMC Hub",
},
"recent_cases": [
{"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()},
{"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()},
],
"last_call": {
"started_at": datetime.utcnow(),
"bruger_navn": "Test Medarbejder",
"duration_sec": 125,
},
},
)
return {
"status": "ok",
"message": "Test popup event sent",
"user_id": int(target_user_id),
"ws_connections": conn_count,
"auth_mode": "token" if token_valid and not getattr(request.state, "user_id", None) else "session",
}
@router.get("/telefoni/users")
async def list_telefoni_users():
"""List users for log filtering (auth-protected by middleware)."""
rows = execute_query(
"""
SELECT user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
FROM users
ORDER BY COALESCE(full_name, username) ASC
""",
(),
)
return rows or []
@router.patch("/telefoni/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
async def update_telefoni_user_mapping(user_id: int, data: TelefoniUserMappingUpdate):
existing = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not existing:
raise HTTPException(status_code=404, detail="User not found")
rows = execute_query(
"""
UPDATE users
SET
telefoni_extension = COALESCE(%s, telefoni_extension),
telefoni_aktiv = COALESCE(%s, telefoni_aktiv),
telefoni_phone_ip = COALESCE(%s, telefoni_phone_ip),
telefoni_phone_username = COALESCE(%s, telefoni_phone_username),
telefoni_phone_password = CASE
WHEN %s IS NULL OR %s = '' THEN telefoni_phone_password
ELSE %s
END
WHERE user_id = %s
RETURNING user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
""",
(
data.telefoni_extension,
data.telefoni_aktiv,
data.telefoni_phone_ip,
data.telefoni_phone_username,
data.telefoni_phone_password,
data.telefoni_phone_password,
data.telefoni_phone_password,
user_id,
),
)
return rows[0] if rows else {"status": "ok"}
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
if not row:
return default
value = row.get("value")
if value is None or value == "":
return default
return str(value)
@router.post("/telefoni/click-to-call")
async def click_to_call(payload: TelefoniClickToCallRequest):
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
if not enabled:
raise HTTPException(status_code=400, detail="Click-to-call is disabled")
template = _get_setting_value("telefoni_action_url_template", "") or ""
if not template:
raise HTTPException(status_code=400, detail="telefoni_action_url_template is not configured")
number_normalized = normalize_e164(payload.number) or payload.number.strip()
extension_value = (payload.extension or "").strip()
phone_ip_value = ""
phone_username_value = ""
phone_password_value = ""
if payload.user_id:
user_row = execute_query_single(
"""
SELECT telefoni_extension, telefoni_phone_ip, telefoni_phone_username, telefoni_phone_password
FROM users
WHERE user_id = %s
""",
(payload.user_id,),
)
if user_row:
if not extension_value:
extension_value = (user_row.get("telefoni_extension") or "").strip()
phone_ip_value = (user_row.get("telefoni_phone_ip") or "").strip()
phone_username_value = (user_row.get("telefoni_phone_username") or "").strip()
phone_password_value = (user_row.get("telefoni_phone_password") or "").strip()
if "{number}" not in template and "{raw_number}" not in template:
raise HTTPException(status_code=400, detail="Template must contain {number} or {raw_number}")
if "{phone_ip}" in template and not phone_ip_value:
raise HTTPException(status_code=400, detail="Template requires {phone_ip}, but selected user has no phone IP")
if "{phone_username}" in template and not phone_username_value:
raise HTTPException(status_code=400, detail="Template requires {phone_username}, but selected user has no phone username")
if "{phone_password}" in template and not phone_password_value:
raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password")
resolved_url = (
template
.replace("{number}", number_normalized)
.replace("{raw_number}", payload.number.strip())
.replace("{extension}", extension_value)
.replace("{phone_ip}", phone_ip_value)
.replace("{phone_username}", phone_username_value)
.replace("{phone_password}", phone_password_value)
)
auth_header: Optional[str] = None
if "@" in resolved_url:
parsed = urlsplit(resolved_url)
if not parsed.scheme or not parsed.netloc:
raise HTTPException(status_code=400, detail="Action URL template resolves to invalid URL")
netloc = parsed.netloc
if "@" in netloc:
userinfo, host_part = netloc.rsplit("@", 1)
if ":" in userinfo:
username, password = userinfo.split(":", 1)
else:
username, password = userinfo, ""
credentials = f"{username}:{password}".encode("utf-8")
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
try:
request = UrlRequest(resolved_url, method="GET")
if auth_header:
request.add_header("Authorization", auth_header)
with urlopen(request, timeout=8) as response:
status = getattr(response, "status", 200)
except HTTPError as e:
logger.error("❌ Click-to-call HTTP error: %s", e)
raise HTTPException(status_code=502, detail=f"Action URL returned HTTP {e.code}")
except URLError as e:
logger.error("❌ Click-to-call URL error: %s", e)
raise HTTPException(status_code=502, detail="Could not reach Action URL")
except Exception as e:
logger.error("❌ Click-to-call failed: %s", e)
raise HTTPException(status_code=500, detail="Click-to-call failed")
display_url = resolved_url
if auth_header:
display_url = "[basic-auth] " + resolved_url
return {
"status": "ok",
"action_url": display_url,
"http_status": status,
"number": number_normalized,
}
@router.get("/telefoni/calls")
async def list_calls(
user_id: Optional[int] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
without_case: bool = Query(False),
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
):
where = []
params = []
if user_id is not None:
where.append("t.bruger_id = %s")
params.append(user_id)
if date_from:
where.append("t.started_at >= %s")
params.append(date_from)
if date_to:
where.append("t.started_at <= %s")
params.append(date_to)
if without_case:
where.append("t.sag_id IS NULL")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
query = f"""
SELECT
t.id,
t.callid,
t.bruger_id,
t.direction,
t.ekstern_nummer,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS display_number,
t.intern_extension,
t.kontakt_id,
t.sag_id,
t.started_at,
t.ended_at,
t.duration_sec,
t.created_at,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name,
(
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS contact_company,
s.titel AS sag_titel
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
{where_sql}
ORDER BY t.started_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
rows = execute_query(query, tuple(params))
return rows or []
@router.patch("/telefoni/calls/{call_id}")
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
if not existing:
raise HTTPException(status_code=404, detail="Call not found")
fields = []
params = []
if "sag_id" in data.model_fields_set:
fields.append("sag_id = %s")
params.append(data.sag_id)
if "kontakt_id" in data.model_fields_set:
fields.append("kontakt_id = %s")
params.append(data.kontakt_id)
if not fields:
raise HTTPException(status_code=400, detail="No fields provided")
query = f"""
UPDATE telefoni_opkald
SET {", ".join(fields)}
WHERE id = %s
RETURNING *
"""
params.append(call_id)
rows = execute_query(query, tuple(params))
return rows[0] if rows else {"status": "ok"}