Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(workflow): implement WorkItem redo pattern #1656

Merged
merged 1 commit into from
Jan 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
winged marked this conversation as resolved.
Show resolved Hide resolved
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
77 changes: 77 additions & 0 deletions caluma/caluma_workflow/migrations/0028_redoable_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Generated by Django 2.2.24 on 2022-01-17 12:50

import django.db.models.deletion
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="taskflow",
name="flow",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="task_flows",
to="caluma_workflow.Flow",
),
),
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: 37 additions & 1 deletion caluma/caluma_workflow/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ class TaskFlow(UUIDModel):
Workflow, on_delete=models.CASCADE, related_name="task_flows"
)
task = models.ForeignKey(Task, related_name="task_flows", on_delete=models.CASCADE)
flow = models.ForeignKey(Flow, related_name="task_flows", on_delete=models.CASCADE)
flow = models.ForeignKey(
Flow, related_name="task_flows", on_delete=models.CASCADE, null=True, blank=True
)
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 +176,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 +262,33 @@ class WorkItem(UUIDModel):
null=True,
)

def get_redoable(self):
"""
Get redoable tasks for this WorkItem.

For this we evaluate the `redoable`-field of the WorkItem-tasks TaskFlow.

:return: QuerySet
"""
task_flow = self.task.task_flows.filter(workflow=self.case.workflow).first()

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

jexl = self.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).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"]
Loading