release: v2.8.22

This commit is contained in:
Christian 2026-11-03 09:08:51 +01:00
parent 1fb5b4a191
commit 47afd41877
17 changed files with 947 additions and 8 deletions

2
.gitignore vendored
View File

@ -28,4 +28,6 @@ htmlcov/
.coverage .coverage
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
# Generated Graphify graphs and caches (keep local only)
graphify-out/
RELEASE_NOTES_v2.2.38.md RELEASE_NOTES_v2.2.38.md

View File

@ -0,0 +1,22 @@
# BMC Hub v2.8.22
## Arbejdsgange, sager og tid
- Tilføjer den reversible workflow-motor med kontrolleret aktivering, preview og revisionsspor for afregning.
- Udvider sagsarbejdsrummet, tidsregistrering og planlægning med tydeligere kapacitet, opgaver og opfølgning.
- Tilføjer sikker fjernstyring og forbedrede sagskommandoer uden ændring af eksisterende API-kontrakter.
## Kunder, e-mail og drift
- Forbedrer kunde- og kontaktarbejdsgange, e-mailidentitet og routing samt Drift-filtersikkerhed.
- Tilføjer planlagt onboarding/offboarding på kontaktkortet med eksplicit sagsoprettelse og audit-spor.
- Udvider hardware-intake, leverandørdata og økonomiske integrationsvisninger.
## Database
Kør migrationerne `1053` til `1076` i numerisk rækkefølge før eller sammen med opstart af versionen. Migration `1076_contact_lifecycle.sql` opretter kun nye tabeller og indeks.
## Validering
- Python: `986 passed, 103 skipped` med workflow- og remote-feature-flags slået fra i unit-testmiljøet.
- JavaScript: `86 passed`.

View File

@ -1 +1 @@
2.8.21 2.8.22

View File

@ -0,0 +1,213 @@
"""Additive, customer-scoped contact onboarding and offboarding API."""
from datetime import date
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.core.auth_dependencies import require_any_permission
from app.core.case_visibility import hidden_case_ids, require_case
from app.core.database import atomic_transaction, execute_query, execute_query_single
router = APIRouter()
read_access = require_any_permission('cases.view', 'tickets.view')
edit_access = require_any_permission('cases.edit', 'tickets.edit')
EventType = Literal['onboarding', 'offboarding']
class LifecycleCreate(BaseModel):
customer_id: int = Field(gt=0)
event_type: EventType
event_date: date
lead_days: int = Field(default=14, ge=0, le=3650)
auto_create: bool = False
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
class LifecycleUpdate(BaseModel):
customer_id: Optional[int] = Field(default=None, gt=0)
event_date: Optional[date] = None
lead_days: Optional[int] = Field(default=None, ge=0, le=3650)
auto_create: Optional[bool] = None
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
def _ready():
if not (execute_query_single("SELECT to_regclass('public.contact_lifecycle_events') AS relation") or {}).get('relation'):
raise HTTPException(503, detail='Onboarding/offboarding afventer databasemigration 1076')
def _contact(contact_id):
row = execute_query_single('SELECT id FROM contacts WHERE id=%s', (contact_id,))
if not row:
raise HTTPException(404, detail='Kontaktpersonen findes ikke')
def _membership(contact_id, customer_id):
if not execute_query_single('SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s',
(contact_id, customer_id)):
raise HTTPException(400, detail='Kunden er ikke tilknyttet kontaktpersonen')
def _has(user, *permissions):
return bool(user.get('is_superadmin') or set(permissions).intersection(user.get('permissions') or []))
def _validate(row, user):
_membership(row['contact_id'], row['customer_id'])
if row['auto_create'] and not _has(user, 'cases.create', 'tickets.create'):
raise HTTPException(403, detail='Du mangler rettighed til at oprette sager')
if row.get('responsible_user_id') and not execute_query_single(
'SELECT user_id FROM users WHERE user_id=%s AND is_active=TRUE', (row['responsible_user_id'],)
):
raise HTTPException(400, detail='Den ansvarlige bruger er ikke aktiv')
if row.get('template_id'):
if not _has(user, 'templates.run'):
raise HTTPException(403, detail='Du mangler rettighed til at anvende skabeloner')
if not execute_query_single('''SELECT id FROM task_templates WHERE id=%s
AND is_active=TRUE AND deleted_at IS NULL AND category=%s
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=%s))''',
(row['template_id'], row['event_type'], row['customer_id'])):
raise HTTPException(400, detail='Skabelonen er ikke tilgængelig for kunden og forløbstypen')
from app.contacts.backend.lifecycle_service import find_parent_candidates
candidates = find_parent_candidates(row['contact_id'], row['customer_id'], row['event_type'])
hidden = hidden_case_ids(user)
candidates = [c for c in candidates if c['id'] not in hidden]
# A generated case must never become its own parent during later edits.
candidates = [c for c in candidates if c['id'] != row.get('case_id')]
if row.get('parent_case_id') and row['parent_case_id'] not in [c['id'] for c in candidates]:
# Parent can have been closed after creation: retaining it is valid; changing it is not.
if not row.get('case_id'):
raise HTTPException(400, detail='Hovedsagen skal være en åben sag for samme kontakt, kunde og type')
return candidates
def _serialize(row):
from app.contacts.backend.lifecycle_service import scheduled_at
result = dict(row)
result['scheduled_at'] = scheduled_at(row['event_date'], row['lead_days']).isoformat()
return result
def _find_event(contact_id, event_id, lock=False):
row = execute_query_single('SELECT * FROM contact_lifecycle_events WHERE contact_id=%s AND id=%s' +
(' FOR UPDATE' if lock else ''), (contact_id, event_id))
if not row:
raise HTTPException(404, detail='Forløbet findes ikke')
return row
@router.get('/contacts/{contact_id}/lifecycle')
def list_lifecycle(contact_id: int, current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
companies = execute_query('''SELECT c.id,c.name FROM customers c JOIN contact_companies cc
ON cc.customer_id=c.id WHERE cc.contact_id=%s ORDER BY c.name''', (contact_id,)) or []
templates = []
if _has(current_user, 'templates.view', 'templates.run'):
templates = execute_query('''SELECT id,name,category,customer_id,template_type FROM task_templates
WHERE deleted_at IS NULL AND is_active=TRUE AND category IN ('onboarding','offboarding')
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=ANY(%s)))
ORDER BY name''', ([c['id'] for c in companies],)) or []
hidden = hidden_case_ids(current_user)
items = []
for row in execute_query(
'SELECT * FROM contact_lifecycle_events WHERE contact_id=%s ORDER BY event_date DESC,id DESC',
(contact_id,)) or []:
item = _serialize(row)
for key in ('case_id', 'parent_case_id'):
if item.get(key) in hidden:
item[key] = None
items.append(item)
return {'items': items, 'companies': companies, 'templates': templates,
'can_edit': _has(current_user, 'cases.edit', 'tickets.edit'),
'users': execute_query('''SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name
FROM users WHERE is_active=TRUE ORDER BY name''') or []}
@router.get('/contacts/{contact_id}/lifecycle/preview')
def preview_lifecycle(contact_id: int, customer_id: int = Query(gt=0),
event_type: EventType = Query(), event_date: date = Query(),
lead_days: int = Query(default=14, ge=0, le=3650),
current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
_membership(contact_id, customer_id)
from app.contacts.backend.lifecycle_service import find_parent_candidates, scheduled_at
candidates = find_parent_candidates(contact_id, customer_id, event_type)
hidden = hidden_case_ids(current_user)
candidates = [c for c in candidates if c['id'] not in hidden]
return {'scheduled_at': scheduled_at(event_date, lead_days).isoformat(), 'candidates': candidates,
'parent_case_id': candidates[0]['id'] if len(candidates) == 1 else None,
'needs_parent': len(candidates) > 1}
@router.post('/contacts/{contact_id}/lifecycle', status_code=201)
def create_lifecycle(contact_id: int, payload: LifecycleCreate, current_user: dict = Depends(edit_access)):
_ready()
_contact(contact_id)
row = dict(payload.model_dump(), contact_id=contact_id)
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row['parent_case_id'] else 'scheduled')
columns = list(row)
result = execute_query_single(f'''INSERT INTO contact_lifecycle_events
({','.join(columns)},status,created_by) VALUES ({','.join(['%s'] * len(columns))},%s,%s) RETURNING *''',
tuple(row.values()) + (status, current_user['id']))
return _serialize(result)
@router.patch('/contacts/{contact_id}/lifecycle/{event_id}')
def update_lifecycle(contact_id: int, event_id: int, payload: LifecycleUpdate,
current_user: dict = Depends(edit_access)):
_ready()
updates = payload.model_dump(exclude_unset=True)
for required in ('customer_id', 'event_date', 'lead_days', 'auto_create'):
if required in updates and updates[required] is None:
raise HTTPException(422, detail=f'{required} må ikke være tom')
with atomic_transaction():
previous = _find_event(contact_id, event_id, lock=True)
if previous.get('case_id'):
require_case(current_user, previous['case_id'])
if previous['status'] == 'cancelled':
raise HTTPException(409, detail='Et annulleret forløb kan ikke ændres; opret et nyt')
row = dict(previous, **updates)
if row.get('case_id') and any(row.get(k) != previous.get(k) for k in
('customer_id', 'template_id', 'parent_case_id', 'responsible_user_id')):
raise HTTPException(409, detail='Kunde, skabelon, hovedsag og ansvarlig ændres på den oprettede sag')
status = previous['status']
last_error = previous.get('last_error')
if row.get('case_id'):
if row['event_date'] != previous['event_date']:
from app.contacts.backend.lifecycle_service import update_created_case_date
result = update_created_case_date(row, previous['event_date'], current_user['id'])
status, last_error = result['status'], result.get('last_error')
else:
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row.get('parent_case_id') else 'scheduled')
last_error = None
if row['auto_create']:
# The last user authorizing automation is the actor whose rights
# are checked by the worker, including when a disabled user is replaced.
updates['created_by'] = current_user['id']
updates.update(status=status, last_error=last_error)
columns = list(updates)
result = execute_query_single(f'''UPDATE contact_lifecycle_events SET
{','.join(c + '=%s' for c in columns)},updated_at=NOW() WHERE id=%s RETURNING *''',
tuple(updates.values()) + (event_id,))
return _serialize(result)
@router.post('/contacts/{contact_id}/lifecycle/{event_id}/cancel')
def cancel_lifecycle(contact_id: int, event_id: int, current_user: dict = Depends(edit_access)):
_ready()
with atomic_transaction():
_find_event(contact_id, event_id, lock=True)
row = execute_query_single('''UPDATE contact_lifecycle_events SET status='cancelled',auto_create=FALSE,
last_error=NULL,updated_at=NOW() WHERE id=%s RETURNING *''', (event_id,))
return _serialize(row)

View File

@ -0,0 +1,199 @@
"""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

View File

@ -34,6 +34,8 @@ from email.utils import parseaddr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
from app.contacts.backend.lifecycle import router as lifecycle_router
router.include_router(lifecycle_router)
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
@ -687,6 +689,9 @@ def _contact_merge_counts(contact_id: int) -> list[dict]:
for label, table, column in CONTACT_MERGE_RELATIONS: for label, table, column in CONTACT_MERGE_RELATIONS:
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {} row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)}) counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
if (execute_query_single("SELECT to_regclass('contact_lifecycle_events') AS relation") or {}).get('relation'):
row = execute_query_single("SELECT COUNT(*)::int AS count FROM contact_lifecycle_events WHERE contact_id=%s", (contact_id,)) or {}
counts.append({"key": "contact_lifecycle_events", "label": "Onboarding/offboarding", "count": int(row.get('count') or 0)})
conversation_row = execute_query_single( conversation_row = execute_query_single(
""" """
SELECT COUNT(DISTINCT conversation.id)::int AS count SELECT COUNT(DISTINCT conversation.id)::int AS count
@ -817,6 +822,11 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
moved[table] = int(cursor.fetchone()["count"] or 0) moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id)) cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
cursor.execute("SELECT to_regclass('contact_lifecycle_events') AS relation")
if cursor.fetchone()['relation']:
cursor.execute("UPDATE contact_lifecycle_events SET contact_id=%s,updated_at=NOW() WHERE contact_id=%s", (contact_id, source_id))
moved['contact_lifecycle_events'] = cursor.rowcount
cursor.execute( cursor.execute(
""" """
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations) INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)

View File

@ -314,6 +314,14 @@
</div> </div>
</div> </div>
<div class="col-12">
<div class="info-card" id="contactLifecycle" aria-labelledby="contactLifecycleHeading">
<h5 class="fw-bold mb-2" id="contactLifecycleHeading">Onboarding og offboarding</h5>
<p class="text-muted small mb-3">Planlæg start og fratrædelse, og få oprettet en sag i god tid.</p>
<div id="contactLifecycleFeedback" class="d-none" role="status" aria-live="polite"></div>
<div class="row g-3" id="contactLifecycleCards"><div class="col-12 text-muted">Henter forløb…</div></div>
</div>
</div>
<div class="col-12"> <div class="col-12">
<div class="info-card"> <div class="info-card">
<h5 class="fw-bold mb-3">System Info</h5> <h5 class="fw-bold mb-3">System Info</h5>
@ -934,6 +942,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/contact-lifecycle.js?v=20261102-1"></script>
<!-- Alert Notes Components --> <!-- Alert Notes Components -->
<script src="/static/alert_notes/alert_check.js"></script> <script src="/static/alert_notes/alert_check.js"></script>
{% include "alert_notes/frontend/alert_box.html" %} {% include "alert_notes/frontend/alert_box.html" %}

36
docs/CONTACT_LIFECYCLE.md Normal file
View File

@ -0,0 +1,36 @@
# Onboarding og offboarding på kontaktkortet
Kontaktkortets oversigt viser separate forløb. En dato kan gemmes uden automatik.
Automatik bruger kalenderdage og kl. 08 i Europe/Copenhagen, kontrolleres ved
API-start og hvert femte minut og indhenter forfaldne forløb efter nedetid.
Sagens deadline sættes til kl. 17 på datoen, gemt som UTC efter sagsmodulets konvention.
Åbne hovedsager matches på kunde, direkte kontaktrelation og eksplicit type/tag.
En kandidat giver en undersag; flere kræver valg på kontaktkortet. En oprettet
sags reference bevares ved datoændring. Afsluttede sager kræver manuel behandling.
Annullering stopper automatikken og sletter ingen sager.
## Installation
Anvend kun `migrations/1076_contact_lifecycle.sql` i det ønskede miljø og genstart
API'et. Migrationen tilføjer en tabel og indeks uden at ændre eksisterende data.
Uden migration svarer de nye endpoints med 503; eksisterende kontakt-API bevares.
Ingen forløb eller sager oprettes af migrationen.
API: GET/POST `/api/v1/contacts/{id}/lifecycle`, GET `/preview`,
PATCH `/{event_id}` og POST `/{event_id}/cancel` under samme base.
Læsning kræver cases.view/tickets.view; redigering cases.edit/tickets.edit.
Automatik kræver desuden sagsoprettelse og skabeloner templates.run.
Automatikkens seneste godkender skal fortsat være aktiv og have rettighederne.
## Kontrol og drift
Kør `pytest tests/test_contact_lifecycle_api.py tests/test_contact_lifecycle_worker.py tests/test_contacts_router_simple.py`
med projektets venv og `node tests/js/contact-lifecycle.test.cjs`.
Kontrollér i browseren: gem dato uden automatik, planlæg automatisk forløb,
vælg mellem flere hovedsager, åbn oprettet sag, ændr dato og annuller.
Kortet viser fejl og behov for manuel behandling. Tekniske fejl logges af
jobbet `check_contact_lifecycles`; forløb i fejl genforsøges ved næste kørsel.
Ved tilbagerulning deaktiveres jobbet/kodeændringen; behold tabellen for at
bevare historik og referencer. Sammenlægning af kontakter flytter forløbene med.

13
main.py
View File

@ -203,6 +203,19 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ Reminder job scheduled (every 5 minutes)") logger.info("✅ Reminder job scheduled (every 5 minutes)")
# Contact lifecycle automation is independent of reminder notification settings.
from datetime import datetime, timezone
from app.contacts.backend.lifecycle_service import check_contact_lifecycles
backup_scheduler.scheduler.add_job(
func=check_contact_lifecycles,
trigger=IntervalTrigger(minutes=5),
id='check_contact_lifecycles',
name='Contact Onboarding / Offboarding',
next_run_time=datetime.now(timezone.utc),
max_instances=1,
replace_existing=True,
)
# Register subscription invoice processing job # Register subscription invoice processing job
from app.jobs.process_subscriptions import process_subscriptions from app.jobs.process_subscriptions import process_subscriptions

View File

@ -0,0 +1,23 @@
-- Additive contact onboarding/offboarding planning. Existing contacts and cases are unchanged.
CREATE TABLE IF NOT EXISTS contact_lifecycle_events (
id BIGSERIAL PRIMARY KEY,
contact_id INTEGER NOT NULL REFERENCES contacts(id),
customer_id INTEGER NOT NULL REFERENCES customers(id),
event_type TEXT NOT NULL CHECK (event_type IN ('onboarding', 'offboarding')),
event_date DATE NOT NULL,
lead_days INTEGER NOT NULL DEFAULT 14 CHECK (lead_days BETWEEN 0 AND 3650),
auto_create BOOLEAN NOT NULL DEFAULT FALSE,
template_id INTEGER REFERENCES task_templates(id),
responsible_user_id INTEGER REFERENCES users(user_id),
parent_case_id INTEGER REFERENCES sag_sager(id),
case_id INTEGER UNIQUE REFERENCES sag_sager(id),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN
('draft','scheduled','needs_parent','created','manual_review','cancelled','error')),
last_error TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS contact_lifecycle_contact_idx ON contact_lifecycle_events(contact_id);
CREATE INDEX IF NOT EXISTS contact_lifecycle_due_idx ON contact_lifecycle_events(event_date)
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error');

View File

@ -0,0 +1,157 @@
(function (root) {
'use strict';
const labels = {onboarding: 'Onboarding', offboarding: 'Offboarding'};
const statuses = {draft: 'Dato gemt · automatik slået fra', scheduled: 'Planlagt', needs_parent: 'Vælg hovedsag', created: 'Sag oprettet', manual_review: 'Kræver manuel behandling', cancelled: 'Annulleret', error: 'Oprettelse fejlede'};
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const date = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium'}).format(new Date(value.length === 10 ? value + 'T12:00:00Z' : value)) : 'Ikke planlagt';
const schedule = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium', timeStyle:'short'}).format(new Date(value)) : '';
function templateOptions(templates, customerId, type) {
return templates.filter(item => (['global','internal'].includes(item.template_type) || !item.customer_id || Number(item.customer_id) === Number(customerId)) && (!item.category || item.category === type));
}
function editPayload(payload, created) {
if (!created) return payload;
return {event_date:payload.event_date,lead_days:payload.lead_days,auto_create:payload.auto_create};
}
root.ContactLifecycle = {escape, templateOptions, editPayload};
if (typeof document === 'undefined') return;
document.addEventListener('DOMContentLoaded', () => {
const cards = document.getElementById('contactLifecycleCards');
if (!cards) return;
const contactId = Number(location.pathname.split('/').filter(Boolean).pop());
const base = `/api/v1/contacts/${contactId}/lifecycle`;
let data = {items:[],companies:[],users:[],templates:[]};
let current = null;
let previewVersion = 0;
const feedback = document.getElementById('contactLifecycleFeedback');
async function request(url, method = 'GET', body) {
const headers = {'Content-Type':'application/json'};
const token = localStorage.getItem('access_token');
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(url, {method, headers, credentials:'same-origin', ...(body === undefined ? {} : {body:JSON.stringify(body)})});
const result = await response.json();
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne, og prøv igen.');
return result;
}
function notify(message, danger = false) {
feedback.className = `alert ${danger ? 'alert-danger' : 'alert-success'} py-2`;
feedback.textContent = message;
}
async function load() {
try {
data = await request(base);
cards.innerHTML = Object.entries(labels).map(([type,label]) => {
const items = data.items.filter(item => item.event_type === type);
const live = items.filter(item => item.status !== 'cancelled');
return `<div class="col-md-6"><div class="border rounded-3 p-3 h-100"><div class="d-flex align-items-center justify-content-between gap-2 mb-3"><h6 class="fw-bold mb-0">${label}</h6><button type="button" class="btn btn-sm btn-outline-primary" data-lifecycle-new="${type}"><i class="bi bi-plus-lg me-1"></i>Nyt forløb</button></div>${live.length ? live.map(item => `<div class="${live.length > 1 ? 'border-top pt-2 mt-2' : ''}"><div class="fw-semibold">${escape(date(item.event_date))}</div><div class="small text-muted">${escape((data.companies.find(company => Number(company.id) === Number(item.customer_id)) || {}).name || '')}</div><div class="small mt-1 ${['error','needs_parent','manual_review'].includes(item.status) ? 'text-warning-emphasis' : 'text-muted'}">${escape(statuses[item.status] || item.status)}</div>${item.auto_create && item.scheduled_at && !item.case_id ? `<div class="small text-muted">Oprettelse: ${escape(schedule(item.scheduled_at))}</div>` : ''}${item.last_error ? `<div class="small text-danger mt-1">${escape(item.last_error)}</div>` : ''}<div class="d-flex flex-wrap gap-2 mt-2">${item.case_id ? `<a class="btn btn-sm btn-outline-primary" href="/sag/${Number(item.case_id)}">Åbn sag #${Number(item.case_id)}</a>` : ''}<button type="button" class="btn btn-sm btn-outline-secondary" data-lifecycle-edit="${Number(item.id)}">${item.status === 'needs_parent' ? 'Vælg hovedsag' : 'Rediger'}</button><button type="button" class="btn btn-sm btn-link text-muted" data-lifecycle-cancel="${Number(item.id)}">Annuller forløb</button></div></div>`).join('') : '<div class="small text-muted">Ingen dato planlagt</div>'}${items.some(item => item.status === 'cancelled') ? `<div class="small text-muted mt-3">${items.filter(item => item.status === 'cancelled').length} annulleret · eksisterende sager er bevaret</div>` : ''}</div></div>`;
}).join('');
if (data.can_edit === false) cards.querySelectorAll('[data-lifecycle-new],[data-lifecycle-edit],[data-lifecycle-cancel]').forEach(button => button.remove());
} catch (error) {
cards.innerHTML = '<div class="col-12"><button type="button" class="btn btn-outline-secondary btn-sm" data-lifecycle-reload>Prøv igen</button></div>';
notify(`Forløbene kunne ikke hentes: ${error.message}`, true);
}
}
cards.addEventListener('click', async event => {
const button = event.target.closest('button');
if (!button) return;
if (button.hasAttribute('data-lifecycle-reload')) return load();
if (button.dataset.lifecycleNew) return open(button.dataset.lifecycleNew);
if (button.dataset.lifecycleEdit) {
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleEdit));
if (item) open(item.event_type, item);
}
if (button.dataset.lifecycleCancel) {
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleCancel));
if (!item || !confirm(`Annuller ${labels[item.event_type].toLowerCase()} den ${date(item.event_date)}? Eksisterende sager bevares.`)) return;
button.disabled = true;
try { await request(`${base}/${item.id}/cancel`, 'POST'); await load(); notify('Forløbet er annulleret. Eksisterende sager er bevaret.'); }
catch (error) { notify(error.message, true); button.disabled = false; }
}
});
function open(type, item = null) {
if (data.can_edit === false) return;
current = item;
previewVersion++;
document.getElementById('contactLifecycleModal')?.remove();
const element = document.createElement('div');
element.id = 'contactLifecycleModal';
element.className = 'modal fade';
element.tabIndex = -1;
element.setAttribute('aria-labelledby','contactLifecycleModalTitle');
element.innerHTML = `<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"><form class="modal-content" id="contactLifecycleForm"><div class="modal-header"><div><h5 class="modal-title" id="contactLifecycleModalTitle">${item ? 'Rediger' : 'Planlæg'} ${labels[type].toLowerCase()}</h5><div class="small text-muted">Dato og arbejdsopgaver for denne kontakt</div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><div class="alert alert-danger d-none" id="lifecycleError" role="alert"></div><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleDate">${type === 'onboarding' ? 'Startdato' : 'Fratrædelsesdato'}</label><input class="form-control" type="date" id="lifecycleDate" name="event_date" required></div><div class="col-md-6"><label class="form-label" for="lifecycleCompany">Kunde</label><select class="form-select" id="lifecycleCompany" name="customer_id" required><option value="">Vælg kunde</option>${data.companies.map(company => `<option value="${Number(company.id)}">${escape(company.name)}</option>`).join('')}</select></div><div class="col-12"><div class="form-check form-switch"><input class="form-check-input" id="lifecycleAuto" name="auto_create" type="checkbox"><label class="form-check-label fw-semibold" for="lifecycleAuto">Opret automatisk en sag før datoen</label></div></div><div class="col-12" id="lifecycleAutoFields"><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleLead">Kalenderdage før</label><input class="form-control" id="lifecycleLead" name="lead_days" type="number" min="0" max="3650" required><div class="d-flex gap-2 mt-2">${[7,14,30].map(days => `<button type="button" class="btn btn-sm btn-outline-secondary" data-lead="${days}">${days} dage</button>`).join('')}</div></div><div class="col-md-6"><label class="form-label" for="lifecycleResponsible">Ansvarlig</label><select class="form-select" id="lifecycleResponsible" name="responsible_user_id"><option value="">Ikke tildelt</option>${data.users.map(user => `<option value="${Number(user.id)}">${escape(user.name)}</option>`).join('')}</select></div><div class="col-12"><label class="form-label" for="lifecycleTemplate">Opgaveskabelon</label><select class="form-select" id="lifecycleTemplate" name="template_id"></select><div class="form-text">Uden skabelon bruges kontaktens navn, kunde og dato.</div></div><div class="col-12"><label class="form-label" for="lifecycleParent">Hovedsag</label><select class="form-select" id="lifecycleParent" name="parent_case_id"><option value="">Finder relevante åbne sager…</option></select><div class="form-text" id="lifecycleParentHelp"></div></div></div></div><div class="col-12"><div class="bg-light border rounded-3 p-3 small" id="lifecyclePreview" role="status" aria-live="polite">Vælg dato og kunde.</div></div></div></div><div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button type="submit" class="btn btn-primary" id="lifecycleSave">Gem forløb</button></div></form></div>`;
document.body.append(element);
const form = element.querySelector('form');
const field = name => form.elements.namedItem(name);
field('event_date').value = item?.event_date || '';
field('customer_id').value = item?.customer_id || (data.companies.length === 1 ? data.companies[0].id : '');
field('lead_days').value = item?.lead_days ?? 14;
field('auto_create').checked = Boolean(item?.auto_create);
field('responsible_user_id').value = item?.responsible_user_id || '';
if (item?.case_id) ['customer_id','template_id','parent_case_id','responsible_user_id'].forEach(name => { field(name).disabled = true; });
let templateId = item?.template_id || '';
function templates() {
const available = templateOptions(data.templates, field('customer_id').value, type);
field('template_id').innerHTML = '<option value="">Ingen skabelon</option>' + available.map(template => `<option value="${Number(template.id)}">${escape(template.name)}</option>`).join('');
field('template_id').value = available.some(template => Number(template.id) === Number(templateId)) ? templateId : '';
}
async function preview() {
const version = ++previewVersion;
const container = element.querySelector('#lifecyclePreview');
element.querySelector('#lifecycleAutoFields').classList.toggle('d-none', !field('auto_create').checked);
const customer = field('customer_id').value;
const eventDate = field('event_date').value;
const days = Number(field('lead_days').value);
if (!customer || !eventDate || !Number.isInteger(days) || days < 0) { container.textContent = 'Vælg dato og kunde.'; return; }
if (current?.case_id) {
field('parent_case_id').innerHTML = `<option value="">${current.parent_case_id ? `Hovedsag #${Number(current.parent_case_id)}` : 'Selvstændig sag'}</option>`;
element.querySelector('#lifecycleParentHelp').textContent = 'Sagens tilknytninger bevares.';
container.textContent = `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.`;
return;
}
const selectedParent = field('parent_case_id').value || current?.parent_case_id;
container.textContent = 'Henter forslag…';
try {
const query = new URLSearchParams({customer_id:customer,event_type:type,event_date:eventDate,lead_days:String(days)});
const result = await request(`${base}/preview?${query}`);
if (version !== previewVersion || !element.isConnected) return;
const candidates = result.candidates || [];
field('parent_case_id').innerHTML = `<option value="">${candidates.length ? 'Automatisk valg' : 'Selvstændig sag'}</option>` + candidates.map(candidate => `<option value="${Number(candidate.id)}">#${Number(candidate.id)} · ${escape(candidate.titel || candidate.title || '')}</option>`).join('');
field('parent_case_id').value = candidates.some(candidate => Number(candidate.id) === Number(selectedParent)) ? selectedParent : (result.parent_case_id || '');
element.querySelector('#lifecycleParentHelp').textContent = candidates.length > 1 ? 'Flere relevante sager: vælg hovedsag. Uden valg afventer automatikken.' : candidates.length === 1 ? 'Der oprettes en undersag til denne åbne sag.' : 'Ingen relevant åben sag. Der oprettes en selvstændig sag.';
container.textContent = current?.case_id ? `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.` : field('auto_create').checked ? `Sagen oprettes ${schedule(result.scheduled_at)} med deadline ${date(eventDate)}.${candidates.length > 1 && !field('parent_case_id').value ? ' Afventer valg af hovedsag.' : ''}` : `Datoen ${date(eventDate)} gemmes. Ingen automatisk sagsoprettelse.`;
} catch (error) { if (version === previewVersion) container.textContent = `Forslag kunne ikke hentes: ${error.message}`; }
}
templates();
form.addEventListener('change', event => {
if (event.target.name === 'customer_id') templates();
if (event.target.name === 'template_id') templateId = event.target.value;
if (!['template_id','responsible_user_id'].includes(event.target.name)) preview();
});
form.addEventListener('click', event => {
const button = event.target.closest('[data-lead]');
if (button) { field('lead_days').value = button.dataset.lead; preview(); }
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (!form.reportValidity()) return;
const button = element.querySelector('#lifecycleSave');
const errorBox = element.querySelector('#lifecycleError');
button.disabled = true;
errorBox.classList.add('d-none');
const payload = {customer_id:Number(field('customer_id').value),event_date:field('event_date').value,lead_days:Number(field('lead_days').value),auto_create:field('auto_create').checked,template_id:Number(field('template_id').value)||null,responsible_user_id:Number(field('responsible_user_id').value)||null,parent_case_id:Number(field('parent_case_id').value)||null};
if (!current) payload.event_type = type;
try {
const saved = await request(current ? `${base}/${current.id}` : base, current ? 'PATCH' : 'POST', editPayload(payload, Boolean(current?.case_id)));
modal.hide();
await load();
notify(saved.status === 'needs_parent' ? 'Forløbet er gemt. Vælg en hovedsag for at aktivere sagsoprettelsen.' : 'Forløbet er gemt.');
} catch (error) { errorBox.textContent = error.message; errorBox.classList.remove('d-none'); button.disabled = false; }
});
const modal = new bootstrap.Modal(element);
element.addEventListener('shown.bs.modal', () => field('event_date').focus(), {once:true});
element.addEventListener('hidden.bs.modal', () => { previewVersion++; modal.dispose(); element.remove(); }, {once:true});
modal.show();
preview();
}
load();
});
})(typeof window === 'undefined' ? globalThis : window);

View File

@ -0,0 +1,18 @@
const assert = require('node:assert/strict');
global.window = {};
require('../../static/js/contact-lifecycle.js');
const {escape, templateOptions, editPayload} = window.ContactLifecycle;
assert.equal(escape('<img src=x onerror="bad">'), '&lt;img src=x onerror=&quot;bad&quot;&gt;');
const templates = [
{id:1,category:'onboarding',customer_id:null},
{id:2,category:'onboarding',customer_id:12},
{id:3,category:'offboarding',customer_id:12},
{id:4,category:'onboarding',customer_id:13},
];
assert.deepEqual(templateOptions(templates, '12', 'onboarding').map(item => item.id), [1,2]);
assert.deepEqual(templateOptions(templates, '12', 'offboarding').map(item => item.id), [3]);
assert.deepEqual(templateOptions(templates, '', 'onboarding').map(item => item.id), [1]);
const update = {customer_id:12,event_date:'2026-11-20',lead_days:14,auto_create:true,parent_case_id:99,template_id:1,responsible_user_id:2};
assert.deepEqual(editPayload(update,true), {event_date:'2026-11-20',lead_days:14,auto_create:true});
assert.equal(editPayload(update,false), update);
console.log('Contact lifecycle escaping and customer/type template scoping passed');

View File

@ -15,7 +15,8 @@ def test_mail_inbox_supports_unread_deep_link():
assert "get('filter') === 'unread'" in source assert "get('filter') === 'unread'" in source
assert "url += '&unread=true'" in source assert "url += '&unread=true'" in source
assert "state.filter === 'unread' ? 500 : 150" in source # The inbox now uses a single, count-aware page size for every filter.
assert "pageSize: 100" in source
def test_procurement_page_applies_requested_state_filter(): def test_procurement_page_applies_requested_state_filter():

View File

@ -0,0 +1,123 @@
from contextlib import nullcontext
from datetime import date
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.contacts.backend import lifecycle as api
from app.contacts.backend import lifecycle_service as service
USER = {'id': 5, 'is_superadmin': True}
def event(**overrides):
return dict(id=9, contact_id=1, customer_id=2, event_type='onboarding',
event_date=date(2026, 11, 17), lead_days=14, auto_create=False,
template_id=None, responsible_user_id=None, parent_case_id=None,
case_id=None, status='draft', last_error=None, **overrides)
@pytest.fixture
def setup_api(monkeypatch):
monkeypatch.setattr(api, '_ready', lambda: None)
monkeypatch.setattr(api, '_contact', lambda _: None)
monkeypatch.setattr(api, '_membership', lambda *args: None)
monkeypatch.setattr(api, 'atomic_transaction', nullcontext)
monkeypatch.setattr(api, 'hidden_case_ids', lambda _: set())
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [])
def test_default_automation_is_disabled_and_lead_is_fourteen():
request = api.LifecycleCreate(customer_id=2, event_type='onboarding', event_date='2026-11-17')
assert request.auto_create is False
assert request.lead_days == 14
with pytest.raises(ValidationError):
api.LifecycleCreate(customer_id=2, event_type='offboarding', event_date='2026-11-17', lead_days=-1)
def test_missing_migration_is_explicit_503(monkeypatch):
monkeypatch.setattr(api, 'execute_query_single', lambda *args: {'relation': None})
with pytest.raises(HTTPException) as error:
api._ready()
assert error.value.status_code == 503
def test_customer_must_belong_to_contact(monkeypatch):
monkeypatch.setattr(api, 'execute_query_single', lambda *args: None)
with pytest.raises(HTTPException) as error:
api._membership(1, 90)
assert error.value.status_code == 400
def test_enabling_automation_requires_case_creation(setup_api):
row = event()
row['auto_create'] = True
with pytest.raises(HTTPException) as error:
api._validate(row, {'id': 5, 'permissions': ['cases.edit']})
assert error.value.status_code == 403
def test_template_requires_run_permission(setup_api):
row = event()
row['template_id'] = 4
with pytest.raises(HTTPException) as error:
api._validate(row, {'id': 5, 'permissions': ['cases.edit']})
assert error.value.status_code == 403
def test_ambiguous_parent_saves_waiting_for_choice(setup_api, monkeypatch):
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}])
writes = []
def query(sql, params):
writes.append((sql, params))
row = event()
row.update(auto_create=True, status='needs_parent')
return row
monkeypatch.setattr(api, 'execute_query_single', query)
result = api.create_lifecycle(1, api.LifecycleCreate(
customer_id=2, event_type='onboarding', event_date='2026-11-17', auto_create=True), USER)
assert result['status'] == 'needs_parent'
assert writes[0][1][-2] == 'needs_parent'
def test_preview_excludes_confidential_candidates(setup_api, monkeypatch):
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}])
monkeypatch.setattr(api, 'hidden_case_ids', lambda _: {11})
result = api.preview_lifecycle(1, 2, 'onboarding', date(2026, 11, 17), 14, USER)
assert result['candidates'] == [{'id': 10}]
assert result['parent_case_id'] == 10
assert result['needs_parent'] is False
def test_date_change_updates_same_case_without_insert(setup_api, monkeypatch):
row = event()
row.update(case_id=45, status='created')
monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: row)
monkeypatch.setattr(api, 'require_case', lambda *args: None)
calls = []
monkeypatch.setattr(service, 'update_created_case_date', lambda *args:
calls.append(args) or {'status': 'created', 'last_error': None})
writes = []
def query(sql, params):
writes.append(sql)
return dict(row, event_date=date(2026, 11, 20))
monkeypatch.setattr(api, 'execute_query_single', query)
result = api.update_lifecycle(1, 9, api.LifecycleUpdate(event_date='2026-11-20'), USER)
assert result['case_id'] == 45
assert calls[0][1] == date(2026, 11, 17)
assert all('INSERT' not in sql for sql in writes)
def test_cancel_preserves_case_and_disables_automation(setup_api, monkeypatch):
monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: event())
writes = []
def query(sql, params):
writes.append(sql)
return event()
monkeypatch.setattr(api, 'execute_query_single', query)
api.cancel_lifecycle(1, 9, USER)
assert 'auto_create=FALSE' in writes[0]
assert 'DELETE' not in writes[0]
assert 'case_id=' not in writes[0]

View File

@ -0,0 +1,109 @@
import asyncio
from contextlib import contextmanager
from datetime import date, datetime, timezone
import pytest
from app.contacts.backend import lifecycle_service as service
@pytest.mark.parametrize("event,lead,expected", [
(date(2026, 3, 30), 1, "2026-03-29T08:00:00+02:00"),
(date(2026, 10, 26), 1, "2026-10-25T08:00:00+01:00"),
(date(2026, 1, 3), 7, "2025-12-27T08:00:00+01:00"),
])
def test_calendar_days_preserve_eight_am_across_dst(event, lead, expected):
assert service.scheduled_at(event, lead).isoformat() == expected
def test_candidate_query_requires_person_customer_type_and_open_main_case(monkeypatch):
calls = []
monkeypatch.setattr(service, "closed_statuses", lambda: ["closed"])
monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)) or [])
assert service.find_parent_candidates(11, 22, "offboarding") == []
sql, params = calls[0]
assert params == (22, ["closed"], 11, "offboarding", "offboarding", "offboarding")
assert "s.customer_id=%s" in sql and "k.contact_id=%s" in sql
assert "LOWER(s.type)=%s" in sql and "LOWER(TRIM(t.tag_navn))=%s" in sql
assert "LIKE" not in sql and "'undersag'" in sql and "<> ALL" in sql
@contextmanager
def transaction():
yield
def test_due_job_rechecks_locked_row_and_catches_up_once(monkeypatch):
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14)
created = []
def single(sql, params=None):
return {"ready": True} if "to_regclass" in sql else dict(event)
async def create(row):
created.append(row["id"])
event["case_id"] = 70
return True
monkeypatch.setattr(service, "execute_query_single", single)
monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}])
monkeypatch.setattr(service, "atomic_transaction", transaction)
monkeypatch.setattr(service, "_create_case", create)
now = datetime(2026, 11, 2, tzinfo=timezone.utc)
assert asyncio.run(service.process_due_lifecycles(now)) == 1
assert asyncio.run(service.process_due_lifecycles(now)) == 0
assert created == [9]
def test_locked_event_rescheduled_after_candidate_scan_is_not_created(monkeypatch):
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2027, 1, 1), lead_days=0)
monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event)
monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}])
monkeypatch.setattr(service, "atomic_transaction", transaction)
assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0
def test_ambiguous_parent_blocks_before_any_case_is_created(monkeypatch):
calls = []
monkeypatch.setattr(service, "_actor", lambda event: {"id": 1, "is_superadmin": True})
monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 1})
monkeypatch.setattr(service, "find_parent_candidates", lambda *args: [{"id": 10}, {"id": 20}])
monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)))
event = dict(id=9, contact_id=11, customer_id=22, event_type="onboarding", parent_case_id=None)
assert asyncio.run(service._create_case(event)) is False
assert len(calls) == 1 and "status='needs_parent'" in calls[0][0]
def test_date_change_of_closed_case_requires_manual_review(monkeypatch):
from app.core import auth_service, case_visibility
monkeypatch.setattr(auth_service.AuthService, "get_user_permissions", lambda uid: ["cases.edit"])
monkeypatch.setattr(case_visibility, "require_case", lambda *args: None)
monkeypatch.setattr(service, "closed_statuses", lambda: ["lukket"])
monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 70, "status": "Lukket", "deleted_at": None})
writes = []
monkeypatch.setattr(service, "execute_query", lambda *args: writes.append(args))
result = service.update_created_case_date(dict(case_id=70, event_date=date(2026, 12, 1), event_type="offboarding"), date(2026, 11, 1), 1)
assert result["status"] == "manual_review" and not writes
def test_template_failure_escapes_case_transaction_for_rollback(monkeypatch):
from fastapi import HTTPException
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14)
rolled_back = []
@contextmanager
def rollback_transaction():
try:
yield
except Exception:
rolled_back.append(True)
raise
async def fail(event):
raise HTTPException(409, "Skabelon utilgængelig")
writes = []
monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event)
monkeypatch.setattr(service, "execute_query", lambda sql, params: [{"id": 9}] if sql.lstrip().startswith("SELECT") else writes.append((sql, params)))
monkeypatch.setattr(service, "atomic_transaction", rollback_transaction)
monkeypatch.setattr(service, "_create_case", fail)
assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0
assert rolled_back == [True]
assert writes[0][1] == ("Skabelon utilgængelig", 9)

View File

@ -271,6 +271,7 @@ def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
"_analyze_single_month", "_analyze_single_month",
fake_analyze_single_month, fake_analyze_single_month,
) )
monkeypatch.setattr(DetectionService, "_ignore_existing_issues", lambda self: None)
service = DetectionService() service = DetectionService()
counts = service.analyze() counts = service.analyze()
@ -385,7 +386,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
return None return None
def fake_execute_query(query, params=None): def fake_execute_query(query, params=None):
if "FROM month_window mw" in query: if "SELECT generate_series(" in query:
return [ return [
{ {
"month_start": date(2026, 6, 1), "month_start": date(2026, 6, 1),
@ -418,7 +419,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
"is_reference_month": False, "is_reference_month": False,
}, },
] ]
if "WITH ranked_invoices AS" in query: if "FROM invoice_error_finder_economic_invoices inv" in query:
return [ return [
{ {
"month_start": date(2026, 6, 1), "month_start": date(2026, 6, 1),
@ -527,7 +528,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
return None return None
def fake_execute_query(query, params=None): def fake_execute_query(query, params=None):
if "FROM month_window mw" in query: if "SELECT generate_series(" in query:
return [ return [
{ {
"month_start": date(2026, 7, 1), "month_start": date(2026, 7, 1),
@ -540,7 +541,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
"is_reference_month": True, "is_reference_month": True,
}, },
] ]
if "WITH ranked_invoices AS" in query: if "FROM invoice_error_finder_economic_invoices inv" in query:
return [ return [
{ {
"month_start": date(2026, 7, 1), "month_start": date(2026, 7, 1),
@ -557,7 +558,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
"line_number": 1, "line_number": 1,
"product_number": "INET-1000", "product_number": "INET-1000",
"product_name": None, "product_name": None,
"description": "Fiberforbindelse", "description": "1/1 Gbit Internet",
"quantity": 1, "quantity": 1,
"unit_price": 722.0, "unit_price": 722.0,
"line_net_amount": 722.0, "line_net_amount": 722.0,

View File

@ -1,3 +1,4 @@
import os
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -7,6 +8,8 @@ from app.core.database import execute_query
def test_module_deactivation(): def test_module_deactivation():
"""Ensure module deactivation preserves data integrity.""" """Ensure module deactivation preserves data integrity."""
if os.getenv("BMC_HUB_INTEGRATION_DB") != "1":
pytest.skip("requires an explicitly configured, isolated integration database")
# Simulate module deactivation # Simulate module deactivation
execute_query("UPDATE modules SET enabled = false WHERE name = 'sag';") execute_query("UPDATE modules SET enabled = false WHERE name = 'sag';")
@ -26,4 +29,4 @@ def test_module_deactivation():
assert soft_deleted_cases is not None, "Soft-deleted cases should be preserved." assert soft_deleted_cases is not None, "Soft-deleted cases should be preserved."
assert soft_deleted_relations is not None, "Soft-deleted relations should be preserved." assert soft_deleted_relations is not None, "Soft-deleted relations should be preserved."
assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved." assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved."