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 58e572c
Show file tree
Hide file tree
Showing 16 changed files with 452 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
63 changes: 63 additions & 0 deletions caluma/caluma_workflow/domain_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,66 @@ def post_resume(work_item, user, context=None):
)

return work_item


class RedoWorkItemLogic:
@classmethod
def validate_for_redo(cls, work_item):
if work_item.status == models.WorkItem.STATUS_READY:
raise ValidationError("Ready work items can't be redone.")
ready_work_items = cls._find_ready_in_work_item_tree(
work_item, allowed_states=[models.WorkItem.STATUS_READY]
)
allowed = False
for wi in ready_work_items:
if work_item.task in wi.get_redoable():
allowed = True
break
if not allowed:
raise ValidationError("Workflow doesn't allow to redo this work item.")

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

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

@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,
),
),
]
28 changes: 28 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,27 @@ class WorkItem(UUIDModel):
null=True,
)

def get_redoable(self):
jexl = None
if self.previous_work_item:
jexl = self.previous_work_item.task.task_flows.get(
workflow=self.case.workflow
).redoable

slugs = []

if jexl:
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 58e572c

Please sign in to comment.