workflows: store trigger data in structured objects (#64818) #835

Merged
fpeters merged 1 commits from wip/64818-trigger-data-part into main 2023-11-15 19:07:04 +01:00
5 changed files with 163 additions and 10 deletions

View File

@ -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
Outdated
Review

Ajouter un
assert substvars['form_trigger_plop_kind'] == 'global'
et ça sera bien complet.

Ajouter un `assert substvars['form_trigger_plop_kind'] == 'global'` et ça sera bien complet.
assert substvars['form_trigger_plop_kind'] == 'global'
def test_workflow_global_webservice_trigger_no_trailing_slash(pub, local_user):

View File

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

View File

@ -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
Outdated
Review

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):

View File

@ -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
Outdated
Review

(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.

(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.

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).

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()

View File

@ -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']