workflows: store trigger data in structured objects (#64818) #835
|
@ -8,6 +8,7 @@ from wcs.api_access import ApiAccess
|
|||
from wcs.formdef import FormDef
|
||||
from wcs.qommon.http_request import HTTPRequest
|
||||
from wcs.qommon.ident.password_accounts import PasswordAccount
|
||||
from wcs.qommon.substitution import CompatibilityNamesDict
|
||||
from wcs.wf.register_comment import JournalEvolutionPart
|
||||
from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef
|
||||
|
||||
|
@ -141,22 +142,46 @@ def test_workflow_trigger_with_data(pub, local_user):
|
|||
sign_uri(formdata.get_url() + 'jump/trigger/XXX'), status=200, params={'test': 'data'}
|
||||
)
|
||||
assert formdef.data_class().get(formdata.id).status == 'wf-st2'
|
||||
# unstructured storage:
|
||||
assert formdef.data_class().get(formdata.id).workflow_data == {'test': 'data'}
|
||||
# structured storage:
|
||||
formdata.refresh_from_storage()
|
||||
substvars = CompatibilityNamesDict()
|
||||
substvars.update(formdata.get_substitution_variables())
|
||||
assert 'form_trigger_XXX_content_test' in substvars.get_flat_keys()
|
||||
assert substvars['form_trigger_XXX_content_test'] == 'data'
|
||||
assert 'form_trigger_XXX_datetime' in substvars.get_flat_keys()
|
||||
assert 'form_trigger_XXX_kind' in substvars.get_flat_keys()
|
||||
assert substvars['form_trigger_XXX_kind'] == 'jump'
|
||||
assert 'form_trigger_XXX_0_content_test' in substvars.get_flat_keys()
|
||||
assert substvars['form_trigger_XXX_0_content_test'] == 'data'
|
||||
assert 'form_trigger_XXX_0_datetime' in substvars.get_flat_keys()
|
||||
assert 'form_trigger_XXX_0_kind' in substvars.get_flat_keys()
|
||||
assert substvars['form_trigger_XXX_0_kind'] == 'jump'
|
||||
assert len(substvars['form_trigger_XXX']) == 1
|
||||
for trigger in substvars['form_trigger_XXX']: # noqa pylint: disable=not-an-iterable
|
||||
assert trigger.kind == 'jump'
|
||||
|
||||
# post with empty dictionary
|
||||
formdata.store() # reset
|
||||
formdata = formdef.data_class()()
|
||||
formdata.just_created()
|
||||
formdata.store()
|
||||
get_app(pub).post_json(sign_uri(formdata.get_url() + 'jump/trigger/XXX'), status=200, params={})
|
||||
assert formdef.data_class().get(formdata.id).status == 'wf-st2'
|
||||
assert not formdef.data_class().get(formdata.id).workflow_data
|
||||
|
||||
# post with empty data
|
||||
formdata.store() # reset
|
||||
formdata = formdef.data_class()()
|
||||
formdata.just_created()
|
||||
formdata.store()
|
||||
get_app(pub).post(sign_uri(formdata.get_url() + 'jump/trigger/XXX'), status=200)
|
||||
assert formdef.data_class().get(formdata.id).status == 'wf-st2'
|
||||
assert not formdef.data_class().get(formdata.id).workflow_data
|
||||
|
||||
# post with empty data, but declare json content-type
|
||||
formdata.store() # reset
|
||||
formdata = formdef.data_class()()
|
||||
formdata.just_created()
|
||||
formdata.store()
|
||||
get_app(pub).post(
|
||||
sign_uri(formdata.get_url() + 'jump/trigger/XXX'),
|
||||
status=200,
|
||||
|
@ -166,7 +191,9 @@ def test_workflow_trigger_with_data(pub, local_user):
|
|||
assert not formdef.data_class().get(formdata.id).workflow_data
|
||||
|
||||
# post with invalid JSON data
|
||||
formdata.store() # reset
|
||||
formdata = formdef.data_class()()
|
||||
formdata.just_created()
|
||||
formdata.store()
|
||||
get_app(pub).post(
|
||||
sign_uri(formdata.get_url() + 'jump/trigger/XXX'),
|
||||
status=400,
|
||||
|
@ -529,7 +556,12 @@ def test_workflow_global_webservice_trigger(pub, local_user, admin_user):
|
|||
get_app(pub).post_json(
|
||||
sign_uri(formdata.get_api_url() + 'hooks/plop/', user=local_user), {'test': 'BAR'}, status=200
|
||||
)
|
||||
assert formdef.data_class().get(formdata.id).workflow_data == {'plop': {'test': 'BAR'}}
|
||||
formdata.refresh_from_storage()
|
||||
assert formdata.workflow_data == {'plop': {'test': 'BAR'}}
|
||||
substvars = CompatibilityNamesDict()
|
||||
substvars.update(formdata.get_substitution_variables())
|
||||
assert substvars['form_trigger_plop_content_test'] == 'BAR'
|
||||
fpeters marked this conversation as resolved
Outdated
|
||||
assert substvars['form_trigger_plop_kind'] == 'global'
|
||||
|
||||
|
||||
def test_workflow_global_webservice_trigger_no_trailing_slash(pub, local_user):
|
||||
|
|
|
@ -799,6 +799,7 @@ class FormData(StorableObject):
|
|||
return None
|
||||
|
||||
def jump_status(self, status_id, user_id=None):
|
||||
from wcs.wf.jump import WorkflowTriggeredEvolutionPart
|
||||
from wcs.workflows import ContentSnapshotPart
|
||||
|
||||
if status_id == '_previous':
|
||||
|
@ -820,10 +821,16 @@ class FormData(StorableObject):
|
|||
self.status == status
|
||||
and self.evolution[-1].status == status
|
||||
and not self.evolution[-1].comment
|
||||
and not [x for x in self.evolution[-1].parts or [] if not isinstance(x, ContentSnapshotPart)]
|
||||
and not [
|
||||
x
|
||||
for x in self.evolution[-1].parts or []
|
||||
if not isinstance(x, (ContentSnapshotPart, WorkflowTriggeredEvolutionPart))
|
||||
]
|
||||
):
|
||||
# if status do not change and last evolution is empty,
|
||||
# just update last jump time on last evolution, do not add one
|
||||
# (ContentSnapshotPart and WorkflowTriggeredEvolutionPart are ignored
|
||||
# as they contain their own datetime attribute).
|
||||
self.evolution[-1].last_jump_datetime = datetime.datetime.now()
|
||||
self.store()
|
||||
return True
|
||||
|
|
|
@ -21,6 +21,7 @@ from quixote.directory import Directory
|
|||
|
||||
from wcs.api import get_user_from_api_query_string, is_url_signed
|
||||
from wcs.roles import logged_users_role
|
||||
from wcs.wf.jump import WorkflowTriggeredEvolutionPart
|
||||
from wcs.workflows import WorkflowGlobalActionWebserviceTrigger, perform_items, push_perform_workflow
|
||||
|
||||
from ..qommon import errors
|
||||
|
@ -55,10 +56,13 @@ class HookDirectory(Directory):
|
|||
if not ('_signed_calls' in self.trigger.roles and is_url_signed()):
|
||||
raise errors.AccessForbiddenError('insufficient roles')
|
||||
|
||||
workflow_data = get_request().json if hasattr(get_request(), '_json') else None
|
||||
self.formdata.evolution[-1].add_part(
|
||||
WorkflowTriggeredEvolutionPart(self.trigger.identifier, workflow_data, 'global')
|
||||
)
|
||||
fpeters marked this conversation as resolved
Outdated
tnoel
commented
Je serai pour toujours enregistrer, même si on a reçu du vide, pour que l'interrogation de form_trigger_xxx renvoie bien le dernier appel reçu. Je serai pour toujours enregistrer, même si on a reçu du vide, pour que l'interrogation de form_trigger_xxx renvoie bien le dernier appel reçu.
|
||||
if hasattr(get_request(), '_json'):
|
||||
workflow_data = {self.trigger.identifier: get_request().json}
|
||||
self.formdata.update_workflow_data(workflow_data)
|
||||
self.formdata.store()
|
||||
self.formdata.update_workflow_data({self.trigger.identifier: workflow_data})
|
||||
self.formdata.store()
|
||||
|
||||
self.formdata.record_workflow_event('global-api-trigger', global_action_id=self.action.id)
|
||||
with push_perform_workflow(self.formdata):
|
||||
|
|
|
@ -999,6 +999,15 @@ class LazyFormData(LazyFormDef):
|
|||
|
||||
return LazyFormDataWorkflowForms(self._formdata)
|
||||
|
||||
@property
|
||||
def trigger(self):
|
||||
# form_ trigger_ <slug (trigger name)> _ <index> _content_ etc.
|
||||
# ex: form_trigger_paid_content_XXX
|
||||
# (index can be "latest")
|
||||
from .wf.jump import LazyFormDataWorkflowTriggers
|
||||
|
||||
return LazyFormDataWorkflowTriggers(self._formdata)
|
||||
tnoel marked this conversation as resolved
Outdated
tnoel
commented
(commentaire posé ici mais c'est un peu général) L'idée d'avoir un seul type de Part pour les appels sur les actions globales ou les sauts, et donc que form_trigger_xxx renvoie le dernier "xxx" reçu quelque soit le type d'appel, c'est voulu ? Note que ça me va très bien, c'est juste la question de savoir si c'est bien une feature :) Je me demande quand même dans quelle mesure on ne pourrait pas stocker dans WorkflowTriggeredEvolutionPart l'origine de l'appel (global ou jump, et une référence vers l'action ou le saut). Peut-être sans l'exposer au départ, mais à terme je verrais bien :
l'idée étant que dans un workflow, quand on va faire référence à form_trigger_paid_xxx on pourra savoir d'où les données viennent, quand elles sont arrivée, qui, etc. (commentaire posé ici mais c'est un peu général)
L'idée d'avoir un seul type de Part pour les appels sur les actions globales ou les sauts, et donc que form_trigger_xxx renvoie le dernier "xxx" reçu quelque soit le type d'appel, c'est voulu ? Note que ça me va très bien, c'est juste la question de savoir si c'est bien une feature :)
Je me demande quand même dans quelle mesure on ne pourrait pas stocker dans WorkflowTriggeredEvolutionPart l'origine de l'appel (global ou jump, et une référence vers l'action ou le saut). Peut-être sans l'exposer au départ, mais à terme je verrais bien :
* les données : form_trigger_paid_var_XXX
* le type de provenance : form_trigger_paid_kind (type d'appel, 'global' ou 'jump')
* le moment de la réception : form_trigger_paid_datetime
* la reférence : form_trigger_paid_global_action ? form_trigger_paid_jump ? (là je sèche un peu, mais bon, stocker l'affaire, déjà)
* le user qui a fait l'appel : form_trigger_paid_user
l'idée étant que dans un workflow, quand on va faire référence à form_trigger_paid_xxx on pourra savoir d'où les données viennent, quand elles sont arrivée, qui, etc.
fpeters
commented
J'ai :
mais pas encore les deux autres, que je garderais bien pour plus tard (via le WorkflowTrace qui s'écrit en même temps on doit déjà pouvoir retrouver l'origine, je me demande dans quelle mesure les deux objets pourraient être liés, mais je préfère ne pas m'avancer là-dedans pour le moment). J'ai :
* les données : form_trigger_paid_content_…
* le type de provenance : form_trigger_paid_kind (global ou jump)
* le moment de la réception : form_trigger_paid_datetime
mais pas encore les deux autres, que je garderais bien pour plus tard (via le WorkflowTrace qui s'écrit en même temps on doit déjà pouvoir retrouver l'origine, je me demande dans quelle mesure les deux objets pourraient être liés, mais je préfère ne pas m'avancer là-dedans pour le moment).
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
formdata = self._formdata.get_parent()
|
||||
|
|
103
wcs/wf/jump.py
|
@ -22,13 +22,20 @@ import math
|
|||
import os
|
||||
import time
|
||||
|
||||
from django.utils.timezone import now
|
||||
from quixote import get_publisher, get_request, get_response, redirect
|
||||
from quixote.directory import Directory
|
||||
from quixote.html import htmltext
|
||||
|
||||
from wcs.api import get_user_from_api_query_string, is_url_signed
|
||||
from wcs.sql_criterias import Equal, LessOrEqual, Null
|
||||
from wcs.workflows import Workflow, WorkflowGlobalAction, WorkflowStatusJumpItem, register_item_class
|
||||
from wcs.workflows import (
|
||||
EvolutionPart,
|
||||
Workflow,
|
||||
WorkflowGlobalAction,
|
||||
WorkflowStatusJumpItem,
|
||||
register_item_class,
|
||||
)
|
||||
|
||||
from ..qommon import _, errors, force_str
|
||||
from ..qommon.cron import CronJob
|
||||
|
@ -40,6 +47,19 @@ from ..qommon.template import Template
|
|||
JUMP_TIMEOUT_INTERVAL = max((60 // int(os.environ.get('WCS_JUMP_TIMEOUT_CHECKS', '3')), 1))
|
||||
|
||||
|
||||
class WorkflowTriggeredEvolutionPart(EvolutionPart):
|
||||
content = None
|
||||
trigger_name = None
|
||||
datetime = None
|
||||
kind = None
|
||||
|
||||
def __init__(self, trigger_name, content, kind):
|
||||
self.trigger_name = trigger_name
|
||||
self.content = content
|
||||
self.kind = kind
|
||||
self.datetime = now()
|
||||
|
||||
|
||||
def jump_and_perform(formdata, action, workflow_data=None):
|
||||
action.handle_markers_stack(formdata)
|
||||
if workflow_data:
|
||||
|
@ -92,6 +112,10 @@ class TriggerDirectory(Directory):
|
|||
workflow_data = None
|
||||
if hasattr(get_request(), '_json'):
|
||||
workflow_data = get_request().json
|
||||
self.formdata.evolution[-1].add_part(
|
||||
WorkflowTriggeredEvolutionPart(component, workflow_data, 'jump')
|
||||
)
|
||||
self.formdata.store()
|
||||
self.formdata.record_workflow_event('api-trigger', action_item_id=item.id)
|
||||
url = jump_and_perform(self.formdata, item, workflow_data=workflow_data)
|
||||
else:
|
||||
|
@ -386,3 +410,80 @@ def register_cronjob():
|
|||
minutes=range(0, 60, JUMP_TIMEOUT_INTERVAL),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LazyFormDataWorkflowTriggers:
|
||||
def __init__(self, formdata):
|
||||
self._formdata = formdata
|
||||
|
||||
def __getattr__(self, trigger_name):
|
||||
triggers = []
|
||||
if '_varnames' not in self.__dict__:
|
||||
# keep a cache of valid attribute names
|
||||
self.__dict__['_varnames'] = varnames = set()
|
||||
else:
|
||||
# use cache to avoid iterating on parts
|
||||
varnames = self.__dict__['_varnames']
|
||||
if trigger_name not in varnames:
|
||||
raise AttributeError(trigger_name)
|
||||
for part in self._formdata.iter_evolution_parts():
|
||||
if not isinstance(part, WorkflowTriggeredEvolutionPart):
|
||||
continue
|
||||
varnames.add(part.trigger_name)
|
||||
if part.trigger_name == trigger_name:
|
||||
triggers.append(LazyFormDataWorkflowTriggersItem(part))
|
||||
if triggers:
|
||||
return LazyFormDataWorkflowTriggersItems(triggers)
|
||||
raise AttributeError(trigger_name)
|
||||
|
||||
def inspect_keys(self):
|
||||
for part in self._formdata.iter_evolution_parts():
|
||||
if isinstance(part, WorkflowTriggeredEvolutionPart) and part.trigger_name:
|
||||
yield part.trigger_name
|
||||
|
||||
|
||||
class LazyFormDataWorkflowTriggersItems:
|
||||
def __init__(self, triggers):
|
||||
self._triggers = triggers
|
||||
|
||||
def inspect_keys(self):
|
||||
return [str(x) for x in range(len(self._triggers))] + ['content', 'datetime', 'kind']
|
||||
|
||||
# alias to latest values
|
||||
@property
|
||||
def content(self):
|
||||
return self._triggers[-1].content
|
||||
|
||||
@property
|
||||
def datetime(self):
|
||||
return self._triggers[-1].datetime
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
return self._triggers[-1].kind
|
||||
|
||||
def __getitem__(self, key):
|
||||
try:
|
||||
key = int(key)
|
||||
except ValueError:
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except AttributeError:
|
||||
return self._triggers[-1][key]
|
||||
return self._triggers[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self._triggers)
|
||||
|
||||
def __iter__(self):
|
||||
yield from self._triggers
|
||||
|
||||
|
||||
class LazyFormDataWorkflowTriggersItem:
|
||||
def __init__(self, part):
|
||||
self.content = part.content
|
||||
self.datetime = part.datetime
|
||||
self.kind = part.kind
|
||||
|
||||
def inspect_keys(self):
|
||||
return ['content', 'datetime', 'kind']
|
||||
|
|
Ajouter un
assert substvars['form_trigger_plop_kind'] == 'global'
et ça sera bien complet.