Add restore functionality for template management and enhance related components
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m8s
ci / tests (push) Successful in 2m28s
ci / sonar (push) Successful in 6m49s

Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
This commit is contained in:
Leonid Pershin
2026-07-27 08:43:53 +03:00
parent a302750de6
commit 0fd4e762f4
21 changed files with 2227 additions and 133 deletions
@@ -161,7 +161,9 @@ public sealed class GridScheduleGenerator(
await SaveCursorsAsync(result.Cursors, cancellationToken);
template.MarkApplied();
// Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката,
// и снимать его надо ровно тогда, когда по нему собрана лента.
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
@@ -93,6 +93,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
template.AppliedSnapshotJson is not null,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
@@ -0,0 +1,18 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Restore;
/// <summary>
/// Откат правок сетки к тому состоянию, по которому идёт эфир. Возвращает шаблон к снимку,
/// сделанному при последнем применении, — правки слоёв, слотов и правил после него отменяются.
/// </summary>
public sealed record RestoreTemplateCommand(Guid ChannelId)
: ICommand<Result<RestoreTemplateResultDto>>;
/// <param name="Slots">Сколько слотов в сетке после отката.</param>
/// <param name="DroppedRefs">
/// Ссылок, которые восстановить не удалось: группа или стык из снимка успели удалить. Такие слоты
/// возвращаются без них — иначе откат уронил бы вся операцию из-за одной удалённой группы.
/// </param>
public sealed record RestoreTemplateResultDto(int Slots, int DroppedRefs);
@@ -0,0 +1,182 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Restore;
/// <summary>
/// Возвращает шаблон к снимку последнего применения. Слои и слоты правятся на месте по
/// идентификаторам из снимка: у пережившего откат слота сохраняется состояние (курсор сериала),
/// и после отката сериал не начинается заново. Слот, который успели удалить, вернётся новым —
/// его состояние ушло вместе с ним, и сделать с этим ничего нельзя.
///
/// Эфир не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен.
/// </summary>
public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RestoreTemplateCommand, Result<RestoreTemplateResultDto>>
{
public async Task<Result<RestoreTemplateResultDto>> Handle(
RestoreTemplateCommand 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<RestoreTemplateResultDto>(ChannelErrors.TemplateNotFound);
if (TemplateSnapshot.FromJson(template.AppliedSnapshotJson) is not { } snapshot)
return Result.Failure<RestoreTemplateResultDto>(TemplateErrors.NothingToRestore);
var refs = await LoadAliveRefsAsync(snapshot, cancellationToken);
// Слои, которых в снимке нет, — заведены после применения. Фоновый не трогаем: он есть
// в любом шаблоне и удалению не подлежит.
foreach (var layer in template.Layers.ToList())
if (!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id))
template.RemoveLayer(layer.Id);
foreach (var layerSnapshot in snapshot.Layers)
RestoreLayer(template, layerSnapshot, refs);
template.Rename(snapshot.Name);
template.SetFallbackGroup(refs.GroupOrNull(snapshot.FallbackGroupId));
template.SetDefaultJunction(refs.JunctionOrNull(snapshot.DefaultJunctionId));
template.SetRules(snapshot.RulesJson);
// Шаблон снова равен эфирному состоянию — отметка «изменено» гаснет, снимок остаётся тем же.
template.MarkApplied();
return Result.Success(
new RestoreTemplateResultDto(template.Layers.Sum(l => l.Slots.Count), refs.Dropped)
);
}
private static void RestoreLayer(
ScheduleTemplate template,
LayerSnapshot snapshot,
AliveRefs refs
)
{
var layer =
template.FindLayer(snapshot.Id)
?? (snapshot.IsBackground ? template.Background : null)
?? template.AddLayer(snapshot.Name, snapshot.Priority);
layer.Update(
snapshot.Name,
snapshot.Priority,
snapshot.ApplicabilityJson,
snapshot.IsEnabled
);
foreach (var slot in layer.Slots.ToList())
if (snapshot.Slots.All(s => s.Id != slot.Id))
layer.RemoveSlot(slot.Id);
foreach (var slotSnapshot in snapshot.Slots)
RestoreSlot(layer, slotSnapshot, refs);
}
private static void RestoreSlot(GridLayer layer, SlotSnapshot snapshot, AliveRefs refs)
{
var slot =
layer.FindSlot(snapshot.Id)
?? layer.AddSlot(
snapshot.Title,
snapshot.TargetStart,
snapshot.TargetDurationMinutes,
snapshot.Daypart,
snapshot.SlotKind,
snapshot.Weekday
);
slot.UpdateTiming(
snapshot.Weekday,
snapshot.TargetStart,
snapshot.TargetDurationMinutes,
snapshot.Daypart,
snapshot.IsAnchor,
snapshot.MaxDriftMinutes,
snapshot.SnapToMinutes
);
slot.UpdateContent(
new SlotContent(
snapshot.Title,
snapshot.SlotKind,
refs.GroupOrNull(snapshot.GroupId),
snapshot.StrategyJson,
snapshot.RepeatSourceJson,
snapshot.BlockMode,
snapshot.BlockValue,
snapshot.OverflowPolicy,
refs.JunctionOrNull(snapshot.JunctionBetweenId),
refs.JunctionOrNull(snapshot.JunctionAfterId)
)
);
}
/// <summary>
/// Какие группы и стыки из снимка ещё существуют. Ссылку на удалённое восстанавливать нельзя —
/// внешний ключ не даст сохранить, и откат упал бы целиком из-за одной прибранной группы.
/// </summary>
private async Task<AliveRefs> LoadAliveRefsAsync(
TemplateSnapshot snapshot,
CancellationToken cancellationToken
)
{
var slots = snapshot.Layers.SelectMany(l => l.Slots).ToList();
var groupIds = slots
.Select(s => s.GroupId)
.Append(snapshot.FallbackGroupId)
.OfType<Guid>()
.Distinct()
.ToList();
var junctionIds = slots
.SelectMany(s => new[] { s.JunctionBetweenId, s.JunctionAfterId })
.Append(snapshot.DefaultJunctionId)
.OfType<Guid>()
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => g.Id)
.ToListAsync(cancellationToken);
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => junctionIds.Contains(j.Id))
.Select(j => j.Id)
.ToListAsync(cancellationToken);
return new AliveRefs([.. groups], [.. junctions]);
}
private sealed class AliveRefs(HashSet<Guid> groups, HashSet<Guid> junctions)
{
public int Dropped { get; private set; }
public Guid? GroupOrNull(Guid? id) => Keep(id, groups);
public Guid? JunctionOrNull(Guid? id) => Keep(id, junctions);
private Guid? Keep(Guid? id, HashSet<Guid> alive)
{
if (id is not { } value)
return null;
if (alive.Contains(value))
return value;
Dropped++;
return null;
}
}
}
@@ -47,6 +47,8 @@ public sealed record ScheduleTemplateDto(
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Есть ли к чему откатываться — снимок появляется при первом применении.</summary>
bool CanRestore,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
@@ -59,6 +59,11 @@ public static class TemplateErrors
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error NothingToRestore = Error.Validation(
"Templates.NothingToRestore",
"Откатывать не к чему: сетка ещё ни разу не применялась."
);
public static readonly Error NoGroupsToGenerate = Error.Validation(
"Templates.NoGroupsToGenerate",
"Собирать сетку не из чего: нет ни одной группы с готовым медиа."
@@ -0,0 +1,119 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Слот в снимке. Идентификатор сохраняется — по нему откат правит слот на месте.</summary>
public sealed record SlotSnapshot(
Guid Id,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? StrategyJson,
string? RepeatSourceJson,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record LayerSnapshot(
Guid Id,
string Name,
int Priority,
string? ApplicabilityJson,
bool IsEnabled,
bool IsBackground,
IReadOnlyList<SlotSnapshot> Slots
);
/// <summary>
/// Слепок шаблона: слои, слоты и настройки на момент применения. Снимается при каждом применении —
/// это ровно то, по чему идёт эфир, и потому корректная точка отката.
///
/// Хранится JSON-строкой в самом шаблоне, а не отдельной таблицей: снимок читается только целиком
/// и только при откате, и своя таблица со слоями и слотами задвоила бы всю схему сетки — включая
/// внешние ключи на группы, которые к моменту отката могут быть уже удалены.
/// </summary>
public sealed record TemplateSnapshot(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
string? RulesJson,
IReadOnlyList<LayerSnapshot> Layers
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public static TemplateSnapshot Capture(ScheduleTemplate template) =>
new(
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
template.RulesJson,
[
.. template.Layers.Select(layer => new LayerSnapshot(
layer.Id,
layer.Name,
layer.Priority,
layer.ApplicabilityJson,
layer.IsEnabled,
layer.IsBackground,
[
.. layer.Slots.Select(slot => new SlotSnapshot(
slot.Id,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
)),
]
)),
]
);
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Разбирает снимок. Повреждённый JSON — как отсутствие снимка: откатывать не к чему.</summary>
public static TemplateSnapshot? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<TemplateSnapshot>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}