Skip to content

Commit f9bf8cd

Browse files
committed
Use viewflow instead of django-fsm for submissions transitions
1 parent ee03f0e commit f9bf8cd

3 files changed

Lines changed: 109 additions & 71 deletions

File tree

hypha/apply/funds/models/submissions.py

Lines changed: 105 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,13 @@
2424
from django.db.models.expressions import OrderBy, RawSQL
2525
from django.db.models.fields.json import KeyTextTransform
2626
from django.db.models.functions import Cast
27-
from django.dispatch import receiver
2827
from django.urls import reverse
2928
from django.utils import timezone
3029
from django.utils.functional import cached_property
3130
from django.utils.html import strip_tags
3231
from django.utils.text import slugify
3332
from django.utils.translation import gettext_lazy as _
34-
from django_fsm import RETURN_VALUE, FSMField, can_proceed, transition
35-
from django_fsm.signals import post_transition
33+
from viewflow.fsm import State
3634
from wagtail.contrib.forms.models import AbstractFormSubmission
3735
from wagtail.fields import StreamField
3836

@@ -273,7 +271,7 @@ def can_transition(instance, user):
273271

274272
def wrap_method(func):
275273
def wrapped(*args, **kwargs):
276-
# Provides a new function that can be wrapped with the django_fsm method
274+
# Provides a new function that can be wrapped with the viewflow-fsm method
277275
# Without this using the same method for multiple transitions fails as
278276
# the fsm wrapping is overwritten
279277
return func(*args, **kwargs)
@@ -286,8 +284,18 @@ def transition_id(target, phase):
286284
return "__".join([transition_prefix, phase.stage.name.lower(), phase.name, target])
287285

288286

287+
def get_all_possible_states():
288+
all_states = set()
289+
for workflow in WORKFLOWS.values():
290+
for phase_name, data in workflow.items():
291+
all_states.add((phase_name, data.display_name))
292+
return all_states
293+
294+
289295
class AddTransitions(models.base.ModelBase):
290296
def __new__(cls, name, bases, attrs, **kwargs):
297+
status_field = attrs.get("status_field")
298+
291299
for workflow in WORKFLOWS.values():
292300
for phase_name, data in workflow.items():
293301
for transition_name, action in data.transitions.items():
@@ -296,43 +304,55 @@ def __new__(cls, name, bases, attrs, **kwargs):
296304
permission_func = make_permission_check(action["permissions"])
297305

298306
# Get the method defined on the parent or default to a NOOP
299-
transition_state = wrap_method(
300-
attrs.get(action.get("method"), lambda *args, **kwargs: None)
301-
)
307+
method = action.get("method")
308+
309+
if method in attrs:
310+
transition_m = attrs[method]
311+
elif method and hasattr(cls, method):
312+
transition_m = getattr(cls, method)
313+
else:
314+
# Create a bound noop method that updates status
315+
def make_noop(target_state):
316+
def noop_method(instance, *args, **kwargs):
317+
return True
318+
319+
return noop_method
320+
321+
transition_m = make_noop(transition_name)
322+
323+
transition_state = wrap_method(transition_m)
302324
# Provide a neat name for graph viz display
303325
transition_state.__name__ = slugify(action["display"])
304326

305327
conditions = [
306328
attrs[condition] for condition in action.get("conditions", [])
307329
]
308-
# Wrap with transition decorator
309-
transition_func = transition(
310-
attrs["status"],
330+
331+
# Create the transition
332+
transition_func = status_field.transition(
311333
source=phase_name,
312334
target=transition_name,
313335
permission=permission_func,
314336
conditions=conditions,
315337
custom=action.get("custom", {}),
316-
)(transition_state)
338+
)
317339

318-
# Attach to new class
319-
attrs[method_name] = transition_func
340+
# Bind the transition method
341+
attrs[method_name] = transition_func(transition_state)
320342
attrs[permission_name] = permission_func
321343

322344
def get_transition(self, transition):
323345
try:
324346
return getattr(self, transition_id(transition, self.phase))
325-
except TypeError:
326-
# Defined on the class
327-
return None
328-
except AttributeError:
329-
# For the other workflow
347+
except (TypeError, AttributeError):
330348
return None
331349

332350
attrs["get_transition"] = get_transition
333351

334352
def get_actions_for_user(self, user):
335-
transitions = self.get_available_user_status_transitions(user)
353+
transitions = type(self).status_field.get_available_transitions(
354+
self, self.status, user
355+
)
336356
actions = [
337357
(
338358
transition.target,
@@ -346,15 +366,19 @@ def get_actions_for_user(self, user):
346366
attrs["get_actions_for_user"] = get_actions_for_user
347367

348368
def perform_transition(self, action, user, request=None, **kwargs):
349-
transition = self.get_transition(action)
350-
if not transition:
369+
transition_method = self.get_transition(action)
370+
if not transition_method:
351371
raise PermissionDenied(f'Invalid "{action}" transition')
352-
if not can_proceed(transition):
372+
373+
if not transition_method.can_proceed():
353374
action = self.phase.transitions[action]
354375
raise PermissionDenied(f'You do not have permission to "{action}"')
355376

356-
transition(by=user, request=request, **kwargs)
357-
self.save(update_fields=["status"])
377+
# Execute the transition
378+
result = transition_method(by=user, request=request, **kwargs)
379+
if result:
380+
self.status = action
381+
self.save(update_fields=["status"])
358382

359383
self.progress_stage_when_possible(user, request, **kwargs)
360384

@@ -472,7 +496,15 @@ class ApplicationSubmission(
472496
search_document = SearchVectorField(null=True)
473497

474498
# Workflow inherited from WorkflowHelpers
475-
status = FSMField(default=INITIAL_STATE, protected=True)
499+
status = models.CharField(
500+
max_length=100,
501+
choices=get_all_possible_states(),
502+
default=INITIAL_STATE,
503+
)
504+
status_field = State(
505+
default=INITIAL_STATE,
506+
states=get_all_possible_states(),
507+
)
476508

477509
screening_statuses = models.ManyToManyField(
478510
"funds.ScreeningStatus", related_name="submissions", blank=True
@@ -513,6 +545,10 @@ class Meta:
513545
def is_draft(self):
514546
return self.status == DRAFT_STATE
515547

548+
@status_field.getter()
549+
def _get_object_status(self):
550+
return self.status
551+
516552
@property
517553
def title_text_display(self):
518554
"""Return the title text for display across the site.
@@ -532,10 +568,9 @@ def title_text_display(self):
532568
def not_progressed(self):
533569
return not self.next
534570

535-
@transition(
536-
status,
571+
@status_field.transition(
537572
source="*",
538-
target=RETURN_VALUE(INITIAL_STATE, "draft_proposal", "invited_to_proposal"),
573+
target=[INITIAL_STATE, "draft_proposal", "invited_to_proposal"],
539574
permission=make_permission_check({UserPermissions.ADMIN}),
540575
)
541576
def restart_stage(self, **kwargs):
@@ -636,8 +671,8 @@ def is_determination_form_attached(self):
636671
def progress_application(self, **kwargs):
637672
target = None
638673
for phase in STAGE_CHANGE_ACTIONS:
639-
transition = self.get_transition(phase)
640-
if can_proceed(transition):
674+
transition_method = self.get_transition(phase)
675+
if transition_method and transition_method.can_proceed():
641676
# We convert to dict as not concerned about transitions from the first phase
642677
# See note in workflow.py
643678
target = dict(PHASES)[phase].stage
@@ -662,6 +697,7 @@ def progress_application(self, **kwargs):
662697

663698
submission_in_db.next = self
664699
submission_in_db.save()
700+
return True
665701

666702
def from_draft(self) -> Self:
667703
"""Sets current `form_data` to the `form_data` from the draft revision.
@@ -745,8 +781,7 @@ def create_revision(
745781
self.save(skip_custom=True)
746782

747783
return revision
748-
749-
return None
784+
return True
750785

751786
def clean_submission(self):
752787
self.process_form_data()
@@ -1000,48 +1035,49 @@ def get_yes_screening_status(self):
10001035
def get_no_screening_status(self):
10011036
return self.screening_statuses.filter(yes=False).first()
10021037

1038+
@status_field.on_success()
1039+
def log_status_update(self, descriptor, source, target, **kwargs):
1040+
instance = self
1041+
old_phase = self.workflow[source]
10031042

1004-
@receiver(post_transition, sender=ApplicationSubmission)
1005-
def log_status_update(sender, **kwargs):
1006-
instance = kwargs["instance"]
1007-
old_phase = instance.workflow[kwargs["source"]]
1043+
by = kwargs["by"]
1044+
request = kwargs["request"]
1045+
notify = kwargs.get("notify", True)
10081046

1009-
by = kwargs["method_kwargs"]["by"]
1010-
request = kwargs["method_kwargs"]["request"]
1011-
notify = kwargs["method_kwargs"].get("notify", True)
1047+
if request and notify:
1048+
if source == DRAFT_STATE:
1049+
# remove task from applicant dashboard for this instance
1050+
remove_tasks_for_user(
1051+
code=SUBMISSION_DRAFT, user=by, related_obj=instance
1052+
)
1053+
# notify for a new submission
1054+
messenger(
1055+
MESSAGES.NEW_SUBMISSION,
1056+
request=request,
1057+
user=by,
1058+
source=instance,
1059+
)
1060+
else:
1061+
messenger(
1062+
MESSAGES.TRANSITION,
1063+
user=by,
1064+
request=request,
1065+
source=instance,
1066+
related=old_phase,
1067+
)
10121068

1013-
if request and notify:
1014-
if kwargs["source"] == DRAFT_STATE:
1015-
# remove task from applicant dashboard for this instance
1016-
remove_tasks_for_user(code=SUBMISSION_DRAFT, user=by, related_obj=instance)
1017-
# notify for a new submission
1018-
messenger(
1019-
MESSAGES.NEW_SUBMISSION,
1020-
request=request,
1021-
user=by,
1022-
source=instance,
1023-
)
1024-
else:
1025-
messenger(
1026-
MESSAGES.TRANSITION,
1027-
user=by,
1028-
request=request,
1029-
source=instance,
1030-
related=old_phase,
1031-
)
1069+
if instance.status in review_statuses:
1070+
messenger(
1071+
MESSAGES.READY_FOR_REVIEW,
1072+
user=by,
1073+
request=request,
1074+
source=instance,
1075+
)
10321076

1033-
if instance.status in review_statuses:
1077+
if instance.status in STAGE_CHANGE_ACTIONS:
10341078
messenger(
1035-
MESSAGES.READY_FOR_REVIEW,
1036-
user=by,
1079+
MESSAGES.INVITED_TO_PROPOSAL,
10371080
request=request,
1081+
user=by,
10381082
source=instance,
10391083
)
1040-
1041-
if instance.status in STAGE_CHANGE_ACTIONS:
1042-
messenger(
1043-
MESSAGES.INVITED_TO_PROPOSAL,
1044-
request=request,
1045-
user=by,
1046-
source=instance,
1047-
)

hypha/apply/funds/tables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ class SubmissionFilter(filters.FilterSet):
353353

354354
class Meta:
355355
model = ApplicationSubmission
356-
fields = ("status", "fund", "round")
356+
fields = ("fund", "round")
357357

358358
def __init__(self, *args, exclude=None, limit_statuses=None, **kwargs):
359359
if exclude is None:

hypha/apply/funds/views/submission_edit.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ def get_on_submit_transition(self, user):
145145
return next(
146146
(
147147
t
148-
for t in self.object.get_available_user_status_transitions(user)
148+
for t in type(self.object).status_field.get_available_transitions(
149+
self.object, self.object.status, user
150+
)
149151
if t.custom.get("trigger_on_submit", False)
150152
),
151153
None,

0 commit comments

Comments
 (0)