111 lines
6.6 KiB
Python
111 lines
6.6 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 * FROM tticket_prepaid_cards WHERE customer_id=%s AND status='active' AND (expires_at IS NULL OR 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,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
|