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
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!-- @format -->

# Последняя активность участника

Запрос организатора: добавить столбец «Дата последней активности», при отсутствии даты — «—».

## 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 совместимо
со старым фронтом; новый фронт поддерживает отсутствующее поле при поэтапной выкладке.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface ManagementParticipant {
email: string | null;
city: string | null;
registeredAt: string;
lastActivity?: string | null;
}
export type ManagementEvaluationStatus =
| "not_submitted"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ <h3>
<th scope="col">Почта</th>
<th scope="col">Город</th>
<th scope="col">В программе с</th>
<th scope="col" title="Время по Москве">Дата последней активности</th>
</tr>
</thead>
<tbody>
Expand All @@ -199,6 +200,15 @@ <h3>
<td data-label="В программе с">
{{ person.registeredAt | date: "dd.MM.yyyy" : "+0300" }}
</td>
<td data-label="Последняя активность">
@if (person.lastActivity) {
<time [attr.datetime]="person.lastActivity" title="Время по Москве">{{
person.lastActivity | date: "dd.MM.yyyy HH:mm" : "+0300"
}}</time>
} @else {
—
}
</td>
</tr>
}
</tbody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,14 @@ describe("Списки управления программой в DOM", () =>
program: signal(program),
participants: signal({
count: 1,
results: [{ id: 1, name: "Анна", email: "[email protected]" }],
results: [
{
id: 1,
name: "Анна",
email: "[email protected]",
lastActivity: null as string | null | undefined,
},
],
}),
projects: signal<ManagementProjectsPage | null>({
count: 1,
Expand Down Expand Up @@ -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");
Expand Down