Skip to content

Commit

Permalink
feat(workflow): implement WorkItem redo pattern
Browse files Browse the repository at this point in the history
This commit implements a possibility to redo an already finished
WorkItem and all the ones that were finished in between.

Closes #1510
  • Loading branch information
open-dynaMIX committed Jan 17, 2022
1 parent cc9f89f commit 094d980
Show file tree
Hide file tree
Showing 16 changed files with 465 additions and 17 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ For further information on our license choice, you can read up on the [correspon
how to start with your first contribution.
- [Caluma Guide](docs/guide.md) - How to get up and running with Caluma
- [Workflow Concepts](docs/workflow-concepts.md) - How to use caluma workflows
- [Workflow Advice](docs/workflow-advice.md) - Advice about certain aspects of dealing with workflows
- [Historical Records](docs/historical-records.md) - Undo and audit trail
functionality
- [GraphQL](docs/graphql.md) - Further information on how to use the GraphQL
Expand Down
27 changes: 27 additions & 0 deletions caluma/caluma_workflow/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,30 @@ def resume_work_item(
domain_logic.ResumeWorkItemLogic.post_resume(work_item, user, context)

return work_item


def redo_work_item(
work_item: models.WorkItem, user: BaseUser, context: Optional[dict] = None
) -> models.WorkItem:
"""
Redo a work item.
>>> redo_work_item(
... work_item=models.WorkItem.objects.first(),
... user=AnonymousUser()
... )
<WorkItem: WorkItem object (some-uuid)>
"""
domain_logic.RedoWorkItemLogic.validate_for_redo(work_item)

validated_data = domain_logic.RedoWorkItemLogic.pre_redo(
work_item, {}, user, context
)

domain_logic.RedoWorkItemLogic.set_succeeding_work_item_status_redo(work_item)

update_model(work_item, validated_data)

domain_logic.RedoWorkItemLogic.post_redo(work_item, user, context)

return work_item
66 changes: 66 additions & 0 deletions caluma/caluma_workflow/domain_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,69 @@ def post_resume(work_item, user, context=None):
)

return work_item


class RedoWorkItemLogic:
@classmethod
def validate_for_redo(cls, work_item):
"""
Validate if we can redo (jump back to) the given work item.
Every taskflow has a potential JEXL list of other tasks that can be redone from
that task's workitem (redoable).
We allow redoing a work item if at least one ready work item allows "our" task
to be re-done.
"""
if work_item.status == models.WorkItem.STATUS_READY:
raise ValidationError("Ready work items can't be redone.")

for wi in cls._find_ready_in_work_item_tree(
work_item, allowed_states=[models.WorkItem.STATUS_READY]
):
if work_item.task in wi.get_redoable():
return

raise ValidationError("Workflow doesn't allow to redo this work item.")

@classmethod
def set_succeeding_work_item_status_redo(cls, work_item):
for wi in cls._find_ready_in_work_item_tree(work_item):
wi.status = models.WorkItem.STATUS_REDO
wi.save()

@classmethod
def _find_ready_in_work_item_tree(cls, work_item, allowed_states=None):
allowed_states = allowed_states if allowed_states else []
succeeding = work_item.succeeding_work_items.all()
if not succeeding:
return
for wi in succeeding:
if allowed_states == [] or wi.status in allowed_states:
yield wi
yield from cls._find_ready_in_work_item_tree(wi, allowed_states)

@staticmethod
def pre_redo(work_item, validated_data, user, context=None):
send_event_with_deprecations(
"pre_redo_work_item",
sender="pre_redo_work_item",
work_item=work_item,
user=user,
context=context,
)

validated_data["status"] = models.WorkItem.STATUS_READY

return validated_data

@staticmethod
def post_redo(work_item, user, context=None):
send_event_with_deprecations(
"post_redo_work_item",
sender="post_redo_work_item",
work_item=work_item,
user=user,
context=context,
)

return work_item
2 changes: 1 addition & 1 deletion caluma/caluma_workflow/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
module = sys.modules[__name__]

MODEL_ACTIONS = {
"work_item": ["create", "complete", "cancel", "skip", "suspend", "resume"],
"work_item": ["create", "complete", "cancel", "skip", "suspend", "resume", "redo"],
"case": ["create", "complete", "cancel", "suspend", "resume"],
}

Expand Down
1 change: 1 addition & 0 deletions caluma/caluma_workflow/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class TaskFlowFactory(DjangoModelFactory):
workflow = SubFactory(WorkflowFactory)
task = SubFactory(TaskFactory)
flow = SubFactory(FlowFactory)
redoable = None

class Meta:
model = models.TaskFlow
Expand Down
65 changes: 65 additions & 0 deletions caluma/caluma_workflow/migrations/0028_redoable_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 2.2.24 on 2022-01-13 06:51

from django.db import migrations, models

import caluma.caluma_core.models


class Migration(migrations.Migration):

dependencies = [
("caluma_workflow", "0027_add_modified_by_user_group"),
]

operations = [
migrations.AddField(
model_name="historicaltaskflow",
name="redoable",
field=models.TextField(
blank=True,
help_text="jexl returning what tasks can be redone from this taskflow.",
null=True,
),
),
migrations.AddField(
model_name="taskflow",
name="redoable",
field=models.TextField(
blank=True,
help_text="jexl returning what tasks can be redone from this taskflow.",
null=True,
),
),
migrations.AlterField(
model_name="historicalworkitem",
name="status",
field=caluma.caluma_core.models.ChoicesCharField(
choices=[
("ready", "Work item is ready to be processed."),
("completed", "Work item is done."),
("canceled", "Work item is canceled."),
("skipped", "Work item is skipped."),
("suspended", "Work item is suspended."),
("redo", "Work item has been marked for redo."),
],
db_index=True,
max_length=50,
),
),
migrations.AlterField(
model_name="workitem",
name="status",
field=caluma.caluma_core.models.ChoicesCharField(
choices=[
("ready", "Work item is ready to be processed."),
("completed", "Work item is done."),
("canceled", "Work item is canceled."),
("skipped", "Work item is skipped."),
("suspended", "Work item is suspended."),
("redo", "Work item has been marked for redo."),
],
db_index=True,
max_length=50,
),
),
]
38 changes: 38 additions & 0 deletions caluma/caluma_workflow/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ class TaskFlow(UUIDModel):
)
task = models.ForeignKey(Task, related_name="task_flows", on_delete=models.CASCADE)
flow = models.ForeignKey(Flow, related_name="task_flows", on_delete=models.CASCADE)
redoable = models.TextField(
blank=True,
null=True,
help_text="jexl returning what tasks can be redone from this taskflow.",
)

class Meta:
unique_together = ("workflow", "task")
Expand Down Expand Up @@ -169,13 +174,15 @@ class WorkItem(UUIDModel):
STATUS_CANCELED = "canceled"
STATUS_SKIPPED = "skipped"
STATUS_SUSPENDED = "suspended"
STATUS_REDO = "redo"

STATUS_CHOICE_TUPLE = (
(STATUS_READY, "Work item is ready to be processed."),
(STATUS_COMPLETED, "Work item is done."),
(STATUS_CANCELED, "Work item is canceled."),
(STATUS_SKIPPED, "Work item is skipped."),
(STATUS_SUSPENDED, "Work item is suspended."),
(STATUS_REDO, "Work item has been marked for redo."),
)

name = LocalizedField(
Expand Down Expand Up @@ -253,6 +260,37 @@ class WorkItem(UUIDModel):
null=True,
)

def get_redoable(self):
"""
Get redoable tasks for this WorkItem.
For this we evaluate the `redoable`-field of the previous WorkItem-tasks
TaskFlow.
:return: QuerySet
"""
if not self.previous_work_item: # pragma: no cover
# This is prevented at calling site
return Task.objects.none()

jexl = self.previous_work_item.task.task_flows.get(
workflow=self.case.workflow
).redoable

if not jexl:
return Task.objects.none()

from .jexl import FlowJexl

slugs = FlowJexl(
case=self.case, prev_work_item=self.previous_work_item
).evaluate(jexl)

if not isinstance(slugs, list):
slugs = [slugs]

return Task.objects.filter(pk__in=slugs)

class Meta:
indexes = [
GinIndex(fields=["addressed_groups"]),
Expand Down
7 changes: 7 additions & 0 deletions caluma/caluma_workflow/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ class Meta:
model_operations = ["update"]


class RedoWorkItem(Mutation):
class Meta:
serializer_class = serializers.WorkItemRedoTaskSerializer
model_operations = ["update"]


class SaveWorkItem(Mutation):
class Meta:
serializer_class = serializers.SaveWorkItemSerializer
Expand Down Expand Up @@ -345,6 +351,7 @@ class Mutation(object):
cancel_work_item = CancelWorkItem().Field()
suspend_work_item = SuspendWorkItem().Field()
resume_work_item = ResumeWorkItem().Field()
redo_work_item = RedoWorkItem().Field()
save_work_item = SaveWorkItem().Field()
create_work_item = CreateWorkItem().Field()

Expand Down
43 changes: 41 additions & 2 deletions caluma/caluma_workflow/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class AddWorkflowFlowSerializer(serializers.ModelSerializer):
queryset=models.Task.objects, many=True
)
next = FlowJexlField(required=True)
redoable = FlowJexlField(required=False, write_only=True)

def validate_next(self, value):
jexl = FlowJexl()
Expand All @@ -69,10 +70,14 @@ def validate_next(self, value):
)
return value

def validate_redoable(self, value):
return self.validate_next(value)

@transaction.atomic
def update(self, instance, validated_data):
user = self.context["request"].user
tasks = validated_data["tasks"]
redoable = validated_data.get("redoable")
models.Flow.objects.filter(
task_flows__workflow=instance, task_flows__task__in=tasks
).delete()
Expand All @@ -83,12 +88,14 @@ def update(self, instance, validated_data):
)

for task in tasks:
models.TaskFlow.objects.create(task=task, workflow=instance, flow=flow)
models.TaskFlow.objects.create(
task=task, workflow=instance, flow=flow, redoable=redoable
)

return instance

class Meta:
fields = ["workflow", "tasks", "next"]
fields = ["workflow", "tasks", "next", "redoable"]
model = models.Workflow


Expand Down Expand Up @@ -590,3 +597,35 @@ def update(self, work_item, validated_data):
class Meta:
model = models.WorkItem
fields = ["id", "context"]


class WorkItemRedoTaskSerializer(ContextModelSerializer):
id = serializers.GlobalIDField()

def validate(self, data):
try:
domain_logic.RedoWorkItemLogic.validate_for_redo(self.instance)
except ValidationError as e:
raise exceptions.ValidationError(str(e))

return super().validate(data)

@transaction.atomic
def update(self, work_item, validated_data):
user = self.context["request"].user

validated_data = domain_logic.RedoWorkItemLogic.pre_redo(
work_item, validated_data, user, self.context_data
)

domain_logic.RedoWorkItemLogic.set_succeeding_work_item_status_redo(work_item)
work_item = super().update(work_item, validated_data)
work_item = domain_logic.RedoWorkItemLogic.post_redo(
work_item, user, self.context_data
)

return work_item

class Meta:
model = models.WorkItem
fields = ["id", "context"]
39 changes: 38 additions & 1 deletion caluma/caluma_workflow/tests/__snapshots__/test_workflow.ambr
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
# name: test_add_workflow_flow[task-slug-"task-slug"|task-True]
# name: test_add_workflow_flow[task-slug-"task-slug"|task-"task-slug"|task-True]
<class 'OrderedDict'> {
'addWorkflowFlow': <class 'dict'> {
'clientMutationId': None,
'workflow': <class 'dict'> {
'flows': <class 'dict'> {
'edges': <class 'list'> [
<class 'dict'> {
'node': <class 'dict'> {
'createdByGroup': 'admin',
'createdByUser': 'admin',
'next': '"task-slug"|task',
'tasks': <class 'list'> [
<class 'dict'> {
'slug': 'task-slug',
},
],
},
},
],
'totalCount': 1,
},
'tasks': <class 'list'> [
<class 'dict'> {
'slug': 'more-form-level',
},
<class 'dict'> {
'slug': 'song-light',
},
<class 'dict'> {
'slug': 'task-slug',
},
],
},
},
}
---
# name: test_add_workflow_flow[task-slug-"task-slug"|task-None-True]
<class 'OrderedDict'> {
'addWorkflowFlow': <class 'dict'> {
'clientMutationId': None,
Expand Down
Loading

0 comments on commit 094d980

Please sign in to comment.