Skip to content

Commit c860b47

Browse files
committed
v0.55.0: Custom Document Templates with merge fields
- Add DocumentTemplate model with merge field syntax ({field_name}) and conditional blocks - Add render() method for populating templates with submission data - Update submission_pdf view to use custom templates (query param or default) - Add Document Templates CRUD UI in form builder (list/save/delete API) - Register DocumentTemplateAdmin in admin.py - Fix template syntax error: wrap merge field examples in verbatim block - Add DocumentTemplate to workflows/models.py backwards-compat exports - Add 17 tests (10 model + 7 API endpoint) - Bump version 0.54.0 -> 0.55.0
1 parent a2f0364 commit c860b47

10 files changed

Lines changed: 834 additions & 21 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ApprovalTask,
2626
AuditLog,
2727
ChangeHistory,
28+
DocumentTemplate,
2829
FileUploadConfig,
2930
FileWorkflowHook,
3031
FormCategory,
@@ -1418,6 +1419,21 @@ def get_urls(self):
14181419
self.admin_site.admin_view(form_builder_views.form_builder_clone),
14191420
name="form_builder_api_clone",
14201421
),
1422+
path(
1423+
"builder/api/doc-templates/<int:form_id>/",
1424+
self.admin_site.admin_view(form_builder_views.document_template_list),
1425+
name="form_builder_api_doc_templates",
1426+
),
1427+
path(
1428+
"builder/api/doc-templates/<int:form_id>/save/",
1429+
self.admin_site.admin_view(form_builder_views.document_template_save),
1430+
name="form_builder_api_doc_template_save",
1431+
),
1432+
path(
1433+
"builder/api/doc-templates/<int:form_id>/delete/<int:template_id>/",
1434+
self.admin_site.admin_view(form_builder_views.document_template_delete),
1435+
name="form_builder_api_doc_template_delete",
1436+
),
14211437
# Workflow Builder URLs
14221438
path(
14231439
"<int:form_id>/workflow/",
@@ -2147,6 +2163,44 @@ def save_model(self, request, obj, form, change):
21472163
super().save_model(request, obj, form, change)
21482164

21492165

2166+
# --- Document Template Admin ---
2167+
2168+
2169+
@admin.register(DocumentTemplate)
2170+
class DocumentTemplateAdmin(admin.ModelAdmin):
2171+
"""Admin for custom PDF document templates with merge fields."""
2172+
2173+
list_display = (
2174+
"name",
2175+
"form_definition",
2176+
"page_size",
2177+
"is_default",
2178+
"is_active",
2179+
"updated_at",
2180+
)
2181+
list_filter = ("is_default", "is_active", "page_size")
2182+
search_fields = ("name", "form_definition__name")
2183+
readonly_fields = ("created_at", "updated_at")
2184+
2185+
fieldsets = (
2186+
(None, {"fields": ("form_definition", "name", "is_default", "is_active")}),
2187+
(
2188+
"Template Content",
2189+
{
2190+
"fields": ("html_content", "page_size"),
2191+
"description": (
2192+
"Use {field_name} merge fields and "
2193+
"{% if field_name %}...{% endif %} conditional blocks."
2194+
),
2195+
},
2196+
),
2197+
(
2198+
"Timestamps",
2199+
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
2200+
),
2201+
)
2202+
2203+
21502204
# --- File Upload Configuration Admin ---
21512205

21522206

django_forms_workflows/form_builder_urls.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,20 @@
2020
),
2121
path("api/save/", form_builder_views.form_builder_save, name="api_save"),
2222
path("api/preview/", form_builder_views.form_builder_preview, name="api_preview"),
23+
# Document template API endpoints
24+
path(
25+
"api/doc-templates/<int:form_id>/",
26+
form_builder_views.document_template_list,
27+
name="api_doc_templates",
28+
),
29+
path(
30+
"api/doc-templates/<int:form_id>/save/",
31+
form_builder_views.document_template_save,
32+
name="api_doc_template_save",
33+
),
34+
path(
35+
"api/doc-templates/<int:form_id>/delete/<int:template_id>/",
36+
form_builder_views.document_template_delete,
37+
name="api_doc_template_delete",
38+
),
2339
]

django_forms_workflows/form_builder_views.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from django.views.decorators.http import require_GET, require_POST
1717

1818
from .models import (
19+
DocumentTemplate,
1920
FormDefinition,
2021
FormField,
2122
FormTemplate,
@@ -683,3 +684,104 @@ def form_builder_preview(request):
683684
return JsonResponse(
684685
{"success": False, "error": "An internal error occurred."}, status=500
685686
)
687+
688+
689+
# ---------------------------------------------------------------------------
690+
# Document Template API endpoints
691+
# ---------------------------------------------------------------------------
692+
693+
694+
@staff_member_required
695+
@require_GET
696+
def document_template_list(request, form_id):
697+
"""List document templates for a form."""
698+
templates = DocumentTemplate.objects.filter(form_definition_id=form_id).order_by(
699+
"name"
700+
)
701+
return JsonResponse(
702+
{
703+
"success": True,
704+
"templates": [
705+
{
706+
"id": t.id,
707+
"name": t.name,
708+
"is_default": t.is_default,
709+
"is_active": t.is_active,
710+
"page_size": t.page_size,
711+
"html_content": t.html_content,
712+
"updated_at": t.updated_at.isoformat(),
713+
}
714+
for t in templates
715+
],
716+
}
717+
)
718+
719+
720+
@staff_member_required
721+
@require_POST
722+
def document_template_save(request, form_id):
723+
"""Create or update a document template."""
724+
try:
725+
data = json.loads(request.body)
726+
form_def = get_object_or_404(FormDefinition, id=form_id)
727+
728+
template_id = data.get("id")
729+
name = data.get("name", "").strip()
730+
html_content = data.get("html_content", "")
731+
page_size = data.get("page_size", "letter")
732+
is_default = data.get("is_default", False)
733+
is_active = data.get("is_active", True)
734+
735+
if not name:
736+
return JsonResponse(
737+
{"success": False, "error": "Template name is required"}, status=400
738+
)
739+
740+
with transaction.atomic():
741+
# If marking as default, clear other defaults for this form
742+
if is_default:
743+
DocumentTemplate.objects.filter(
744+
form_definition=form_def, is_default=True
745+
).update(is_default=False)
746+
747+
if template_id:
748+
tpl = get_object_or_404(
749+
DocumentTemplate, id=template_id, form_definition=form_def
750+
)
751+
tpl.name = name
752+
tpl.html_content = html_content
753+
tpl.page_size = page_size
754+
tpl.is_default = is_default
755+
tpl.is_active = is_active
756+
tpl.save()
757+
else:
758+
tpl = DocumentTemplate.objects.create(
759+
form_definition=form_def,
760+
name=name,
761+
html_content=html_content,
762+
page_size=page_size,
763+
is_default=is_default,
764+
is_active=is_active,
765+
)
766+
767+
return JsonResponse(
768+
{"success": True, "id": tpl.id, "message": "Template saved successfully"}
769+
)
770+
except json.JSONDecodeError:
771+
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
772+
except Exception:
773+
logger.exception("Error saving document template")
774+
return JsonResponse(
775+
{"success": False, "error": "An internal error occurred."}, status=500
776+
)
777+
778+
779+
@staff_member_required
780+
@require_POST
781+
def document_template_delete(request, form_id, template_id):
782+
"""Delete a document template."""
783+
tpl = get_object_or_404(
784+
DocumentTemplate, id=template_id, form_definition_id=form_id
785+
)
786+
tpl.delete()
787+
return JsonResponse({"success": True, "message": "Template deleted"})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Generated by Django 5.2.7 on 2026-04-01 16:42
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("django_forms_workflows", "0081_add_success_page_fields"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="DocumentTemplate",
16+
fields=[
17+
(
18+
"id",
19+
models.BigAutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="ID",
24+
),
25+
),
26+
(
27+
"name",
28+
models.CharField(
29+
help_text="Display name (e.g. 'Approval Certificate', 'Offer Letter').",
30+
max_length=200,
31+
),
32+
),
33+
(
34+
"is_default",
35+
models.BooleanField(
36+
default=False,
37+
help_text="Use this template instead of the built-in PDF layout when downloading a submission PDF.",
38+
),
39+
),
40+
("is_active", models.BooleanField(default=True)),
41+
(
42+
"html_content",
43+
models.TextField(
44+
help_text="Full HTML document with CSS. Use {field_name} merge fields to insert submitted values. Use {% if field_name %}...{% endif %} for conditional sections. Available variables: {form_name}, {submission_id}, {submitted_at}, {status}, {submitter_name}, and all form field names."
45+
),
46+
),
47+
(
48+
"page_size",
49+
models.CharField(
50+
choices=[
51+
("letter", "US Letter (8.5 x 11 in)"),
52+
("a4", "A4 (210 x 297 mm)"),
53+
("legal", "US Legal (8.5 x 14 in)"),
54+
],
55+
default="letter",
56+
max_length=20,
57+
),
58+
),
59+
("created_at", models.DateTimeField(auto_now_add=True)),
60+
("updated_at", models.DateTimeField(auto_now=True)),
61+
(
62+
"form_definition",
63+
models.ForeignKey(
64+
help_text="The form this template belongs to.",
65+
on_delete=django.db.models.deletion.CASCADE,
66+
related_name="document_templates",
67+
to="django_forms_workflows.formdefinition",
68+
),
69+
),
70+
],
71+
options={
72+
"verbose_name": "Document Template",
73+
"verbose_name_plural": "Document Templates",
74+
"ordering": ["form_definition", "name"],
75+
},
76+
),
77+
]

0 commit comments

Comments
 (0)