Skip to content

fix: add guard to prevent bulk export across multiple forms in admin … - #30

Merged
metaforx merged 1 commit into
mainfrom
feat/t28-db-store-export-mixed-form-guard
May 8, 2026
Merged

fix: add guard to prevent bulk export across multiple forms in admin …#30
metaforx merged 1 commit into
mainfrom
feat/t28-db-store-export-mixed-form-guard

Conversation

@metaforx

@metaforx metaforx commented May 8, 2026

Copy link
Copy Markdown
Owner
  • add bulk download guard
  • add feedback messages with translations

Summary by Sourcery

Guard the db_store admin bulk export action against mixed-form querysets and surface a translated error instead of generating misleading multi-form exports.

New Features:

  • Add a guard on the SavedFormDataEntry bulk export action to block exports spanning multiple forms and redirect back to the changelist with a user-facing error message.

Enhancements:

  • Localize the inline export endpoint's invalid form_entry_id error response and reuse the existing translated short description for the export action label.

Tests:

  • Extend admin export regression tests to cover mixed-form bulk export rejection, redirect behavior, delegation for single-form and empty querysets, and ensure the inline export path remains unaffected.

Chores:

  • Document Task T28 implementation details and intent in an internal task specification file.

@sourcery-ai

sourcery-ai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a server-side guard to the SavedFormDataEntry admin bulk export action so it rejects querysets spanning multiple form entries with a localized error message and redirect, while leaving single-form and inline export behavior unchanged and covered by new regression tests.

Sequence diagram for guarded bulk export action in SavedFormDataEntry admin

sequenceDiagram
    actor AdminUser
    participant DjangoAdmin as DjangoAdminChangelist
    participant SavedAdmin as SavedFormDataEntryAdmin
    participant Messages as DjangoMessages
    participant Exporter as SuperExportData

    AdminUser->>DjangoAdmin: Select rows and choose export_data
    DjangoAdmin->>SavedAdmin: export_data(request, queryset)
    SavedAdmin->>SavedAdmin: form_entry_ids = queryset.values_list(...).distinct()
    alt Multiple form_entry_ids
        SavedAdmin->>Messages: error(request, "Export across multiple forms is not supported ...")
        SavedAdmin-->>DjangoAdmin: HttpResponseRedirect(request.get_full_path())
        DjangoAdmin-->>AdminUser: Redirect back to filtered changelist
    else Single or zero form_entry_ids
        SavedAdmin->>Exporter: export_data(request, queryset)
        Exporter-->>SavedAdmin: HttpResponse(response)
        SavedAdmin-->>DjangoAdmin: HttpResponse(response)
        DjangoAdmin-->>AdminUser: Download CSV/XLS
    end

    rect rgb(230,230,230)
        note over SavedAdmin: Inline export path
        AdminUser->>DjangoAdmin: Click inline export button
        DjangoAdmin->>SavedAdmin: export_for_form_entry(request)
        SavedAdmin->>SavedAdmin: queryset = filter(form_entry_id=form_entry_id)
        SavedAdmin->>SavedAdmin: export_data(request, queryset)
        SavedAdmin->>Exporter: export_data(request, single_form_queryset)
        Exporter-->>SavedAdmin: HttpResponse(response)
        SavedAdmin-->>DjangoAdmin: HttpResponse(response)
        DjangoAdmin-->>AdminUser: Download CSV/XLS
    end
Loading

Class diagram for SavedFormDataEntry admin export_data override

classDiagram
    class BaseSavedFormDataEntryAdmin {
        +export_data(request, queryset) HttpResponse
    }

    class SavedFormDataEntryAdminIntegrationMixin {
        +export_for_form_entry(request) HttpResponse
        +export_data(request, queryset) HttpResponse
        +_parse_json_field(raw) dict
    }

    class SavedFormDataEntryAdmin {
    }

    class MessagesFramework {
        +error(request, message) None
    }

    class HttpResponseRedirect {
        +url str
    }

    BaseSavedFormDataEntryAdmin <|-- SavedFormDataEntryAdmin
    SavedFormDataEntryAdminIntegrationMixin <|-- SavedFormDataEntryAdmin

    SavedFormDataEntryAdminIntegrationMixin ..> BaseSavedFormDataEntryAdmin : calls_super_export_data
    SavedFormDataEntryAdminIntegrationMixin ..> MessagesFramework : uses
    SavedFormDataEntryAdminIntegrationMixin ..> HttpResponseRedirect : returns

    note for SavedFormDataEntryAdminIntegrationMixin "export_data enforces single-form querysets; on mixed forms it sends an error message and returns HttpResponseRedirect(request.get_full_path()). On single or zero forms it delegates to super().export_data unchanged. export_for_form_entry always builds a single-form queryset before calling export_data."
Loading

File-Level Changes

Change Details Files
Guard bulk admin export against querysets spanning multiple forms and surface a localized error message instead of producing a mixed-form CSV.
  • Override SavedFormDataEntryAdminIntegrationMixin.export_data to compute distinct form_entry_id values in the queryset and block when more than one is present.
  • On mixed-form querysets, emit a messages.error explaining that export across multiple forms is not supported and redirect back to the current changelist URL (preserving query parameters).
  • Delegate to super().export_data unchanged for single-form and empty querysets, and retain the existing short_description label for the action.
  • Use translated strings (gettext) for the inline endpoint validation error and for the bulk export action label and error message, updating locale catalogs accordingly.
src/unfold_fobi/admin/saved_data_entry.py
src/unfold_fobi/locale/en/LC_MESSAGES/django.po
src/unfold_fobi/locale/de/LC_MESSAGES/django.po
Extend regression test coverage to verify the mixed-form guard behavior and ensure the inline export endpoint remains unaffected.
  • Document T28 behavior and scope in a task design doc under .agents/tasks.
  • Add a context manager helper that monkeypatches BaseSavedFormDataEntryAdmin.export_data to capture calls and queryset IDs without invoking the real DataExporter.
  • Introduce a helper to build RequestFactory-based requests with message storage attached for testing the admin action.
  • Add fixtures and tests that cover mixed-form rejection, redirect URL preservation, delegation for single-form and empty querysets, and confirm the inline export endpoint continues to call through to the guarded export_data exactly once.
tests/admin/test_db_store_export_action.py
.agents/tasks/T28_db_store_export_mixed_form_guard.md

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

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 1 issue, and left some high level feedback:

  • In the new export_data override, you only need to know whether there is more than one distinct form_entry_id, so you can avoid materializing the full form_entry_ids list by using something like distinct().values_list(..., flat=True)[:2] (or .count()), which will be more efficient on large querysets.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the new `export_data` override, you only need to know whether there is more than one distinct `form_entry_id`, so you can avoid materializing the full `form_entry_ids` list by using something like `distinct().values_list(..., flat=True)[:2]` (or `.count()`), which will be more efficient on large querysets.

## Individual Comments

### Comment 1
<location path="tests/admin/test_db_store_export_action.py" line_range="450-452" />
<code_context>
+
+        assert response.status_code == 302
+        assert captured["calls"] == 0
+        emitted = [str(m) for m in request._messages]
+        assert len(emitted) == 1
+        assert "multiple forms" in emitted[0].lower()
+
+    def test_mixed_form_redirect_preserves_query_string(
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen the assertion on the message level/severity, not only its text

The test currently asserts only the message text. Since the code uses `messages.error`, it would be helpful to also assert the message level (e.g. `emitted_messages = list(request._messages); assert emitted_messages[0].level == messages.ERROR`) to prevent future refactors from silently changing this to a non-error message while the test still passes.
</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 on lines +450 to +452
emitted = [str(m) for m in request._messages]
assert len(emitted) == 1
assert "multiple forms" in emitted[0].lower()

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): Strengthen the assertion on the message level/severity, not only its text

The test currently asserts only the message text. Since the code uses messages.error, it would be helpful to also assert the message level (e.g. emitted_messages = list(request._messages); assert emitted_messages[0].level == messages.ERROR) to prevent future refactors from silently changing this to a non-error message while the test still passes.

@metaforx
metaforx merged commit 898192c into main May 8, 2026
4 checks passed
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.

1 participant