233 lines
11 KiB
Python
233 lines
11 KiB
Python
|
|
"""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
|