"""Versioned workflow administration. Simulation is deliberately read-only.""" import base64 import hashlib import hmac import json import time from fastapi import APIRouter, Depends, HTTPException from app.core.auth_dependencies import require_permission from app.core.config import settings from app.workflows.models import WorkflowDraft, SimulationRequest, ActivationRequest, CopyRequest from app.workflows import engine, service, registry router = APIRouter(prefix='/workflows') view_access = require_permission('workflows.view') manage_access = require_permission('workflows.manage') activate_access = require_permission('workflows.activate') def _hash(definition): return hashlib.sha256(json.dumps(definition, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def _receipt(workflow_id, version, actor): body = json.dumps({'workflow_id': workflow_id, 'version_id': version['id'], 'hash': _hash(version['definition']), 'actor': str(actor), 'expires': int(time.time()) + 3600}, sort_keys=True).encode() encoded = base64.urlsafe_b64encode(body).decode() signature = hmac.new(settings.JWT_SECRET_KEY.encode(), encoded.encode(), hashlib.sha256).hexdigest() return encoded + '.' + signature def _verify_receipt(receipt, workflow_id, version, actor): try: encoded, signature = receipt.split('.') expected = hmac.new(settings.JWT_SECRET_KEY.encode(), encoded.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): raise ValueError() data = json.loads(base64.urlsafe_b64decode(encoded)) if (data['workflow_id'] != workflow_id or data['version_id'] != version['id'] or data['hash'] != _hash(version['definition']) or data['actor'] != str(actor) or data['expires'] < time.time()): raise ValueError() except (ValueError, KeyError, TypeError): raise HTTPException(409, 'Test denne version igen før aktivering') def _validate(payload): try: engine.validate_definition(payload.definition) except (ValueError, TypeError) as exc: raise HTTPException(422, str(exc)) definition = dict(payload.definition) definition['metadata'] = {'name': payload.name.strip(), 'customer_id': payload.customer_id, 'priority': payload.priority} return definition def _get(cursor, workflow_id, lock=False): cursor.execute('SELECT * FROM bmc_workflows WHERE id=%s' + (' FOR UPDATE' if lock else ''), (workflow_id,)) row = cursor.fetchone() if not row: raise HTTPException(404, 'Workflow findes ikke') return dict(row) def _version(cursor, workflow_id, version_id=None): if version_id: cursor.execute('SELECT * FROM bmc_workflow_versions WHERE workflow_id=%s AND id=%s', (workflow_id, version_id)) else: cursor.execute('SELECT * FROM bmc_workflow_versions WHERE workflow_id=%s ORDER BY version DESC LIMIT 1', (workflow_id,)) row = cursor.fetchone() if not row: raise HTTPException(404, 'Version findes ikke') return dict(row) def _insert_version(cursor, workflow_id, definition, actor): cursor.execute('SELECT COALESCE(MAX(version),0)+1 AS next FROM bmc_workflow_versions WHERE workflow_id=%s', (workflow_id,)) number = cursor.fetchone()['next'] cursor.execute('INSERT INTO bmc_workflow_versions(workflow_id,version,definition,created_by) VALUES(%s,%s,%s::jsonb,%s) RETURNING *', (workflow_id, number, json.dumps(definition), str(actor))) return dict(cursor.fetchone()) @router.get('/catalog') def catalog(user=Depends(view_access)): return registry.catalog() @router.get('/customers') def customers(user=Depends(view_access)): with service.transaction() as cursor: cursor.execute('SELECT id,name FROM customers ORDER BY name,id') return [dict(row) for row in cursor.fetchall()] @router.get('/tags') def tag_choices(user=Depends(view_access)): with service.transaction() as cursor: cursor.execute('SELECT id,name,parent_id FROM tags WHERE is_active ORDER BY name,id') return [dict(row) for row in cursor.fetchall()] @router.get('/options') def action_options(user=Depends(view_access)): with service.transaction() as cursor: result = {} for key, query in ( ('users', "SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name FROM users WHERE is_active ORDER BY name,user_id"), ('groups', 'SELECT id,name FROM groups ORDER BY name,id'), ('workflows', 'SELECT id,name FROM bmc_workflows ORDER BY name,id'), ('agreements', "SELECT id,agreement_number AS name,customer_id FROM customer_fixed_price_agreements WHERE status IN ('active','pending_cancellation') ORDER BY customer_id,id"), ('cards', "SELECT id,card_number AS name,customer_id FROM tticket_prepaid_cards WHERE status='active' ORDER BY customer_id,id"), ): cursor.execute(query) result[key] = [dict(row) for row in cursor.fetchall()] return result @router.get('') def list_workflows(user=Depends(view_access)): with service.transaction() as cursor: cursor.execute('SELECT * FROM bmc_workflows ORDER BY customer_id NULLS LAST,priority,id') return [dict(row) for row in cursor.fetchall()] @router.post('/validate') def validate(payload: WorkflowDraft, user=Depends(manage_access)): _validate(payload) return {'valid': True} @router.post('', status_code=201) def create(payload: WorkflowDraft, user=Depends(manage_access)): definition = _validate(payload) with service.transaction() as cursor: cursor.execute('INSERT INTO bmc_workflows(name,customer_id,priority,enabled) VALUES(%s,%s,%s,FALSE) RETURNING *', (payload.name.strip(), payload.customer_id, payload.priority)) row = dict(cursor.fetchone()) row['draft'] = _insert_version(cursor, row['id'], definition, user['id']) return row @router.get('/{workflow_id:int}') def get_workflow(workflow_id: int, user=Depends(view_access)): with service.transaction() as cursor: row = _get(cursor, workflow_id) cursor.execute('SELECT * FROM bmc_workflow_versions WHERE workflow_id=%s ORDER BY version DESC', (workflow_id,)) row['versions'] = [dict(v) for v in cursor.fetchall()] cursor.execute('SELECT * FROM bmc_workflow_activation_history WHERE workflow_id=%s ORDER BY created_at DESC,id DESC', (workflow_id,)) row['activation_history'] = [dict(v) for v in cursor.fetchall()] return row @router.post('/{workflow_id:int}/versions', status_code=201) def add_version(workflow_id: int, payload: WorkflowDraft, user=Depends(manage_access)): definition = _validate(payload) with service.transaction() as cursor: _get(cursor, workflow_id, lock=True) return _insert_version(cursor, workflow_id, definition, user['id']) @router.post('/{workflow_id:int}/copy', status_code=201) def copy_workflow(workflow_id: int, payload: CopyRequest, user=Depends(manage_access)): with service.transaction() as cursor: original = _get(cursor, workflow_id) version = _version(cursor, workflow_id) definition = dict(version['definition']) metadata = dict(definition.get('metadata', {})) metadata['name'] = payload.name.strip() definition['metadata'] = metadata cursor.execute('INSERT INTO bmc_workflows(name,customer_id,priority,enabled) VALUES(%s,%s,%s,FALSE) RETURNING *', (payload.name.strip(), metadata.get('customer_id', original['customer_id']), metadata.get('priority', original['priority']))) row = dict(cursor.fetchone()) row['draft'] = _insert_version(cursor, row['id'], definition, user['id']) return row @router.post('/{workflow_id:int}/simulate') def simulate(workflow_id: int, payload: SimulationRequest, user=Depends(manage_access)): if bool(payload.entity_type) != bool(payload.entity_id): raise HTTPException(422, 'Angiv både entitetstype og ID') with service.transaction() as cursor: row = _get(cursor, workflow_id) version = _version(cursor, workflow_id, payload.version_id) try: context = service.load_context(payload.entity_type, payload.entity_id) if payload.entity_type else dict(payload.context) if not payload.entity_type: from app.workflows.classification import enrich_time_context with service.transaction() as cursor: context = enrich_time_context(cursor, dict(context, time_id=0)) except ValueError as exc: raise HTTPException(404, str(exc)) metadata = version['definition'].get('metadata', {}) candidate = dict(row, definition=version['definition'], version_id=version['id'], customer_id=metadata.get('customer_id', row['customer_id']), priority=metadata.get('priority', row['priority'])) result = engine.evaluate([candidate], context, version['definition']['trigger']) return {'result': result, 'context': context, 'version_id': version['id'], 'simulation_receipt': _receipt(workflow_id, version, user['id'])} @router.post('/{workflow_id:int}/activate') def activate(workflow_id: int, payload: ActivationRequest, user=Depends(activate_access)): with service.transaction() as cursor: row = _get(cursor, workflow_id, lock=True) version = _version(cursor, workflow_id, payload.version_id) _verify_receipt(payload.simulation_receipt, workflow_id, version, user['id']) try: engine.validate_definition(version['definition']) except (ValueError, TypeError) as exc: raise HTTPException(422, str(exc)) unsupported = {action['type'] for branch in version['definition']['branches'] for action in branch['actions']} - registry.EXECUTABLE_ACTIONS if unsupported: raise HTTPException(409, 'Disse handlinger mangler en executor: ' + ', '.join(sorted(unsupported))) metadata = version['definition'].get('metadata', {}) cursor.execute('UPDATE bmc_workflows SET active_version_id=%s,enabled=%s,name=%s,customer_id=%s,priority=%s,updated_at=NOW() WHERE id=%s RETURNING *', (version['id'], payload.enabled, metadata.get('name', row['name']), metadata.get('customer_id', row['customer_id']), metadata.get('priority', row['priority']), workflow_id)) result = dict(cursor.fetchone()) cursor.execute('INSERT INTO bmc_workflow_activation_history(workflow_id,version_id,previous_version_id,created_by) VALUES(%s,%s,%s,%s)', (workflow_id, version['id'], row['active_version_id'], str(user['id']))) return result @router.post('/{workflow_id:int}/disable') def disable(workflow_id: int, user=Depends(activate_access)): with service.transaction() as cursor: row = _get(cursor, workflow_id, lock=True) cursor.execute('UPDATE bmc_workflows SET enabled=FALSE,updated_at=NOW() WHERE id=%s RETURNING *', (workflow_id,)) result = dict(cursor.fetchone()) cursor.execute('INSERT INTO bmc_workflow_activation_history(workflow_id,version_id,previous_version_id,created_by) VALUES(%s,%s,%s,%s)', (workflow_id, row['active_version_id'], row['active_version_id'], str(user['id']))) return result