Skip to content

feat(server): add bulk asset management (enable/disable/edit/delete)#3048

Open
vpetersson wants to merge 11 commits into
masterfrom
feat/bulk-asset-management
Open

feat(server): add bulk asset management (enable/disable/edit/delete)#3048
vpetersson wants to merge 11 commits into
masterfrom
feat/bulk-asset-management

Conversation

@vpetersson

Copy link
Copy Markdown
Contributor

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.

  • Row selection — a checkbox on every asset row, plus a per-section "select all" in each surface header (works on mobile too, since it lives in the header rather than the hidden-on-mobile thead).
  • Bulk-action bar — a floating bar appears whenever a selection is active, with Enable, Disable, Edit, Delete, and a clear-selection control. It shows the live selection count.
  • Bulk edit — a modal that applies common schedule fields across the whole selection: start/end dates, duration, play-from/until times, and weekdays. Each field group is opt-in via a "Change …" toggle, so an operator only overwrites the fields they ticked.

Backend

Two new server-rendered endpoints, both reusing the shared _asset_table_response so 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 through delete_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-asset assets_update guard.

Frontend

Selection state lives in the homeApp Alpine store (selectedIds), so each row's :checked binding re-evaluates after the table's 5s HTMX swap and the selection survives. The table partial re-publishes its on-screen ids via setVisible() 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.py for 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 new asset_ids filter and the rendered selection controls.

  • uv run pytest -m "not integration" — 1093 passed
  • ruff check + ruff format --check clean
  • JS/CSS bundles rebuilt; tsc clean (only pre-existing alpinejs declaration warnings)

🤖 Generated with Claude Code

@vpetersson vpetersson requested a review from a team as a code owner June 9, 2026 15:47
@vpetersson vpetersson requested a review from Copilot June 9, 2026 15:47
@vpetersson vpetersson force-pushed the feat/bulk-asset-management branch from 536829d to 4b2220b Compare June 9, 2026 15:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/anthias_server/app/templatetags/asset_filters.py Outdated
Comment thread src/anthias_server/app/views.py Outdated
Comment thread src/anthias_server/app/views.py
Comment thread src/anthias_server/app/views.py
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>
@vpetersson vpetersson force-pushed the feat/bulk-asset-management branch from 4b2220b to 5188402 Compare June 9, 2026 16:01
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread src/anthias_server/app/views.py Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/app/views.py Outdated
Comment thread src/anthias_server/app/templates/_bulk_edit_modal.html Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/app/static/src/home.ts Outdated
Comment thread src/anthias_server/app/views.py
vpetersson and others added 2 commits June 10, 2026 05:06
… 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread src/anthias_server/app/templates/_bulk_edit_modal.html
Comment thread src/anthias_server/app/static/src/home.ts
Comment thread 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/app/views.py
Comment thread src/anthias_server/app/views.py Outdated
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>
@vpetersson vpetersson requested a review from Copilot June 10, 2026 05:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread src/anthias_server/app/views.py Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/anthias_server/app/views.py
Comment thread src/anthias_server/app/views.py Outdated
…-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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Comment thread src/anthias_server/app/templates/_bulk_action_bar.html Outdated
Comment thread src/anthias_server/app/templates/_bulk_action_bar.html Outdated
Comment thread src/anthias_server/app/templates/_bulk_action_bar.html Outdated
Comment thread src/anthias_server/app/templates/_bulk_edit_modal.html Outdated
Comment thread src/anthias_server/app/views.py Outdated
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>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

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.

Add bulk asset management (enable/disable + edit multiple assets at once)

2 participants