Files
Anthias/static
Viktor Petersson 8c7b554e4d feat(scheduling): per-asset day-of-week and time-of-day windows (#2751)
* feat(scheduling): add per-asset day-of-week and time-of-day windows

Adds three nullable scheduling fields to Asset:

- play_days: JSON list of weekdays 1-7 (default: all days)
- play_time_from / play_time_to: optional time window (default: NULL,
  meaning anytime)

Asset.is_active() now applies these filters on top of the existing
is_enabled + start_date/end_date check. With defaults, the filter is
a no-op, so existing assets behave identically. Overnight windows
(time_from > time_to) are supported and play_days refers to the
*start* day of such a window — Mon 22:00-06:00 plays through to
Tuesday 06:00 if Mon is in days_of_week.

generate_asset_list() simply filters with is_active(); there is no
priority/masking layer. When the playlist contains a windowed asset,
the deadline is capped at now + 60s so day-of-week and time-of-day
boundaries are picked up promptly (these transitions don't show up
in the date columns the original scheduler used for deadline calc).

The v2 API exposes the new fields on read, accepts them on create
and update, and validates play_days as a list of ints 1-7 normalized
to a sorted JSON string. The v1.x API surface is intentionally
unchanged: legacy clients see no new fields, and assets they create
get the model defaults (i.e., no schedule constraints).

The edit-asset modal in the React UI gains a ScheduleFields section
between the date fields and the duration field: seven day-of-week
checkboxes plus a "restrict to a daily window" toggle that reveals
two time inputs. The asset row -> edit handler now threads the new
fields through so edits don't clobber them. The add-asset modal
keeps its minimal create flow; operators set schedule via edit.

Why this approach instead of a separate slots table

This PR replaces the design previously checked in on this branch
(taken from #2673), which introduced a parallel ScheduleSlot /
ScheduleSlotItem table model with priority types (event > time >
default), per-slot item ordering, and a "schedule mode" that bypassed
the existing Asset.is_active() pipeline. After review we concluded
that approach was over-engineered for the actual signage use case
and had several structural problems we did not want to ship:

1. Two date systems that did not compose. In schedule mode the
   viewer checked only is_enabled, ignoring asset.start_date /
   asset.end_date. An expired asset added to a slot kept playing.
   The new design has a single source of truth: Asset.is_active().

2. Events were modeled as time windows whose time_to was derived
   from the sum of item durations, which mutated whenever items
   changed. Time-overlap validation ran at slot create/update but
   not when items moved, so the constraint silently drifted. The
   new design has no events as a distinct concept; one-shot
   "play once at T" is a different problem we will solve later
   with its own primitive if there is real demand.

3. A `no_loop` flag plus a threading.Timer-driven deadline
   interrupt added bug surface (DST/NTP jitter, unclear lifecycle
   on Scheduler restart) for marginal benefit. The new design
   reuses the existing deadline-polling mechanism with a 60-second
   cap when windowing is in play; transitions are picked up within
   a minute, which is fine for digital signage.

4. The slot model required a UI to be useful (admin alone could
   not preview "what would play right now"). Without that UI it
   was an API-only feature, and the API did not match what a UI
   would need (the status endpoint was anemic). Asset-level fields
   slot into the existing asset edit form with three optional
   inputs and need no new pages.

5. Most stated use cases — different content at different times,
   day-of-week rotations, holiday content — require only windowing,
   not the M:N slot/item relationship. For the rare "exclusive
   takeover" case (Black Friday suppresses regular content) we are
   deliberately not shipping a priority field; if operators ask
   for it we can add a single int field and one filter line.

Credit to @Alex1981-tech (#2673) for the original direction and
the days-of-week JSON validator pattern, which is reused here.

Test plan

- ruff check + ruff format --check pass on all changed files
- ./manage.py test --exclude-tag=integration: 89/89 pass
  including new tests for play_days restriction, time-of-day
  window, overnight windows (active before/after midnight,
  inactive outside days), windowed-asset deadline cap, and
  backward compat for unscheduled assets
- bun test: 24/24 pass including 4 new ScheduleFields tests
  covering day toggling, time-restriction toggle, time persistence,
  and clearing on toggle-off
- bun run build succeeds against the new types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(serializers): apply ruff format to v2.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review feedback

- has_window_filter: compare play_days as a set so a stored value like
  [7,6,5,4,3,2,1] (or with duplicates) is recognised as "all days" and
  doesn't trigger the windowed deadline cap.
- generate_asset_list: evaluate is_active() once per asset against a
  shared `now`, then pass the resulting flags to _compute_deadline so
  playlist filtering and boundary selection can't disagree on activeness.
- is_active(): accept an optional `now` so callers can share a clock.
- schedule-fields.tsx: import Dispatch/SetStateAction from 'react'
  instead of relying on the React global; recompute the
  restrict-time-enabled flag from `prev` inside setFormData to avoid
  acting on a stale closure when updates are batched.

* Address second round of Copilot review feedback

- get_play_days(): also dedupe and sort, and treat empty list as
  invalid (falls back to ALL_DAYS). Keeps API responses consistent
  for rows admin/DB tools may have written unsorted, with duplicates,
  or as []. has_window_filter() can then go back to a plain list
  comparison.
- _normalise_play_days(): drop the JSON-string-decoding branch — the
  upstream ListField(child=IntegerField) has already coerced inputs
  to a list of ints by the time validate_play_days() runs, so the
  string path was dead. Docstring updated to match.
- schedule-fields.tsx: clearing either time input now collapses both
  sides to null, so the UI can't produce a partial window the v2 API
  rejects. Added a test for this.
- docs/asset-scheduling.md: drop leftover "slot" wording from the
  abandoned slot-model design; the feature is asset-level.

* docs: move asset-scheduling guide into the website

The Hugo migration in #2807 moved operator docs under
website/content/docs/. asset-scheduling.md was added on the
schedule-slots branch before that migration landed, so it ended up
in the legacy docs/ tree. Moved it to its proper home and added
the standard Hugo front matter (title/description/slug/alias).

* test(scheduler): use make_aware instead of direct tzinfo assignment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(edit-modal): normalize HH:MM:SS to HH:MM for time inputs

The v2 API serializes play_time_from / play_time_to as HH:MM:SS, but
<input type="time"> defaults to minute precision and renders blank in
some browsers when fed an HH:MM:SS string. Trim to HH:MM when loading
the asset into form state so the input and state agree; DRF's
TimeField accepts HH:MM on submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address third round of Copilot review feedback

- Serializer: _normalise_play_days now returns list[int] instead of a
  JSON string, so ListField.to_representation round-trips cleanly when
  AssetListViewV2.post passes serializer.data into Asset.objects.create.
- Update path: set play_days/play_time_* on the instance before calling
  super().update(), letting the parent's save() persist them in a
  single write instead of saving twice.
- Frontend: collapse partial play_time windows to (null, null) when
  loading legacy/admin-edited assets, so the form never enters an
  unsaveable state.
- Docs: clarify that the 17:00:01 boundary trick requires the v2 API;
  the web UI's <input type="time"> is minute-precision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(types): align validate_play_days return type with list[int]

mypy: _normalise_play_days now returns list[int], so the two
validate_play_days methods must declare the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scheduling): treat partial time window as no time-of-day filter

has_window_filter() returned True if either play_time_from or
play_time_to was set, but _matches_play_window() treats a partial
window as "no time-of-day filter". The mismatch forced the 60s
windowed deadline cap on every tick for assets with one endpoint
set, without any actual filtering.

The v2 API rejects partial windows, but admin / direct DB edits can
still produce one. Require both endpoints together in
has_window_filter() to match _matches_play_window()'s behavior. Add
a regression test asserting the cap doesn't apply for the partial
shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scheduler): preserve shuffle play-through across cap-driven refresh

With shuffle_playlist on, generate_asset_list() produces a fresh
random order every call. Combined with the windowed-asset deadline
cap (every ~60s when any asset has a day/time filter), the previous
update_playlist() would replace self.assets with the new shuffle every
minute and reset the counter, disrupting the play-through and causing
repeats/skips.

Compare *membership* (sorted asset_ids) instead of full equality:
when the same set of assets is active, keep the current order/index/
counter and only refresh the deadline. Counter-driven reshuffles
(counter >= 5, end of cycle) opt in via allow_reshuffle=True.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(types): use dict indexing for asset_id sort key

mypy: dict.get('asset_id') returns Any | None which sorted() rejects.
asset_id is always present on playlist dicts (set by _asset_to_dict),
so direct indexing is correct and type-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address fourth round of Copilot review feedback

- Scheduler: the membership-only fast path now applies only when
  shuffle_playlist is on (where list equality always fails because
  generate_asset_list reshuffles every call). With shuffle off, fall
  back to list equality so DB-driven play_order changes propagate
  on the next refresh.
- Edit modal: preserve original HH:MM:SS in form state instead of
  trimming on load, so opening + saving without touching the time
  inputs no longer silently drops sub-minute precision configured
  via the v2 API. The <input type="time"> displays HH:MM by slicing
  at render time; user edits replace the value with HH:MM (their
  explicit choice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scheduler): refresh dict contents when shuffle membership unchanged

With shuffle on and the same set of asset_ids, the previous fast path
returned without updating self.assets, so DB-driven field edits
(duration, uri, etc.) were ignored until membership changed. Rebuild
the list in current order using the freshly-fetched dicts so edits
take effect on the next get_next_asset() while preserving the
play-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: ruff format

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scheduler): apply 60s cap only inside the asset's date range

Outside the date range, the day/time window can't change activeness:
before start_date the asset is dormant, after end_date it's expired.
The previous implementation kept the cap firing every ~60s for any
windowed asset regardless of date-range state, causing unnecessary
refresh churn for future-dated or expired assets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(types): guard date-range comparison against None for mypy

The candidates query filters on start_date__isnull=False / end_date__
isnull=False, but mypy can't narrow that into the model field types.
Guard the comparison explicitly so the cap-eligibility check is well-
typed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:54:42 +01:00
..