Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,19 @@ jobs:
name: Test on Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
runs-on: ubuntu-latest

env:
DJANGO_DEBUG: '1'
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
django-version: ['5.1']
include:
- {python-version: '3.11', django-version: '5.2'}
- {python-version: '3.12', django-version: '5.2'}
- {python-version: '3.13', django-version: '5.2'}
- {python-version: '3.14', django-version: '5.2'}
- {python-version: '3.12', django-version: '6.0'}
- {python-version: '3.13', django-version: '6.1'}
- {python-version: '3.14', django-version: '6.1'}

steps:
- name: Checkout code
Expand All @@ -31,8 +39,9 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install Django==${{ matrix.django-version }}.*
pip install -e .[dev,qr]
pip install -e '.[dev,qr,markdown,pdf,excel]' 'Django==${{ matrix.django-version }}.*'
- name: Install PDF system libraries
run: sudo apt-get update && sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0

- name: Run migrations check
run: |
Expand Down Expand Up @@ -111,3 +120,27 @@ jobs:
name: dist-packages
path: dist/


javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci
- run: npm test
- run: npm audit --audit-level=moderate

dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install pip-audit poetry
- run: poetry check --lock
- name: Audit all locked Python dependencies, including extras and tooling
run: python scripts/audit_lock.py
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Enterprise-grade, database-driven form builder with multi-stage approval workflows, external data integration, and cross-instance sync**

[![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)
[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![Django Version](https://img.shields.io/badge/django-5.2%2B-green)](https://www.djangoproject.com/)
[![Version](https://img.shields.io/badge/version-0.49.0-orange)](https://github.com/opensensor/django-forms-workflows)

Expand Down Expand Up @@ -189,6 +189,8 @@ Move form definitions between environments from the Django Admin:

## Quick Start

See [security upgrade notes](docs/SECURITY_UPGRADE.md) before upgrading an existing deployment.

### Installation

```bash
Expand Down Expand Up @@ -344,8 +346,8 @@ graph TB

## Requirements

- Python 3.10+
- Django 5.2+
- Python 3.11+
- Django 5.2.18+, 6.0.9+, or 6.1.2+ (security patch minimums)
- PostgreSQL, MySQL, or SQLite (PostgreSQL recommended for production)
- Optional: Celery 5.0+ with Redis/Valkey for background task processing
- Optional: `openpyxl` for Excel spreadsheet field support (`pip install django-forms-workflows[excel]`)
Expand Down
23 changes: 23 additions & 0 deletions django_forms_workflows/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,15 @@ def export_as_json(self, request, queryset):

def sync_import_admin_view(self, request):
"""Admin page for importing form definitions from a JSON file or raw JSON."""
from django.core.exceptions import PermissionDenied

if not self.has_view_permission(request):
raise PermissionDenied
if not (
self.has_add_permission(request) and self.has_change_permission(request)
):
raise PermissionDenied

from .sync_api import import_payload

context = dict(self.admin_site.each_context(request))
Expand Down Expand Up @@ -1219,6 +1228,15 @@ def sync_pull_admin_view(self, request):
Step 1 (POST) – fetch available forms from the remote, show checkbox list
Step 2 (POST) – import selected forms, show results
"""
from django.core.exceptions import PermissionDenied

if not self.has_view_permission(request):
raise PermissionDenied
if not (
self.has_add_permission(request) and self.has_change_permission(request)
):
raise PermissionDenied

from .sync_api import (
build_export_payload,
fetch_remote_payload,
Expand Down Expand Up @@ -1372,6 +1390,11 @@ def sync_push_admin_view(self, request):
Step 1 (POST) – show all local forms with checkboxes + diff status
Step 2 (POST) – execute push for selected forms, show results
"""
from django.core.exceptions import PermissionDenied

if not self.has_view_permission(request):
raise PermissionDenied

from .sync_api import (
build_export_payload,
fetch_remote_payload,
Expand Down
2 changes: 1 addition & 1 deletion django_forms_workflows/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def wrapper(request, *args, **kwargs):
raw = auth.removeprefix("Bearer ").strip()
try:
token_obj = APIToken.objects.select_related("user").get(
token=raw, is_active=True
token=raw, is_active=True, user__is_active=True
)
except (APIToken.DoesNotExist, ValueError, Exception): # noqa: BLE001
return JsonResponse({"error": "Invalid or inactive token."}, status=401)
Expand Down
2 changes: 1 addition & 1 deletion django_forms_workflows/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def ready(self):
Import signal handlers and perform app initialization.
"""
# Import signals to register handlers
from . import signals # noqa: F401
from . import checks, signals # noqa: F401

# Load custom callback handlers from FORMS_WORKFLOWS_CALLBACKS setting
from .callback_registry import load_from_settings
Expand Down
24 changes: 24 additions & 0 deletions django_forms_workflows/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Deployment checks for public submission throttling."""

from django.conf import settings
from django.core.checks import Tags, Warning, register


@register(Tags.security, deploy=True)
def check_rate_limit_cache(app_configs, **kwargs):
alias = getattr(settings, "FORMS_WORKFLOWS_RATE_LIMIT_CACHE", "default")
backend = settings.CACHES.get(alias, {}).get("BACKEND", "")
if backend not in {
"django.core.cache.backends.redis.RedisCache",
"django.core.cache.backends.memcached.PyMemcacheCache",
"django.core.cache.backends.memcached.PyLibMCCache",
"django_redis.cache.RedisCache",
}:
return [
Warning(
"Public form rate limiting requires a shared cache with atomic add/incr.",
hint="Configure Redis or Memcached and FORMS_WORKFLOWS_RATE_LIMIT_CACHE; local-memory caches only limit one process.",
id="django_forms_workflows.W001",
)
]
return []
2 changes: 2 additions & 0 deletions django_forms_workflows/diff_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.shortcuts import render

from .models import FormDefinition
from .security import builder_permission_required
from .sync_api import build_export_payload


Expand Down Expand Up @@ -429,6 +430,7 @@ def _build_summary(forms_data):
return summaries


@builder_permission_required("view_formdefinition")
def diff_forms_view(request):
"""Render a side-by-side JSON diff of selected FormDefinitions."""
pks = request.GET.get("pks", "")
Expand Down
14 changes: 14 additions & 0 deletions django_forms_workflows/form_builder_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
WorkflowDefinition,
WorkflowStage,
)
from .security import builder_permission_required

logger = logging.getLogger(__name__)


@require_GET
@builder_permission_required("view_formtemplate")
def form_builder_templates(request):
"""
API endpoint to list available form templates.
Expand Down Expand Up @@ -63,6 +65,7 @@ def form_builder_templates(request):


@require_GET
@builder_permission_required("view_formtemplate")
def form_builder_load_template(request, template_id):
"""
API endpoint to load a form template.
Expand All @@ -83,6 +86,7 @@ def form_builder_load_template(request, template_id):


@require_POST
@builder_permission_required("view_formdefinition", "add_formdefinition")
def form_builder_clone(request, form_id):
"""
API endpoint to clone an existing form.
Expand Down Expand Up @@ -225,6 +229,7 @@ def form_builder_clone(request, form_id):


@require_GET
@builder_permission_required("view_formdefinition")
def form_builder_view(request, form_id=None):
"""
Main form builder page.
Expand Down Expand Up @@ -269,6 +274,7 @@ def form_builder_view(request, form_id=None):


@require_GET
@builder_permission_required("view_formdefinition")
def form_builder_load(request, form_id):
"""
API endpoint to load form data as JSON.
Expand Down Expand Up @@ -353,6 +359,7 @@ def form_builder_load(request, form_id):


@require_POST
@builder_permission_required("save_formdefinition")
def form_builder_save(request):
"""
API endpoint to save form data.
Expand Down Expand Up @@ -604,6 +611,7 @@ def save_form_definition_from_builder_data(data, user, form_definition=None):


@require_POST
@builder_permission_required("view_formdefinition")
def form_builder_preview(request):
"""
API endpoint to generate a preview of the form.
Expand Down Expand Up @@ -764,6 +772,7 @@ def form_builder_preview(request):


@require_GET
@builder_permission_required("view_formdefinition", "view_documenttemplate")
def document_template_list(request, form_id):
"""List document templates for a form."""
templates = DocumentTemplate.objects.filter(form_definition_id=form_id).order_by(
Expand All @@ -789,6 +798,7 @@ def document_template_list(request, form_id):


@require_POST
@builder_permission_required("change_formdefinition", "save_documenttemplate")
def document_template_save(request, form_id):
"""Create or update a document template."""
try:
Expand Down Expand Up @@ -847,6 +857,7 @@ def document_template_save(request, form_id):


@require_POST
@builder_permission_required("change_formdefinition", "delete_documenttemplate")
def document_template_delete(request, form_id, template_id):
"""Delete a document template."""
tpl = get_object_or_404(
Expand All @@ -862,6 +873,7 @@ def document_template_delete(request, form_id, template_id):


@require_GET
@builder_permission_required("view_sharedoptionlist")
def shared_option_list_api(request):
"""List all shared option lists (for form builder dropdowns)."""
lists = SharedOptionList.objects.filter(is_active=True).order_by("name")
Expand All @@ -882,6 +894,7 @@ def shared_option_list_api(request):


@require_POST
@builder_permission_required("save_sharedoptionlist")
def shared_option_list_save(request):
"""Create or update a shared option list."""
try:
Expand Down Expand Up @@ -924,6 +937,7 @@ def shared_option_list_save(request):


@require_POST
@builder_permission_required("delete_sharedoptionlist")
def shared_option_list_delete(request, list_id):
"""Delete a shared option list."""
ol = get_object_or_404(SharedOptionList, id=list_id)
Expand Down
31 changes: 6 additions & 25 deletions django_forms_workflows/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,18 +333,6 @@ def __init__(
# files). Also used to carry forward draft / resubmit file data.
self.stashed_files = stashed_files or {}

# Recover stashed-file metadata carried forward via hidden input from
# a prior validation-failure render (browsers cannot re-send files).
if not self.stashed_files and self.data:
prev = self.data.get("_stashed_files")
if prev:
try:
self.stashed_files = json.loads(prev)
except (json.JSONDecodeError, TypeError):
# Ignore malformed client metadata; uploaded files are rebuilt
# from validated server-side data below.
pass

# Build form fields from definition
# Exclude fields scoped to a workflow stage - those are for approvers only
for field in (
Expand All @@ -354,15 +342,6 @@ def __init__(
):
self.add_field(field, initial_data)

# Embed stashed-file metadata as a hidden field so it survives
# validation-failure round-trips (browsers cannot re-send files).
if self.stashed_files:
self.fields["_stashed_files"] = forms.CharField(
widget=forms.HiddenInput(),
required=False,
initial=json.dumps(self.stashed_files),
)

# Setup form layout with Crispy Forms
self.helper = FormHelper()
self.helper.form_method = "post"
Expand Down Expand Up @@ -763,7 +742,7 @@ def add_field(self, field_def, initial_data):
# failure, saved with a draft, or carried from a resubmission).
existing_file = self._get_existing_file_info(field_def.field_name)
if existing_file:
fname = existing_file.get("filename", "file")
fname = html_escape(existing_file.get("filename", "file"))
extra_help = f"Previously uploaded: <strong>{fname}</strong>. Leave blank to keep it."
base_help = field_args.get("help_text", "") or ""
field_args["help_text"] = (
Expand All @@ -789,10 +768,11 @@ def add_field(self, field_def, initial_data):
if isinstance(existing_file, list)
else existing_file.get("filename", "file")
)
extra = f"Previously uploaded: <strong>{names}</strong>. Leave blank to keep."
extra = f"Previously uploaded: <strong>{html_escape(names)}</strong>. Leave blank to keep."
help_text = f"{help_text} {extra}" if help_text else extra
required = False
self.fields[field_def.field_name] = MultipleFileField(
validators=_build_file_validators(field_def),
required=required,
label=field_def.field_label,
help_text=help_text,
Expand Down Expand Up @@ -931,12 +911,13 @@ def add_field(self, field_def, initial_data):
)

elif field_def.field_type == "display_text":
display_html = _render_display_text(initial or field_def.default_value)
display_html = _render_display_text(field_def.default_value)
self.fields[field_def.field_name] = forms.CharField(
required=False,
label=field_def.field_label,
help_text=field_def.help_text,
initial=initial or field_def.default_value,
initial=field_def.default_value,
disabled=True,
widget=DisplayTextWidget(
display_html=display_html,
attrs={"data-display-text": "true"},
Expand Down
3 changes: 3 additions & 0 deletions django_forms_workflows/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,8 @@ def render(self, submission):
"""Render this template with submission data, returning HTML string."""
import re

from django.utils.html import escape

form_data = submission.form_data or {}

# Build merge context with system variables + form data
Expand All @@ -906,6 +908,7 @@ def render(self, submission):
else:
context[key] = str(val) if val is not None else ""

context = {key: str(escape(value)) for key, value in context.items()}
html = self.html_content

# Process conditional blocks: {% if field_name %}...{% endif %}
Expand Down
Loading
Loading