|
2 | 2 | Dynamic Form Generation for Django Form Workflows |
3 | 3 |
|
4 | 4 | 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. |
6 | 7 | """ |
7 | 8 |
|
8 | 9 | import logging |
@@ -540,3 +541,285 @@ def get_enhancements_config(self): |
540 | 541 | ) |
541 | 542 |
|
542 | 543 | 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 | + ) |
0 commit comments