- Implemented the Drift module with a new FastAPI router and HTML templates for the frontend. - Created database tables for drift sources, devices, events, event history, and customer mappings. - Added functionality to manage and display drift events, including filtering and bulk mapping of monitors to customers. - Introduced tests for the Drift module, covering various functionalities including event blacklisting and UISP connector configuration. - Enhanced Telefoni service tests to ensure proper call termination handling.
389 lines
14 KiB
Python
389 lines
14 KiB
Python
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TelefoniService:
|
|
@staticmethod
|
|
def find_user_by_extension(extension: Optional[str]) -> list[int]:
|
|
"""Find all users with the given extension - returns list of user_ids."""
|
|
if not extension:
|
|
return []
|
|
rows = execute_query(
|
|
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s ORDER BY user_id",
|
|
(extension,),
|
|
)
|
|
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
|
|
|
|
@staticmethod
|
|
def find_contact_by_phone(number: Optional[str]) -> Optional[dict]:
|
|
"""Two-step lookup: full normalised number first, 8-digit suffix as fallback."""
|
|
from app.modules.telefoni.backend.utils import phone_digits_full, phone_suffix_8
|
|
|
|
full = phone_digits_full(number)
|
|
suffix = phone_suffix_8(number)
|
|
|
|
if not full and not suffix:
|
|
return None
|
|
|
|
_contact_cte = """
|
|
SELECT
|
|
c.id,
|
|
c.first_name,
|
|
c.last_name,
|
|
(
|
|
SELECT cu.id
|
|
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 company_id,
|
|
(
|
|
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 company,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM sag_kontakter sk
|
|
JOIN sag_sager s ON s.id = sk.sag_id
|
|
WHERE sk.contact_id = c.id
|
|
AND sk.deleted_at IS NULL
|
|
AND s.deleted_at IS NULL
|
|
AND LOWER(COALESCE(s.status, '')) <> 'lukket'
|
|
) AS open_case_count,
|
|
(
|
|
SELECT MAX(t.started_at)
|
|
FROM telefoni_opkald t
|
|
WHERE t.kontakt_id = c.id
|
|
) AS last_call_at
|
|
FROM contacts c
|
|
"""
|
|
|
|
row = None
|
|
|
|
# Step 1: exact full-digit match (strips country code first)
|
|
if full:
|
|
query_full = _contact_cte + """
|
|
WHERE regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g') LIKE %s
|
|
OR regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g') LIKE %s
|
|
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
|
|
LIMIT 1
|
|
"""
|
|
# Match ending with full digits (covers both with and without country code stored)
|
|
pattern = f"%{full}"
|
|
row = execute_query_single(query_full, (pattern, pattern))
|
|
if row:
|
|
logger.debug("📞 Phone lookup: full-digit match for %s → contact %s", number, row["id"])
|
|
|
|
# Step 2: 8-digit suffix fallback
|
|
if not row and suffix:
|
|
query_suffix = _contact_cte + """
|
|
WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
|
|
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
|
|
ORDER BY open_case_count DESC, last_call_at DESC NULLS LAST, c.id ASC
|
|
LIMIT 1
|
|
"""
|
|
row = execute_query_single(query_suffix, (suffix, suffix))
|
|
if row:
|
|
logger.debug("📞 Phone lookup: suffix-8 fallback for %s → contact %s", number, row["id"])
|
|
|
|
if not row:
|
|
return None
|
|
return {
|
|
"id": row["id"],
|
|
"name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
|
|
"company_id": row.get("company_id"),
|
|
"company": row.get("company"),
|
|
}
|
|
|
|
@staticmethod
|
|
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
|
|
"""Deprecated: use find_contact_by_phone(). Kept for backward compatibility."""
|
|
return TelefoniService.find_contact_by_phone(suffix8)
|
|
|
|
@staticmethod
|
|
def upsert_call(
|
|
*,
|
|
callid: str,
|
|
user_id: Optional[int],
|
|
direction: str,
|
|
ekstern_nummer: Optional[str],
|
|
intern_extension: Optional[str],
|
|
kontakt_id: Optional[int],
|
|
raw_payload: Any,
|
|
started_at: datetime,
|
|
) -> dict:
|
|
query = """
|
|
INSERT INTO telefoni_opkald
|
|
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
|
|
VALUES
|
|
(%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
ON CONFLICT (callid)
|
|
DO UPDATE SET
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
direction = EXCLUDED.direction,
|
|
intern_extension = COALESCE(telefoni_opkald.intern_extension, EXCLUDED.intern_extension),
|
|
ekstern_nummer = COALESCE(telefoni_opkald.ekstern_nummer, EXCLUDED.ekstern_nummer),
|
|
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
|
|
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
|
|
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
|
|
RETURNING *
|
|
"""
|
|
rows = execute_query(
|
|
query,
|
|
(
|
|
callid,
|
|
user_id,
|
|
direction,
|
|
ekstern_nummer,
|
|
intern_extension,
|
|
kontakt_id,
|
|
started_at,
|
|
raw_payload,
|
|
),
|
|
)
|
|
return rows[0] if rows else {}
|
|
|
|
@staticmethod
|
|
def terminate_call(callid: str, duration_sec: Optional[int]) -> bool:
|
|
if not callid:
|
|
return False
|
|
|
|
rows = execute_query(
|
|
"""
|
|
INSERT INTO telefoni_opkald
|
|
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
|
|
VALUES
|
|
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
|
|
ON CONFLICT (callid)
|
|
DO UPDATE SET
|
|
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
|
|
duration_sec = COALESCE(
|
|
EXCLUDED.duration_sec,
|
|
CASE
|
|
WHEN telefoni_opkald.started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int, 0)
|
|
ELSE NULL
|
|
END
|
|
)
|
|
RETURNING id
|
|
""",
|
|
(callid, duration_sec),
|
|
)
|
|
return bool(rows)
|
|
|
|
@staticmethod
|
|
def get_contact_details(contact_id: int, company_id: Optional[int] = None) -> dict:
|
|
"""Get extended contact details for telefoni popups and call notifications."""
|
|
if not contact_id:
|
|
return {
|
|
"recent_cases": [],
|
|
"contact_cases": [],
|
|
"company_cases": [],
|
|
"related_contacts": [],
|
|
"last_call": None,
|
|
}
|
|
|
|
contact_row = execute_query_single(
|
|
"""
|
|
SELECT
|
|
c.id,
|
|
c.first_name,
|
|
c.last_name,
|
|
c.email,
|
|
c.phone,
|
|
c.mobile,
|
|
c.title,
|
|
c.department,
|
|
c.is_active,
|
|
c.user_company,
|
|
(
|
|
SELECT cu.id
|
|
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 company_id,
|
|
(
|
|
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 company
|
|
FROM contacts c
|
|
WHERE c.id = %s
|
|
""",
|
|
(contact_id,),
|
|
)
|
|
|
|
effective_company_id = company_id or (contact_row.get("company_id") if contact_row else None)
|
|
|
|
open_cases_query = """
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.created_at
|
|
FROM sag_sager s
|
|
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
WHERE sk.contact_id = %s
|
|
AND s.status = 'åben'
|
|
AND s.deleted_at IS NULL
|
|
AND sk.deleted_at IS NULL
|
|
ORDER BY s.created_at DESC
|
|
LIMIT 3
|
|
"""
|
|
recent_open_cases = execute_query(open_cases_query, (contact_id,)) or []
|
|
|
|
contact_cases_query = """
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.customer_id,
|
|
cu.name AS customer_name,
|
|
s.created_at,
|
|
s.updated_at
|
|
FROM sag_sager s
|
|
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
WHERE sk.contact_id = %s
|
|
AND s.deleted_at IS NULL
|
|
AND sk.deleted_at IS NULL
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
LIMIT 5
|
|
"""
|
|
contact_cases = execute_query(contact_cases_query, (contact_id,)) or []
|
|
|
|
company_cases = []
|
|
related_contacts = []
|
|
if effective_company_id:
|
|
company_cases_query = """
|
|
SELECT
|
|
s.id,
|
|
s.titel,
|
|
s.status,
|
|
s.customer_id,
|
|
cu.name AS customer_name,
|
|
s.created_at,
|
|
s.updated_at
|
|
FROM sag_sager s
|
|
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
WHERE s.customer_id = %s
|
|
AND s.deleted_at IS NULL
|
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
LIMIT 5
|
|
"""
|
|
company_cases = execute_query(company_cases_query, (effective_company_id,)) or []
|
|
|
|
related_contacts_query = """
|
|
SELECT
|
|
c.id,
|
|
c.first_name,
|
|
c.last_name,
|
|
c.email,
|
|
c.phone,
|
|
c.mobile,
|
|
c.title,
|
|
c.department,
|
|
c.is_active,
|
|
c.created_at,
|
|
c.updated_at,
|
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
FROM contacts c
|
|
INNER JOIN contact_companies cc ON c.id = cc.contact_id
|
|
INNER JOIN customers cu ON cc.customer_id = cu.id
|
|
WHERE cc.customer_id = %s
|
|
AND c.id <> %s
|
|
AND c.is_active = TRUE
|
|
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
ORDER BY c.last_name, c.first_name
|
|
LIMIT 8
|
|
"""
|
|
related_contacts = execute_query(related_contacts_query, (effective_company_id, contact_id)) or []
|
|
|
|
last_call_query = """
|
|
SELECT
|
|
t.started_at,
|
|
t.bruger_id,
|
|
t.duration_sec,
|
|
u.full_name,
|
|
u.username
|
|
FROM telefoni_opkald t
|
|
LEFT JOIN users u ON t.bruger_id = u.user_id
|
|
WHERE t.kontakt_id = %s
|
|
AND t.ended_at IS NOT NULL
|
|
ORDER BY t.started_at DESC
|
|
LIMIT 1
|
|
"""
|
|
last_call_row = execute_query_single(last_call_query, (contact_id,))
|
|
|
|
last_call_data = None
|
|
if last_call_row:
|
|
last_call_data = {
|
|
"started_at": last_call_row.get("started_at"),
|
|
"bruger_navn": last_call_row.get("full_name") or last_call_row.get("username"),
|
|
"duration_sec": last_call_row.get("duration_sec"),
|
|
}
|
|
|
|
return {
|
|
"recent_cases": [
|
|
{
|
|
"id": case["id"],
|
|
"titel": case["titel"],
|
|
"created_at": case["created_at"],
|
|
}
|
|
for case in recent_open_cases
|
|
],
|
|
"contact_cases": [
|
|
{
|
|
"id": case["id"],
|
|
"titel": case["titel"],
|
|
"status": case.get("status"),
|
|
"customer_id": case.get("customer_id"),
|
|
"customer_name": case.get("customer_name"),
|
|
"created_at": case.get("created_at"),
|
|
"updated_at": case.get("updated_at"),
|
|
}
|
|
for case in contact_cases
|
|
],
|
|
"company_cases": [
|
|
{
|
|
"id": case["id"],
|
|
"titel": case["titel"],
|
|
"status": case.get("status"),
|
|
"customer_id": case.get("customer_id"),
|
|
"customer_name": case.get("customer_name"),
|
|
"created_at": case.get("created_at"),
|
|
"updated_at": case.get("updated_at"),
|
|
}
|
|
for case in company_cases
|
|
],
|
|
"related_contacts": [
|
|
{
|
|
"id": contact["id"],
|
|
"first_name": contact.get("first_name"),
|
|
"last_name": contact.get("last_name"),
|
|
"email": contact.get("email"),
|
|
"phone": contact.get("phone"),
|
|
"mobile": contact.get("mobile"),
|
|
"title": contact.get("title"),
|
|
"department": contact.get("department"),
|
|
"is_active": contact.get("is_active"),
|
|
"company_names": contact.get("company_names") or [],
|
|
}
|
|
for contact in related_contacts
|
|
],
|
|
"last_call": last_call_data,
|
|
}
|