web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -84,6 +84,23 @@ func (s State) IsObservable() bool {
|
||||
return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
|
||||
}
|
||||
|
||||
// deletableStates — состояния, из которых человеку доступно полное удаление
|
||||
// (снять библиотечные ссылки + снести раздачу с файлами из qBittorrent).
|
||||
// Единственный дом перечня: на него смотрят и допуск в ядре (worker.Delete), и
|
||||
// все транспорты, решающие, показывать действие или нет.
|
||||
var deletableStates = []State{
|
||||
StateDone, StateOrphaned, StateTargetMissing,
|
||||
}
|
||||
|
||||
// CanDelete сообщает, допускает ли состояние полное удаление загрузки
|
||||
// пользователем. Транспорт спрашивает его, чтобы не завести своего перечня
|
||||
// состояний; проверку в ядре это не заменяет — допуск обязан держаться без
|
||||
// транспорта (см. openspec/specs/state-reconciliation, «Полное удаление
|
||||
// загрузки пользователем»).
|
||||
func (s State) CanDelete() bool {
|
||||
return slices.Contains(deletableStates, s)
|
||||
}
|
||||
|
||||
// allowedTransitions — декларативный граф легальных переходов машины состояний
|
||||
// (from → множество допустимых to). Единственный источник истины о легальности
|
||||
// рёбер: покрывает все переходы, которые worker выполняет по всем capability
|
||||
|
||||
@@ -683,3 +683,22 @@ func TestIsObservable(t *testing.T) {
|
||||
t.Error("failed должно быть терминальным и при этом наблюдаемым")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
|
||||
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
|
||||
// состояние, попавшее в перечень молча, тест покажет.
|
||||
func TestCanDelete(t *testing.T) {
|
||||
deletable := []State{StateDone, StateOrphaned, StateTargetMissing}
|
||||
|
||||
for _, s := range allStates {
|
||||
want := slices.Contains(deletable, s)
|
||||
if got := s.CanDelete(); got != want {
|
||||
t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
// deleted терминально и удалению не подлежит — иначе повторная отправка
|
||||
// формы группового удаления сносила бы уже снесённое второй раз.
|
||||
if StateDeleted.CanDelete() {
|
||||
t.Error("deleted не должно допускать удаление")
|
||||
}
|
||||
}
|
||||
|
||||
+36
-4
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
|
||||
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
|
||||
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
|
||||
// списка и страница группового удаления не разъехались порядком.
|
||||
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
|
||||
FROM download
|
||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
|
||||
|
||||
const listOrder = `
|
||||
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
|
||||
|
||||
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
|
||||
// полное удаление, без постраничной выдачи: страница группового удаления
|
||||
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
|
||||
// пачку. Перечень состояний берётся из единой точки домена — своего списка
|
||||
// запрос не держит.
|
||||
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
|
||||
ph := make([]string, len(deletableStates))
|
||||
args := make([]any, len(deletableStates))
|
||||
for i, st := range deletableStates {
|
||||
ph[i] = "?"
|
||||
args[i] = string(st)
|
||||
}
|
||||
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
|
||||
|
||||
var out []Download
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list deletable downloads: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list deletable downloads: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
|
||||
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
||||
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
|
||||
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
|
||||
FROM download
|
||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
|
||||
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||
q := listSelect + where + listOrder + `
|
||||
LIMIT ? OFFSET ?`
|
||||
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||
|
||||
|
||||
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||
t.Fatalf("display_name = %q", d.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
|
||||
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
|
||||
// разъезд запроса с предикатом иначе виден только на проде.
|
||||
func TestListDeletableDownloads(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
all := []State{
|
||||
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
|
||||
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
|
||||
StateFailed, StateCancelled, StateReverted,
|
||||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||
}
|
||||
byID := map[string]State{}
|
||||
for i, s := range all {
|
||||
byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
|
||||
}
|
||||
|
||||
got, err := st.ListDeletableDownloads(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[State]bool{}
|
||||
for _, d := range got {
|
||||
if !d.State.CanDelete() {
|
||||
t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
|
||||
}
|
||||
seen[d.State] = true
|
||||
}
|
||||
for _, s := range all {
|
||||
if s.CanDelete() && !seen[s] {
|
||||
t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user