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

214 lines
11 KiB
Python

"""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)