Skip to content

Commit 94529a9

Browse files
committed
feat: public form support with anonymous submissions and rate limiting
- Make FormSubmission.submitter and AuditLog.user nullable for anonymous submissions (migration 0077) - Remove @login_required from form_list (shows public forms to anonymous users) and form_submit (conditional auth based on requires_login) - Add IP-based rate limiting for anonymous submissions using Django cache (configurable via FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT, default 10/hour) - Disable auto-save and Save Draft for anonymous users - Add confirmation page for anonymous submissions and 429 rate-limit page - Guard all templates, admin, workflow engine, file handlers against null submitter - Update tests for new public form list behavior Bump to v0.47.0
1 parent 3f79708 commit 94529a9

22 files changed

Lines changed: 307 additions & 61 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.47.0] - 2026-03-31
11+
12+
### Added
13+
- **Public form support** — Forms with `requires_login=False` are now fully accessible to anonymous (unauthenticated) users. Anonymous users can:
14+
- Browse the form list (only public forms are shown)
15+
- Submit public forms
16+
- See a confirmation page after submission
17+
- **Rate limiting for anonymous submissions** — IP-based rate limiting using Django's cache framework prevents abuse. Configurable via `settings.FORMS_WORKFLOWS_PUBLIC_RATE_LIMIT` (default: `"10/hour"`). Format: `"<count>/<period>"` where period is `minute`, `hour`, or `day`. Returns a 429 page when exceeded.
18+
- **Anonymous submission handling**`FormSubmission.submitter` is now nullable. Anonymous submissions store IP address and user agent for audit purposes. Auto-save and draft saving are disabled for anonymous users.
19+
20+
### Changed
21+
- `FormSubmission.submitter` is now `null=True, blank=True` (migration included)
22+
- `AuditLog.user` is now `null=True, blank=True` for anonymous submission logging
23+
- `form_list` view no longer requires login — shows public forms to anonymous users, full list to authenticated users
24+
- `form_submit` view uses conditional authentication instead of `@login_required`
25+
- `form_auto_save` returns 403 for unauthenticated requests instead of redirecting to login
26+
- All templates (submission detail, approve, PDF, email, reassign) handle null submitter gracefully
27+
- Workflow engine guards `requires_manager_approval` against null submitter
28+
1029
## [0.46.0] - 2026-03-31
1130

1231
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Move form definitions between environments from the Django Admin:
132132
- `admin_groups` — full administrative view of all submissions
133133
- Group-based approval routing via `WorkflowStage.approval_groups`
134134
- Complete audit logging (`AuditLog` — who, what, when, IP address)
135+
- **Public / anonymous forms** — mark any form as `requires_login=False` and anonymous users can submit it; IP-based rate limiting prevents abuse
135136
- `UserProfile` auto-created on first login with LDAP/SSO sync
136137

137138
## Quick Start

django_forms_workflows/admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,9 +702,10 @@ def last_submission(self, obj):
702702
"""Show the most recent submission info."""
703703
last = obj.submissions.order_by("-created_at").first()
704704
if last:
705+
who = last.submitter.username if last.submitter_id else "Anonymous"
705706
return format_html(
706707
"{}<br><small>{}</small>",
707-
last.submitter.username,
708+
who,
708709
last.created_at.strftime("%Y-%m-%d %H:%M"),
709710
)
710711
return "-"

django_forms_workflows/forms.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ def __init__(self, form_definition, user=None, initial_data=None, *args, **kwarg
322322

323323
# Add submit buttons
324324
buttons = [Submit("submit", "Submit", css_class="btn btn-primary")]
325-
if form_definition.allow_save_draft:
325+
# Save Draft is only available for authenticated users
326+
if form_definition.allow_save_draft and user is not None:
326327
buttons.append(
327328
Submit(
328329
"save_draft",
@@ -946,8 +947,14 @@ def get_enhancements_config(self):
946947

947948
from django.urls import reverse
948949

950+
# Disable auto-save for anonymous users (no drafts without a user)
951+
auto_save_enabled = (
952+
getattr(self.form_definition, "enable_auto_save", True)
953+
and self.user is not None
954+
)
955+
949956
config = {
950-
"autoSaveEnabled": getattr(self.form_definition, "enable_auto_save", True),
957+
"autoSaveEnabled": auto_save_enabled,
951958
"autoSaveInterval": getattr(self.form_definition, "auto_save_interval", 30)
952959
* 1000, # Convert to ms
953960
"autoSaveEndpoint": reverse(

django_forms_workflows/handlers/file_handler.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,11 @@ def _build_payload(self):
312312
"status": submission.status,
313313
},
314314
"user": {
315-
"id": submission.submitter.id,
316-
"username": submission.submitter.username,
317-
"email": submission.submitter.email,
315+
"id": submission.submitter.id if submission.submitter_id else None,
316+
"username": submission.submitter.username
317+
if submission.submitter_id
318+
else "anonymous",
319+
"email": submission.submitter.email if submission.submitter_id else "",
318320
},
319321
}
320322

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 6.0.3 on 2026-03-31 17:58
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("django_forms_workflows", "0077_drop_legacy_notification_models"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="auditlog",
18+
name="user",
19+
field=models.ForeignKey(
20+
blank=True,
21+
null=True,
22+
on_delete=django.db.models.deletion.PROTECT,
23+
to=settings.AUTH_USER_MODEL,
24+
),
25+
),
26+
migrations.AlterField(
27+
model_name="formsubmission",
28+
name="submitter",
29+
field=models.ForeignKey(
30+
blank=True,
31+
help_text="Null for anonymous (public-form) submissions.",
32+
null=True,
33+
on_delete=django.db.models.deletion.PROTECT,
34+
related_name="form_submissions",
35+
to=settings.AUTH_USER_MODEL,
36+
),
37+
),
38+
]

django_forms_workflows/models.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,6 +1562,9 @@ class FormSubmission(models.Model):
15621562
settings.AUTH_USER_MODEL,
15631563
on_delete=models.PROTECT,
15641564
related_name="form_submissions",
1565+
null=True,
1566+
blank=True,
1567+
help_text="Null for anonymous (public-form) submissions.",
15651568
)
15661569

15671570
# Submission Data
@@ -1604,7 +1607,8 @@ class Meta:
16041607
verbose_name_plural = "Form Submissions"
16051608

16061609
def __str__(self):
1607-
return f"{self.form_definition.name} - {self.submitter.username} - {self.get_status_display()}"
1610+
who = self.submitter.username if self.submitter_id else "Anonymous"
1611+
return f"{self.form_definition.name} - {who} - {self.get_status_display()}"
16081612

16091613

16101614
class ApprovalTask(models.Model):
@@ -1730,8 +1734,13 @@ class AuditLog(models.Model):
17301734
object_type = models.CharField(max_length=50)
17311735
object_id = models.IntegerField()
17321736

1733-
# Who did it
1734-
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
1737+
# Who did it (null for anonymous / public-form submissions)
1738+
user = models.ForeignKey(
1739+
settings.AUTH_USER_MODEL,
1740+
on_delete=models.PROTECT,
1741+
null=True,
1742+
blank=True,
1743+
)
17351744
user_ip = models.GenericIPAddressField(null=True, blank=True)
17361745

17371746
# Details

django_forms_workflows/templates/django_forms_workflows/approve.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ <h5 class="mb-0"><i class="bi bi-info-circle"></i> Submission Information</h5>
142142
<div class="row">
143143
<div class="col-md-6">
144144
<p><strong>Form:</strong> {{ submission.form_definition.name }}</p>
145-
<p><strong>Submitter:</strong> {{ submission.submitter.get_full_name|default:submission.submitter.username }}</p>
146-
<p><strong>Email:</strong> {{ submission.submitter.email }}</p>
145+
<p><strong>Submitter:</strong> {% if submission.submitter %}{{ submission.submitter.get_full_name|default:submission.submitter.username }}{% else %}Anonymous{% endif %}</p>
146+
<p><strong>Email:</strong> {% if submission.submitter %}{{ submission.submitter.email }}{% else %}N/A{% endif %}</p>
147147
</div>
148148
<div class="col-md-6">
149149
<p><strong>Submitted:</strong> {{ submission.submitted_at|date:"Y-m-d H:i" }}</p>

django_forms_workflows/templates/django_forms_workflows/form_submit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ <h1>{{ form_def.name }}</h1>
103103
<a href="{% url 'forms_workflows:form_list' %}" class="btn btn-outline-secondary">
104104
<i class="bi bi-arrow-left"></i> Back to Forms
105105
</a>
106-
{% if is_draft and draft_id %}
106+
{% if not is_anonymous and is_draft and draft_id %}
107107
<a href="{% url 'forms_workflows:discard_draft' draft_id %}"
108108
class="btn btn-outline-danger ms-auto">
109109
<i class="bi bi-trash3"></i> Discard Draft
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% extends "django_forms_workflows/base.html" %}
2+
3+
{% block title %}Submission Received{% endblock %}
4+
5+
{% block content %}
6+
<div class="container py-5">
7+
<div class="row justify-content-center">
8+
<div class="col-md-8 col-lg-6 text-center">
9+
<div class="card shadow-sm p-5">
10+
<div class="mb-4">
11+
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
12+
</div>
13+
<h2 class="mb-3">Submission Received</h2>
14+
<p class="text-muted mb-4">
15+
Thank you! Your form has been submitted successfully.
16+
{% if form_def.workflow %}
17+
It will be reviewed by the appropriate team.
18+
{% endif %}
19+
</p>
20+
<div class="d-grid gap-2">
21+
<a href="{% url 'forms_workflows:form_list' %}" class="btn btn-primary">
22+
<i class="bi bi-arrow-left"></i> Back to Forms
23+
</a>
24+
</div>
25+
</div>
26+
</div>
27+
</div>
28+
</div>
29+
{% endblock %}
30+

0 commit comments

Comments
 (0)