|
6 | 6 | class for handling approval-step field editing. |
7 | 7 | """ |
8 | 8 |
|
| 9 | +import json |
9 | 10 | import logging |
10 | 11 | import re |
11 | 12 | from datetime import date, datetime |
@@ -320,6 +321,23 @@ def __init__(self, form_definition, user=None, initial_data=None, *args, **kwarg |
320 | 321 | ) |
321 | 322 | i += 1 |
322 | 323 |
|
| 324 | + # CAPTCHA field (rendered before submit buttons) |
| 325 | + if form_definition.enable_captcha: |
| 326 | + self.fields["captcha_token"] = forms.CharField( |
| 327 | + widget=forms.HiddenInput(attrs={"data-captcha-field": "true"}), |
| 328 | + required=True, |
| 329 | + error_messages={ |
| 330 | + "required": "Please complete the CAPTCHA verification." |
| 331 | + }, |
| 332 | + ) |
| 333 | + layout_fields.append( |
| 334 | + Div( |
| 335 | + HTML('<div data-captcha-widget="true" class="mb-3"></div>'), |
| 336 | + Field("captcha_token"), |
| 337 | + css_class="captcha-wrapper", |
| 338 | + ) |
| 339 | + ) |
| 340 | + |
323 | 341 | # Add submit buttons |
324 | 342 | buttons = [Submit("submit", "Submit", css_class="btn btn-primary")] |
325 | 343 | # Save Draft is only available for authenticated users |
@@ -681,6 +699,113 @@ def add_field(self, field_def, initial_data): |
681 | 699 | widget=forms.HiddenInput(), required=False, initial=initial |
682 | 700 | ) |
683 | 701 |
|
| 702 | + elif field_def.field_type == "rating": |
| 703 | + max_stars = int(field_def.max_value) if field_def.max_value else 5 |
| 704 | + rating_choices = [(str(i), str(i)) for i in range(1, max_stars + 1)] |
| 705 | + widget_attrs.update( |
| 706 | + { |
| 707 | + "class": "rating-stars-input " + widget_attrs.get("class", ""), |
| 708 | + "data-max-stars": str(max_stars), |
| 709 | + } |
| 710 | + ) |
| 711 | + self.fields[field_def.field_name] = forms.ChoiceField( |
| 712 | + choices=[("", "")] + rating_choices, |
| 713 | + widget=forms.Select(attrs=widget_attrs), |
| 714 | + **field_args, |
| 715 | + ) |
| 716 | + |
| 717 | + elif field_def.field_type == "slider": |
| 718 | + min_val = float(field_def.min_value) if field_def.min_value else 0 |
| 719 | + max_val = float(field_def.max_value) if field_def.max_value else 100 |
| 720 | + step = float(field_def.default_value) if field_def.default_value else 1 |
| 721 | + widget_attrs.update( |
| 722 | + { |
| 723 | + "type": "range", |
| 724 | + "min": str(min_val), |
| 725 | + "max": str(max_val), |
| 726 | + "step": str(step), |
| 727 | + "class": "form-range " + widget_attrs.get("class", ""), |
| 728 | + "data-slider-field": "true", |
| 729 | + } |
| 730 | + ) |
| 731 | + self.fields[field_def.field_name] = forms.DecimalField( |
| 732 | + min_value=field_def.min_value, |
| 733 | + max_value=field_def.max_value, |
| 734 | + widget=forms.NumberInput(attrs=widget_attrs), |
| 735 | + **field_args, |
| 736 | + ) |
| 737 | + |
| 738 | + elif field_def.field_type == "address": |
| 739 | + # Structured address stored as JSON with sub-fields rendered |
| 740 | + # via a single Textarea. The form-enhancements JS splits this |
| 741 | + # into sub-inputs (street, city, state, zip, country) on the |
| 742 | + # client side. |
| 743 | + widget_attrs.update( |
| 744 | + { |
| 745 | + "rows": 4, |
| 746 | + "data-address-field": "true", |
| 747 | + "placeholder": widget_attrs.get( |
| 748 | + "placeholder", |
| 749 | + "Street Address\nCity, State ZIP\nCountry", |
| 750 | + ), |
| 751 | + } |
| 752 | + ) |
| 753 | + self.fields[field_def.field_name] = forms.CharField( |
| 754 | + widget=forms.Textarea(attrs=widget_attrs), |
| 755 | + max_length=500, |
| 756 | + **field_args, |
| 757 | + ) |
| 758 | + |
| 759 | + elif field_def.field_type == "matrix": |
| 760 | + # Matrix/grid: rows and columns defined via `choices` JSON. |
| 761 | + # Expected format: {"rows": ["Row 1", ...], "columns": ["Col A", ...]} |
| 762 | + # Stored as JSON dict mapping row labels to selected column values. |
| 763 | + rows = [] |
| 764 | + cols = [] |
| 765 | + if field_def.choices and isinstance(field_def.choices, dict): |
| 766 | + rows = field_def.choices.get("rows", []) |
| 767 | + cols = field_def.choices.get("columns", []) |
| 768 | + |
| 769 | + if rows and cols: |
| 770 | + # Create one radio/select per row, rendered together by template |
| 771 | + for row_label in rows: |
| 772 | + sub_name = f"{field_def.field_name}__{row_label}" |
| 773 | + col_choices = [(c, c) for c in cols] |
| 774 | + self.fields[sub_name] = forms.ChoiceField( |
| 775 | + choices=[("", "—")] + col_choices, |
| 776 | + label=row_label, |
| 777 | + required=field_def.required, |
| 778 | + widget=forms.RadioSelect( |
| 779 | + attrs={ |
| 780 | + "class": "matrix-row-input", |
| 781 | + "data-matrix-group": field_def.field_name, |
| 782 | + } |
| 783 | + ), |
| 784 | + ) |
| 785 | + # Marker field so the template knows this is a matrix group |
| 786 | + self.fields[field_def.field_name] = forms.CharField( |
| 787 | + widget=forms.HiddenInput( |
| 788 | + attrs={ |
| 789 | + "data-matrix-field": "true", |
| 790 | + "data-matrix-rows": ",".join(rows), |
| 791 | + } |
| 792 | + ), |
| 793 | + required=False, |
| 794 | + initial="matrix", |
| 795 | + ) |
| 796 | + else: |
| 797 | + # Fallback if rows/cols not configured yet |
| 798 | + widget_attrs["rows"] = 3 |
| 799 | + widget_attrs["placeholder"] = ( |
| 800 | + "Configure rows/columns in choices as JSON: " |
| 801 | + '{"rows": [...], "columns": [...]}' |
| 802 | + ) |
| 803 | + self.fields[field_def.field_name] = forms.CharField( |
| 804 | + widget=forms.Textarea(attrs=widget_attrs), |
| 805 | + required=False, |
| 806 | + **{k: v for k, v in field_args.items() if k != "required"}, |
| 807 | + ) |
| 808 | + |
684 | 809 | # Add custom validation if regex provided |
685 | 810 | if field_def.regex_validation and field_def.field_type in ["text", "textarea"]: |
686 | 811 | self.fields[field_def.field_name].validators.append( |
@@ -873,6 +998,19 @@ def clean(self): |
873 | 998 |
|
874 | 999 | cleaned_data = super().clean() |
875 | 1000 |
|
| 1001 | + # ── CAPTCHA verification ─────────────────────────────────────────── |
| 1002 | + if self.form_definition.enable_captcha and "captcha_token" in cleaned_data: |
| 1003 | + token = cleaned_data.pop("captcha_token", "") |
| 1004 | + if token: |
| 1005 | + if not self._verify_captcha_token(token): |
| 1006 | + self.add_error( |
| 1007 | + None, |
| 1008 | + ValidationError( |
| 1009 | + "CAPTCHA verification failed. Please try again.", |
| 1010 | + code="captcha_failed", |
| 1011 | + ), |
| 1012 | + ) |
| 1013 | + |
876 | 1014 | for field_def in ( |
877 | 1015 | self.form_definition.fields.exclude(field_type="section") |
878 | 1016 | .filter(workflow_stage__isnull=True) |
@@ -938,6 +1076,46 @@ def clean(self): |
938 | 1076 |
|
939 | 1077 | return cleaned_data |
940 | 1078 |
|
| 1079 | + def _verify_captcha_token(self, token): |
| 1080 | + """Verify a CAPTCHA token with the provider's API. |
| 1081 | +
|
| 1082 | + Supports both Google reCAPTCHA v2/v3 and hCaptcha — both use the |
| 1083 | + same ``siteverify`` POST contract. Configure via Django settings: |
| 1084 | +
|
| 1085 | + - ``FORMS_WORKFLOWS_CAPTCHA_SECRET_KEY`` (required) |
| 1086 | + - ``FORMS_WORKFLOWS_CAPTCHA_VERIFY_URL`` (optional; defaults to |
| 1087 | + Google reCAPTCHA) |
| 1088 | + """ |
| 1089 | + import urllib.parse |
| 1090 | + import urllib.request |
| 1091 | + |
| 1092 | + from django.conf import settings |
| 1093 | + |
| 1094 | + secret = getattr(settings, "FORMS_WORKFLOWS_CAPTCHA_SECRET_KEY", "") |
| 1095 | + if not secret: |
| 1096 | + logger.warning( |
| 1097 | + "CAPTCHA enabled but FORMS_WORKFLOWS_CAPTCHA_SECRET_KEY not set" |
| 1098 | + ) |
| 1099 | + return True # Fail open if misconfigured |
| 1100 | + |
| 1101 | + verify_url = getattr( |
| 1102 | + settings, |
| 1103 | + "FORMS_WORKFLOWS_CAPTCHA_VERIFY_URL", |
| 1104 | + "https://www.google.com/recaptcha/api/siteverify", |
| 1105 | + ) |
| 1106 | + |
| 1107 | + try: |
| 1108 | + data = urllib.parse.urlencode( |
| 1109 | + {"secret": secret, "response": token} |
| 1110 | + ).encode() |
| 1111 | + req = urllib.request.Request(verify_url, data=data, method="POST") |
| 1112 | + with urllib.request.urlopen(req, timeout=5) as resp: |
| 1113 | + result = json.loads(resp.read()) |
| 1114 | + return result.get("success", False) |
| 1115 | + except Exception: |
| 1116 | + logger.exception("CAPTCHA verification request failed") |
| 1117 | + return False |
| 1118 | + |
941 | 1119 | def get_enhancements_config(self): |
942 | 1120 | """ |
943 | 1121 | Generate JavaScript configuration for form enhancements. |
|
0 commit comments