Implement dynamic group management features with exclusions and mode handling
ci / build-backend (push) Successful in 2m30s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 2m36s
ci / sonar (push) Failing after 33s

Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
This commit is contained in:
Leonid Pershin
2026-07-27 05:03:09 +03:00
parent 24fdbdf680
commit 91af589547
35 changed files with 2571 additions and 219 deletions
@@ -4,10 +4,20 @@ namespace TeleWave.Domain.Programming;
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
///
/// Состав — всегда явный список (<see cref="Items"/>), а <see cref="FilterJson"/> — лишь правило
/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками.
/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку
/// перетасовывало бы всё будущее расписание всех каналов.
/// Откуда берётся состав, решает <see cref="Mode"/>:
///
/// • <see cref="GroupMode.Static"/> — состав это явный список (<see cref="Items"/>), а
/// <see cref="FilterJson"/> лишь помогает его наполнить: «применить фильтр» добавляет найденное
/// в список, дальше он правится руками.
///
/// • <see cref="GroupMode.Dynamic"/> — состав вычисляет правило в момент обращения, а список хранит
/// только ручные поправки к нему: закреплённое (<see cref="GroupItemRole.Pinned"/>) и исключённое
/// (<see cref="GroupItemRole.Excluded"/>). Само правило домен по-прежнему не интерпретирует —
/// вычисление живёт в Application.
///
/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится
/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку
/// перетасовывало бы будущий эфир всех каналов.
/// </summary>
public class Group
{
@@ -20,6 +30,9 @@ public class Group
/// <summary>Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.</summary>
public string? FilterJson { get; private set; }
/// <summary>Откуда берётся состав: явный список либо вычисление правилом.</summary>
public GroupMode Mode { get; private set; }
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
/// <summary>Число позиций в группе.</summary>
@@ -59,6 +72,33 @@ public class Group
public void SetFilter(string? filterJson) =>
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
/// <summary>
/// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав
/// переносится, а не теряется: при переходе в динамический список позиций становится
/// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое
/// становится обычными позициями, а исключения снимаются — исключать больше нечего.
///
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
/// </summary>
public void SetMode(GroupMode mode)
{
if (Mode == mode)
return;
Mode = mode;
if (mode == GroupMode.Dynamic)
{
foreach (var item in _items)
item.SetRole(GroupItemRole.Pinned);
return;
}
_items.RemoveAll(i => i.Role == GroupItemRole.Excluded);
foreach (var item in _items)
item.SetRole(GroupItemRole.Member);
}
/// <summary>Записать пересчитанную статистику.</summary>
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
{
@@ -71,18 +111,55 @@ public class Group
public bool Contains(GroupElementKind kind, Guid elementId) =>
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
/// <summary>Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null.</summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId)
/// <summary>
/// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по
/// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить»,
/// иначе правило вымело бы позицию на первом же обращении.
/// </summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null)
{
if (Contains(kind, elementId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = GroupItem.Create(Id, kind, elementId, nextPosition);
var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole);
_items.Add(item);
return item;
}
private GroupItemRole DefaultRole =>
Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member;
/// <summary>
/// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать
/// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа
/// статическая — там состав правится списком, а исключать из списка нечего.
/// </summary>
public bool Exclude(GroupElementKind kind, Guid elementId)
{
if (Mode != GroupMode.Dynamic)
return false;
var existing = _items.FirstOrDefault(i =>
i.ElementKind == kind && i.ElementId == elementId
);
if (existing is not null)
{
existing.SetRole(GroupItemRole.Excluded);
return true;
}
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
_items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded));
return true;
}
/// <summary>Снять исключение — элемент вернётся в состав, если его находит правило.</summary>
public bool RemoveExclusion(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i =>
i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId
) > 0;
public bool RemoveItem(Guid itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);