Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/modules/program-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export abstract class ProgramManagementRepositoryPort {
body: ManagementProjectFormWrite,
): Observable<ManagementProjectForm>;
abstract get(programId: number): Observable<ProgramManagement>;
abstract returnToDraft(
programId: number,
expectedUpdatedAt: string,
): Observable<ProgramManagement>;
abstract participants(
programId: number,
query: ManagementQuery,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository
get(id: number) {
return this.api.get<ProgramManagement>(`${this.root(id)}/`);
}
returnToDraft(id: number, expectedUpdatedAt: string) {
return this.api.post<ProgramManagement>(`${this.root(id)}/unpublish/`, { expectedUpdatedAt });
}
participants(id: number, q: ManagementQuery) {
return this.api.get<ManagementParticipantsPage>(
`${this.root(id)}/participants/`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProgramManagement>();
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<ProgramManagement>();
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ <h1>Управление программой</h1>
</header>
@if (state.program()?.draft) {
<p class="management__muted">Черновик · Не виден участникам</p>
<a class="management__back" [routerLink]="paths.program.launch(state.program()!.id)"
<a
id="program-launch-link"
class="management__back"
[routerLink]="paths.program.launch(state.program()!.id)"
>К подготовке к запуску</a
>
}
Expand Down Expand Up @@ -808,6 +811,34 @@ <h3>Изображения</h3>
</button>
</div>
</form>
@if (!program.draft) {
<section
class="management__card management__form"
aria-labelledby="program-publication-title"
>
<h2 id="program-publication-title">Публикация программы</h2>
<p class="management__muted">
Можно скрыть программу от участников, сохранив заявки, решения и оценки.
</p>
<div class="management__inline">
<button
type="button"
appButton="secondary"
[appButtonLoading]="returningToDraft()"
[disabled]="dirty() || saving() || !!uploading() || state.pending()"
[attr.aria-describedby]="dirty() ? 'program-publication-dirty' : null"
(click)="returnToDraft()"
>
Вернуть в черновик
</button>
</div>
@if (dirty()) {
<p class="management__muted" id="program-publication-dirty">
Сначала сохраните или отмените изменения в настройках.
</p>
}
</section>
}
} @else if (settingsTab() === "dates") {
<form [formGroup]="datesForm" (ngSubmit)="save()" class="management__card management__form">
<h2>Календарь программы</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ describe("Списки управления программой в DOM", () =>
team: signal<ManagementTeam | null>(null),
context: null as ManagementContext | null,
api: {
returnToDraft: vi.fn<(id: number, version: string) => Observable<ProgramManagement>>(() =>
of({ ...program, draft: true, updatedAt: "2026-10-30T10:01:00Z" }),
),
previewProjectAction: vi.fn(() =>
of({
items: [],
Expand Down Expand Up @@ -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<HTMLButtonElement>("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<ProgramManagement>();
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<HTMLButtonElement>("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(
Expand Down
Loading