Skip to content

Commit a96cab3

Browse files
committed
feat: Add approval step fields for sequential workflows
New feature allowing form fields to be assigned to specific approval steps in sequential workflows. Approvers can now fill in step-specific fields during their approval process. Changes: - Add approval_step field to FormField model (nullable int, 1-4 for step) - Add step_number field to ApprovalTask model to track sequence position - Create ApprovalStepForm class for rendering step-editable fields - Update approve_submission view to handle step field editing - Update approve.html template with crispy forms support for step fields - Update workflow_engine to set step_number on sequential tasks This enables multi-step approval workflows where each approver can fill in their own section of the form (e.g., Advisor → Financial Aid → Registrar → Manager). Bump version to 0.7.0
1 parent 5756eb9 commit a96cab3

9 files changed

Lines changed: 1801 additions & 28 deletions

File tree

django_forms_workflows/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Enterprise-grade, database-driven form builder with approval workflows
44
"""
55

6-
__version__ = "0.6.5"
6+
__version__ = "0.7.0"
77
__author__ = "Django Forms Workflows Contributors"
88
__license__ = "LGPL-3.0-only"
99

django_forms_workflows/forms.py

Lines changed: 284 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Dynamic Form Generation for Django Form Workflows
33
44
This module provides the DynamicForm class that generates forms
5-
based on database-stored form definitions.
5+
based on database-stored form definitions, and the ApprovalStepForm
6+
class for handling approval-step field editing.
67
"""
78

89
import logging
@@ -540,3 +541,285 @@ def get_enhancements_config(self):
540541
)
541542

542543
return config
544+
545+
546+
class ApprovalStepForm(forms.Form):
547+
"""
548+
Form for approvers to fill in fields specific to their approval step.
549+
550+
This form is used during the approval process to allow approvers to:
551+
- View all previously submitted/approved data as read-only
552+
- Edit fields designated for their approval step (approval_step = current step)
553+
- Have approver name auto-filled from their user account
554+
- Have date fields auto-filled with the current date
555+
"""
556+
557+
def __init__(
558+
self,
559+
form_definition,
560+
submission,
561+
approval_task,
562+
user=None,
563+
*args,
564+
**kwargs,
565+
):
566+
super().__init__(*args, **kwargs)
567+
self.form_definition = form_definition
568+
self.submission = submission
569+
self.approval_task = approval_task
570+
self.user = user
571+
self.current_step = approval_task.step_number or 1
572+
573+
# Get existing form data
574+
self.form_data = submission.form_data or {}
575+
576+
# Build form fields from definition
577+
self._build_fields()
578+
579+
# Setup form layout with Crispy Forms
580+
self._setup_layout()
581+
582+
def _build_fields(self):
583+
"""Build all form fields, making approval step fields editable."""
584+
for field_def in self.form_definition.fields.exclude(
585+
field_type="section"
586+
).order_by("order"):
587+
self._add_field(field_def)
588+
589+
def _add_field(self, field_def):
590+
"""Add a single field to the form."""
591+
# Determine if this field is editable for the current approval step
592+
is_editable = field_def.approval_step == self.current_step
593+
594+
# Get current value from form data
595+
current_value = self.form_data.get(field_def.field_name, "")
596+
597+
# Auto-fill approver name from current user
598+
if is_editable and self._is_approver_name_field(field_def):
599+
current_value = self._get_approver_name()
600+
# Auto-fill date with current date
601+
elif is_editable and self._is_date_field(field_def):
602+
current_value = date.today()
603+
604+
# Common field arguments
605+
field_args = {
606+
"label": field_def.field_label,
607+
"required": field_def.required if is_editable else False,
608+
"help_text": field_def.help_text,
609+
"initial": current_value,
610+
}
611+
612+
# Widget attributes
613+
widget_attrs = {}
614+
if field_def.placeholder:
615+
widget_attrs["placeholder"] = field_def.placeholder
616+
if field_def.css_class:
617+
widget_attrs["class"] = field_def.css_class
618+
619+
# Make non-editable fields read-only
620+
if not is_editable:
621+
widget_attrs["readonly"] = "readonly"
622+
widget_attrs["disabled"] = "disabled"
623+
field_args["required"] = False
624+
625+
# Create appropriate field type
626+
self._create_field(field_def, field_args, widget_attrs, is_editable)
627+
628+
def _is_approver_name_field(self, field_def):
629+
"""Check if this is an approver name field (to auto-fill)."""
630+
name_lower = field_def.field_name.lower()
631+
return "name" in name_lower and (
632+
"advisor" in name_lower
633+
or "registrar" in name_lower
634+
or "manager" in name_lower
635+
or "fa_" in name_lower
636+
or "approver" in name_lower
637+
)
638+
639+
def _is_date_field(self, field_def):
640+
"""Check if this is a date field for the approval step."""
641+
return field_def.field_type in ["date", "datetime"]
642+
643+
def _get_approver_name(self):
644+
"""Get the approver's name for auto-fill."""
645+
if self.user:
646+
full_name = self.user.get_full_name()
647+
if full_name:
648+
return full_name
649+
return self.user.username
650+
return ""
651+
652+
def _create_field(self, field_def, field_args, widget_attrs, is_editable):
653+
"""Create the appropriate Django form field."""
654+
if field_def.field_type == "text":
655+
if widget_attrs:
656+
field_args["widget"] = forms.TextInput(attrs=widget_attrs)
657+
self.fields[field_def.field_name] = forms.CharField(
658+
max_length=field_def.max_length or 255,
659+
**field_args,
660+
)
661+
662+
elif field_def.field_type == "textarea":
663+
widget_attrs["rows"] = 4
664+
self.fields[field_def.field_name] = forms.CharField(
665+
widget=forms.Textarea(attrs=widget_attrs),
666+
**field_args,
667+
)
668+
669+
elif field_def.field_type == "number":
670+
if widget_attrs:
671+
field_args["widget"] = forms.NumberInput(attrs=widget_attrs)
672+
self.fields[field_def.field_name] = forms.IntegerField(**field_args)
673+
674+
elif field_def.field_type == "decimal":
675+
if widget_attrs:
676+
field_args["widget"] = forms.NumberInput(attrs=widget_attrs)
677+
self.fields[field_def.field_name] = forms.DecimalField(
678+
decimal_places=2,
679+
**field_args,
680+
)
681+
682+
elif field_def.field_type == "date":
683+
widget_attrs["type"] = "date"
684+
self.fields[field_def.field_name] = forms.DateField(
685+
widget=forms.DateInput(attrs=widget_attrs), **field_args
686+
)
687+
688+
elif field_def.field_type == "datetime":
689+
widget_attrs["type"] = "datetime-local"
690+
self.fields[field_def.field_name] = forms.DateTimeField(
691+
widget=forms.DateTimeInput(attrs=widget_attrs), **field_args
692+
)
693+
694+
elif field_def.field_type == "email":
695+
if widget_attrs:
696+
field_args["widget"] = forms.EmailInput(attrs=widget_attrs)
697+
self.fields[field_def.field_name] = forms.EmailField(**field_args)
698+
699+
elif field_def.field_type == "select":
700+
choices = [("", "-- Select --")] + self._parse_choices(field_def.choices)
701+
if widget_attrs:
702+
field_args["widget"] = forms.Select(attrs=widget_attrs)
703+
self.fields[field_def.field_name] = forms.ChoiceField(
704+
choices=choices, **field_args
705+
)
706+
707+
elif field_def.field_type == "radio":
708+
choices = self._parse_choices(field_def.choices)
709+
self.fields[field_def.field_name] = forms.ChoiceField(
710+
choices=choices,
711+
widget=forms.RadioSelect(attrs=widget_attrs),
712+
**field_args,
713+
)
714+
715+
elif field_def.field_type == "checkbox":
716+
self.fields[field_def.field_name] = forms.BooleanField(
717+
required=field_args.get("required", False),
718+
label=field_def.field_label,
719+
help_text=field_def.help_text,
720+
initial=field_args["initial"],
721+
)
722+
723+
else:
724+
# Default to text field for unknown types
725+
if widget_attrs:
726+
field_args["widget"] = forms.TextInput(attrs=widget_attrs)
727+
self.fields[field_def.field_name] = forms.CharField(
728+
max_length=255,
729+
**field_args,
730+
)
731+
732+
def _parse_choices(self, choices):
733+
"""Parse choices from either JSON format or comma-separated string."""
734+
if not choices:
735+
return []
736+
if isinstance(choices, list):
737+
return [(c["value"], c["label"]) for c in choices]
738+
if isinstance(choices, str):
739+
return [(c.strip(), c.strip()) for c in choices.split(",") if c.strip()]
740+
return []
741+
742+
def _setup_layout(self):
743+
"""Setup Crispy Forms layout."""
744+
self.helper = FormHelper()
745+
self.helper.form_method = "post"
746+
self.helper.form_class = "needs-validation"
747+
748+
layout_fields = []
749+
750+
# Add fields organized by approval step
751+
current_step_fields = []
752+
other_fields = []
753+
754+
for field_def in self.form_definition.fields.order_by("order"):
755+
if field_def.field_type == "section":
756+
section_html = HTML(
757+
f'<h4 class="mt-4 mb-3">{field_def.field_label}</h4>'
758+
)
759+
if field_def.approval_step == self.current_step:
760+
current_step_fields.append(section_html)
761+
else:
762+
other_fields.append(section_html)
763+
else:
764+
field_wrapper = Div(
765+
Field(field_def.field_name),
766+
css_class=f"field-wrapper field-{field_def.field_name}",
767+
)
768+
if field_def.approval_step == self.current_step:
769+
current_step_fields.append(field_wrapper)
770+
else:
771+
other_fields.append(field_wrapper)
772+
773+
# Add submitted data section first (read-only)
774+
if other_fields:
775+
layout_fields.append(
776+
HTML('<h3 class="mb-3">Submitted Information (Read-Only)</h3>')
777+
)
778+
layout_fields.extend(other_fields)
779+
780+
# Add current step fields section
781+
if current_step_fields:
782+
layout_fields.append(HTML('<hr class="my-4">'))
783+
step_name = self.approval_task.step_name or f"Step {self.current_step}"
784+
layout_fields.append(
785+
HTML(f'<h3 class="mb-3">{step_name} - Your Input</h3>')
786+
)
787+
layout_fields.extend(current_step_fields)
788+
789+
self.helper.layout = Layout(*layout_fields)
790+
self.helper.form_id = f"approval_form_{self.submission.pk}"
791+
792+
def get_updated_form_data(self):
793+
"""
794+
Get the updated form data after validation.
795+
Merges the approval step field values with existing form data.
796+
"""
797+
if not self.is_valid():
798+
return self.form_data
799+
800+
# Start with existing form data
801+
updated_data = dict(self.form_data)
802+
803+
# Update only fields for the current approval step
804+
for field_def in self.form_definition.fields.filter(
805+
approval_step=self.current_step
806+
):
807+
field_name = field_def.field_name
808+
if field_name in self.cleaned_data:
809+
value = self.cleaned_data[field_name]
810+
# Convert date/datetime to string for JSON serialization
811+
if isinstance(value, datetime):
812+
value = value.isoformat()
813+
elif isinstance(value, date):
814+
value = value.isoformat()
815+
updated_data[field_name] = value
816+
817+
return updated_data
818+
819+
def get_editable_field_names(self):
820+
"""Get list of field names that are editable for the current approval step."""
821+
return list(
822+
self.form_definition.fields.filter(
823+
approval_step=self.current_step
824+
).values_list("field_name", flat=True)
825+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 5.2.7 on 2026-01-21 11:48
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
(
10+
"django_forms_workflows",
11+
"0011_postsubmissionaction_email_body_template_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="approvaltask",
18+
name="step_number",
19+
field=models.IntegerField(
20+
blank=True,
21+
help_text="Step number in sequential approval workflow (1, 2, 3, etc.)",
22+
null=True,
23+
),
24+
),
25+
migrations.AddField(
26+
model_name="formfield",
27+
name="approval_step",
28+
field=models.IntegerField(
29+
blank=True,
30+
help_text="Approval step that can edit this field (1, 2, 3, etc.). NULL means submitter fills this field. Used in sequential approval workflows.",
31+
null=True,
32+
),
33+
),
34+
]

django_forms_workflows/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,14 @@ def get_prefill_source_key(self):
414414
help_text="Step number for multi-step forms (1, 2, 3, etc.)",
415415
)
416416

417+
# Approval Step Fields (for sequential approval workflows)
418+
approval_step = models.IntegerField(
419+
null=True,
420+
blank=True,
421+
help_text="Approval step that can edit this field (1, 2, 3, etc.). "
422+
"NULL means submitter fills this field. Used in sequential approval workflows.",
423+
)
424+
417425
# File Upload Settings
418426
allowed_extensions = models.CharField(
419427
max_length=200, blank=True, help_text="Comma-separated: pdf,doc,docx,xls,xlsx"
@@ -989,6 +997,11 @@ class ApprovalTask(models.Model):
989997
# Status
990998
status = models.CharField(max_length=20, choices=TASK_STATUS, default="pending")
991999
step_name = models.CharField(max_length=100)
1000+
step_number = models.IntegerField(
1001+
null=True,
1002+
blank=True,
1003+
help_text="Step number in sequential approval workflow (1, 2, 3, etc.)",
1004+
)
9921005

9931006
# Response
9941007
completed_by = models.ForeignKey(

0 commit comments

Comments
 (0)