Add reset template functionality to channel management
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m36s
ci / sonar (push) Successful in 4m21s

Implemented a new endpoint for resetting the channel template, which clears the grid and removes future scheduled entries while preserving past and current recordings. Updated the frontend to include a reset button in the channel management interface, along with corresponding localization strings in English and Russian. Enhanced integration tests to verify the reset functionality, ensuring that the expected number of slots, layers, and entries are accurately reported after the reset operation.
This commit is contained in:
Leonid Pershin
2026-07-28 14:19:35 +03:00
parent 3908b6e722
commit 6c85db2d60
10 changed files with 268 additions and 1 deletions
@@ -11,6 +11,7 @@ using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers; using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Reset;
using TeleWave.Application.Programming.Templates.Restore; using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer; using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot; using TeleWave.Application.Programming.Templates.UpdateSlot;
@@ -50,6 +51,10 @@ public static class TemplateEndpoints
admin admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate) .MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>(); .Produces<RestoreTemplateResultDto>();
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
admin
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
.Produces<ResetTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
@@ -165,6 +170,16 @@ public static class TemplateEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ResetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate( private static async Task<IResult> PreviewTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Reset;
/// <summary>
/// Полный сброс сетки канала и собранного по ней эфира: слоты и слои снимаются, будущий хвост
/// ленты удаляется. Настройки шаблона (аварийная группа, стык по умолчанию, правила) остаются —
/// они переживают перекройку сетки, и терять их вместе со слотами админ не просит.
/// </summary>
public sealed record ResetTemplateCommand(Guid ChannelId)
: ICommand<Result<ResetTemplateResultDto>>;
/// <param name="Slots">Сколько слотов снято.</param>
/// <param name="Layers">Сколько слоёв удалено — фоновый в счёт не идёт, он остаётся всегда.</param>
/// <param name="Entries">Сколько будущих записей эфира удалено.</param>
public sealed record ResetTemplateResultDto(int Slots, int Layers, int Entries);
@@ -0,0 +1,74 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Reset;
/// <summary>
/// Сброс сетки и собранного по ней эфира — «начать канал заново», не пересоздавая его.
///
/// Прошлое и идущая сейчас запись не трогаются: инвариант общий с генератором — у зрителя нельзя
/// вырезать программу из-под носа, а история показов нужна остыванию и потолку повторов.
/// Курсоры слотов уходят каскадом вместе со слотами, поэтому сериалы после сброса начинаются
/// с начала — это и просят словом «полностью».
///
/// Снимок последнего применения остаётся: он единственная точка отката, и после сброса «Откатить»
/// вернёт сетку. Ленту это не вернёт — её пересоберёт применение.
/// </summary>
public sealed class ResetTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ResetTemplateCommand, Result<ResetTemplateResultDto>>
{
public async Task<Result<ResetTemplateResultDto>> Handle(
ResetTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
if (template is null)
return Result.Failure<ResetTemplateResultDto>(ChannelErrors.TemplateNotFound);
var now = DateTimeOffset.UtcNow;
// Удаление ленты идёт мимо change-tracker (ExecuteDelete), снятие слотов — через него:
// связывает их в одну операцию только явная транзакция. Лок канала — тот же, что берёт
// генератор: фоновый тик не должен дописывать хвост в момент сброса.
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(command.ChannelId, cancellationToken);
var entries = await dbContext
.ScheduleEntries.Where(e => e.ChannelId == command.ChannelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
var slots = 0;
var layers = 0;
foreach (var layer in template.Layers.ToList())
{
slots += layer.Slots.Count;
// Нефоновый слой уходит целиком — слоты за ним каскадом. Фоновый удалить нельзя,
// поэтому его чистим послотно.
if (template.RemoveLayer(layer.Id))
{
layers++;
continue;
}
foreach (var slot in layer.Slots.ToList())
layer.RemoveSlot(slot.Id);
}
template.MarkChanged();
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result.Success(new ResetTemplateResultDto(slots, layers, entries));
}
}
@@ -3,6 +3,7 @@ using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.Reset;
using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
@@ -136,6 +137,84 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground)); Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
} }
/// <summary>
/// Сброс: сетка снимается целиком, будущий эфир уходит, прошлое и идущая запись остаются.
/// Проверяется на настоящей БД — команда держит транзакцию с advisory-локом канала и сносит
/// ленту через ExecuteDelete, а этого InMemory не умеет.
/// </summary>
[SkippableFact]
public async Task Reset_ClearsGridAndFutureTape_KeepingPastAndCurrentEntry()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (channelId, _, _) = await SeedPairAsync(seedDb);
var now = DateTimeOffset.UtcNow;
var slotId = seedDb
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == channelId)
.Layers.SelectMany(l => l.Slots)
.Single()
.Id;
// Курсор слота: он обязан уйти вместе со слотом, иначе сериал продолжится с той же серии.
var state = SlotState.Create(slotId);
state.MoveTo(GroupElementKind.Show, Guid.NewGuid(), 3);
seedDb.SlotStates.Add(state);
Entry(seedDb, channelId, now.AddHours(-2), now.AddHours(-1));
Entry(seedDb, channelId, now.AddMinutes(-10), now.AddMinutes(20));
Entry(seedDb, channelId, now.AddHours(1), now.AddHours(2));
await seedDb.SaveChangesAsync();
await using var db = fixture.CreateContext();
var result = await new ResetTemplateCommandHandler(db).Handle(
new ResetTemplateCommand(channelId),
default
);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Entries);
await using var verify = fixture.CreateContext();
var template = verify
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == channelId);
// Фоновый слой остаётся всегда — удалить его нельзя, и без него сетку нечем закрывать.
var layer = Assert.Single(template.Layers);
Assert.True(layer.IsBackground);
Assert.Empty(layer.Slots);
Assert.True(template.HasPendingChanges);
Assert.False(verify.SlotStates.Any(s => s.SlotId == slotId));
var entries = verify.ScheduleEntries.Where(e => e.ChannelId == channelId).ToList();
Assert.Equal(2, entries.Count);
Assert.All(entries, e => Assert.True(e.StartsAtUtc < now));
}
private static void Entry(
AppDbContext db,
Guid channelId,
DateTimeOffset from,
DateTimeOffset to
) =>
db.ScheduleEntries.Add(
ScheduleEntry.FromSlot(
channelId,
Guid.NewGuid(),
ScheduleEntryKind.Program,
from,
to,
new ScheduleEntryOrigin(null, null, null, null, null)
)
);
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary> /// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync( private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
AppDbContext db AppDbContext db
+17
View File
@@ -1014,6 +1014,23 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему
равен — `Revision` приравнивается к `AppliedRevision`, плашка гаснет. равен — `Revision` приравнивается к `AppliedRevision`, плашка гаснет.
### 6.6.2. Очистка сетки
Кнопка «Очистить сетку» на вкладке сетки — начать канал заново, не пересоздавая его: снимаются все
слоты и все слои кроме фонового (его удалить нельзя), удаляется будущий хвост ленты. Курсоры слотов
уходят каскадом вместе со слотами, поэтому сериалы после очистки начинаются с начала.
Прошлое и идущая сейчас запись остаются — инвариант общий с генератором: у зрителя нельзя вырезать
программу из-под носа, а история показов нужна остыванию и потолку повторов. Настройки шаблона
(аварийная группа, стык по умолчанию, правила) переживают очистку: они не принадлежат конкретной
раскладке слотов. Снимок последнего применения тоже остаётся — после очистки «Сбросить изменения»
вернёт сетку (но не ленту, её пересоберёт применение).
Команда держит транзакцию с тем же advisory-локом канала, что и генератор: фоновый тик не должен
дописывать хвост в момент очистки. **Пустая сетка эфир не останавливает** — включённый канал
достроит горизонт аварийной группой (см. 3.4), и в интерфейсе об этом предупреждают прямо после
очистки.
### 6.9. Автосборка сетки по профилю ### 6.9. Автосборка сетки по профилю
Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает. Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает.
@@ -16,6 +16,7 @@ import type {
GridProfileKind, GridProfileKind,
LayerApplicability, LayerApplicability,
PlanningRules, PlanningRules,
ResetTemplateResultDto,
RestoreTemplateResultDto, RestoreTemplateResultDto,
ScheduleDiffDto, ScheduleDiffDto,
ScheduleEntryDto, ScheduleEntryDto,
@@ -89,6 +90,16 @@ export function restoreChannelTemplate(channelId: string) {
}) })
} }
/**
* Полный сброс: снимает сетку и удаляет будущий эфир, собранный по ней. Прошлое и идущая сейчас
* запись остаются их нельзя вырезать из-под зрителя.
*/
export function resetChannelTemplate(channelId: string) {
return apiRequest<ResetTemplateResultDto>(`/admin/channels/${channelId}/template/reset`, {
method: 'POST',
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */ /** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
export function getTemplateIssues(channelId: string) { export function getTemplateIssues(channelId: string) {
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`) return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { FileJson, Plus, Wand2 } from 'lucide-react' import { Eraser, FileJson, Plus, Wand2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
@@ -16,6 +16,7 @@ import {
createSlot, createSlot,
deleteLayer, deleteLayer,
listChannels, listChannels,
resetChannelTemplate,
toSlotBody, toSlotBody,
updateLayer, updateLayer,
updateSlot, updateSlot,
@@ -142,6 +143,28 @@ export function GridTab({
onError, onError,
}) })
/**
* Сброс сетки и собранного по ней эфира. Прошлое остаётся, поэтому «полностью» здесь про
* будущее: то, что уже показано зрителю, не переписывается.
*/
const resetMutation = useMutation({
mutationFn: () => resetChannelTemplate(channelId),
onSuccess: (result) => {
toast.success(
t('admin.channels.resetDone', {
slots: result.slots,
layers: result.layers,
entries: result.entries,
}),
)
// Пустая сетка отдаёт эфир аварийной группе целиком: без предупреждения админ решит, что
// сброс не сработал, увидев в предпросмотре те же шоу через минуту после него.
if (template?.fallbackGroupId) toast.message(t('admin.channels.resetHint'))
onChanged()
},
onError,
})
const moveSlotMutation = useMutation({ const moveSlotMutation = useMutation({
mutationFn: ({ mutationFn: ({
slot, slot,
@@ -316,6 +339,20 @@ export function GridTab({
<FileJson className="h-4 w-4" /> <FileJson className="h-4 w-4" />
{t('admin.channels.transfer.action')} {t('admin.channels.transfer.action')}
</Button> </Button>
<Button
size="sm"
variant="outline"
className="text-red-500 hover:text-red-400"
disabled={resetMutation.isPending}
onClick={() => {
// Сетку и хвост эфира назад не вернёшь — спрашиваем, как и перед заменой сетки.
if (!window.confirm(t('admin.channels.resetConfirm'))) return
resetMutation.mutate()
}}
>
<Eraser className="h-4 w-4" />
{t('admin.channels.reset')}
</Button>
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span> <span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input <Input
type="date" type="date"
+3
View File
@@ -914,6 +914,9 @@ export type GridPromptDto = {
/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */ /** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */
export type RestoreTemplateResultDto = { slots: number; droppedRefs: number } export type RestoreTemplateResultDto = { slots: number; droppedRefs: number }
/** Итог сброса: сколько снято слотов и слоёв и сколько будущих записей эфира удалено. */
export type ResetTemplateResultDto = { slots: number; layers: number; entries: number }
/** Проверки сетки по правилам, до генерации (см. 5.1). */ /** Проверки сетки по правилам, до генерации (см. 5.1). */
type TemplateIssueKind = type TemplateIssueKind =
| 'GroupEmpty' | 'GroupEmpty'
+7
View File
@@ -666,6 +666,13 @@ export const en = {
'This channel grid will be replaced with the chosen channel grid. Continue?', 'This channel grid will be replaced with the chosen channel grid. Continue?',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots', templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand', copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
// Not «Reset»: that is already the revert-to-aired action (the restore key).
reset: 'Clear grid',
resetConfirm:
'The whole grid — every layer and slot — will be removed, and the future air built from it deleted. The past and the currently playing entry stay. Continue?',
resetDone: 'Reset: {{layers}} layers, {{slots}} slots, {{entries}} air entries',
resetHint:
'While the channel is on, the tape rebuilds itself from the empty grid — with the fallback group. To keep the air empty, clear it in the rules or turn the channel off.',
fallbackGroup: 'Fallback group', fallbackGroup: 'Fallback group',
fallbackGroupNone: 'None — channel filler only', fallbackGroupNone: 'None — channel filler only',
fallbackGroupHint: fallbackGroupHint:
+7
View File
@@ -661,6 +661,13 @@ export const ru = {
'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?', 'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?',
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}', templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками', copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
// Не «Сбросить»: так уже называется откат правок к эфирному состоянию (ключ restore).
reset: 'Очистить сетку',
resetConfirm:
'Сетка канала будет снята полностью — все слои и слоты, — а будущий эфир, собранный по ней, удалён. Прошлое и идущая сейчас запись останутся. Продолжить?',
resetDone: 'Сброшено: слоёв {{layers}}, слотов {{slots}}, записей эфира {{entries}}',
resetHint:
'Пока канал включён, лента отстроится заново по пустой сетке — аварийной группой. Чтобы эфир остался пустым, снимите её в правилах или выключите канал.',
fallbackGroup: 'Аварийная группа', fallbackGroup: 'Аварийная группа',
fallbackGroupNone: 'Нет — только филлер канала', fallbackGroupNone: 'Нет — только филлер канала',
fallbackGroupHint: fallbackGroupHint: