bmc_hub/app/contacts/backend/lifecycle_service.py
2026-11-03 09:08:51 +01:00

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