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
34 changes: 32 additions & 2 deletions docs/feature-specs/program-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Фон PNG/JPG, прозрачная подпись PNG, директор и должность. Элементы перемещаются
мышью/касанием или стрелками, размер меняется за угол. Подпись сохраняет
пропорции. Нет формы координат. Доступны центрирование по обеим осям,
отмена/повтор действий, увеличенный редактор с масштабом, проверка длинного
отмена/повтор действий, увеличенный редактор с целым листом, проверка длинного
имени и пробный PDF. Размер текста задается в панели выбранного элемента.

Обязательно только имя участника. Все восемь других элементов можно независимо
Expand Down Expand Up @@ -58,7 +58,7 @@ drag/resize/границы/клавиатура; длинная кириллиц
выбор. Ориентацию можно изменить вручную, отменить и повторить вместе с другими
изменениями шаблона. Фон целиком вписывается в лист без обрезки и растяжения.
Положение элементов задано относительно выбранного листа. Подгонка текста,
размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые
размер подписи и PDF используют одинаковые размеры листа. Старые
шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет
ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации.

Expand All @@ -71,3 +71,33 @@ Mont, PT Serif и Lobster. У каждого элемента свой выбо
Файлы хранятся локально, PDF встраивает выбранный шрифт, подгонка длинного ФИО
использует его метрики. Семейство не меняет элементы управления и подписи
редактора. Для выкладки требуется сначала парный API с `layout.*.font_family`.

## Уточнение редактора и отправления

Размер в пикселях применяется при вводе. Рамка растет вместе с текстом, чтобы
ее прежняя высота не отменяла изменение размера. Длинные персональные имена
по-прежнему вписываются в рамку. Процентный масштаб убран: лист помещается
целиком, для большего просмотра есть развернутый редактор. Пробный PDF использует
выбранный пример имени, включая переключатель длинного имени.

Способ доставки выбирается явно: «На платформе» или «На платформе и по почте».
Третий шаг называется «Отправление». В колонке «Письмо» готовый PDF без письма
показывается как «Не отправлено». Есть фильтр неотправленных сертификатов.

В таблице можно выбрать отдельные строки или всех по текущему фильтру на всех
страницах, затем исключить отдельные строки. Выбор очищается при смене поиска
или фильтра. Доступны отправление выбранным, обновление PDF без письма и
скачивание выбранного архива. Перед отправлением отдельное подтверждение;
уже отправленные письма пропускаются. Повтор ошибок сохраняет способ доставки.

При отличии от текущего шаблона строка показывает «Шаблон изменен», действие
называется «Обновить и скачать». Сначала сохраняются изменения, затем API
обновляет персональный PDF и сохраняет прежнюю ссылку участника. Архив также
использует текущий шаблон. Сохранение шаблона само по себе не меняет выданные PDF.

Пока обновляется выданный PDF, прежний файл доступен по ссылке участника.
При ошибке обновления он сохраняется, а в журнале появляется «PDF не обновлен».
После успешной генерации новая версия заменяет прежнюю без изменения ссылки.
Обновление PDF сохраняет факт и дату прежней отправки письма. Такой участник
остается в фильтре «Отправлено» даже при ошибке обновления PDF и не получает
повторное письмо при отправлении выбранным.
70 changes: 70 additions & 0 deletions docs/qa/certificates-ux-20261025.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!-- @format -->

# Certificate editor and recipient actions — 2026-10-25

Scope: the existing program certificate management UI, preserving Mont and the
product controls. The goal includes clear delivery modes, current template PDFs,
recipient selection and responsive editor access.

## Implemented behavior

- A typed text size applies immediately; the text box grows with it instead of
silently limiting it to the old height. Size and geometry undo together.
- The full sheet fits the workspace. Percentage zoom is removed; the expanded
editor keeps the sheet and the preview PDF action together.
- The long-name switch is sent to the native preview endpoint.
- Delivery is explicitly “На платформе” or “На платформе и по почте”.
- Step 3 is “Отправление”. The mail column uses “Не отправлено” for a platform PDF
without email, with an additional unsent filter.
- Checkboxes select individual rows or all matching the current filter on every
page; individual exclusions are supported. Filter/search changes clear selection.
- Selected send skips sent messages. Refresh updates PDF without email. Retry
keeps each row's prior delivery mode, including platform-only failures.
- Changed templates have an explicit update-and-download action. Personal files
and selected archives use the current saved template and keep participant links.

## Verified locally

- 46 Angular component/layout/participant tests, dev build and targeted ESLint/Stylelint/Prettier.
- Paired API: 48 certificate tests, 45 passed and 3 PostgreSQL concurrency tests
skipped on SQLite. The earlier full PostgreSQL CI passed for base API commit
`04008df927b75c1fa0b4de6a1c29c09f54db251c`; the provenance migration follow-up
requires its own PostgreSQL CI before deployment.
- Actual Angular build and Django API on disposable local program 1: size 16 to
48 applies immediately; no zoom control; long-name PDF contains the exact name.
- Updated an old personal PDF from the journal: current portrait background and
selected Lobster face, one A4 page, correct participant name, same secret link.
- Selected all four local rows, excluded one, submitted three: two were queued
and the already sent row was skipped. Running the real service worker against
the local locmem email backend produced exactly two emails and generated PDFs;
repeated processing produced zero duplicate emails. No external mail was sent.
- A selected ZIP contains four one-page portrait PDFs with embedded Lobster,
including the previously outdated document.
- A platform-only generation failure was retried through the actual browser/API,
then processed by the service: a PDF was created, the status became `ready`,
and no mail was produced. The UI distinguishes “PDF не создан” from a mail error.
- A regeneration failure now retains the previous PDF and secret link. API tests
cover access while queued/failed, successful replacement, provenance backfill
without file/token/mail changes and program deletion with both batch references.
- PDF refreshes also retain the prior mail delivery date and sent filter. A PDF
failure/retry cannot turn an already delivered certificate into a new email;
both the selection API and worker prevent duplicate delivery.
- Native browser widths 390 and 320: the expanded editor computes the remaining
height below the tools and above the sticky footer; the whole page and PDF
button are visible. The mobile journal uses stacked rows with mail/PDF labels,
checkboxes and actions together; no horizontal document or table overflow.

Evidence lives outside Git in `design-artifacts/program-certificates-ux-20261025/`.
Local services use synthetic accounts and do not connect to the real dev database.

## Release gates still to verify

- Provenance follow-up PostgreSQL CI, migration/API/worker deployment, then frontend
and acceptance on dev program 6.
- Actual external email delivery has not been invoked. Local email checks use
a capture backend, with the application generation and delivery code.

The earlier font release (API #825 and frontend #508) is deployed and visible on
dev. Its frontend deployment initially failed because the dev disk was full;
cache-only recovery preserved container/image/volume inventories and a rerun
succeeded. This UX change is a separate release and must not be mistaken for it.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export class ProgramCertificatesUseCase {
readonly journal = this.repository.journal.bind(this.repository);
readonly retry = this.repository.retry.bind(this.repository);
readonly pdf = this.repository.pdf.bind(this.repository);
readonly currentPdf = this.repository.currentPdf.bind(this.repository);
readonly selection = this.repository.selection.bind(this.repository);
readonly archive = this.repository.archive.bind(this.repository);
readonly currentArchive = this.repository.currentArchive.bind(this.repository);
readonly mine = this.repository.mine.bind(this.repository);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CertificateFilter,
CertificateJournal,
CertificateSettings,
CertificateSelection,
} from "../program-certificates.model";
export abstract class ProgramCertificatesRepositoryPort {
abstract settings(id: number): Observable<CertificateSettings>;
Expand All @@ -20,7 +21,7 @@ export abstract class ProgramCertificatesRepositoryPort {
file: File,
): Observable<CertificateAsset>;
abstract asset(id: number, assetId: number): Observable<Blob>;
abstract preview(id: number, revision: number): Observable<Blob>;
abstract preview(id: number, revision: number, longName?: boolean): Observable<Blob>;
abstract test(id: number, revision: number): Observable<{ detail: string }>;
abstract activate(id: number, revision: number): Observable<CertificateSettings>;
abstract queue(id: number, action: "pause" | "resume"): Observable<CertificateSettings>;
Expand All @@ -32,6 +33,18 @@ export abstract class ProgramCertificatesRepositoryPort {
): Observable<CertificateJournal>;
abstract retry(id: number, ids?: string[]): Observable<{ count: number }>;
abstract pdf(id: number, certificateId: string): Observable<Blob>;
abstract currentPdf(id: number, certificateId: string, revision: number): Observable<Blob>;
abstract selection(
id: number,
revision: number,
action: "send" | "refresh" | "retry",
selection: CertificateSelection,
): Observable<{ count: number; skipped: number }>;
abstract archive(id: number, status: CertificateFilter, search: string): Observable<Blob>;
abstract currentArchive(
id: number,
revision: number,
selection: CertificateSelection,
): Observable<Blob>;
abstract mine(id: number): Observable<{ id: string; issuedAt: string; url: string }[]>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,34 @@ export interface CertificateSettings {
eligibleCounts: { all: number; submitted: number };
batch: { id: number; active: boolean; issueAt: string; initializedAt: string | null } | null;
}
export type CertificateFilter = "all" | "sent" | "failed";
export type CertificateFilter = "all" | "sent" | "failed" | "unsent";
export interface CertificateSelection {
ids?: string[];
allMatching?: boolean;
excludedIds?: string[];
status?: CertificateFilter;
search?: string;
}
export interface CertificateDelivery {
id: string;
name: string;
email: string;
status: "pending" | "generating" | "sending" | "sent" | "failed" | "ready";
pdfReady: boolean;
pdfError?: boolean;
error: string;
sentAt: string | null;
outdated?: boolean;
}
export interface CertificateJournal {
count: number;
results: CertificateDelivery[];
summary: { total: number; ready: number; sent: number; failed: number };
summary: {
total: number;
ready: number;
sent: number;
failed: number;
unsent?: number;
outdated?: number;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CertificateFilter,
CertificateJournal,
CertificateSettings,
CertificateSelection,
} from "@domain/program/program-certificates.model";
@Injectable()
export class ProgramCertificatesHttpAdapter implements ProgramCertificatesRepositoryPort {
Expand All @@ -31,8 +32,8 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi
asset(id: number, assetId: number) {
return this.api.getFile(this.root(id) + `assets/${assetId}/`);
}
preview(id: number, revision: number) {
return this.api.postFile(this.root(id) + "preview/", { revision });
preview(id: number, revision: number, longName = false) {
return this.api.postFile(this.root(id) + "preview/", { revision, longName });
}
test(id: number, revision: number) {
return this.api.post<{ detail: string }>(this.root(id) + "test/", { revision });
Expand All @@ -58,9 +59,27 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi
pdf(id: number, certificateId: string) {
return this.api.getFile(this.root(id) + `deliveries/${certificateId}/pdf/`);
}
currentPdf(id: number, certificateId: string, revision: number) {
return this.api.postFile(this.root(id) + `deliveries/${certificateId}/pdf/`, { revision });
}
selection(
id: number,
revision: number,
action: "send" | "refresh" | "retry",
selection: CertificateSelection,
) {
return this.api.post<{ count: number; skipped: number }>(this.root(id) + "selection/", {
revision,
action,
...selection,
});
}
archive(id: number, status: CertificateFilter, search: string) {
return this.api.getFile(this.root(id) + "archive/", this.query(status, search));
}
currentArchive(id: number, revision: number, selection: CertificateSelection) {
return this.api.postFile(this.root(id) + "archive/", { revision, ...selection });
}
mine(id: number) {
return this.api.get<{ id: string; issuedAt: string; url: string }[]>(
`/programs/${id}/certificates/me/`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
min="8"
max="72"
[value]="box().fontSize"
[disabled]="selected() === 'signature' || preview()"
[disabled]="selected() === 'signature' || preview() || disabled()"
(input)="fontChanged($event)"
(change)="fontChanged($event)"
/>
<span>px</span>
Expand Down Expand Up @@ -122,30 +123,21 @@
}
</div>
</div>
<div class="canvas__bottom">
<div class="canvas__bottom" #bottom>
<label class="canvas__check"
><input
type="checkbox"
appChoice
[checked]="longName()"
(change)="longNameChange.emit($any($event.target).checked)"
/>Длинное имя</label
/>Проверить длинное имя</label
>
<label class="canvas__zoom">
<span>Масштаб</span>
<select
appField
[value]="zoom()"
[disabled]="disabled()"
(change)="setZoom(+$any($event.target).value)"
>
<option value="1">По размеру окна</option>
<option value="1.25">125%</option>
<option value="1.5">150%</option>
<option value="2">200%</option>
</select>
</label>
<button
type="button"
appButton="secondary"
[disabled]="disabled()"
(click)="downloadPreview.emit()"
>
Скачать пробный PDF
</button>
</div>
<p class="canvas__instruction">
Перетащите элемент · тяните за угол для размера · стрелки для точного перемещения
</p>
Original file line number Diff line number Diff line change
Expand Up @@ -261,32 +261,12 @@
margin-top: 12px;
}

&__check,
&__zoom {
&__check {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;
}

&__zoom {
max-width: 100%;
color: var(--grey-for-text);

select {
width: 164px;
padding-right: 28px;
font-size: 12px;
background-position: right 8px center;
background-size: 12px;
}
}

&__instruction {
margin: 8px 0 0;
font-size: 11px;
color: var(--grey-for-text);
}
}

@media (width <= 749px) {
Expand All @@ -304,8 +284,7 @@

.canvas__font,
.canvas__family,
.canvas__check,
.canvas__zoom {
.canvas__check {
min-height: 44px;
}

Expand Down
Loading