Skip to content

FEAT: add FileUpload and MultipleFileUpload fields in forms#44

Open
corentinbettiol wants to merge 3 commits intodjango-cms:mainfrom
corentinbettiol:feat/input-files
Open

FEAT: add FileUpload and MultipleFileUpload fields in forms#44
corentinbettiol wants to merge 3 commits intodjango-cms:mainfrom
corentinbettiol:feat/input-files

Conversation

@corentinbettiol
Copy link
Copy Markdown

@corentinbettiol corentinbettiol commented Mar 30, 2026

Fix issue #9.

Add single file input field, and multiple files input field.

Also add validator code (and a few basic validators), and update doc to add how to create your own validator.

Add a setting named DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS (a dict) which needs to contain all the validators + their config in order to let them be displayed on file fields options.

Handle validators created for filer just fine (see updated docs for configuration options).

Screenshots

image image

Summary by Sourcery

Add support for validated single and multiple file upload fields in the form builder, including storage, admin display, and AJAX handling updates.

New Features:

  • Introduce single and multiple file upload form field models, plugins, and configuration forms with selectable validation presets.
  • Persist uploaded files to storage and serialize them into JSON-safe metadata for inclusion in form entry data.
  • Expose uploaded files on form entries in the Django admin as read-only download links instead of editable fields.
  • Add a settings-driven registry of file validation presets that can be applied per file field, including support for filer-style validators.

Bug Fixes:

  • Ensure AJAX form submissions send multipart FormData and preserve uploaded files instead of re-parsing the request body.
  • Fix client-side error feedback so Bootstrap 5 correctly highlights invalid fields, including file inputs.
  • Preserve existing uploaded file data on form entries when saving changes to other fields in the admin.

Enhancements:

  • Extend form field generation to accept the request object so upload validators can use user and request context.
  • Add server- and client-side integration to derive HTML accept attributes from extension-based validation presets.
  • Serialize cleaned form data through a helper to normalize file and multi-file values before saving to entries.

Build:

  • Add optional test dependencies for filer and related django CMS packages and update the project homepage URL.

Documentation:

  • Add documentation describing configuration and usage of file validation presets for upload fields.

Tests:

  • Add comprehensive tests for file validation presets, upload form fields, form entry data serialization, and admin handling of uploaded files.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Mar 30, 2026

Reviewer's Guide

Implements single and multiple file upload support in the form builder, including server- and client-side validation presets, storage/serialization of uploaded files into FormEntry, admin display/preservation of file data, AJAX handling fixes for multipart submissions, and new CMS plugins, forms, and tests around these features.

Sequence diagram for AJAX file upload, validation, and storage

sequenceDiagram
    actor User
    participant BrowserForm as Browser_form
    participant AjaxJS as ajax_form_js
    participant View as FormPluginView_ajax_post
    participant Plugin as FileFieldPlugin
    participant DjangoForm as Django_form
    participant FileField as ValidatedFileField
    participant MultiFileField as MultipleUploadedFilesField
    participant Validator as validate_form_builder_file
    participant Storage as default_storage
    participant Entry as FormEntry
    participant Serializer as serialize_cleaned_data_for_entry

    User->>BrowserForm: Select file(s) and submit
    BrowserForm->>AjaxJS: Submit event
    AjaxJS->>AjaxJS: Build FormData(node)
    AjaxJS->>View: fetch(action, method=POST, body=FormData, multipart)

    View->>View: plugin_instance(instance_id)
    View->>Plugin: ajax_post(request, params)
    Plugin->>DjangoForm: instantiate(request=request)

    DjangoForm->>FileField: clean(uploaded_file)
    FileField->>Validator: validate_form_builder_file(file, preset_keys, user, request, field_name)
    Validator-->>FileField: ok or FileValidationError

    DjangoForm->>MultiFileField: clean(uploaded_files)
    MultiFileField->>MultiFileField: iterate files
    MultiFileField->>Validator: validate_form_builder_file(each_file, preset_keys, user, request, field_name)
    Validator-->>MultiFileField: ok or FileValidationError

    DjangoForm-->>Plugin: cleaned_data or errors
    Note over Plugin,Entry: On success
    Plugin->>Entry: FormEntry.objects.create(entry_data=serialize_cleaned_data_for_entry(cleaned_data))
    Serializer->>Storage: default_storage.save(path, uploaded_file)
    Storage-->>Serializer: url
    Serializer-->>Entry: file metadata dict(s)

    Note over Plugin,AjaxJS: On error
    Plugin-->>View: JSON field_errors
    View-->>AjaxJS: JSON response
    AjaxJS->>BrowserForm: feedback(node, data)
    AjaxJS->>BrowserForm: add .is-invalid, .invalid-feedback for fields
Loading

Class diagram for new file upload fields and validation presets

classDiagram
    class FormField {
        +config
        +field_name
        +initialize_from_form(form)
        +get_admin_fieldsets()
        +get_admin_form()
        +get_form_field(request)
    }

    class FileField {
        +Meta
        +get_form_field(request)
    }

    class MultipleFileField {
        +Meta
        +get_form_field(request)
    }

    FormField <|-- FileField
    FormField <|-- MultipleFileField

    class FileFieldForm {
        +field_file_validation_presets
        +__init__(*args, **kwargs)
    }

    class MultipleFileFieldForm {
        +field_file_validation_presets
        +__init__(*args, **kwargs)
    }

    class MultiFileInput {
        +allow_multiple_selected
    }

    class ValidatedFileField {
        -preset_keys: list
        -_user
        -_request
        -_field_name: str
        +__init__(preset_keys, user, request, field_name, **kwargs)
        +clean(data, initial)
    }

    class MultipleUploadedFilesField {
        +needs_multipart_form
        -preset_keys: list
        -_user
        -_request
        -_field_name: str
        +__init__(preset_keys, user, request, field_name, **kwargs)
        +clean(value)
    }

    ValidatedFileField --> MultiFileInput : widget.accept
    MultipleUploadedFilesField --> MultiFileInput : widget

    class BaseFilePresetValidator {
        +__call__(uploaded_file, user, request, field_name)
        +validate(uploaded_file, user, request, field_name)
    }

    class MaxSizePresetValidator {
        -max_bytes: int
        +__init__(max_bytes)
        +validate(uploaded_file, user, request, field_name)
    }

    class ExtensionPresetValidator {
        -allowed_extensions
        +__init__(allowed_extensions)
        +validate(uploaded_file, user, request, field_name)
    }

    class MimeFilenamePresetValidator {
        -allowed_patterns
        +__init__(allowed_patterns)
        +validate(uploaded_file, user, request, field_name)
    }

    BaseFilePresetValidator <|-- MaxSizePresetValidator
    BaseFilePresetValidator <|-- ExtensionPresetValidator
    BaseFilePresetValidator <|-- MimeFilenamePresetValidator

    class file_validation {
        +FileValidationError
        +get_validation_preset_registry()
        +validation_preset_choice_tuples()
        +allowed_extensions_for_accept_attribute(preset_keys)
        +wrap_filer_style_validator(fn, validate_options)
        +validate_form_builder_file(uploaded_file, preset_keys, user, request, field_name)
    }

    class file_validation_validators {
        +enforce_max_size(uploaded_file, max_bytes, field_name)
        +enforce_extension(uploaded_file, allowed_extensions, field_name)
        +enforce_mime_from_filename(uploaded_file, allowed_patterns, field_name)
        +_normalize_extension(ext)
        +_format_size_limit(max_bytes)
    }

    ValidatedFileField --> file_validation : uses
    MultipleUploadedFilesField --> file_validation : uses
    file_validation ..> file_validation_validators : imports
    file_validation ..> BaseFilePresetValidator : instantiates

    class FormEntry {
        +entry_data: JSONField
        +get_file_entry_data_keys()
        +get_admin_form()
        +get_admin_fieldsets()
    }

    class FormEntryAdmin {
        -_entry_file_key_by_attr: dict
        +_entry_file_attr_name(key)
        +_ensure_entry_file_attr_map(obj)
        +get_readonly_fields(request, obj)
        +get_fieldsets(request, obj)
        +format_entry_file_field(obj, key)
        +__getattr__(name)
        +save_model(request, obj, form, change)
    }

    FormEntryAdmin --> FormEntry : manages

    class form_entry_data {
        +serialize_cleaned_data_for_entry(cleaned_data)
        +_store_uploaded_file(uploaded_file)
    }

    form_entry_data --> FormEntry : populates_entry_data

    class FileFieldPlugin {
        +model: FileField
        +form: FileFieldForm
        +settings_fields
    }

    class MultipleFileFieldPlugin {
        +model: MultipleFileField
        +form: MultipleFileFieldForm
        +settings_fields
    }

    FileFieldPlugin --> FileField
    FileFieldPlugin --> FileFieldForm
    MultipleFileFieldPlugin --> MultipleFileField
    MultipleFileFieldPlugin --> MultipleFileFieldForm
Loading

File-Level Changes

Change Details Files
Add proxy FileField and MultipleFileField models and corresponding form fields, widgets, and plugins to support single and multi-file uploads with validation presets.
  • Introduce FileField and MultipleFileField proxy models on FormField, each returning ValidatedFileField or MultipleUploadedFilesField from get_form_field, now accepting the request object so validators can use user/request context.
  • Create upload_form_fields module defining ValidatedFileField and MultipleUploadedFilesField, plus MultiFileInput widget; these run validate_form_builder_file using preset keys and set the HTML accept attribute based on allowed extensions.
  • Register FileFieldPlugin and MultipleFileFieldPlugin CMS plugins with corresponding FileFieldForm and MultipleFileFieldForm admin forms that expose field_file_validation_presets entangled config.
djangocms_form_builder/models.py
djangocms_form_builder/upload_form_fields.py
djangocms_form_builder/cms_plugins/form_plugins.py
djangocms_form_builder/cms_plugins/__init__.py
djangocms_form_builder/forms.py
djangocms_form_builder/migrations/0004_filefield_multiplefilefield.py
Implement a preset-driven file validation framework, including built-in validators, and integrate it with upload fields and admin configuration.
  • Add file_validation module that loads DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS, resolves functions/classes, supports optional filer-style validators, and runs them in order for each upload, rewinding files between presets.
  • Add file_validation_validators providing reusable functions (enforce_max_size, enforce_extension, enforce_mime_from_filename) and preset validator classes (MaxSizePresetValidator, ExtensionPresetValidator, MimeFilenamePresetValidator) with human-readable error messages and helper formatting logic.
  • Expose validation_preset_choice_tuples and allowed_extensions_for_accept_attribute utilities to drive admin choices and file input accept attributes, intersecting allowed extensions across multiple presets.
  • Add comprehensive tests for validation behavior, preset configuration edge cases, filer-style adapters, and built-in validators.
djangocms_form_builder/file_validation.py
djangocms_form_builder/file_validation_validators.py
tests/test_file_validation.py
tests/test_upload_fields.py
Serialize uploaded files from cleaned_data into JSON-safe structures for FormEntry.entry_data and adjust actions and admin to handle file metadata correctly.
  • Introduce form_entry_data.serialize_cleaned_data_for_entry to convert UploadedFile instances (or lists of them) into small dicts containing a _form_builder_file flag, original filename, and storage URL via default_storage.
  • Update SaveFormDataAction.execute (actions.execute) to store serialize_cleaned_data_for_entry(form.cleaned_data) instead of raw cleaned_data in entry_data.
  • Add tests ensuring non-file values pass through unchanged, single/multiple uploads are stored with metadata, and URLs are generated while using a temp MEDIA_ROOT.
djangocms_form_builder/form_entry_data.py
djangocms_form_builder/actions.py
tests/test_form_entry_data.py
Improve FormEntry admin to treat file fields as read-only link outputs, preserve their data on save, and avoid trying to entangle file values into the dynamic form.
  • Add helper is_file_entry_value and FormEntry.get_file_entry_data_keys to detect JSON values that represent uploaded file(s) and exclude them from dynamically generated admin forms/fieldsets.
  • Extend FormEntryAdmin with dynamic readonly pseudo-fields (entry_file*) mapped from entry_data file keys, rendering either a single link or a list of links, and appending an "Uploaded files" fieldset when such keys exist.
  • Override FormEntryAdmin.save_model to merge previously stored file-related entry_data keys back into the saved instance, so editing non-file fields in admin does not drop file payloads.
  • Add admin tests ensuring file fields do not appear as input elements, are rendered as links, and their JSON metadata is preserved on POST.
djangocms_form_builder/entry_model.py
djangocms_form_builder/admin.py
tests/test_admin.py
Adjust AJAX form handling and JS feedback to support file uploads and show server-side validation errors on file inputs.
  • Change ajax_form.js to post FormData instead of URLSearchParams and include same-origin credentials, ensuring multipart/form-data is sent correctly and files are not dropped.
  • Reset .is-invalid classes before re-applying error messages, and add .is-invalid to fields with server-side errors so Bootstrap 5 shows invalid-feedback, specifically for file-related validation failures.
  • Update FormView.ajax_post/ajax_get to stop re-parsing request.body into QueryDict, preventing RawPostDataException and preserving FILES/POST for multipart requests, while keeping parameter decoding for plugins.
djangocms_form_builder/static/djangocms_form_builder/js/ajax_form.js
djangocms_form_builder/views.py
djangocms_form_builder/cms_plugins/ajax_plugins.py
Miscellaneous improvements and test/settings updates needed for new features.
  • Change FormField.get_form_field signature across field types to accept an optional request, so CMS plugins can pass request into field construction for validation context.
  • Adjust FormPlugin traversal (ajax_plugins) to pass request into each instance.get_form_field call.
  • Extend tests dependencies group in pyproject.toml with filer-related packages and update project Homepage URL; set CMS_CONFIRM_VERSION4 in test settings for compatibility.
  • Add model tests asserting that FileField and MultipleFileField return the correct custom field classes in get_form_field and that entangled config initialization handles None vs empty defaults correctly.
djangocms_form_builder/models.py
djangocms_form_builder/cms_plugins/ajax_plugins.py
pyproject.toml
tests/test_models.py
tests/test_settings.py
README.rst
docs/source/index.rst
docs/source/file_validation.rst
djangocms_form_builder/locale/fr/LC_MESSAGES/django.po

Possibly linked issues

  • Multiple File Upload Plugin #9: PR directly implements single/multiple file upload fields plus validation, fulfilling the requested multiple file upload feature.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • The logic that detects file-valued entry_data items is duplicated between _is_file_entry_value in entry_model.py and the type checks in FormEntryAdmin.format_entry_file_field; consider centralizing this in a single helper to avoid divergence if the file metadata format changes.
  • In allowed_extensions_for_accept_attribute, presets that don’t resolve to ExtensionPresetValidator (or missing keys) are silently ignored, which may lead to mismatches between the client-side accept attribute and the actual server-side validation; consider making this asymmetry explicit (e.g., via a comment or defensive checks) or tightening the mapping so the HTML accept reflects all extension-based presets in use.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The logic that detects file-valued entry_data items is duplicated between `_is_file_entry_value` in `entry_model.py` and the type checks in `FormEntryAdmin.format_entry_file_field`; consider centralizing this in a single helper to avoid divergence if the file metadata format changes.
- In `allowed_extensions_for_accept_attribute`, presets that don’t resolve to `ExtensionPresetValidator` (or missing keys) are silently ignored, which may lead to mismatches between the client-side `accept` attribute and the actual server-side validation; consider making this asymmetry explicit (e.g., via a comment or defensive checks) or tightening the mapping so the HTML accept reflects all extension-based presets in use.

## Individual Comments

### Comment 1
<location path="djangocms_form_builder/file_validation.py" line_range="158-167" />
<code_context>
+    return wrapped
+
+
+def validate_form_builder_file(
+    uploaded_file: "UploadedFile",
+    preset_keys: list,
+    *,
+    user: "User",
+    request: typing.Optional["HttpRequest"],
+    field_name: str,
+) -> None:
+    """Run each preset callable in order for the given keys."""
+    if not preset_keys:
+        return
+    registry = get_validation_preset_registry()
+    for key in preset_keys:
+        entry = registry[key]
+        target = import_string(entry["validate"])
+        opts = entry.get("validate_options") or {}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Accessing `registry[key]` directly will raise a bare `KeyError` for unknown preset keys, which may be hard to diagnose.

In `validate_form_builder_file`, a missing key in `DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS` will currently bubble up as a bare `KeyError`, giving a 500 with little context if a preset is removed/renamed. Consider mapping this to `ImproperlyConfigured` with a clearer message, mirroring the existing `filer_validator` check, e.g.

```python
try:
    entry = registry[key]
except KeyError as exc:
    raise ImproperlyConfigured(
        f"Unknown file validation preset key '{key}' in DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS"
    ) from exc
```

Suggested implementation:

```python
    for key in preset_keys:
        try:
            entry = registry[key]
        except KeyError as exc:
            raise ImproperlyConfigured(
                f"Unknown file validation preset key '{key}' in "
                "DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS"
            ) from exc
        target = import_string(entry["validate"])
        opts = entry.get("validate_options") or {}

```

1. Ensure `ImproperlyConfigured` is imported at the top of `djangocms_form_builder/file_validation.py`, e.g.:
   `from django.core.exceptions import ImproperlyConfigured`
2. If the file already imports `ImproperlyConfigured` elsewhere, no further changes are needed.
</issue_to_address>

### Comment 2
<location path="tests/test_upload_fields.py" line_range="112-121" />
<code_context>
+class MultipleUploadedFilesFieldTests(SimpleTestCase):
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for required/optional behavior of MultipleUploadedFilesField.clean.

Please add tests to cover the required/optional behavior of `MultipleUploadedFilesField.clean`, e.g.: (1) `required=False` with `value=None`/`[]` returns `[]`, and (2) `required=True` with `value=None`/`[]` raises `ValidationError` with the `"required"` message. This will guard against regressions in the custom `clean` implementation, which bypasses the default `FileField` logic.

Suggested implementation:

```python
        field = ValidatedFileField(
            preset_keys=["wide", "narrow"],
            user=None,
            request=request,
            field_name="f",
            required=False,
        )
        self.assertEqual(field.widget.attrs["accept"], ".pdf")


class MultipleUploadedFilesFieldRequiredBehaviorTests(SimpleTestCase):
    def test_clean_optional_allows_empty(self):
        field = MultipleUploadedFilesField(required=False)

        self.assertEqual(field.clean(None), [])
        self.assertEqual(field.clean([]), [])

    def test_clean_required_rejects_empty(self):
        field = MultipleUploadedFilesField(required=True)

        with self.assertRaisesMessage(ValidationError, field.error_messages["required"]):
            field.clean(None)

        with self.assertRaisesMessage(ValidationError, field.error_messages["required"]):
            field.clean([])


class MultipleUploadedFilesFieldTests(SimpleTestCase):
    @override_settings(
        DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS={
            "ok": {"label": "OK", "validate": f"{__name__}.preset_accept"},
        }
    )
    def test_clean_validates_each_file(self):
        a = SimpleUploadedFile("a.txt", b"x", content_type="text/plain")
        b = SimpleUploadedFile("b.txt", b"y", content_type="text/plain")
        request = RequestFactory().get("/")
        field = MultipleUploadedFilesField(

```

1. Ensure `ValidationError` is imported in `tests/test_upload_fields.py` (typically via `from django.core.exceptions import ValidationError`). If it's already imported for other tests, no change is needed.
2. If `MultipleUploadedFilesField` requires additional positional or keyword arguments beyond `required`, update the field construction in `MultipleUploadedFilesFieldRequiredBehaviorTests` to match the existing usage pattern (e.g., add `request`, `user`, or `preset_keys` as appropriate). The tests only rely on `clean()` being called with empty values, so other arguments can use minimal valid values.
</issue_to_address>

### Comment 3
<location path="tests/test_form_entry_data.py" line_range="11-16" />
<code_context>
+class SerializeCleanedDataTests(TestCase):
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to ensure non-file lists are not treated as file uploads.

Current tests cover scalars and lists of `UploadedFile`s, but not lists of non-files (e.g. multi-select or checkbox values). Please add a case like `{"choices": ["a", "b"]}` that verifies the list is preserved and not treated as file upload data, to guard against future regressions in list handling.

```suggestion
class SerializeCleanedDataTests(TestCase):
    def test_passes_through_non_files(self):
        data = serialize_cleaned_data_for_entry({"a": "text", "b": 1})
        self.assertEqual(data, {"a": "text", "b": 1})

    def test_passes_through_non_file_lists(self):
        cleaned_data = {"choices": ["a", "b"]}
        data = serialize_cleaned_data_for_entry(cleaned_data)
        self.assertEqual(data, cleaned_data)

    def test_stores_upload_metadata(self):
```
</issue_to_address>

### Comment 4
<location path="djangocms_form_builder/admin.py" line_range="25" />
<code_context>
             kwargs["form"] = obj.get_admin_form()
         return super().get_form(request, obj, **kwargs)

+    @staticmethod
+    def _entry_file_attr_name(key):
+        safe = re.sub(r"[^a-zA-Z0-9_]", "_", str(key))
</code_context>
<issue_to_address>
**issue (complexity):** Consider centralizing the file-entry detection and normalization logic into a shared helper so that `format_entry_file_field` only handles rendering of a normalized structure.

You can trim some of the complexity without changing behavior by centralizing the “file payload” detection/normalization logic instead of re‑implementing it in `format_entry_file_field`.

Right now `format_entry_file_field` repeats the structural checks that (per the other review) already exist in `entry_model.py` (`_is_file_entry_value` / similar). That makes the admin behavior depend on two copies of the same protocol, which increases the cognitive load and maintenance risk.

A small, focused refactor would be:

1. Move the structure inspection into a single shared helper on the model (or reuse the existing one if it already exists).
2. Have `format_entry_file_field` deal only with rendering, based on a normalized representation.

For example, on `FormEntry` (or in a shared utility), introduce a normalizer:

```python
# models.py

class FormEntry(models.Model):
    ...

    @staticmethod
    def get_file_entry_items(value):
        """
        Normalize an entry_data value that may represent a single file
        or a list of files into a list of file dicts, or [] if not a file.
        """
        if isinstance(value, dict) and value.get("_form_builder_file"):
            return [value]

        if (
            isinstance(value, list)
            and value
            and isinstance(value[0], dict)
            and value[0].get("_form_builder_file")
        ):
            return value

        return []
```

Then in your admin:

```python
# admin.py

from .models import FormEntry

    @staticmethod
    def format_entry_file_field(obj, key):
        items = FormEntry.get_file_entry_items(obj.entry_data.get(key))
        if not items:
            return ""

        if len(items) == 1:
            d = items[0]
            return format_html(
                '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
                d["url"],
                d["filename"],
            )

        return format_html(
            "<ul>{}</ul>",
            format_html_join(
                "",
                '<li><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></li>',
                ((d["url"], d["filename"]) for d in items),
            ),
        )
```

If you already have `_is_file_entry_value` or similar, you can adapt `get_file_entry_items` to call that instead of duplicating its conditions. This keeps the admin-side logic focused on rendering and removes the duplicated protocol knowledge about the `_form_builder_file` structure.
</issue_to_address>

### Comment 5
<location path="djangocms_form_builder/file_validation_validators.py" line_range="131" />
<code_context>
+        )
+
+
+class BaseFilePresetValidator:
+    """
+    Subclass and implement :meth:`validate`, or use the concrete validators below.
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the preset validator classes to share a single data-driven base implementation that delegates to the existing helper functions instead of duplicating similar validate and __init__ logic in each subclass.

The main extra complexity is the three nearly-identical preset validator classes that only delegate to the helper functions. You can keep the OO preset API but reduce duplication by making the base class data‑driven and letting subclasses just configure which helper and option names to use.

For example:

```python
class BaseFilePresetValidator:
    """
    Base preset: subclasses only define `helper` and `option_name`.
    """

    helper: typing.Callable[..., None]
    option_name: str

    def __init__(self, **options):
        # Enforce that the expected option is present
        try:
            self._value = options[self.option_name]
        except KeyError:
            raise TypeError(
                f"{self.__class__.__name__} requires option {self.option_name!r}"
            )

    def __call__(self, uploaded_file, *, user, request, field_name: str) -> None:
        self.validate(
            uploaded_file,
            user=user,
            request=request,
            field_name=field_name,
        )

    def validate(self, uploaded_file, *, user, request, field_name: str) -> None:
        # Single shared implementation
        self.helper(uploaded_file, self._value, field_name=field_name)
```

Concrete presets then become thin, declarative wrappers:

```python
class MaxSizePresetValidator(BaseFilePresetValidator):
    helper = enforce_max_size
    option_name = "max_bytes"


class ExtensionPresetValidator(BaseFilePresetValidator):
    helper = enforce_extension
    option_name = "allowed_extensions"


class MimeFilenamePresetValidator(BaseFilePresetValidator):
    helper = enforce_mime_from_filename
    option_name = "allowed_patterns"
```

This keeps:

- The same preset class names and callable interface used by the preset system.
- The functional helpers as the single place where validation logic lives.

But it removes three almost identical `validate()` implementations and the separate per‑class `__init__`, reducing the number of concepts the reader has to hold while keeping all current behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread djangocms_form_builder/file_validation.py
Comment on lines +112 to +121
class MultipleUploadedFilesFieldTests(SimpleTestCase):
@override_settings(
DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS={
"ok": {"label": "OK", "validate": f"{__name__}.preset_accept"},
}
)
def test_clean_validates_each_file(self):
a = SimpleUploadedFile("a.txt", b"x", content_type="text/plain")
b = SimpleUploadedFile("b.txt", b"y", content_type="text/plain")
request = RequestFactory().get("/")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for required/optional behavior of MultipleUploadedFilesField.clean.

Please add tests to cover the required/optional behavior of MultipleUploadedFilesField.clean, e.g.: (1) required=False with value=None/[] returns [], and (2) required=True with value=None/[] raises ValidationError with the "required" message. This will guard against regressions in the custom clean implementation, which bypasses the default FileField logic.

Suggested implementation:

        field = ValidatedFileField(
            preset_keys=["wide", "narrow"],
            user=None,
            request=request,
            field_name="f",
            required=False,
        )
        self.assertEqual(field.widget.attrs["accept"], ".pdf")


class MultipleUploadedFilesFieldRequiredBehaviorTests(SimpleTestCase):
    def test_clean_optional_allows_empty(self):
        field = MultipleUploadedFilesField(required=False)

        self.assertEqual(field.clean(None), [])
        self.assertEqual(field.clean([]), [])

    def test_clean_required_rejects_empty(self):
        field = MultipleUploadedFilesField(required=True)

        with self.assertRaisesMessage(ValidationError, field.error_messages["required"]):
            field.clean(None)

        with self.assertRaisesMessage(ValidationError, field.error_messages["required"]):
            field.clean([])


class MultipleUploadedFilesFieldTests(SimpleTestCase):
    @override_settings(
        DJANGOCMS_FORM_BUILDER_FILE_VALIDATION_PRESETS={
            "ok": {"label": "OK", "validate": f"{__name__}.preset_accept"},
        }
    )
    def test_clean_validates_each_file(self):
        a = SimpleUploadedFile("a.txt", b"x", content_type="text/plain")
        b = SimpleUploadedFile("b.txt", b"y", content_type="text/plain")
        request = RequestFactory().get("/")
        field = MultipleUploadedFilesField(
  1. Ensure ValidationError is imported in tests/test_upload_fields.py (typically via from django.core.exceptions import ValidationError). If it's already imported for other tests, no change is needed.
  2. If MultipleUploadedFilesField requires additional positional or keyword arguments beyond required, update the field construction in MultipleUploadedFilesFieldRequiredBehaviorTests to match the existing usage pattern (e.g., add request, user, or preset_keys as appropriate). The tests only rely on clean() being called with empty values, so other arguments can use minimal valid values.

Comment on lines +11 to +16
class SerializeCleanedDataTests(TestCase):
def test_passes_through_non_files(self):
data = serialize_cleaned_data_for_entry({"a": "text", "b": 1})
self.assertEqual(data, {"a": "text", "b": 1})

def test_stores_upload_metadata(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test to ensure non-file lists are not treated as file uploads.

Current tests cover scalars and lists of UploadedFiles, but not lists of non-files (e.g. multi-select or checkbox values). Please add a case like {"choices": ["a", "b"]} that verifies the list is preserved and not treated as file upload data, to guard against future regressions in list handling.

Suggested change
class SerializeCleanedDataTests(TestCase):
def test_passes_through_non_files(self):
data = serialize_cleaned_data_for_entry({"a": "text", "b": 1})
self.assertEqual(data, {"a": "text", "b": 1})
def test_stores_upload_metadata(self):
class SerializeCleanedDataTests(TestCase):
def test_passes_through_non_files(self):
data = serialize_cleaned_data_for_entry({"a": "text", "b": 1})
self.assertEqual(data, {"a": "text", "b": 1})
def test_passes_through_non_file_lists(self):
cleaned_data = {"choices": ["a", "b"]}
data = serialize_cleaned_data_for_entry(cleaned_data)
self.assertEqual(data, cleaned_data)
def test_stores_upload_metadata(self):

Comment thread djangocms_form_builder/admin.py
Comment thread djangocms_form_builder/file_validation_validators.py
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 81.14754% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.72%. Comparing base (4fe74f9) to head (1c76d6c).

Files with missing lines Patch % Lines
...angocms_form_builder/file_validation_validators.py 61.84% 19 Missing and 10 partials ⚠️
djangocms_form_builder/file_validation.py 80.76% 9 Missing and 6 partials ⚠️
djangocms_form_builder/admin.py 79.03% 6 Missing and 7 partials ⚠️
djangocms_form_builder/upload_form_fields.py 83.33% 5 Missing and 3 partials ⚠️
djangocms_form_builder/form_entry_data.py 90.90% 1 Missing and 1 partial ⚠️
djangocms_form_builder/models.py 94.28% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
- Coverage   81.88%   81.72%   -0.17%     
==========================================
  Files          21       25       +4     
  Lines        1496     1844     +348     
  Branches      188      246      +58     
==========================================
+ Hits         1225     1507     +282     
- Misses        216      255      +39     
- Partials       55       82      +27     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants