mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-11 01:28:23 -04:00
* 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>