bmc_hub/app/workflows/router.py

233 lines
11 KiB
Python
Raw Normal View History

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