bmc_hub/app/workflows/service.py
Christian 85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads.
- Added tests for v5 assignment functionality, validating employee identification and group assignment.
- Implemented tests for remote recovery scenarios, including handling network failures and response validation.
- Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation.
- Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions.
- Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
2026-10-15 12:17:09 +02:00

111 lines
6.8 KiB
Python

"""Transactional workflow persistence. Never calls external systems."""
from contextlib import contextmanager
from datetime import date, datetime
from decimal import Decimal
import uuid
from psycopg2.extras import RealDictCursor, Json
from app.core.database import get_db_connection, release_db_connection
def json_safe(value):
if isinstance(value, dict): return {str(k): json_safe(v) for k,v in value.items()}
if isinstance(value, (list,tuple)): return [json_safe(v) for v in value]
if isinstance(value, (date,datetime)): return value.isoformat()
if isinstance(value, Decimal): return float(value)
return value
def is_incoming_mail(context):
"""Align with mail UI classification and reject authored draft/outbox mail."""
folder=str(context.get('folder') or '').strip().casefold()
status=str(context.get('status') or '').strip().casefold()
direction=str(context.get('direction') or '').strip().casefold()
return not (folder.startswith(('sent','outbox','draft')) or status in ('sent','sending','draft') or direction in ('outgoing','outbound'))
@contextmanager
def transaction():
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
yield cursor
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
release_db_connection(conn)
def load_time_context(time_id, cursor=None):
if cursor is None:
with transaction() as cur: return load_time_context(time_id, cur)
cursor.execute('SELECT t.*, s.titel AS case_title,s.beskrivelse AS case_description,COALESCE(s.customer_id,c.hub_customer_id) AS hub_customer_id FROM tmodule_times t LEFT JOIN tmodule_customers c ON c.id=t.customer_id LEFT JOIN sag_sager s ON s.id=t.sag_id WHERE t.id=%s', (time_id,))
row = cursor.fetchone()
if not row: raise ValueError('Time registration not found')
row = dict(row)
row['time_id'] = row['id']
row['entity_type'] = 'time_entry'
row['case_id'] = row.get('sag_id')
row['hours'] = row.get('original_hours')
row['tracking_customer_id']=row.get('customer_id')
row['customer_mapping_missing']=row.get('customer_id') is not None and row.get('hub_customer_id') is None
row['customer_id'] = str(row['hub_customer_id']) if row.get('hub_customer_id') else None
row['tags'] = []
row['tag_ids'] = []
cursor.execute("SELECT DISTINCT et.tag_id FROM entity_tags et WHERE (et.entity_type='case' AND et.entity_id=%s) OR (et.entity_type='time_entry' AND et.entity_id=%s)", (row.get('sag_id'),row['id']))
row['tag_ids'] = [r['tag_id'] for r in cursor.fetchall()]
row['tags'] = row['tag_ids']
if row['customer_id']:
cursor.execute("SELECT * FROM customer_fixed_price_agreements WHERE customer_id=%s AND status IN ('active','pending_cancellation') AND start_date<=COALESCE(%s::date,CURRENT_DATE) AND (end_date IS NULL OR end_date>=COALESCE(%s::date,CURRENT_DATE)) AND (cancellation_effective_date IS NULL OR cancellation_effective_date>COALESCE(%s::date,CURRENT_DATE))", (int(row['customer_id']),row.get('worked_date'),row.get('worked_date'),row.get('worked_date')))
row['agreements']=cursor.fetchall()
cursor.execute("SELECT c.*,COALESCE(p.workflow_coverage,c.workflow_coverage) AS workflow_coverage FROM tticket_prepaid_cards c LEFT JOIN bmc_workflow_customer_card_coverage p ON p.customer_id=c.customer_id WHERE c.customer_id=%s AND c.status='active' AND (c.expires_at IS NULL OR c.expires_at>now())",(int(row['customer_id']),))
row['cards']=cursor.fetchall()
else:
row['agreements'],row['cards']=[],[]
row['has_active_agreement']=bool(row['agreements'])
row['has_active_card']=bool(row['cards'])
row['remaining_card_hours']=row['cards'][0].get('remaining_hours') if len(row['cards'])==1 else (0 if not row['cards'] else None)
row['has_available_card']=any(float(card.get('remaining_hours') or 0)>0 for card in row['cards'])
return json_safe(row)
def load_case_context(case_id, cursor=None):
if cursor is None:
with transaction() as cur: return load_case_context(case_id,cur)
cursor.execute('SELECT * FROM sag_sager WHERE id=%s',(case_id,))
row = cursor.fetchone()
if not row: raise ValueError('Case not found')
context=dict(row,entity_type='case',case_id=case_id,case_title=row.get('titel'),description=row.get('beskrivelse'),subject=row.get('titel'))
context['customer_id']=str(row['customer_id']) if row.get('customer_id') is not None else None
cursor.execute("SELECT tag_id FROM entity_tags WHERE entity_type='case' AND entity_id=%s",(case_id,))
concrete=[r['tag_id'] for r in cursor.fetchall()]
cursor.execute('SELECT id,parent_id FROM tags')
from .classification import ancestor_ids
effective=sorted(ancestor_ids(concrete,{r['id']:r['parent_id'] for r in cursor.fetchall()}))
context.update(tags=effective,tag_ids=effective,concrete_tag_ids=concrete)
return json_safe(context)
def load_context(entity_type,entity_id):
if entity_type in ('time','time_entry'):
with transaction() as cursor:
from .classification import enrich_time_context
return enrich_time_context(cursor,load_time_context(entity_id,cursor))
if entity_type == 'case': return load_case_context(entity_id)
raise ValueError('Context adapter not available for this entity')
def emit_time_event_on_connection(cursor,time_id,revision,trigger='time.updated',context=None,chain=None):
event_id = str(uuid.uuid4())
cursor.execute('INSERT INTO bmc_workflow_events(id,event_key,trigger,entity_type,entity_id,revision,context,chain) VALUES(%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(event_key) DO NOTHING RETURNING id',
(event_id,f'time:{time_id}:{revision}',trigger,'time',str(time_id),revision,Json(json_safe(context or {})),Json(chain or [])))
row=cursor.fetchone()
return row['id'] if row else None
def enqueue_time_event(time_id,revision,**kwargs):
with transaction() as cursor: return emit_time_event_on_connection(cursor,time_id,revision,**kwargs)
def emit_event(cursor, trigger, entity_type, entity_id, revision, context=None, chain=None, event_key=None):
"""Adapter entry point: caller supplies its own transaction and stable revision."""
from .registry import TRIGGERS
if trigger not in TRIGGERS: raise ValueError('Unknown workflow trigger')
event_id=str(uuid.uuid4())
cursor.execute('INSERT INTO bmc_workflow_events(id,event_key,trigger,entity_type,entity_id,revision,context,chain) SELECT %s,%s,%s,%s,%s,%s,%s,%s WHERE EXISTS(SELECT 1 FROM bmc_workflow_runtime WHERE id=1 AND enabled) ON CONFLICT(event_key) DO NOTHING RETURNING id',
(event_id,event_key or f'{trigger}:{entity_type}:{entity_id}:{revision}',trigger,entity_type,str(entity_id),revision,Json(json_safe(context or {})),Json(chain or [])))
row=cursor.fetchone()
return row['id'] if row else None