From 5100a9ef606ca9c2dd1da5d9f5a05538bb6057f6 Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:53:42 +0300 Subject: [PATCH] feat(programs): add organizer return-to-draft action --- docs/modules/program-management.md | 45 ++++++++ .../use-cases/program-management.use-case.ts | 3 + .../program-management.repository.port.ts | 4 + .../program-management-http.adapter.spec.ts | 11 ++ .../program-management-http.adapter.ts | 3 + .../program-management.repository.spec.ts | 63 +++++++++++ .../program/program-management.repository.ts | 3 + .../management/management.component.html | 33 +++++- .../management/management.component.spec.ts | 101 ++++++++++++++++++ .../management/management.component.ts | 57 ++++++++++ 10 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.spec.ts diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index c0698f1ca..0ce2164c4 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -79,3 +79,48 @@ Headless Chrome, реальная production-сборка + локальный D Снимки локальной проверки: [участники desktop](../qa/program-management-v1/participants-desktop.png), [проекты mobile](../qa/program-management-v1/projects-mobile.png), [команда desktop](../qa/program-management-v1/team-desktop.png). Данные демонстрационные; внешний placeholder аватара заблокирован в тестовом браузере. Реальная загрузка в Selectel CDN, dev после merge и production не проверялись. FileService не менялся; ownership, переключение file→URL и сохранность исходного файла проверены backend tests. Нужна обычная приёмка на dev после обоих merge; merge/deploy выполняет пользователь. + +## Возврат программы в черновик · 30.10.2026 + +Задача: дать организатору уже опубликованной программы действие, которое ранее +было доступно только через флаг Draft в Django admin. Менеджер конкретной программы +и существующий административный scope сохраняются; права создания не требуются. + +В «Настройки → Основное» после существующей формы добавляется вторичная кнопка +«Вернуть в черновик» и стандартный диалог подтверждения. Это локальное дополнение +существующего экрана: новые компоненты, стили и переработка дизайна не нужны. +Кнопка скрыта у черновика, заблокирована при запросе или несохраненных изменениях. +Отмена и ошибка сохраняют текущую форму. После успеха видны статус черновика и +существующая ссылка на подготовку к запуску. Повторная публикация использует +прежние проверки готовности, включая сроки; возврат не продлевает их. + +Новый POST `/programs/{id}/management/unpublish/` получает `expected_updated_at` +и возвращает обычный ответ management с `draft=true` и новой `updated_at`. +Сервер блокирует программу; устаревшая версия дает 409 `stale_settings`, повтор +уже выполненного действия возвращает 200 без изменения версии. 401/403/404 +следуют текущим правилам доступа и сокрытия черновиков. API разворачивается первым. +Миграций и изменений существующих payload нет; регистрационная анкета и ее +опубликованная версия при возврате не изменяются. + +Критерии приемки: + +1. **DRAFT-1:** менеджер опубликованной программы видит кнопку в Основном; + отмена подтверждения не отправляет POST. Проверка DOM и браузером. +2. **DRAFT-2:** подтверждение переводит программу в черновик, обновляет кабинет + и сбрасывает кеши страницы и каталога. Проверка API, HTTP-контракта и браузером. +3. **DRAFT-3:** программа скрыта от посторонних и участников; менеджер сохраняет + доступ. Без авторизации, с чужой ролью или чужим ID изменения запрещены. + Проверка реальными API-запросами, включая прямой вызов endpoint. +4. **DRAFT-4:** участники, ответы анкет, заявки, сданные решения, оценки и материалы + сохраняются; последующая публикация не удаляет их. Проверка PostgreSQL. +5. **DRAFT-5:** устаревший запрос ничего не меняет; повтор и одновременные запросы + безопасны. Проверка версии, транзакций и PostgreSQL. +6. **DRAFT-6:** Django admin предлагает то же подтверждаемое действие через общий + сервис, проверяет change permission и CSRF; GET не меняет данные. Проверка admin. +7. **DRAFT-7:** несохраненные значения не теряются; при запросе повторные действия + и уход заблокированы; ошибка дает повтор/обновление. Проверка тестами и браузером + на desktop/mobile, клавиатурой, без горизонтального переполнения. + +За рамками: удаление и архивирование программы, смена сроков, уведомления, +перенос работ, изменение правил оценивания, отзыв прямых ссылок на файлы, +изменение загрузки положения и production deploy. diff --git a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts index d679d69bf..e37c466e0 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -43,6 +43,9 @@ export class ProgramManagementUseCase { get(id: number) { return this.repository.get(id); } + returnToDraft(id: number, expectedUpdatedAt: string) { + return this.repository.returnToDraft(id, expectedUpdatedAt); + } participants(id: number, q: ManagementQuery) { return this.repository.participants(id, q); } diff --git a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts index 780a32f0d..d6b27b355 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -43,6 +43,10 @@ export abstract class ProgramManagementRepositoryPort { body: ManagementProjectFormWrite, ): Observable; abstract get(programId: number): Observable; + abstract returnToDraft( + programId: number, + expectedUpdatedAt: string, + ): Observable; abstract participants( programId: number, query: ManagementQuery, diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts index a6f610762..246907e42 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts @@ -11,6 +11,17 @@ import { API_URL } from "@core/lib/providers"; describe("Контракт HTTP панели организатора", () => { let adapter: ProgramManagementHttpAdapter, http: HttpTestingController; + it("возврат в черновик передает версию и получает новый статус через общий interceptor", () => { + const updatedAt = "2026-10-30T10:00:00.123456Z"; + adapter.returnToDraft(4, updatedAt).subscribe(program => { + expect(program.draft).toBe(true); + expect(program.updatedAt).toBe("2026-10-30T10:01:00Z"); + }); + const request = http.expectOne(r => r.url.endsWith("/programs/4/management/unpublish/")); + expect(request.request.method).toBe("POST"); + expect(request.request.body).toEqual({ expected_updated_at: updatedAt }); + request.flush({ id: 4, draft: true, updated_at: "2026-10-30T10:01:00Z" }); + }); it("результаты получают независимую готовность, а экспорт передает текущий статус", () => { adapter .results(4) diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts index 2cf33fb0d..d2113f1cc 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -76,6 +76,9 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository get(id: number) { return this.api.get(`${this.root(id)}/`); } + returnToDraft(id: number, expectedUpdatedAt: string) { + return this.api.post(`${this.root(id)}/unpublish/`, { expectedUpdatedAt }); + } participants(id: number, q: ManagementQuery) { return this.api.get( `${this.root(id)}/participants/`, diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.spec.ts new file mode 100644 index 000000000..748f341ef --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.spec.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of, Subject } from "rxjs"; +import { EventBus } from "@domain/shared/event-bus"; +import { Program } from "@domain/program/program.model"; +import { ProgramManagement } from "@domain/program/program-management.model"; +import { ProgramManagementHttpAdapter } from "../../adapters/program/program-management-http.adapter"; +import { ProgramHttpAdapter } from "../../adapters/program/program-http.adapter"; +import { ProgramManagementRepository } from "./program-management.repository"; +import { ProgramRepository } from "./program.repository"; + +describe("Кеш программы после возврата в черновик", () => { + afterEach(() => TestBed.resetTestingModule()); + + it("сбрасывает страницу и уведомляет каталог только после успешного перехода", async () => { + const response = new Subject(); + const read = vi.fn((id: number) => of({ ...Program.default(), id })); + const write = vi.fn(() => response); + TestBed.configureTestingModule({ + providers: [ + ProgramRepository, + ProgramManagementRepository, + { provide: ProgramHttpAdapter, useValue: { getOne: read } }, + { provide: ProgramManagementHttpAdapter, useValue: { returnToDraft: write } }, + ], + }); + const programs = TestBed.inject(ProgramRepository); + const management = TestBed.inject(ProgramManagementRepository); + const changed = vi.fn(); + TestBed.inject(EventBus).on("ProgramUpdated").subscribe(changed); + await firstValueFrom(programs.getOne(4)); + await firstValueFrom(programs.getOne(5)); + management.returnToDraft(4, "version").subscribe(); + await firstValueFrom(programs.getOne(4)); + expect(read).toHaveBeenCalledTimes(2); + expect(changed).not.toHaveBeenCalled(); + response.next({ id: 4, draft: true } as ProgramManagement); + response.complete(); + await firstValueFrom(programs.getOne(4)); + await firstValueFrom(programs.getOne(5)); + expect(read.mock.calls).toEqual([[4], [5], [4]]); + expect(changed).toHaveBeenCalledTimes(1); + expect(changed.mock.calls[0][0].payload).toEqual({ programId: 4 }); + }); + + it("ошибка не сообщает каталогу о смене статуса", () => { + const response = new Subject(); + TestBed.configureTestingModule({ + providers: [ + ProgramManagementRepository, + { provide: ProgramManagementHttpAdapter, useValue: { returnToDraft: () => response } }, + ], + }); + const changed = vi.fn(); + TestBed.inject(EventBus).on("ProgramUpdated").subscribe(changed); + TestBed.inject(ProgramManagementRepository) + .returnToDraft(4, "version") + .subscribe({ error: () => {} }); + response.error(new Error("connection lost")); + expect(changed).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts index 71df45b62..066634092 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -48,6 +48,9 @@ export class ProgramManagementRepository implements ProgramManagementRepositoryP get(id: number) { return this.adapter.get(id); } + returnToDraft(id: number, expectedUpdatedAt: string) { + return this.adapter.returnToDraft(id, expectedUpdatedAt).pipe(tap(() => this.changed(id))); + } participants(id: number, q: ManagementQuery) { return this.adapter.participants(id, q); } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index caafe8926..5eff9dd36 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -17,7 +17,10 @@

Управление программой

@if (state.program()?.draft) {

Черновик · Не виден участникам

- К подготовке к запуску } @@ -808,6 +811,34 @@

Изображения

+ @if (!program.draft) { +
+

Публикация программы

+

+ Можно скрыть программу от участников, сохранив заявки, решения и оценки. +

+
+ +
+ @if (dirty()) { +

+ Сначала сохраните или отмените изменения в настройках. +

+ } +
+ } } @else if (settingsTab() === "dates") {

Календарь программы

diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts index 3aeaff9b1..3c6b82b7d 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts @@ -237,6 +237,9 @@ describe("Списки управления программой в DOM", () => team: signal(null), context: null as ManagementContext | null, api: { + returnToDraft: vi.fn<(id: number, version: string) => Observable>(() => + of({ ...program, draft: true, updatedAt: "2026-10-30T10:01:00Z" }), + ), previewProjectAction: vi.fn(() => of({ items: [], @@ -303,6 +306,104 @@ describe("Списки управления программой в DOM", () => return { harness, component, state, project, element, uploadFile, uploaded }; } + it("возврат в черновик открывает подтверждение; отмена не отправляет запрос", async () => { + const { component, state, element, harness } = await setup("settings", "?tab=basic"); + const button = [...element().querySelectorAll("button")].find( + item => item.textContent?.trim() === "Вернуть в черновик", + )!; + expect(button).toBeTruthy(); + button.click(); + harness.detectChanges(); + expect(component.confirmation()?.text).toContain("решения и оценки сохранятся"); + expect(state.api.returnToDraft).not.toHaveBeenCalled(); + component.resolveConfirmation(false); + await Promise.resolve(); + expect(state.api.returnToDraft).not.toHaveBeenCalled(); + expect(state.program().draft).toBeFalsy(); + }); + + it("подтверждение блокирует повтор и уход, затем показывает черновик и подготовку к запуску", async () => { + const { component, state, element, harness } = await setup("settings", "?tab=basic"); + const response = new Subject(); + state.api.returnToDraft.mockReturnValue(response); + const action = component.returnToDraft(); + component.resolveConfirmation(true); + await action; + expect(state.api.returnToDraft).toHaveBeenCalledWith(program.id, program.updatedAt); + expect(component.returningToDraft()).toBe(true); + expect(component.basicForm.disabled).toBe(true); + expect(await component.canLeave()).toBe(false); + await component.returnToDraft(); + expect(state.api.returnToDraft).toHaveBeenCalledTimes(1); + response.next({ ...program, draft: true, updatedAt: "2026-10-30T10:01:00Z" }); + response.complete(); + harness.detectChanges(); + expect(component.basicForm.enabled).toBe(true); + expect(component.returningToDraft()).toBe(false); + expect(state.program().draft).toBe(true); + expect(element().textContent).toContain("Черновик · Не виден участникам"); + expect(element().textContent).toContain("К подготовке к запуску"); + expect(element().querySelector("#program-publication-title")).toBeNull(); + }); + + it("несохраненные настройки блокируют возврат без потери введенного текста", async () => { + const { component, state, element, harness } = await setup("settings", "?tab=basic"); + component.basicForm.controls.name.setValue("Мое название"); + component.basicForm.controls.name.markAsDirty(); + harness.detectChanges(); + const button = [...element().querySelectorAll("button")].find( + item => item.textContent?.trim() === "Вернуть в черновик", + )!; + expect(button.disabled).toBe(true); + expect(button.getAttribute("aria-describedby")).toBe("program-publication-dirty"); + await component.returnToDraft(); + expect(component.confirmation()).toBeNull(); + expect(state.api.returnToDraft).not.toHaveBeenCalled(); + expect(component.basicForm.controls.name.value).toBe("Мое название"); + expect(component.basicForm.dirty).toBe(true); + }); + + it.each([409, 503])( + "ошибка %s сохраняет опубликованный статус и разрешает повтор", + async status => { + const { component, state } = await setup("settings", "?tab=basic"); + state.api.returnToDraft.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status, + error: { detail: "Программа изменена другим организатором" }, + }), + ), + ); + const action = component.returnToDraft(); + component.resolveConfirmation(true); + await action; + expect(state.program().draft).toBeFalsy(); + expect(component.formError()).toBeTruthy(); + expect(component.returningToDraft()).toBe(false); + expect(component.basicForm.enabled).toBe(true); + expect(component.basicForm.controls.name.value).toBe(program.name); + }, + ); + + it("подтверждение после смены программы не вызывает действие для прежнего ID", async () => { + const { component, state } = await setup("settings", "?tab=basic"); + const action = component.returnToDraft(); + state.program.set({ ...program, id: 2 }); + component.resolveConfirmation(true); + await action; + expect(state.api.returnToDraft).not.toHaveBeenCalled(); + }); + + it("у черновика нет обратной кнопки", async () => { + const { state, element, harness } = await setup("settings", "?tab=basic"); + state.program.set({ ...program, draft: true }); + harness.detectChanges(); + expect(element().querySelector("#program-publication-title")).toBeNull(); + expect(element().textContent).toContain("К подготовке к запуску"); + }); + it("действия строки используют ID связи, а массовая сдача включает другие страницы", async () => { const { component, project } = await setup("projects"); expect(component.projectActions(project).map(a => a.id)).toEqual( diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 4fd70c930..40c0a7adc 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -244,6 +244,7 @@ export class ProgramManagementComponent implements OnInit { }); private searchNavigation: { value: string } | null = null; readonly saving = signal(false); + readonly returningToDraft = signal(false); readonly certificatesForm = viewChild(ManagementCertificatesComponent); readonly certificatesStatus = signal({ dirty: false, busy: false }); readonly uploading = signal(null); @@ -1209,6 +1210,62 @@ export class ProgramManagementComponent implements OnInit { return "Регистрация должна завершиться не позже окончания программы."; return null; } + async returnToDraft(): Promise { + const program = this.state.program(); + if ( + !program || + program.draft || + this.saving() || + this.uploading() || + this.state.pending() || + this.dirty() || + this.confirmation() + ) + return; + const yes = await this.confirm( + "Вернуть программу в черновик?", + "Программа исчезнет из каталога и станет недоступна участникам. Участники, заявки, решения и оценки сохранятся. Программу можно будет опубликовать снова через подготовку к запуску.", + "Вернуть в черновик", + ); + if ( + !yes || + this.destroyRef.destroyed || + this.saving() || + this.uploading() || + this.dirty() || + this.state.program()?.id !== program.id || + this.state.program()?.updatedAt !== program.updatedAt + ) + return; + this.saving.set(true); + this.returningToDraft.set(true); + this.saved.set(false); + this.formError.set(null); + this.basicForm.disable({ emitEvent: false }); + this.state.api + .returnToDraft(program.id, program.updatedAt) + .pipe( + finalize(() => { + this.basicForm.enable({ emitEvent: false }); + this.saving.set(false); + this.returningToDraft.set(false); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + if (this.state.program()?.id !== program.id) return; + this.state.program.set(result); + this.saved.set(true); + afterNextRender(() => this.document.getElementById("program-launch-link")?.focus(), { + injector: this.injector, + }); + }, + error: (error: HttpErrorResponse) => { + if (this.state.program()?.id === program.id) this.formError.set(this.errorMessage(error)); + }, + }); + } save(confirmReopen = false): void { const p = this.state.program(), section = this.settingsTab();