bmc_hub/app/workflows/actions.py

93 lines
7.8 KiB
Python

"""Local allowlisted action executors; run inside the event transaction."""
from psycopg2.extras import Json
import uuid
def case_id_for_event(cursor,event):
if event['entity_type']=='case': return int(event['entity_id'])
if event['entity_type'] in ('task','reminder') and event.get('context',{}).get('sag_id'):
return int(event['context']['sag_id'])
if event['entity_type'] in ('time','time_entry'):
cursor.execute('SELECT sag_id FROM tmodule_times WHERE id=%s',(event['entity_id'],))
row=cursor.fetchone()
if row and row['sag_id']: return row['sag_id']
raise ValueError('Action requires an associated case')
def execute_case_action(cursor,event,action):
case_id=case_id_for_event(cursor,event)
kind=action['type']
cursor.execute('SELECT * FROM sag_sager WHERE id=%s AND deleted_at IS NULL FOR UPDATE',(case_id,))
case=cursor.fetchone()
if not case: raise ValueError('Case no longer exists')
if kind=='set_field':
field,value=action['field'],action['value']
if field=='status':
from app.modules.sag.backend.router import _normalize_case_status
value=_normalize_case_status(value)
if value.lower()=='lukket' and str(case['status']).lower()!='lukket':
cursor.execute("SELECT COUNT(*) AS count FROM tmodule_times WHERE sag_id=%s AND (slut_tid IS NULL OR COALESCE(entry_status,CASE WHEN status IN ('approved','billed') THEN 'godkendt' ELSE 'afventer' END)<>'godkendt')",(case_id,))
if cursor.fetchone()['count']: raise ValueError('Case closure blocked by unfinished time')
cursor.execute('SELECT 1 FROM tmodule_times WHERE sag_id=%s LIMIT 1',(case_id,))
if not cursor.fetchone(): raise ValueError('Closing without time requires manual confirmation')
if value.lower().startswith('afventer') and not str(case['status']).lower().startswith('afventer'):
title=action.get('awaiting_title','')
if len(title.strip())<3: raise ValueError('Awaiting status requires a TODO title')
cursor.execute('INSERT INTO sag_todo_steps(sag_id,title,description) VALUES(%s,%s,%s)',(case_id,title,action.get('awaiting_description')))
if field not in ('status','priority'): raise ValueError('Unsupported case field')
cursor.execute(f'UPDATE sag_sager SET {field}=%s WHERE id=%s AND {field} IS DISTINCT FROM %s',(value,case_id,value))
elif kind=='assign_group':
cursor.execute('SELECT 1 FROM groups WHERE id=%s',(action['group_id'],))
if not cursor.fetchone(): raise ValueError('Group does not exist')
cursor.execute('DELETE FROM sag_case_assignees a WHERE sag_id=%s AND NOT EXISTS(SELECT 1 FROM user_groups ug WHERE ug.user_id=a.user_id AND ug.group_id=%s)',(case_id,action['group_id']))
cursor.execute('UPDATE sag_sager SET assigned_group_id=%s,ansvarlig_bruger_id=NULL WHERE id=%s AND assigned_group_id IS DISTINCT FROM %s',(action['group_id'],case_id,action['group_id']))
elif kind=='assign_user':
cursor.execute('SELECT group_id FROM user_groups WHERE user_id=%s AND group_id=%s',(action['user_id'],case.get('assigned_group_id')))
if not cursor.fetchone(): raise ValueError('User must belong to the assigned case group')
cursor.execute('UPDATE sag_sager SET ansvarlig_bruger_id=%s WHERE id=%s AND ansvarlig_bruger_id IS DISTINCT FROM %s',(action['user_id'],case_id,action['user_id']))
cursor.execute('INSERT INTO sag_case_assignees(sag_id,user_id) VALUES(%s,%s) ON CONFLICT DO NOTHING',(case_id,action['user_id']))
elif kind=='create_task':
cursor.execute('INSERT INTO sag_todo_steps(sag_id,title,description,due_date) VALUES(%s,%s,%s,%s)',(case_id,action['title'],action.get('description'),action.get('due_date')))
elif kind in ('create_reminder','notify'):
scheduled=action.get('scheduled_at') if kind=='create_reminder' else None
cursor.execute("INSERT INTO sag_reminders(sag_id,trigger_type,trigger_config,title,message,recipient_user_ids,notify_frontend,notify_email,notify_mattermost,override_user_preferences,scheduled_at,next_check_at) VALUES(%s,'time_based','{}',%s,%s,%s,TRUE,FALSE,FALSE,TRUE,COALESCE(%s::timestamptz,now()),COALESCE(%s::timestamptz,now())) RETURNING id",(case_id,action['title'],action.get('message'),action['recipient_user_ids'],scheduled,scheduled))
reminder_id=cursor.fetchone()['id']
if kind=='notify':
for user_id in action['recipient_user_ids']:
cursor.execute("INSERT INTO sag_reminder_logs(reminder_id,sag_id,user_id,channels_used,notification_payload,status) VALUES(%s,%s,%s,ARRAY['frontend'],%s,'sent')",(reminder_id,case_id,user_id,Json({'title':action['title'],'message':action.get('message'),'source':'workflow'})))
else: raise ValueError('Unsupported case action')
def execute_local_action(cursor,event,action):
if action['type'] in ('set_field','assign_user','assign_group','create_task','create_reminder','notify'):
return execute_case_action(cursor,event,action)
if action['type']=='start_workflow':
target=action['workflow_id']
chain=list(event.get('chain') or [])
current=action.get('source_workflow_id')
if current is not None and current not in chain: chain.append(current)
if target in chain or len(chain)>=19: raise ValueError('Workflow chain loop detected')
cursor.execute('SELECT v.definition FROM bmc_workflows w JOIN bmc_workflow_versions v ON v.id=w.active_version_id WHERE w.id=%s AND w.enabled',(target,))
row=cursor.fetchone()
if not row: raise ValueError('Target workflow is not active')
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',
(str(uuid.uuid4()),event['id']+':workflow:'+str(target),row['definition']['trigger'],event['entity_type'],event['entity_id'],event['revision'],Json({'target_workflow_id':target}),Json(chain)))
return
entity_type = {'time':'time_entry','mail':'email'}.get(event['entity_type'],event['entity_type'])
entity_id = int(event['entity_id'])
tag_id = action['tag_id']
if action['type']=='remove_tag':
# Automation is never permitted to erase manual or legacy tags.
cursor.execute("DELETE FROM entity_tags WHERE entity_type=%s AND entity_id=%s AND tag_id=%s AND source IN ('workflow','keyword','ai')",(entity_type,entity_id,tag_id))
return
cursor.execute('SELECT t.id,t.tag_group_id,g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=%s',(tag_id,))
tag = cursor.fetchone()
if not tag: raise ValueError('Workflow tag no longer exists')
# A single-choice group with a different existing tag needs human review;
# never delete manually assigned data to make an automation fit.
if tag.get('tag_group_id') and tag.get('behavior') in ('single','toggle'):
cursor.execute('SELECT 1 FROM entity_tags e JOIN tags t ON t.id=e.tag_id WHERE e.entity_type=%s AND e.entity_id=%s AND t.tag_group_id=%s AND e.tag_id<>%s',(entity_type,entity_id,tag['tag_group_id'],tag_id))
conflicting = bool(cursor.fetchone())
else: conflicting = False
if action['type']=='suggest_tag' or conflicting:
cursor.execute("INSERT INTO workflow_tag_proposals(entity_type,entity_id,tag_id,source,explanation,status,billing_relevant) SELECT %s,%s,%s,'workflow',%s,'pending',TRUE WHERE NOT EXISTS(SELECT 1 FROM workflow_tag_proposals WHERE entity_type=%s AND entity_id=%s AND tag_id=%s AND status='pending')",(entity_type,entity_id,tag_id,'Workflow tag suggestion',entity_type,entity_id,tag_id))
else:
cursor.execute("INSERT INTO entity_tags(entity_type,entity_id,tag_id,source) VALUES(%s,%s,%s,'workflow') ON CONFLICT(entity_type,entity_id,tag_id) DO NOTHING",(entity_type,entity_id,tag_id))