200 lines
12 KiB
Python
200 lines
12 KiB
Python
"""Transactional contact lifecycle scheduling using Hub's existing case contracts."""
|
|
import html
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
|
|
|
logger = logging.getLogger(__name__)
|
|
TZ = ZoneInfo("Europe/Copenhagen")
|
|
LABELS = {"onboarding": "Onboarding", "offboarding": "Offboarding"}
|
|
|
|
|
|
def scheduled_at(event_date, lead_days=14):
|
|
if isinstance(event_date, str):
|
|
event_date = date.fromisoformat(event_date)
|
|
return datetime.combine(event_date - timedelta(days=lead_days), time(8), TZ)
|
|
|
|
|
|
def _deadline(event_date):
|
|
"""Existing case timestamps store UTC without timezone information."""
|
|
if isinstance(event_date, str):
|
|
event_date = date.fromisoformat(event_date)
|
|
return datetime.combine(event_date, time(17), TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def closed_statuses():
|
|
values = {"lukket", "løst", "afsluttet", "closed", "resolved", "done", "annulleret", "cancelled", "canceled"}
|
|
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
|
|
if row and row.get("value"):
|
|
try:
|
|
options = row["value"] if isinstance(row["value"], list) else json.loads(row["value"])
|
|
values.update(str(item["value"]).strip().lower() for item in options
|
|
if isinstance(item, dict) and item.get("is_closed") and item.get("value"))
|
|
except (TypeError, ValueError):
|
|
logger.warning("Ignoring invalid case_statuses configuration")
|
|
return sorted(values)
|
|
|
|
|
|
def find_parent_candidates(contact_id, customer_id, event_type):
|
|
"""Only explicit type/tag classification and direct person linkage qualify."""
|
|
return execute_query("""SELECT s.id,s.titel,s.status,s.customer_id
|
|
FROM sag_sager s
|
|
WHERE s.customer_id=%s AND s.deleted_at IS NULL
|
|
AND LOWER(TRIM(s.status)) <> ALL(%s)
|
|
AND EXISTS(SELECT 1 FROM sag_kontakter k WHERE k.sag_id=s.id
|
|
AND k.contact_id=%s AND k.deleted_at IS NULL)
|
|
AND (LOWER(s.type)=%s OR LOWER(s.template_key)=%s
|
|
OR EXISTS(SELECT 1 FROM sag_tags t WHERE t.sag_id=s.id
|
|
AND t.deleted_at IS NULL AND LOWER(TRIM(t.tag_navn))=%s))
|
|
AND NOT EXISTS(SELECT 1 FROM sag_relationer r WHERE r."målsag_id"=s.id
|
|
AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL)
|
|
ORDER BY s.id DESC""",
|
|
(customer_id, closed_statuses(), contact_id, event_type, event_type, event_type)) or []
|
|
|
|
|
|
def update_created_case_date(lifecycle, previous_event_date, actor_id):
|
|
"""Called inside the API transaction; never changes the lifecycle row itself."""
|
|
if not lifecycle.get("case_id") or lifecycle["event_date"] == previous_event_date:
|
|
return {"status": lifecycle.get("status", "created"), "last_error": lifecycle.get("last_error")}
|
|
from app.core.case_visibility import require_case
|
|
from app.core.auth_service import AuthService
|
|
actor = {"id": actor_id, "permissions": AuthService.get_user_permissions(actor_id)}
|
|
require_case(actor, lifecycle["case_id"])
|
|
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE",
|
|
(lifecycle["case_id"],))
|
|
if not case or case.get("deleted_at") or str(case["status"]).strip().lower() in closed_statuses():
|
|
return {"status": "manual_review", "last_error": "Datoen er ændret. Sagen er afsluttet eller fjernet; kontrollér deadline manuelt."}
|
|
execute_query("UPDATE sag_sager SET deadline=%s,updated_at=NOW() WHERE id=%s",
|
|
(_deadline(lifecycle["event_date"]), case["id"]))
|
|
_comment(case["id"], f"{LABELS[lifecycle['event_type']]}dato ændret fra {previous_event_date} til {lifecycle['event_date']}. Deadline opdateret.")
|
|
return {"status": "created", "last_error": None}
|
|
|
|
|
|
def _comment(case_id, message):
|
|
execute_query("""INSERT INTO sag_kommentarer
|
|
(sag_id,forfatter,indhold,er_intern,er_system_besked)
|
|
VALUES(%s,'BMC Hub',%s,TRUE,TRUE)""", (case_id, html.escape(message)))
|
|
|
|
|
|
def _actor(event):
|
|
from app.core.auth_service import AuthService
|
|
user = execute_query_single("SELECT user_id,is_active,is_superadmin,full_name FROM users WHERE user_id=%s",
|
|
(event["created_by"],))
|
|
if not user or not user["is_active"]:
|
|
raise HTTPException(403, "Forløbets opretter er ikke længere aktiv. Gem forløbet med en aktiv bruger.")
|
|
user["id"] = user["user_id"]
|
|
user["permissions"] = AuthService.get_user_permissions(user["id"])
|
|
if not user["is_superadmin"]:
|
|
permissions = set(user["permissions"])
|
|
if not permissions.intersection({"cases.create", "tickets.create"}):
|
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette sager.")
|
|
if event.get("template_id") and "templates.run" not in permissions:
|
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at bruge skabelonen.")
|
|
return user
|
|
|
|
|
|
async def _create_case(event):
|
|
from app.core.case_visibility import require_case
|
|
actor = _actor(event)
|
|
if not execute_query_single("SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s",
|
|
(event["contact_id"], event["customer_id"])):
|
|
raise HTTPException(409, "Kontakten er ikke længere tilknyttet kunden. Vælg kunde igen.")
|
|
candidates = find_parent_candidates(event["contact_id"], event["customer_id"], event["event_type"])
|
|
parent_id = event.get("parent_case_id")
|
|
if parent_id and parent_id not in {row["id"] for row in candidates}:
|
|
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
|
|
updated_at=NOW() WHERE id=%s""",
|
|
("Den valgte hovedsag kan ikke længere bruges. Vælg hovedsag igen.", event["id"]))
|
|
return False
|
|
if not parent_id and len(candidates) > 1:
|
|
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
|
|
updated_at=NOW() WHERE id=%s""",
|
|
("Flere åbne sager matcher. Vælg hovedsag på kontaktkortet.", event["id"]))
|
|
return False
|
|
parent_id = parent_id or (candidates[0]["id"] if candidates else None)
|
|
if parent_id:
|
|
require_case(actor, parent_id)
|
|
if not actor["is_superadmin"] and not set(actor["permissions"]).intersection({"cases.edit", "tickets.edit"}):
|
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette undersager.")
|
|
# Lock and recheck before adding a relation; a concurrently closed parent cannot be used.
|
|
parent = execute_query_single("SELECT status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", (parent_id,))
|
|
if not parent or parent.get("deleted_at") or str(parent["status"]).strip().lower() in closed_statuses():
|
|
raise HTTPException(409, "Hovedsagen er afsluttet. Vælg hovedsag igen.")
|
|
contact = execute_query_single("SELECT first_name,last_name FROM contacts WHERE id=%s", (event["contact_id"],))
|
|
if not contact:
|
|
raise HTTPException(409, "Kontakten findes ikke længere.")
|
|
name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
|
|
label = LABELS[event["event_type"]]
|
|
responsible = event.get("responsible_user_id")
|
|
if responsible and not execute_query_single("SELECT user_id FROM users WHERE user_id=%s AND is_active", (responsible,)):
|
|
raise HTTPException(409, "Den ansvarlige bruger er ikke længere aktiv. Vælg ansvarlig igen.")
|
|
case = execute_query_single("""INSERT INTO sag_sager
|
|
(titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,created_by_user_id,deadline)
|
|
VALUES(%s,%s,'ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id""",
|
|
(f"{label}: {name}"[:255], f"{label} for {name}. Dato: {event['event_date']}.\nOprettet automatisk fra kontaktkortet.",
|
|
event["customer_id"], responsible, actor["id"], _deadline(event["event_date"])))
|
|
case_id = case["id"]
|
|
execute_query("INSERT INTO sag_kontakter(sag_id,contact_id,role,is_primary) VALUES(%s,%s,'Kontakt',TRUE)",
|
|
(case_id, event["contact_id"]))
|
|
execute_query("INSERT INTO sag_tags(sag_id,tag_navn) VALUES(%s,%s)", (case_id, event["event_type"]))
|
|
if parent_id:
|
|
execute_query('INSERT INTO sag_relationer(kilde_sag_id,"målsag_id",relationstype) VALUES(%s,%s,\'undersag\')',
|
|
(parent_id, case_id))
|
|
if event.get("template_id"):
|
|
if not execute_query_single("""SELECT id FROM task_templates WHERE id=%s
|
|
AND category=%s AND is_active AND deleted_at IS NULL""",
|
|
(event["template_id"], event["event_type"])):
|
|
raise HTTPException(409, "Skabelonen er ændret eller deaktiveret. Vælg skabelon igen.")
|
|
from app.modules.task_templates.backend.models import TemplateRunRequest
|
|
from app.modules.task_templates.backend.router import run_template_for_case
|
|
await run_template_for_case(case_id, TemplateRunRequest(template_id=event["template_id"], start_date=event["event_date"]), actor)
|
|
execute_query("""UPDATE contact_lifecycle_events SET case_id=%s,parent_case_id=%s,
|
|
status='created',last_error=NULL,updated_at=NOW() WHERE id=%s""",
|
|
(case_id, parent_id, event["id"]))
|
|
return True
|
|
|
|
|
|
async def process_due_lifecycles(now=None):
|
|
"""Row locks and a single transaction encompass case, relation and template writes."""
|
|
ready = execute_query_single("SELECT to_regclass('contact_lifecycle_events') IS NOT NULL AS ready")
|
|
if not ready or not ready["ready"]:
|
|
return 0
|
|
now = now or datetime.now(timezone.utc)
|
|
if now.tzinfo is None:
|
|
raise ValueError("now must include timezone")
|
|
rows = execute_query("""SELECT id FROM contact_lifecycle_events
|
|
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error')
|
|
AND ((event_date-lead_days)+TIME '08:00') AT TIME ZONE 'Europe/Copenhagen' <= %s
|
|
ORDER BY event_date,id LIMIT 100""", (now,)) or []
|
|
created = 0
|
|
for row in rows:
|
|
try:
|
|
with atomic_transaction():
|
|
event = execute_query_single("SELECT * FROM contact_lifecycle_events WHERE id=%s FOR UPDATE SKIP LOCKED", (row["id"],))
|
|
if not event or not event["auto_create"] or event.get("case_id") or event["status"] not in {"scheduled", "error"}:
|
|
continue
|
|
if scheduled_at(event["event_date"], event["lead_days"]) > now:
|
|
continue
|
|
did_create = await _create_case(event)
|
|
created += int(did_create)
|
|
except Exception as exc:
|
|
logger.exception("Contact lifecycle %s failed", row["id"])
|
|
message = str(exc.detail) if isinstance(exc, HTTPException) else "Sagen kunne ikke oprettes. Systemet prøver igen; kontakt administrator hvis fejlen fortsætter."
|
|
execute_query("""UPDATE contact_lifecycle_events SET status='error',last_error=%s,updated_at=NOW()
|
|
WHERE id=%s AND case_id IS NULL AND status IN ('scheduled','error')""", (message, row["id"]))
|
|
return created
|
|
|
|
|
|
async def check_contact_lifecycles():
|
|
"""Isolate lifecycle failures from other scheduled jobs."""
|
|
try:
|
|
return await process_due_lifecycles()
|
|
except Exception:
|
|
logger.exception("Contact lifecycle scheduler failed")
|
|
return 0
|