Refactor group deletion logic to handle fallback group references
Updated the DeleteGroupCommandHandler to release fallback group references during group deletion, ensuring that groups can be removed even when used as fallback in templates. Adjusted related tests to verify the correct behavior of the deletion process, confirming that fallback references are cleared appropriately. Enhanced localization strings to provide clearer context for fallback group functionality in both English and Russian.
This commit is contained in:
+27
-19
@@ -21,18 +21,12 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
// Слот и врезка стыка держат группу внешним ключом с Restrict — без этой проверки удаление
|
||||
// упало бы исключением БД вместо управляемой ошибки. Запасная группа шаблона внешнего ключа
|
||||
// не имеет, и там висячая ссылка проявилась бы только в эфире: генератор молча остался бы
|
||||
// без замены для пустого слота.
|
||||
// упало бы исключением БД вместо управляемой ошибки.
|
||||
var used =
|
||||
await dbContext.Slots.AnyAsync(s => s.GroupId == group.Id, cancellationToken)
|
||||
|| await dbContext.JunctionElements.AnyAsync(
|
||||
e => e.GroupId == group.Id,
|
||||
cancellationToken
|
||||
)
|
||||
|| await dbContext.ScheduleTemplates.AnyAsync(
|
||||
t => t.FallbackGroupId == group.Id,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// Сам факт занятости и адрес — разные вопросы: адрес собирается по цепочке слой → шаблон →
|
||||
@@ -42,11 +36,35 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
|
||||
GroupErrors.InUse(await UsedByAsync(group.Id, cancellationToken))
|
||||
);
|
||||
|
||||
await ReleaseFallbackAsync(group.Id, cancellationToken);
|
||||
|
||||
dbContext.Groups.Remove(group);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Кто держит группу: слоты каналов, стыки и аварийная группа шаблона.</summary>
|
||||
/// <summary>
|
||||
/// Снимает группу с шаблонов, где она стоит аварийной. Внешнего ключа у этой ссылки нет, и
|
||||
/// висячая проявилась бы только в эфире — генератор молча остался бы без замены для пустого
|
||||
/// слота. Блокировать удаление ею мы намеренно не будем: слот и врезка — это осознанная
|
||||
/// настройка сетки, а аварийную группу проставляет и автосборка, и снять её было нечем.
|
||||
///
|
||||
/// Ревизия поднимается: конфигурация эфира изменилась, и лента должна пересобраться —
|
||||
/// как и от любой другой правки шаблона.
|
||||
/// </summary>
|
||||
private async Task ReleaseFallbackAsync(Guid groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var templates = await dbContext
|
||||
.ScheduleTemplates.Where(t => t.FallbackGroupId == groupId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var template in templates)
|
||||
{
|
||||
template.SetFallbackGroup(null);
|
||||
template.MarkChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Кто держит группу: слоты каналов и врезки стыков.</summary>
|
||||
private async Task<string> UsedByAsync(Guid groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var slots = await (
|
||||
@@ -70,19 +88,9 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var fallback = await (
|
||||
from template in dbContext.ScheduleTemplates
|
||||
join channel in dbContext.Channels on template.ChannelId equals channel.Id
|
||||
where template.FallbackGroupId == groupId
|
||||
select channel.Name
|
||||
)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return UsageText.Join(
|
||||
UsageText.Part("слоты каналов", slots),
|
||||
UsageText.Part("врезки стыков", junctions),
|
||||
UsageText.Part("аварийная группа каналов", fallback)
|
||||
UsageText.Part("врезки стыков", junctions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ public static class GroupErrors
|
||||
"Groups.InUse",
|
||||
where.Length > 0
|
||||
? $"Группа используется в сетке ({where}) — сначала отвяжите её там."
|
||||
: "Группа используется в сетке: на неё ссылается слот, врезка стыка "
|
||||
+ "или запасная группа шаблона."
|
||||
: "Группа используется в сетке: на неё ссылается слот или врезка стыка."
|
||||
);
|
||||
|
||||
public static readonly Error FilterNotSet = Error.Validation(
|
||||
|
||||
@@ -225,14 +225,19 @@ public class DeleteGuardsTests
|
||||
Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code);
|
||||
}
|
||||
|
||||
/// <summary>Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно.</summary>
|
||||
/// <summary>
|
||||
/// Запасная группа шаблона удаление не держит: слот и врезка — осознанная настройка сетки,
|
||||
/// а её проставляет и автосборка, и снять её из интерфейса было нечем — группа оставалась
|
||||
/// неудаляемой навсегда. Ссылка снимается, ревизия растёт: эфиру нужна пересборка.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteGroup_UsedAsTemplateFallback_Fails()
|
||||
public async Task DeleteGroup_UsedAsTemplateFallback_ReleasesReference()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var group = Group.Create("G");
|
||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||
template.SetFallbackGroup(group.Id);
|
||||
template.MarkApplied();
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
@@ -241,14 +246,22 @@ public class DeleteGuardsTests
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
await using (var db = fixture.New())
|
||||
{
|
||||
var result = await new DeleteGroupCommandHandler(db).Handle(
|
||||
new DeleteGroupCommand(group.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = await check.ScheduleTemplates.FirstAsync(t => t.Id == template.Id);
|
||||
Assert.Null(stored.FallbackGroupId);
|
||||
Assert.True(stored.HasPendingChanges);
|
||||
Assert.False(await check.Groups.AnyAsync(g => g.Id == group.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -262,7 +275,20 @@ public class DeleteGuardsTests
|
||||
var group = Group.Create("G");
|
||||
var channel = Channel.Create("Первый", $"first-{Guid.NewGuid():N}", T0);
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
template.SetFallbackGroup(group.Id);
|
||||
var layer = template.AddLayer("Базовый", 10);
|
||||
var slot = layer.AddSlot("Блок", new TimeOnly(6, 0), 60);
|
||||
slot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
SlotKind.Content,
|
||||
group.Id,
|
||||
null,
|
||||
null,
|
||||
SlotBlockMode.FillSlot,
|
||||
1,
|
||||
OverflowPolicy.ContinueNext
|
||||
)
|
||||
);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
|
||||
@@ -351,6 +351,13 @@ Slot
|
||||
случай, когда пуст и фон. Только это и считается пост-проверкой «доля фона»: зацикленный филлер
|
||||
в эфире выглядит поломкой, а фоновый слот — ночным блоком.
|
||||
|
||||
Аварийная группа выбирается в карточке правил канала и снимается там же. Настройка обязана быть
|
||||
видимой: её проставляет ещё и автосборка сетки (если своя не выбрана), а сетка без слотов отдаёт
|
||||
эфир целиком ей — и в предпросмотре это выглядит так, будто пустая сетка что-то построила.
|
||||
Удаление группы этой ссылкой **не блокируется**: слот и врезка — осознанная настройка сетки, а
|
||||
аварийная снимается сама, шаблон помечается изменённым и после применения край опускается на
|
||||
филлер канала.
|
||||
|
||||
### 3.5. Стратегии
|
||||
|
||||
Хранятся в слоте как JSON с полем `type`. Стратегия отвечает за выбор **элемента** группы; внутри
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import {
|
||||
SHOW_AUDIENCES,
|
||||
type AudienceWindow,
|
||||
@@ -55,6 +57,11 @@ export function RulesCard({
|
||||
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
||||
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||
const [fallbackGroupId, setFallbackGroupId] = useState(() => template.fallbackGroupId)
|
||||
|
||||
// Аварийную группу проставляет и автосборка сетки, поэтому список нужен всегда: без него
|
||||
// ссылка остаётся невидимой, а снять её нечем — и группа не удаляется.
|
||||
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(toRows(template.rules?.maxAudienceByTime ?? []))
|
||||
@@ -64,6 +71,7 @@ export function RulesCard({
|
||||
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
||||
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
||||
setFallbackGroupId(template.fallbackGroupId)
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
@@ -78,7 +86,7 @@ export function RulesCard({
|
||||
}
|
||||
return updateTemplate(template.id, {
|
||||
name: template.name,
|
||||
fallbackGroupId: template.fallbackGroupId,
|
||||
fallbackGroupId,
|
||||
defaultJunctionId: template.defaultJunctionId,
|
||||
rules,
|
||||
})
|
||||
@@ -203,6 +211,25 @@ export function RulesCard({
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.fallbackGroup')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={fallbackGroupId ?? ''}
|
||||
onChange={(e) => setFallbackGroupId(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('admin.channels.fallbackGroupNone')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.fallbackGroupHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.postChecks')}
|
||||
|
||||
@@ -666,6 +666,10 @@ export const en = {
|
||||
'This channel grid will be replaced with the chosen channel grid. Continue?',
|
||||
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
|
||||
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
|
||||
fallbackGroup: 'Fallback group',
|
||||
fallbackGroupNone: 'None — channel filler only',
|
||||
fallbackGroupHint:
|
||||
'What fills a pause when even the background layer is empty. An empty grid plays it for the whole air. Set by grid auto-build unless you pick your own.',
|
||||
postChecks: 'Post-checks',
|
||||
breakLimit: 'Breaks per hour cap, min',
|
||||
genreShare: 'Genre share per day cap, %',
|
||||
|
||||
@@ -661,6 +661,10 @@ export const ru = {
|
||||
'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?',
|
||||
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
|
||||
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
|
||||
fallbackGroup: 'Аварийная группа',
|
||||
fallbackGroupNone: 'Нет — только филлер канала',
|
||||
fallbackGroupHint:
|
||||
'Чем закрывается пауза, если пуст и фоновый слой. Её же играет пустая сетка — весь эфир, пока слотов нет. Проставляется автосборкой сетки, если своя не выбрана.',
|
||||
postChecks: 'Пост-проверки',
|
||||
breakLimit: 'Потолок врезок в час, мин',
|
||||
genreShare: 'Потолок доли жанра за сутки, %',
|
||||
|
||||
Reference in New Issue
Block a user