Skip to content

Add bulk delete to query builder#8120

Open
alesan99 wants to merge 23 commits into
mainfrom
issue-6882
Open

Add bulk delete to query builder#8120
alesan99 wants to merge 23 commits into
mainfrom
issue-6882

Conversation

@alesan99
Copy link
Copy Markdown
Contributor

@alesan99 alesan99 commented May 27, 2026

Partially fixes issue #6882

Adds a "Bulk Delete" button to Query Builder. Also adds a new "bulk_delete" permission.

Implemented through the new bulk_delete endpoint.

TODO:

  • Add deletion blockers to the warning window. It should should which related records are going to be deleted, like in the regular deletion warning.
  • Add a "bulk delete" button to the record set form. This should delete all the records within a dataset, rather than the dataset itself.
  • Audit Log?

Checklist

  • Self-review the PR after opening it to make sure the changes look good and
    self-explanatory (or properly documented)
  • Add relevant issue to release milestone
  • Add pr to documentation list
  • Add automated tests

Testing instructions

  • Create a bunch of records (Bulk carry forward is a good way)
  • Create a query in Query Builder, and run it.
  • See that the "Bulk Delete" button appears if you are an institution admin
  • Select some records in your query results
  • Click bulk delete, and see you are warned that you will delete X records permantently
  • Click delete, and see that the records were deleted and removed from the query results.
  • Reset your selection
  • Click bulk delete, and see you are warned that you will delete ALL record from the query permanently
  • Check existing behavior:
  • Try to delete individual records in query builder through browse in forms. They should still get removed from query results like normal

Summary by CodeRabbit

  • New Features

    • Bulk delete: remove multiple records from query results or selected rows in one action.
    • Multi-step confirmation requiring typing the exact delete count to prevent accidental deletions.
    • Bulk delete control only shown when appropriate delete permissions exist.
  • Localization

    • Added UI strings and a “Bulk Delete” label for dialogs and confirmations.
  • Documentation

    • API endpoint for bulk delete added to the generated OpenAPI docs.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 27, 2026

Review Change Stack

Warning

Review limit reached

@alesan99, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 minutes and 30 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10c487a3-040f-4bcd-ab4c-fffabde7be0c

📥 Commits

Reviewing files that changed from the base of the PR and between 925f58f and 101e90f.

📒 Files selected for processing (1)
  • specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
📝 Walkthrough

Walkthrough

Adds a backend atomic bulk-delete POST endpoint accepting explicit IDs or a stored query and a frontend two-step bulk-delete UI with permissions, result updates, localization, and OpenAPI documentation.

Changes

Bulk Delete Feature

Layer / File(s) Summary
Backend bulk delete endpoint
specifyweb/backend/bulk_copy/bulk_copy.py, specifyweb/backend/bulk_copy/urls.py, specifyweb/backend/bulk_copy/views.py
New atomic collection_dispatch_bulk_delete endpoint accepts POST with ids array or query object, derives PKs when a query is provided, deletes records via delete_resource, returns 400 if no IDs/query, and 204 on success. Adds URL routing and API view binding.
Bulk delete permission policy
specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts
Added '/record/bulk_delete' operation policy requiring the delete permission.
BulkDelete dialog and button components
specifyweb/frontend/js_src/lib/components/QueryBuilder/BulkDelete.tsx
New QueryBulkDelete and BulkDeletionDialog implement two-step confirmation: initial dialog with count, warning dialog requiring typed-count confirmation, compute delete count from selected IDs or total, POST { ids, query } to backend, call onDeleted on success.
Query results integration
specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
Conditionally renders QueryBulkDelete when global and table delete permissions exist. Refactored handleDelete to accept single or multiple IDs, deduplicate deletions, update results and selection state, and adjust total count.
Localization and schema documentation
specifyweb/frontend/js_src/lib/localization/forms.ts, specifyweb/frontend/js_src/lib/localization/query.ts, specifyweb/specify/models_utils/schema.py
Adds localization strings for bulk-delete confirmations (templates for {count} and {tableName}) and OpenAPI documentation for POST /bulk_copy/bulk_delete/{table} with request body schema and 204 response.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Automatic Tests ⚠️ Warning PR adds bulk delete functionality with new backend endpoints and frontend components but includes no automated tests, which are listed as pending in PR objectives. Add automated tests for bulk_delete endpoint and BulkDelete component following existing test patterns in the codebase.
Testing Instructions ⚠️ Warning Testing instructions lack critical details: no two-stage confirmation UI flow, incomplete permission testing, unclear query validation, missing error cases, and vague regression test scope. Add: (1) two-stage dialog and count-typing steps, (2) non-admin permission denial test, (3) query filter validation, (4) error handling scenarios, (5) specific deletion regression test locations.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add bulk delete to query builder' clearly and accurately describes the main change: introducing bulk delete functionality to the Query Builder UI.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6882

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread specifyweb/backend/bulk_copy/bulk_copy.py Fixed
Comment thread specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx Fixed
Comment thread specifyweb/backend/bulk_copy/bulk_copy.py Fixed
@alesan99 alesan99 marked this pull request as ready for review June 3, 2026 18:44
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specifyweb/backend/bulk_copy/bulk_copy.py`:
- Around line 94-124: The code is using spquery["contexttableid"] to fetch IDs
but then deletes rows from the URL {model}, allowing mismatched-table deletes;
update the logic in the block that builds the query (the session_context /
build_query path using fields_from_json and build_query) to verify the resolved
query target matches the route model before proceeding: after obtaining entity =
query.column_descriptions[0]["entity"] (or inspecting pk_col via
inspect(entity)) compare the entity/table identifier to the route model's table
identity (e.g., model.__tablename__ or model's table id), and if they differ
raise/return a 400 error (abort the operation) rather than continuing to call
delete_resource; keep the check local to the code that computes ids (the
session_context / build_query / ids logic) so deletions only proceed when the
query resolves to the same model.
- Around line 84-90: The handler currently assumes json.loads(request.body)
returns a dict and calls data.get(...), which will crash for list/scalar bodies;
update the code in bulk_copy.py (the section handling data, ids, spquery and
model) to first verify that data is a dict and return a 400 (bad request) if
not, then validate that ids (if present) is a list and spquery (if present) is
the expected type (e.g., str or dict per your API) before using .get; keep the
existing model handling (isinstance(model, str) -> get_model_or_404(model)) but
perform these body/type checks immediately after parsing to reject invalid
bodies and provide clear error messages.

In `@specifyweb/frontend/js_src/lib/components/QueryBuilder/BulkDelete.tsx`:
- Around line 132-156: The final confirmation Dialog (rendered when
isWarningOpen) only shows generic text; update the bulk-delete confirmation UI
to surface dependent/related records and potential deletion blockers before the
delete is committed: compute or fetch the dependent records (e.g., a new
dependentRecords array derived alongside totalDeleteCount in the BulkDelete
logic or via an async call in the component), then render a clear section inside
the Dialog (near formsText.bulkDeleteFinalConfirmationDescription()) listing
each dependent record type/count and any blockers, and disable the Button.Danger
(handleClick) unless the user has acknowledged or resolved blockers; update the
Dialog content to include this dependent-record summary and any required user
acknowledgement state so handleClick only runs when safe.
- Around line 34-36: The button is incorrectly disabled when queryResource is
undefined even if there are selected rows; change the disabled logic on
Button.Small to only be true when there are no rows to delete (e.g. replace
disabled={totalCount === 0 || queryResource === undefined} with something like
disabled={totalCount === 0 && (selectedIds?.length ?? 0) === 0} so the button is
enabled when selectedIds (or your actual selected-row array/ids variable) has
entries even if queryResource is undefined; update the expression to use the
actual selected-row variable name present in this file (e.g. selectedIds,
selectedRowIds, or selectedRows) and keep onClick={handleOpen} as-is.

In `@specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx`:
- Around line 407-420: The bulk-delete button renders even for distinct queries
which makes recordIds() unsafe; update the rendering guard so QueryBulkDelete
(used here with props table, totalCount, onDeleted, recordIds, queryResource) is
only shown when canBulkDeleteTable is true AND the result set is not distinct
(e.g., !isDistinct) or when a guaranteed primary-key derivation is available;
specifically, change the condition around the QueryBulkDelete component to check
isDistinct (or a new safeBulkDelete boolean) and skip rendering QueryBulkDelete
when isDistinct is true unless you can derive real primary keys for each row
instead of casting display values via queryIdField from
loadedResults/selectedRows.
- Around line 221-238: The bug is that totalRemoveCount is accumulated with a
negative sign (totalRemoveCount = totalRemoveCount - removeCount) so later
subtracting it in setTotalCount increases totalCount; change the accumulator to
add positives (use totalRemoveCount += removeCount) and ensure the "no ids
provided" branch sets totalRemoveCount to the positive count (totalRemoveCount =
totalCount ?? 0) so the final setTotalCount(... Math.max(0, totalCount -
totalRemoveCount)) correctly decreases totalCount; update references in the
block handling recordIds, the loop using removeCount and deletingRef, and the
no-ids branch so all usages treat totalRemoveCount as a positive number of
removed rows.
- Around line 240-244: The selection filter currently uses a stale recordIds and
ends up preserving deleted row ids; update the newSelectedRows implementation so
it computes the current set of existing IDs (e.g., map the current records to
currentIds) and then return new Set(Array.from(selectedRows).filter(id =>
currentIds.includes(id))) so setSelectedRows(newSelectedRows(selectedRows))
drops any ids that no longer exist; modify newSelectedRows to reference that
fresh currentIds (instead of the old recordIds) to ensure deleted selections are
removed.
- Around line 199-247: The handleDelete closure uses stale
results/selectedRows/totalCount because those values aren't in its deps; change
it to read latest state from refs and use functional state updates: inside
handleDelete, read the current list from resultsRef.current (fallback to [])
instead of the closed-over results, compute filteredResults and
totalRemoveCount, then call setResults(prev => /* use prev or resultsRef.current
to compute new array */) and setTotalCount(prev => /* compute from prev and
totalRemoveCount */) and setSelectedRows(prev => new
Set(Array.from(prev).filter(id => !recordIds.includes(id)))), and keep
deletingRef usage; update the dependency list to only include stable setters and
refs (setResults, setTotalCount, setSelectedRows, deletingRef, resultsRef) so
the callback no longer captures stale snapshots.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 427c36b6-8c21-45e8-af63-818c2fc6b464

📥 Commits

Reviewing files that changed from the base of the PR and between 7fc918f and 0d1e07c.

📒 Files selected for processing (9)
  • specifyweb/backend/bulk_copy/bulk_copy.py
  • specifyweb/backend/bulk_copy/urls.py
  • specifyweb/backend/bulk_copy/views.py
  • specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts
  • specifyweb/frontend/js_src/lib/components/QueryBuilder/BulkDelete.tsx
  • specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
  • specifyweb/frontend/js_src/lib/localization/forms.ts
  • specifyweb/frontend/js_src/lib/localization/query.ts
  • specifyweb/specify/models_utils/schema.py

Comment thread specifyweb/backend/bulk_copy/bulk_copy.py
Comment thread specifyweb/backend/bulk_copy/bulk_copy.py
Comment on lines +132 to +156
<div className="mb-4 flex flex-col gap-4">
<section>{formsText.deleteConfirmationDescription()}</section>
</div>
</Dialog>
{
isWarningOpen ?
(
<Dialog
buttons={
<>
<Button.DialogClose>{commonText.close()}</Button.DialogClose>
<Button.Danger
disabled={confirmationText !== totalDeleteCount.toString()}
onClick={handleClick}
>
{formsText.bulkDeleteFinalConfirmationOption({ count: totalDeleteCount })}
</Button.Danger>
</>
}
header={formsText.bulkDeleteFinalConfirmation()}
onClose={onClose}
>
<div className="mb-4 flex flex-col gap-4">
<section>{formsText.deleteConfirmationDescription()}</section>
<section>{formsText.bulkDeleteFinalConfirmationDescription()}</section>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Show dependent-record impact before the destructive confirmation.

This dialog still only shows generic warning text and the typed count. The linked issue/PR objective requires surfacing related records/deletion blockers before confirmation, so users can see what else will be affected before committing the delete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/QueryBuilder/BulkDelete.tsx` around
lines 132 - 156, The final confirmation Dialog (rendered when isWarningOpen)
only shows generic text; update the bulk-delete confirmation UI to surface
dependent/related records and potential deletion blockers before the delete is
committed: compute or fetch the dependent records (e.g., a new dependentRecords
array derived alongside totalDeleteCount in the BulkDelete logic or via an async
call in the component), then render a clear section inside the Dialog (near
formsText.bulkDeleteFinalConfirmationDescription()) listing each dependent
record type/count and any blockers, and disable the Button.Danger (handleClick)
unless the user has acknowledged or resolved blockers; update the Dialog content
to include this dependent-record summary and any required user acknowledgement
state so handleClick only runs when safe.

Comment thread specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
Comment thread specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx Outdated
Comment thread specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
Comment thread specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx
Comment thread specifyweb/specify/models_utils/schema.py
@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board Jun 3, 2026
Verify query table matches bulk_delete table
Comment thread specifyweb/backend/bulk_copy/bulk_copy.py Fixed
alesan99 and others added 3 commits June 3, 2026 14:29
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Disable bulk delete for distinct queries
@alesan99 alesan99 added this to the 7.12.2 milestone Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Dev Attention Needed

Development

Successfully merging this pull request may close these issues.

2 participants