feat(server): add bulk asset management (enable/disable/edit/delete)#3048
Open
vpetersson wants to merge 11 commits into
Open
feat(server): add bulk asset management (enable/disable/edit/delete)#3048vpetersson wants to merge 11 commits into
vpetersson wants to merge 11 commits into
Conversation
536829d to
4b2220b
Compare
There was a problem hiding this comment.
Pull request overview
Adds bulk asset management on the home/schedule page, allowing operators to select multiple assets and apply enable/disable/edit/delete actions in a single UI flow backed by new batch endpoints.
Changes:
- Added bulk selection UI (row checkboxes + per-section select-all) and a floating bulk-action bar with Enable/Disable/Edit/Delete.
- Added server endpoints for bulk enable/disable/delete and bulk schedule updates, plus template filters to support HTMX/Alpine state sync.
- Added test coverage for bulk action/update behavior and new template rendering expectations.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_template_views.py | Adds bulk action/update tests and template rendering checks for new selection UI. |
| src/anthias_server/app/views.py | Implements assets_bulk_action and assets_bulk_update endpoints plus shared helpers. |
| src/anthias_server/app/urls.py | Wires new bulk endpoints under /assets/bulk/.... |
| src/anthias_server/app/templatetags/asset_filters.py | Adds asset_ids filter to feed visible IDs into Alpine after HTMX swaps. |
| src/anthias_server/app/templates/home.html | Includes the new bulk-action bar and bulk-edit modal on the home page. |
| src/anthias_server/app/templates/_bulk_edit_modal.html | Introduces bulk-edit modal UI and form posting to bulk update endpoint. |
| src/anthias_server/app/templates/_bulk_action_bar.html | Introduces floating bulk-action bar and bulk delete confirmation modal. |
| src/anthias_server/app/templates/_asset_table.html | Adds per-section select-all controls and publishes visible IDs via x-init. |
| src/anthias_server/app/templates/_asset_row.html | Adds per-row selection checkbox and selected-row styling hook. |
| src/anthias_server/app/static/src/home.ts | Adds Alpine state + helpers for selection persistence across HTMX swaps and bulk modal controls. |
| src/anthias_server/app/static/sass/_styles.scss | Styles new selection column, selected-row highlight, select-all indeterminate state, and bulk-action bar/modal UI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Operators with large libraries previously had to act on assets one row at a time — a recurring forum request for years (#3046). The home page now has per-row selection checkboxes, a per-section select-all in each surface header, and a floating bulk-action bar (Enable, Disable, Edit, Delete) that appears whenever a selection is active. Backend: two new server-rendered endpoints reuse the shared _asset_table_response so the whole batch swaps the table partial and nudges the viewer once, instead of one round-trip per row. - assets_bulk_action — enable/disable/delete a selected set; delete goes through delete_asset_with_file so on-disk cleanup matches the per-asset path (#2908). - assets_bulk_update — applies common schedule fields (start/end dates, duration, play-from/until times, weekdays). Each group is opt-in via an apply_* flag so an operator only overwrites the fields they ticked, and everything is parsed before any row is mutated so a bad date/time toasts without a half-applied batch. Video duration stays owned by the probe task, mirroring assets_update. Frontend: selection lives in the homeApp Alpine state (selectedIds), so row :checked bindings re-evaluate after the table's 5s HTMX swap and the selection survives. setVisible() is re-published from the table partial on every swap to drive the select-all state and prune a selection of rows that have since disappeared. The bulk-edit modal reuses the existing flatpickr date/time inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4b2220b to
5188402
Compare
- asset_ids filter: stop mark_safe'ing the JSON. It lands in a double-quoted x-init="…" attribute, so its own double quotes were closing the attribute early and breaking the Alpine expression. Return a plain str and let Django autoescaping entity-encode the quotes; the browser decodes them back to valid JSON. Adds a regression test asserting the rendered attribute is escaped. - bulk delete: pass delete_asset_with_file(nudge_viewer=False) per row and fire a single viewer reload after the batch, instead of one reload per asset. New keyword-only flag defaults True so the API / single-delete paths are unchanged. - bulk enable/disable: collapse the per-row save() loop into one queryset update() (no model signals here); its row count drives the toast and the empty-selection guard. - bulk duration: a blank field with apply_duration on no longer clobbers every asset's duration to 0 — it toasts and changes nothing (mirrors the per-asset edit form's preserve-on-blank intent). Negative values are rejected too, and the modal input is now required as client-side defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second Copilot pass on #3048: assets_bulk_update still did one asset.save() per selected row, so a large selection (the point of bulk editing) fired N UPDATEs. Mutate the in-memory objects, track exactly which columns were touched (honouring the skip-videos-for-duration rule), and write them all with one Asset.objects.bulk_update(). An edit that ends up touching nothing (apply_dates ticked with both fields blank, or a duration-only edit on an all-video selection) now short-circuits with the "nothing to change" toast, since bulk_update() rejects an empty field list. Adds a test asserting exactly one UPDATE statement for a 5-asset batch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault Third Copilot pass on #3048: - The single bulk_update() folded `duration` into the all-rows field list, which writes every video row's stale in-memory duration back and can clobber a concurrent probe_video_duration UPDATE. Write duration on its own bulk_update() over only the non-video subset, so video rows are never touched by the duration column at all. Added a test spying on bulk_update to assert no video object is in a duration write. - The bulk-edit duration input was prefilled with 10, so toggling "Duration" and submitting would silently set every non-video asset to 10s. Drop the default to an empty placeholder and rely on the existing `required` to force an intentional value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… count Fourth Copilot pass on #3048: - syncVisibleIds(active, inactive): the table partial now publishes both sections' ids in one call and selectedIds is pruned once against their union. The previous two sequential setVisible() calls pruned on the first call while the other section's list was still stale, so a row that moved between sections (e.g. an enabled asset just disabled) lost its selection across the swap. - assets_bulk_update toast now reports only the rows actually written: a duration-only edit skips videos, so a mixed selection reports the non-video count instead of claiming every row was updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agement # Conflicts: # src/anthias_server/app/static/src/home.ts
…ction Fifth Copilot pass on #3048: - Bulk endpoints return 200 even when they refuse the input (bad date, partial time window, blank duration, "nothing to change") and just ride an error/info toast on HX-Trigger. The forms cleared the selection / closed the modal on any 2xx, wiping the operator's work when nothing was applied. New isSuccessResponse(event) helper gates the clear/close on a 2xx whose toast is absent or kind 'success'; wired into all four bulk forms (enable/disable/delete/edit). - sectionAllSelected/sectionSomeSelected did linear includes() against selectedIds, i.e. O(visible × selected) on every reactive re-evaluation. Build a Set once per call so they stay O(n) for the large selections bulk editing targets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sixth Copilot pass on #3048: - _bulk_ids() now strips each CSV segment, so a hand-built "a, b" (with spaces) still matches its rows instead of silently no-op'ing. - enable/disable take the count from a separate matched-rows count() rather than update()'s return value, which reports rows *changed* on some backends — re-enabling already-enabled assets would otherwise count 0 and (returning no toast) make the client treat it as success and clear the selection. A genuinely empty match now returns an info toast so the client's success gate keeps the selection. The empty bulk-delete case gets the same info toast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seventh Copilot pass on #3048: assets_bulk_update returned a silent no-toast 2xx when the posted ids matched no rows (stale selection), so the client's success gate cleared the selection / closed the modal even though nothing applied. Return the same info toast the enable/disable path uses so the selection is kept. Adds a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-op action Eighth Copilot pass on #3048: - assets_bulk_action no longer returns a silent no-toast 2xx for an unknown action or empty id set — an unknown action toasts an error, an empty selection an info toast, so the client's success gate keeps the selection instead of treating it as success. - assets_bulk_update applies the (uniform) new values with plain queryset update()s instead of loading every selected row and building a bulk_update() CASE. Shared fields go in one update(**shared); duration goes through a separate exclude(mimetype='video').update(), which keeps the never-touch-video-duration guarantee at the SQL level (no in-memory staleness). Row counts come from matched-rows count()s, not update()'s changed-rows return. Replaced the bulk_update-spy test with a SQL-level video-duration-untouched test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ninth Copilot pass on #3048 — and a real runtime bug: the bulk forms' hx-on::after-request handlers called Alpine component methods (isSuccessResponse / clearSelection / closeBulkEdit / bulkDeleteOpen) directly, but hx-on runs in GLOBAL scope, so those are undefined there and the handler threw ReferenceError — the selection never cleared and the modals never closed on success. Fixed with the same dispatch-to-window bridge the Add modal already uses for 'asset-saved': hx-on now calls the global window.bulkSucceeded() gate and, on success, dispatches a 'bulk-done' window CustomEvent (with detail flags for which modal to close). The root x-data handles it via @bulk-done.window="onBulkDone($event)" in Alpine scope. Added a render test asserting the forms use the bridge and never call Alpine methods from hx-on. Also: assets_bulk_update now returns an info toast on an empty id set (was a silent no-toast 2xx the success gate would mis-read as success). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Adds the long-requested ability to act on multiple assets at once from the home page (closes #3046). Operators with large libraries no longer have to enable/disable or edit assets one row at a time.
thead).Backend
Two new server-rendered endpoints, both reusing the shared
_asset_table_responseso the entire batch swaps the table partial and nudges the viewer once rather than one HTTP round-trip per row (the painful scripting workaround this feature replaces):assets_bulk_action— enable / disable / delete a selected set. Delete routes throughdelete_asset_with_file, so on-disk cleanup matches the per-asset path (the GH DELETE /api/v2/assets/{id} removes DB row but leaves the binary file on disk #2908 behaviour).assets_bulk_update— applies the ticked schedule fields. Everything is parsed up-front, so a bad date/time surfaces a toast without leaving a half-applied batch. Video duration stays owned by the probe task, mirroring the per-assetassets_updateguard.Frontend
Selection state lives in the
homeAppAlpine store (selectedIds), so each row's:checkedbinding re-evaluates after the table's 5s HTMX swap and the selection survives. The table partial re-publishes its on-screen ids viasetVisible()on every swap, which drives the select-all indeterminate/checked state and prunes a selection of rows that have since disappeared. The bulk-edit modal reuses the existing flatpickr date/time inputs.Tests
Added coverage in
tests/test_template_views.pyfor bulk enable/disable/delete (including selected-subset and no-op guards) and bulk update (dates, duration skipping videos, time window set/clear, partial-window rejection, weekdays, no-flags and invalid-input no-ops), plus the newasset_idsfilter and the rendered selection controls.uv run pytest -m "not integration"— 1093 passedruff check+ruff format --checkcleantscclean (only pre-existing alpinejs declaration warnings)🤖 Generated with Claude Code