Skip to content

Commit 5897934

Browse files
committed
feat: release 0.53.0 — rating/slider/address/matrix fields, submission controls, CAPTCHA, analytics CSV export, 156 new tests
1 parent 5577435 commit 5897934

16 files changed

Lines changed: 1561 additions & 94 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ db.sqlite3
44
form-workflows/
55
__pycache__/
66
.venv/
7+
FEATURE_GAP_ANALYSIS.md

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.53.0] - 2026-04-01
11+
12+
### Added
13+
- **Rating field** (`field_type = "rating"`) — star-based rating widget rendered via a CSS-only radio button group. `max_value` controls the star count (default 5). Stored as a string `"1"``"5"` (or up to the configured max).
14+
- **Slider field** (`field_type = "slider"`) — range slider input backed by `DecimalField`. `min_value`/`max_value` set the range; `default_value` sets the step size. Live value badge updates as the user drags. Rendered with Bootstrap's `form-range` class.
15+
- **Address field** (`field_type = "address"`) — structured address stored as free-text (up to 500 chars). The JS form-enhancements layer splits the textarea into labelled sub-inputs (street, city, state, ZIP, country) on the client side.
16+
- **Matrix / Grid field** (`field_type = "matrix"`) — questionnaire-style grid defined by `choices = {"rows": [...], "columns": [...]}`. Each row becomes a separate `RadioSelect` sub-field; answers are collected as a hidden marker field. Falls back to a plain `Textarea` when no rows/columns are configured.
17+
- **Submission controls on `FormDefinition`** — three new fields enforced at the view layer before any form is rendered or submitted:
18+
- `close_date` (`DateTimeField`, nullable) — stops accepting submissions after the configured date/time.
19+
- `max_submissions` (`PositiveIntegerField`, nullable) — caps the total number of non-draft submissions.
20+
- `one_per_user` (`BooleanField`, default `False`) — restricts each authenticated user to a single non-draft, non-withdrawn submission.
21+
- **CAPTCHA support** (`enable_captcha` on `FormDefinition`) — when enabled, injects a hidden `captcha_token` field and a `<div data-captcha-widget>` placeholder. The JS layer dynamically loads either Google reCAPTCHA v2/v3 or hCaptcha (detected by script tag). Server-side verification via `DynamicForm._verify_captcha_token()` calls the provider's `siteverify` endpoint using `FORMS_WORKFLOWS_CAPTCHA_SECRET_KEY` / `FORMS_WORKFLOWS_CAPTCHA_VERIFY_URL` settings; fails open when the key is not configured.
22+
- **Analytics CSV export** — new `/analytics/export/` endpoint (`analytics_export_csv`) returns a CSV of all non-draft submissions in the selected time range, filterable by form slug. Columns: Date, Form, Status, Submitter, Submission ID.
23+
- **Analytics period-over-period comparison** — the analytics dashboard now computes the previous equivalent period and exposes `total_change`, `approved_change`, `rejected_change`, and `approval_rate` / `approval_rate_change` context variables for trend indicators.
24+
- **Form builder: Submission Controls panel** — close date picker, max-submissions input, and one-per-user / CAPTCHA checkboxes added to the form-settings panel in the visual form builder; values round-trip through `form_builder_load` / `form_builder_save`.
25+
- **Form builder: palette search** — live filter input in the field-palette panel narrows the displayed field types as the user types.
26+
- **Migration `0080`** — adds `close_date`, `max_submissions`, `one_per_user`, `enable_captcha` to `FormDefinition`; extends `FormField.field_type` choices with `rating`, `matrix`, `address`, `slider`.
27+
28+
### Fixed
29+
- **QR code view: inactive form now returns 404 before 501**`form_qr_code` previously checked for the `segno` package before resolving the form, so requests for inactive or non-existent slugs returned 501 (Not Implemented) instead of 404 when `segno` was not installed. The `get_object_or_404` call is now performed first.
30+
31+
### Tests
32+
- 156 new test cases across `tests/test_forms.py` and `tests/test_views.py` covering all four new field types, CAPTCHA injection and verification, all three submission controls (positive and negative paths), the analytics dashboard context keys and period comparison, and the CSV export (content, headers, filtering, filename).
33+
1034
## [0.49.0] - 2026-04-01
1135

1236
### Added

django_forms_workflows/form_builder_views.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ def form_builder_load(request, form_id):
311311
"requires_login": form_definition.requires_login,
312312
"allow_save_draft": form_definition.allow_save_draft,
313313
"allow_withdrawal": form_definition.allow_withdrawal,
314+
"close_date": form_definition.close_date.isoformat()
315+
if form_definition.close_date
316+
else None,
317+
"max_submissions": form_definition.max_submissions,
318+
"one_per_user": form_definition.one_per_user,
319+
"enable_captcha": form_definition.enable_captcha,
314320
"enable_multi_step": form_definition.enable_multi_step,
315321
"form_steps": form_definition.form_steps or [],
316322
"enable_auto_save": form_definition.enable_auto_save,
@@ -343,6 +349,10 @@ def form_builder_save(request):
343349
requires_login = data.get("requires_login", True)
344350
allow_save_draft = data.get("allow_save_draft", True)
345351
allow_withdrawal = data.get("allow_withdrawal", True)
352+
close_date = data.get("close_date") or None
353+
max_submissions = data.get("max_submissions") or None
354+
one_per_user = data.get("one_per_user", False)
355+
enable_captcha = data.get("enable_captcha", False)
346356
enable_multi_step = data.get("enable_multi_step", False)
347357
form_steps = data.get("form_steps", [])
348358
enable_auto_save = data.get("enable_auto_save", True)
@@ -372,6 +382,10 @@ def form_builder_save(request):
372382
form_definition.requires_login = requires_login
373383
form_definition.allow_save_draft = allow_save_draft
374384
form_definition.allow_withdrawal = allow_withdrawal
385+
form_definition.close_date = close_date
386+
form_definition.max_submissions = max_submissions
387+
form_definition.one_per_user = one_per_user
388+
form_definition.enable_captcha = enable_captcha
375389
form_definition.enable_multi_step = enable_multi_step
376390
form_definition.form_steps = form_steps
377391
form_definition.enable_auto_save = enable_auto_save
@@ -388,6 +402,10 @@ def form_builder_save(request):
388402
requires_login=requires_login,
389403
allow_save_draft=allow_save_draft,
390404
allow_withdrawal=allow_withdrawal,
405+
close_date=close_date,
406+
max_submissions=max_submissions,
407+
one_per_user=one_per_user,
408+
enable_captcha=enable_captcha,
391409
enable_multi_step=enable_multi_step,
392410
form_steps=form_steps,
393411
enable_auto_save=enable_auto_save,

django_forms_workflows/forms.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class for handling approval-step field editing.
77
"""
88

9+
import json
910
import logging
1011
import re
1112
from datetime import date, datetime
@@ -320,6 +321,23 @@ def __init__(self, form_definition, user=None, initial_data=None, *args, **kwarg
320321
)
321322
i += 1
322323

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+
323341
# Add submit buttons
324342
buttons = [Submit("submit", "Submit", css_class="btn btn-primary")]
325343
# Save Draft is only available for authenticated users
@@ -681,6 +699,113 @@ def add_field(self, field_def, initial_data):
681699
widget=forms.HiddenInput(), required=False, initial=initial
682700
)
683701

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+
684809
# Add custom validation if regex provided
685810
if field_def.regex_validation and field_def.field_type in ["text", "textarea"]:
686811
self.fields[field_def.field_name].validators.append(
@@ -873,6 +998,19 @@ def clean(self):
873998

874999
cleaned_data = super().clean()
8751000

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+
8761014
for field_def in (
8771015
self.form_definition.fields.exclude(field_type="section")
8781016
.filter(workflow_stage__isnull=True)
@@ -938,6 +1076,46 @@ def clean(self):
9381076

9391077
return cleaned_data
9401078

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+
9411119
def get_enhancements_config(self):
9421120
"""
9431121
Generate JavaScript configuration for form enhancements.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Generated by Django 5.2.7 on 2026-04-01 09:52
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("django_forms_workflows", "0079_webhookendpoint_webhookdeliverylog"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="formdefinition",
15+
name="close_date",
16+
field=models.DateTimeField(
17+
blank=True,
18+
help_text="Automatically stop accepting submissions after this date/time",
19+
null=True,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="formdefinition",
24+
name="enable_captcha",
25+
field=models.BooleanField(
26+
default=False,
27+
help_text="Show a CAPTCHA challenge before submission. Requires FORMS_WORKFLOWS_CAPTCHA_SITE_KEY and FORMS_WORKFLOWS_CAPTCHA_SECRET_KEY in settings.",
28+
),
29+
),
30+
migrations.AddField(
31+
model_name="formdefinition",
32+
name="max_submissions",
33+
field=models.PositiveIntegerField(
34+
blank=True,
35+
help_text="Maximum total submissions allowed (leave blank for unlimited)",
36+
null=True,
37+
),
38+
),
39+
migrations.AddField(
40+
model_name="formdefinition",
41+
name="one_per_user",
42+
field=models.BooleanField(
43+
default=False,
44+
help_text="Restrict each authenticated user to a single submission",
45+
),
46+
),
47+
migrations.AlterField(
48+
model_name="formfield",
49+
name="field_type",
50+
field=models.CharField(
51+
choices=[
52+
("text", "Single Line Text"),
53+
("phone", "Phone Number"),
54+
("textarea", "Multi-line Text"),
55+
("number", "Whole Number"),
56+
("decimal", "Decimal Number"),
57+
("currency", "Currency ($)"),
58+
("date", "Date"),
59+
("datetime", "Date and Time"),
60+
("time", "Time"),
61+
("email", "Email Address"),
62+
("url", "Website URL"),
63+
("select", "Dropdown Select"),
64+
("multiselect", "Multiple Select (Checkboxes)"),
65+
("multiselect_list", "Multiple Select (List)"),
66+
("radio", "Radio Buttons"),
67+
("checkbox", "Single Checkbox"),
68+
("checkboxes", "Multiple Checkboxes"),
69+
("file", "File Upload"),
70+
("multifile", "Multi-File Upload"),
71+
("hidden", "Hidden Field"),
72+
("section", "Section Header (not a field)"),
73+
("calculated", "Calculated / Formula"),
74+
("spreadsheet", "Spreadsheet Upload (CSV / Excel)"),
75+
("country", "Country Picker"),
76+
("us_state", "US State Picker"),
77+
("signature", "Signature"),
78+
("rating", "Rating (Stars)"),
79+
("matrix", "Matrix / Grid"),
80+
("address", "Address"),
81+
("slider", "Slider"),
82+
],
83+
max_length=20,
84+
),
85+
),
86+
]

0 commit comments

Comments
 (0)