diff --git a/docs/qa/program-management-v1/participant-last-activity-20261017.md b/docs/qa/program-management-v1/participant-last-activity-20261017.md new file mode 100644 index 000000000..f6e2a5cb5 --- /dev/null +++ b/docs/qa/program-management-v1/participant-last-activity-20261017.md @@ -0,0 +1,60 @@ + + +# Последняя активность участника + +Запрос организатора: добавить столбец «Дата последней активности», при отсутствии даты — «—». + +## Scope и контракт + +Источник — существующий `users.CustomUser.last_activity`, обновляемый при авторизованных +запросах к платформе. Это общая активность на платформе; учёт активности не меняется. +Сейчас `GET /programs/{id}/management/participants/` не возвращает дату активности. +Добавляется поле `last_activity` с ISO timestamp или `null`; остальные поля, поиск, +порядок, pagination и server-side organizer/object permissions сохраняются. +Миграции не нужны: поле уже существует. Публичные API участников не расширяются. + +Angular получает `lastActivity` через существующий camelCase interceptor. Дата выводится +после «В программе с» в формате `dd.MM.yyyy HH:mm`, время по Москве (+03:00). +При `null` или отсутствующем поле API показывается «—» (совместимость rollout). +Расширяется существующая таблица и мобильная карточка строки, без нового UI pattern. + +## Acceptance criteria + +- A1: API организатора возвращает реальную last_activity для участника своей программы. +- A2: при отсутствии last_activity API возвращает null; UI показывает «—». +- A3: столбец отображает дату/время МСК, в том числе при переходе суток относительно UTC. +- A4: desktop и mobile сохраняют читаемость, поиск и pagination; доступ остаётся program-scoped. + +План: API поле + regression test → Angular contract/table/widths + DOM test → +проверки и review → API dev deploy → frontend dev deploy → release smoke. + +## Evidence + +- Checked by source: поле добавлено только в защищённый management endpoint. + `select_related("user")` уже существует, N+1 не добавлен. Учёт активности сохраняет + существующий JWT throttle (по умолчанию 15 минут). +- Checked by tests: 22 local PostgreSQL API/auth activity tests PASS; 300 focused + frontend tests / 37 files PASS. Новый API regression проверяет timestamp/null, + новый DOM test — дату МСК с переходом суток, null и старый API без поля. +- Checked by build: Angular development build PASS, ESLint/Stylelint/Black/Flake8 PASS. +- Checked in browser: Chrome 1440×1000, 390×844, 360×844 с настоящим локальным + Django/PostgreSQL. API timestamp/null, таблица из пяти столбцов, поиск и мобильная + строка — PASS; горизонтального overflow и page errors нет. +- Not checked: авторизованный organizer API на dev, так как нет live-сессии. + Полный backend suite и dev release проверяются через CI и anonymous smoke. + +Скриншоты: [desktop](participant-last-activity-20261017/desktop.png), +[mobile](participant-last-activity-20261017/mobile.png). + +## Requirement audit / review + +| AC | Verdict | Evidence | +| --- | ------- | -------------------------------------------------------------------------- | +| A1 | PASS | API JSON regression + real local API browser response | +| A2 | PASS | API null with last_login present; DOM null/missing; browser dash | +| A3 | PASS | UTC 16.10 22:30 → МСК 17.10 01:30 in DOM and browser | +| A4 | PASS | API scope tests; browser search and responsive table; unchanged pagination | + +Review: BLOCKER/MAJOR/MINOR findings отсутствуют. Сохранены permissions, queries, +pagination, существующие DS table/mobile patterns и Mont. API extension совместимо +со старым фронтом; новый фронт поддерживает отсутствующее поле при поэтапной выкладке. diff --git a/docs/qa/program-management-v1/participant-last-activity-20261017/desktop.png b/docs/qa/program-management-v1/participant-last-activity-20261017/desktop.png new file mode 100644 index 000000000..65546f1ba Binary files /dev/null and b/docs/qa/program-management-v1/participant-last-activity-20261017/desktop.png differ diff --git a/docs/qa/program-management-v1/participant-last-activity-20261017/mobile.png b/docs/qa/program-management-v1/participant-last-activity-20261017/mobile.png new file mode 100644 index 000000000..ea216a937 Binary files /dev/null and b/docs/qa/program-management-v1/participant-last-activity-20261017/mobile.png differ diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 360f56df0..a8c060d0d 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -46,6 +46,7 @@ export interface ManagementParticipant { email: string | null; city: string | null; registeredAt: string; + lastActivity?: string | null; } export type ManagementEvaluationStatus = | "not_submitted" 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 f35e6c8c1..873709873 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 @@ -188,6 +188,7 @@

Почта Город В программе с + Дата последней активности @@ -199,6 +200,15 @@

{{ person.registeredAt | date: "dd.MM.yyyy" : "+0300" }} + + @if (person.lastActivity) { + + } @else { + — + } + } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss index 79bbfe479..9883d4201 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.scss @@ -161,15 +161,23 @@ } &__participants th:nth-child(1) { - width: 29%; + width: 23%; } &__participants th:nth-child(2) { - width: 32%; + width: 27%; } &__participants th:nth-child(3) { - width: 19%; + width: 12%; + } + + &__participants th:nth-child(4) { + width: 17%; + } + + &__participants th:nth-child(5) { + width: 21%; } &__row-selected { 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 c332e4537..eab83ed35 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 @@ -125,7 +125,14 @@ describe("Списки управления программой в DOM", () => program: signal(program), participants: signal({ count: 1, - results: [{ id: 1, name: "Анна", email: "anna@example.com" }], + results: [ + { + id: 1, + name: "Анна", + email: "anna@example.com", + lastActivity: null as string | null | undefined, + }, + ], }), projects: signal({ count: 1, @@ -181,6 +188,31 @@ describe("Списки управления программой в DOM", () => return event as DragEvent; } + it("показывает последнюю активность по Москве, а для null и старого API — прочерк", async () => { + const { harness, state, element } = await setup(); + const participant = state.participants().results[0]; + state.participants.set({ + count: 3, + results: [ + { ...participant, lastActivity: "2026-10-16T22:30:00Z" }, + { ...participant, id: 2, lastActivity: null }, + { ...participant, id: 3, lastActivity: undefined }, + ], + }); + harness.detectChanges(); + const headings = element().querySelectorAll(".management__participants th"); + expect(headings).toHaveLength(5); + expect(headings[4].textContent).toContain("Дата последней активности"); + const cells = element().querySelectorAll('td[data-label="Последняя активность"]'); + expect(Array.from(cells, cell => cell.textContent?.trim())).toEqual([ + "17.10.2026 01:30", + "—", + "—", + ]); + expect(cells[0].querySelector("time")?.getAttribute("datetime")).toBe("2026-10-16T22:30:00Z"); + expect(cells[1].querySelector("time")).toBeNull(); + }); + async function materialDialog() { const context = await setup("settings", "?tab=materials"); context.component.editMaterial("new");