# Последний вход пользователя: дата и IP на странице «Пользователи» (изменение 035) ## Context Суперадминистратору нужно видеть в разделе «Пользователи», когда каждый пользователь последний раз входил в систему и с какого IP. Сейчас это можно найти только поиском по журналу. `known_logins` для этого не подходит: там IP за 30 дней, ротация удаляет старые записи, и он хранит набор IP, а не последний вход. **Решение:** два поля в `users` — время и IP последнего успешного входа. Поля обновляются в `POST /auth/login`, отдаются в `UserOut` и выводятся колонкой в таблице пользователей. IP берётся тот же, что уже вычисляет `request_context` для журнала: реальный адрес клиента за Caddy (`TRUSTED_PROXIES`, изменение 034). ## Изменения ### БД и модель - `app/models.py::User`: - `last_login_at: Mapped[datetime | None]` (`DateTime(timezone=True)`); - `last_login_ip: Mapped[str | None]` (`INET`); - комментарий «изменение 035». - Миграция `alembic/versions/0014_users_last_login.py` (down_revision `0013`): - две nullable-колонки; - backfill из журнала: для каждого пользователя берётся последнее событие `session.login` (`DISTINCT ON (username) … ORDER BY username, ts DESC`) по `audit_log.username = users.username`, из него — `ts` и `client_ip`; - если журнал ротирован или очищен, значения остаются `NULL` («—» в UI). - `downgrade` — удалить колонки. ### API - `app/api/v1/auth.py::login`, ветка успешного входа (рядом с `_remember_login`): `user.last_login_at = func.now()`, `user.last_login_ip = ip`, в той же транзакции, что и запись `session.login`. Неудачные попытки поля не трогают. - `app/schemas.py::UserOut`: `last_login_at: datetime | None = None`, `last_login_ip: str | None = None`. INET приводить к строке тем же способом, что уже используется для `client_ip` в `AuditOut` и адресов (`ip_text`), чтобы в JSON был `"192.168.5.9"`, а не объект. `UserOut` собирается через `model_validate` из ORM, поэтому нужен `@field_validator("last_login_ip", mode="before")` с вызовом `ip_text()`. `UserOut` отдают и `/users` (только `superadmin`), и `/auth/me` (свои данные) — утечки чужих IP нет. ### UI (`web/app.js::screens.users`) - Новая колонка «Последний вход» после «Статус»: - дата — `fmtDate(u.last_login_at)` (как в журнале, UTC); - под ней или через разделитель — IP моноширинным шрифтом, `muted`; - «—», если входа не было. - Расширить `cols` (`selCols(...)`) и заголовок `.th` на одну колонку. Ширину подобрать так, чтобы таблица не переполнялась, по образцу соседних колонок (≈170–190px). ### Тесты (минимально) - `tests/test_users.py::test_users_management`: после входа созданного пользователя (`_client(name, ...)`) `GET /users?q=` → у записи заполнены `last_login_at` и `last_login_ip`. Новых тестов не добавлять. ### Документация (правила проекта) - `docs/changes/035-user-last-login/PLAN.md` — этот план; по завершении — `SUMMARY.md`. - `README.md`: - раздел «Интерфейс»: в «Пользователях» видны дата и IP последнего входа; - миграции `0001`–`0014`; - строка 035 в истории изменений. ## Исполнение По принятой схеме: код и тест пишет агент на Sonnet (без запуска тестов и стенда), затем ревью, пересборка стенда, проверки и SUMMARY — моя часть. ## Проверка - `docker compose -p ipam_control_006 up -d --build app` → `alembic current` = `0014`, `alembic check` — чисто; откат до 0013 и повторный upgrade — без ошибок. - Backfill: у суперадминистратора из `.env`, который входил, `last_login_at`/`last_login_ip` совпадают с последним `session.login` в журнале. - Вход через Caddy с LAN-адреса хоста (`--resolve rxipam.rxmsk.ru:443:192.168.5.9`) → `last_login_ip = 192.168.5.9`; неудачная попытка не меняет поля. - `GET /users` — поля в JSON строками; `pytest -q` — все зелёные. - UI: колонка «Последний вход» с датой и IP, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.