From 5128fac6e5eb48ad36367e4b54ee9df6217f9420 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 12 Oct 2026 11:05:04 +0300 Subject: [PATCH 1/3] feat: add first-login usage guide --- .codex-session.md | 17 ++++++++ .gitea/workflows/ci.yml | 1 + README.md | 6 +++ app/security.py | 6 ++- app/static/admin.css | 13 ++++++ app/static/guide.js | 69 +++++++++++++++++++++++++++++ app/templates/base.html | 5 +++ app/templates/guide.html | 12 +++++ app/templates/guide_content.html | 71 ++++++++++++++++++++++++++++++ app/templates/guide_dialog.html | 24 ++++++++++ app/templates/user_activity.html | 2 +- app/users.py | 28 +++++++++++- app/version.py | 6 +-- pyproject.toml | 2 +- tests/browser_users.py | 60 ++++++++++++++++++++++++- tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_users.py | 75 ++++++++++++++++++++++++++++++++ 18 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 .codex-session.md create mode 100644 app/static/guide.js create mode 100644 app/templates/guide.html create mode 100644 app/templates/guide_content.html create mode 100644 app/templates/guide_dialog.html diff --git a/.codex-session.md b/.codex-session.md new file mode 100644 index 0000000..357d55a --- /dev/null +++ b/.codex-session.md @@ -0,0 +1,17 @@ +## Сделано + +- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.0`. +- Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД. +- Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении. +- Проверки: `pytest -q` — 412 passed, 11 skipped; браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис всех четырёх JS-файлов и двух shell-скриптов CI — passed; `git diff --check` — passed. + +## Остановились на + +- Изменения готовы к коммиту, push и PR в `main`. Открытого PR на момент проверки нет. +- PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных. +- Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя. + +## Следующий шаг + +- Выполнить коммит и `git push -u origin feature/first-login-guide`, создать единственный PR в `main` через `gitea-api`. +- После публикации остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ba168d..4fbb0b7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: node --check app/static/admin.js - run: node --check app/static/council.js - run: node --check app/static/users.js + - run: node --check app/static/guide.js - run: sh -n infra/postgres-backup.sh - run: sh -n infra/install-postgres-backup-cron.sh diff --git a/README.md b/README.md index 06af733..44e05ca 100644 --- a/README.md +++ b/README.md @@ -285,3 +285,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv файлы с LF. После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления публикаций из БД продолжают отображаться без перезапуска. + +### Подсказки первого входа + +После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения. + +Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения. diff --git a/app/security.py b/app/security.py index 48d8d35..3b84852 100644 --- a/app/security.py +++ b/app/security.py @@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from app.config import Settings, get_settings -from app.models import SystemUser, utcnow +from app.models import SystemUser, UserAction, utcnow SESSION_COOKIE = "miem_admin_session" LOGIN_CSRF_COOKIE = "miem_login_csrf" @@ -138,6 +138,10 @@ def optional_user(request: Request, settings: Settings, db: Session, *, allow_pa user = db.scalar(query) if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold(): user = ensure_bootstrap_admin(db, settings) + if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password + and request.method == 'GET' and request.url.path.startswith('/admin')): + request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id, + UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start' except SQLAlchemyError: db.rollback() raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз') diff --git a/app/static/admin.css b/app/static/admin.css index f3b2738..140d67c 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -966,3 +966,16 @@ @media (max-width: 600px) { .users .form__input { font-size: 16px; } } .users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; } + +.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } +.guide__panel > :not([data-guide-step]) { flex-shrink: 0; } +.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; } +.guide__heading { margin-block: 20px 12px; font-size: 18px; } +.guide__fields { margin-block: 16px; } +.guide__fields dt { margin-block-start: 14px; font-weight: 600; } +.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; } +.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; } +.guide--page { max-width: 75ch; } +.guide--page .guide__step + .guide__step { margin-block-start: 32px; } +.guide .columns-modal__header { flex-wrap: wrap; } diff --git a/app/static/guide.js b/app/static/guide.js new file mode 100644 index 0000000..8311e91 --- /dev/null +++ b/app/static/guide.js @@ -0,0 +1,69 @@ +(() => { + const dialog = document.querySelector('[data-guide-dialog]'); + if (!dialog) return; + const trigger = document.querySelector('[data-guide-open]'); + const steps = Array.from(dialog.querySelectorAll('[data-guide-step]')); + const back = dialog.querySelector('[data-guide-back]'); + const next = dialog.querySelector('[data-guide-next]'); + const close = dialog.querySelector('[data-guide-close]'); + const error = dialog.querySelector('[data-guide-error]'); + const unsaved = dialog.querySelector('[data-guide-unsaved]'); + let index = 0; + let saving = false; + + function showStep() { + steps.forEach((step, number) => { step.hidden = number !== index; }); + dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`; + back.disabled = index === 0; + next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее'; + steps[index].scrollTop = 0; + steps[index].querySelector('h3').focus({preventScroll: true}); + } + function open() { + index = 0; + error.hidden = true; + unsaved.hidden = true; + dialog.showModal(); + showStep(); + } + async function finish() { + if (saving) return; + if (dialog.dataset.guidePending !== 'true') { + dialog.close(); + return; + } + saving = true; + dialog.setAttribute('aria-busy', 'true'); + close.disabled = next.disabled = back.disabled = true; + error.hidden = true; + try { + const response = await fetch('/admin/guide/dismiss', { + method: 'POST', credentials: 'same-origin', redirect: 'error', + headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(); + dialog.dataset.guidePending = 'false'; + dialog.close(); + } catch (_failure) { + error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.'; + error.hidden = false; + unsaved.hidden = false; + } finally { + saving = false; + dialog.removeAttribute('aria-busy'); + close.disabled = next.disabled = false; + back.disabled = index === 0; + } + } + trigger?.addEventListener('click', event => { event.preventDefault(); open(); }); + back.addEventListener('click', () => { index -= 1; showStep(); }); + next.addEventListener('click', () => { + if (index === steps.length - 1) finish(); + else { index += 1; showStep(); } + }); + close.addEventListener('click', finish); + unsaved.addEventListener('click', () => { if (!saving) dialog.close(); }); + dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); }); + dialog.addEventListener('close', () => trigger?.focus()); + if (dialog.dataset.guidePending === 'true') open(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index 360b73d..27ae68e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,7 @@ Запуски {% if current_user and current_user.role == 'admin' %}Пользователи{% endif %} Личный кабинет + {% if current_user and not current_user.must_change_password %}Как пользоваться{% endif %}
@@ -34,6 +35,10 @@
Backend {{ backend_version }} · Frontend {{ frontend_version }}
+ {% if current_user and not current_user.must_change_password and not guide_page|default(false) %} + {% include 'guide_dialog.html' %} + + {% endif %} {% block scripts %}{% endblock %} diff --git a/app/templates/guide.html b/app/templates/guide.html new file mode 100644 index 0000000..61fb31c --- /dev/null +++ b/app/templates/guide.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Как пользоваться · MIEM Employees{% endblock %} +{% block content %} +
+

Как пользоваться сайтом

+ {% include 'guide_content.html' %} + + + + +
+{% endblock %} diff --git a/app/templates/guide_content.html b/app/templates/guide_content.html new file mode 100644 index 0000000..5c6129d --- /dev/null +++ b/app/templates/guide_content.html @@ -0,0 +1,71 @@ +
+

Выберите задачу

+

Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.

+
+
Сотрудники
Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.
+
Диссертационный совет
Подбор по специальности ВАК, отрасли оценки и публикационным показателям.
+
Обзор и Запуски
Количество сотрудников, состояние загрузки данных и история её запусков.
+
Личный кабинет
Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.
+
+ {% if current_user.role == 'admin' %}

Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».

{% else %}

Вы можете просматривать данные и исправлять сведения о публикациях. Запуск загрузки данных и управление пользователями доступны администратору.

{% endif %} +

Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».

+
+
+

Найдите сотрудника и откройте профиль

+

В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.

+
+
Статус, Email, Учёная степень
Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.
+
Период обнаружения: От / До
Введите даты первого обнаружения профиля системой. Это не период приёма на работу.
+
Сортировка и страница
Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.
+
Настроить колонки
Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.
+
+

Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.

+

«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.

+
+
+

Подберите кандидатов в диссовет

+
+
Специальность ВАК
Выберите код и название либо «Все специальности». Например, 2.3.2 — вычислительные системы и их элементы.
+
Отрасль оценки
Выберите отрасль предполагаемого включения в совет. Для 1.1.2 доступны только физико-математические науки, для 2.3.2 — технические, для остальных специальностей сайта — обе отрасли.
+
Поиск по ФИО
Введите фамилию или часть полного имени, чтобы сузить список.
+
Сортировать по / Направление
Например, выберите «Подтверждено» и «По убыванию», затем нажмите «Применить». Получится список сотрудников в заданном порядке.
+
+

Фильтр специальности ищет сотрудников с подходящей связью журнала. Он не доказывает тематику каждой статьи и не ограничивает «ВСЕГО» только работами по выбранной специальности.

+

Показатели рассчитаны за пять календарных лет, включая текущий год. Для подготовки ходатайства отдельно проверьте требуемый период и документальные основания. Подробности доступны в блоке «Как рассчитываются показатели».

+
+
+

Прочитайте показатели сотрудника

+

В строке сотрудника показаны ФИО, обнаруженные степени, отметка об увольнении при наличии, результат оценки и счётчики. Откройте строку, чтобы загрузить публикации и причины дополнительной проверки.

+
+
Q1, Q2, К1
Количество работ с соответствующим основанием. Одна работа может попадать в несколько этих счётчиков.
+
ВСЕГО (предв.)
Уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Одна работа учитывается один раз. Для кандидата наук или PhD одной категории К2 недостаточно.
+
Подтверждено
Работы с проверенными видом материала, периодом и основанием зачёта. Именно это число сравнивается с минимумом: 9 для доктора по технической отрасли, 11 по физико-математической, 20 для кандидата наук или PhD.
+
ЛК сотрудника ВШЭ
Публикации из личного кабинета сотрудника за все годы.
+
Все публикации
Работы из личного кабинета и выгрузки ВШЭ, объединённые без дублей за все годы и независимо от категории.
+
+

При раскрытии также показаны К2, RSCI, подтверждение степени, причины статуса и ссылка на профиль ВШЭ. «Требует проверки» означает нехватку подтверждений; выполнение публикационного минимума не гарантирует включение в совет.

+
+
+

Проверьте строку публикации

+
+
Название и год
Помогают определить работу и период. Неизвестный или спорный год отмечен подсказкой.
+
Источник и тип материала
«ЛК сотрудника ВШЭ» или «Данные ВШЭ» показывают происхождение записи; тип отличает статью от книги и других материалов.
+
Журнал и квартиль
Название издания и Q1–Q4 при наличии. Квартиль должен быть применим к году статьи; К1/К2 — отдельные категории ВАК, а не квартили.
+
DOI и Публикация на сайте
Ссылки на исходную работу, если они доступны.
+
Основания и специальность
В раскрытой карточке сотрудника приведены источники зачёта, годы метрик, данные каталогов, ISSN и ссылки на паспорта при наличии. При выборе специальности показаны сведения о тематическом соответствии.
+
+

Кнопка «Уточнение» рядом с полем объясняет, что требует проверки. Её можно открыть мышью или клавишей Enter. Предупреждение само по себе не означает, что публикации нет.

+

В разделе «Статьи, журналы и квартили» показана сокращённая строка: сотрудник, название, год, тип, журнал, квартиль и специальности журнала. Там можно искать по ФИО, названию статьи и журналу.

+
+
+

Исправьте сведения и выгрузите результат

+

Войдя в систему, выберите строку публикации и откройте «Редактировать статью». С клавиатуры: перейдите на строку, нажмите Enter, затем активируйте кнопку редактирования.

+
+
Название статьи / Название журнала
Введите проверенные названия. Для журнала можно выбрать предложение из списка.
+
Квартиль
Выберите подтверждённый Q1–Q4. Если год неизвестен, поле недоступно. Изменение квартиля затронет все статьи этого журнала за тот же год.
+
Специальности журнала
Оставьте связь из справочников или задайте ручную. Ручной выбор заменяет связь для всех статей журнала за все годы; снятие всех специальностей исключает связь.
+
Сохранить / Отмена
Сохранение обновит показатели и запишет действие в историю. Ручная правка не заменяет подтверждение источником для зачёта работы.
+
Скачать XLSX
В разделе «Диссертационный совет» выгружает параметры оценки, всех найденных сотрудников и их публикации, включая другие страницы списка.
+
+

Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».

+
diff --git a/app/templates/guide_dialog.html b/app/templates/guide_dialog.html new file mode 100644 index 0000000..78897f5 --- /dev/null +++ b/app/templates/guide_dialog.html @@ -0,0 +1,24 @@ + +
+
+

Как пользоваться сайтом

+ +
+

+ {% include 'guide_content.html' %} + + +
+ + +
+
+
+{% if request.state.guide_pending|default(false) %} + +{% endif %} diff --git a/app/templates/user_activity.html b/app/templates/user_activity.html index be41220..c07a145 100644 --- a/app/templates/user_activity.html +++ b/app/templates/user_activity.html @@ -4,7 +4,7 @@ {% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }} {% else %}{{ value if value else 'не указано' }}{% endif %} {% endmacro %} -{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа'} %} +{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа', 'guide_start': 'Подсказки первого входа доступны', 'guide_finish': 'Подсказки первого входа закрыты'} %} {% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
diff --git a/app/users.py b/app/users.py index 68f7bcc..8f8b54a 100644 --- a/app/users.py +++ b/app/users.py @@ -1,6 +1,6 @@ import re from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, Response from sqlalchemy import desc, func, or_, select from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session @@ -157,6 +157,7 @@ def change_password(request: Request, current_password: str = Form(..., max_leng if new_password != confirmation: return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422) try: + first_password_change = user.password_changed_at is None user.password_hash = hash_password(new_password) user.must_change_password = False user.password_changed_at = utcnow() @@ -164,6 +165,8 @@ def change_password(request: Request, current_password: str = Form(..., max_leng user.failed_login_attempts = 0 user.locked_until = None db.add(UserAction(user_id=user.id, action='password_change')) + if first_password_change: + db.add(UserAction(user_id=user.id, action='guide_start')) db.commit() except SQLAlchemyError: db.rollback() @@ -172,3 +175,26 @@ def change_password(request: Request, current_password: str = Form(..., max_leng response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version), httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https') return response + + +@router.get('/guide') +def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + require_user(request, settings, db) + return _render(request, 'guide.html', {'guide_page': True}) + + +@router.post('/guide/dismiss') +def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + user = require_user(request, settings, db, lock=True) + try: + latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id, + UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) + if latest == 'guide_start': + db.add(UserAction(user_id=user.id, action='guide_finish')) + db.commit() + except SQLAlchemyError: + db.rollback() + raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз') + if request.headers.get('accept') == 'application/json': + return Response(status_code=204) + return RedirectResponse('/admin', status_code=303) diff --git a/app/version.py b/app/version.py index 768ca95..58c2ce9 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.2.3" -FRONTEND_VERSION = "1.2.3" -BACKEND_VERSION = "1.2.3" +APP_VERSION = "1.3.0" +FRONTEND_VERSION = "1.3.0" +BACKEND_VERSION = "1.3.0" diff --git a/pyproject.toml b/pyproject.toml index b456a18..bb99ce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.2.3" +version = "1.3.0" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/browser_users.py b/tests/browser_users.py index 34d17e8..e46db73 100644 --- a/tests/browser_users.py +++ b/tests/browser_users.py @@ -79,6 +79,9 @@ def main(): page.set_viewport_size({'width':320,'height':844}) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True) + creation.get_by_label('Логин', exact=True).fill('nojs.reader') + creation.get_by_role('button', name='Создать пользователя', exact=True).click() + nojs_password = page.locator('[data-temporary-password]').input_value() page.get_by_role('button', name='Выйти', exact=True).click() page.goto(base_url + '/admin/login') page.get_by_label('Логин', exact=True).fill('reader') @@ -97,7 +100,62 @@ def main(): modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click() - expect(page.get_by_role('dialog')).to_have_count(0) + guide = page.get_by_role('dialog', name='Как пользоваться сайтом') + expect(guide).to_be_visible() + expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused() + for width in (1440, 320): + page.set_viewport_size({'width': width, 'height': 900}) + assert guide.evaluate('node => node.scrollWidth <= node.clientWidth') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.screenshot(path=str(output / f'guide-first-{width}.png')) + page.keyboard.press('Tab') + assert page.evaluate('document.querySelector("[data-guide-dialog]").contains(document.activeElement)') + page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure')) + guide.get_by_role('button', name='Закрыть подсказки', exact=True).click() + expect(guide.locator('[data-guide-error]')).to_be_visible() + guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click() + expect(guide).not_to_be_visible() + expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused() + page.unroute('**/admin/guide/dismiss') + page.reload() + expect(guide).to_be_visible() + page.keyboard.press('Escape') + expect(guide).not_to_be_visible() + expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused() + page.reload() + expect(guide).not_to_be_visible() + page.get_by_role('link', name='Как пользоваться', exact=True).click() + expect(guide).to_be_visible() + guide.get_by_role('button', name='Далее', exact=True).click() + expect(guide.get_by_role('heading', name='Найдите сотрудника и откройте профиль')).to_be_visible() + guide.get_by_role('button', name='Назад', exact=True).click() + expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_visible() + for number in range(1, 7): + expect(guide.locator('[data-guide-progress]')).to_have_text(f'Шаг {number} из 6') + expect(guide.locator('[data-guide-step]:visible')).to_have_count(1) + if number == 4: + expect(guide).to_contain_text('ВСЕГО (предв.)') + page.screenshot(path=str(output / 'guide-counts-mobile.png')) + if number < 6: + guide.get_by_role('button', name='Далее', exact=True).click() + guide.get_by_role('button', name='Завершить подсказки', exact=True).click() + expect(guide).not_to_be_visible() + no_js = browser.new_context(java_script_enabled=False) + nojs_page = no_js.new_page() + nojs_page.goto(base_url + '/admin/login') + nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader') + nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password) + nojs_page.get_by_role('button', name='Войти', exact=True).click() + nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password) + nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123') + nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123') + nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click() + nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click() + expect(nojs_page.locator('[data-guide-step]')).to_have_count(6) + expect(nojs_page.get_by_role('heading', name='Проверьте строку публикации')).to_be_visible() + nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click() + expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0) + no_js.close() expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible() expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0) page.get_by_role('link', name='Обзор', exact=True).click() diff --git a/tests/test_api.py b/tests/test_api.py index 9ca93be..32df934 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "1.2.3" + assert response.json()["backend_version"] == "1.3.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 3874a76..0c44d84 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -223,7 +223,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.2.3 · Frontend 1.2.3" in response.text + assert "Backend 1.3.0 · Frontend 1.3.0" in response.text assert "Вход в систему" in response.text assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403) assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text diff --git a/tests/test_users.py b/tests/test_users.py index d84be3a..3f23185 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -108,6 +108,7 @@ def test_create_user_first_login_forces_password_change(accounts): assert not user.must_change_password and user.password_changed_at and user.session_version == 1 assert user.first_login_at == first_login assert 'data-required-password' not in client.get('/admin/account').text + assert 'data-guide-pending="true"' in client.get('/admin/account').text assert client.get('/admin/directory').status_code == 200 assert client.get('/admin/users').status_code == 403 client.cookies.clear() @@ -286,6 +287,80 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch): db.refresh(user) assert user.password_hash == before and user.session_version == 0 assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None + assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None + + +@pytest.mark.parametrize('role', ['user', 'admin']) +def test_guide_first_password_dismissal_and_repeat_change(accounts, role): + client, db, settings, _, user, _ = accounts + user.role = role + db.commit() + headers = act_as(client, user, settings) + assert 'data-guide-pending="false"' in client.get('/admin/account').text + data = {'current_password': 'Initial user password', 'new_password': 'New onboarding passphrase', + 'confirmation': 'New onboarding passphrase'} + assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303 + page = client.get('/admin/account') + assert 'data-guide-pending="true"' in page.text + assert 'data-required-password' not in page.text + assert page.headers['Cache-Control'] == 'no-store' + guide = client.get('/admin/guide') + parsed = BeautifulSoup(guide.text, 'html.parser') + assert len(parsed.select('[data-guide-step]')) == 6 + assert not parsed.select('[data-guide-dialog]') + assert 'Прочитайте показатели сотрудника' in guide.text and 'Проверьте строку публикации' in guide.text + assert ('управлять доступом' in guide.text) == (role == 'admin') + assert 'не ограничивает «ВСЕГО»' in guide.text + csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide-dialog]')['data-guide-csrf'] + assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403 + assert 'data-guide-pending="true"' in client.get('/admin/directory').text + for _ in range(2): + response = client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': csrf, 'Accept': 'application/json'}, follow_redirects=False) + assert response.status_code == 204 + assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_finish')).all()) == 1 + client.cookies.clear() + headers = act_as(client, user, settings) + assert 'data-guide-pending="false"' in client.get('/admin').text + data = {'current_password': data['new_password'], 'new_password': 'Second onboarding passphrase', + 'confirmation': 'Second onboarding passphrase'} + assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303 + assert 'data-guide-pending="false"' in client.get('/admin/account').text + assert client.get('/admin/guide').status_code == 200 + assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_start')).all()) == 1 + + +def test_guide_access_and_no_javascript_completion(accounts): + client, db, settings, _, user, _ = accounts + assert 'data-guide-dialog' not in client.get('/admin/council').text + assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login' + user.must_change_password = True + db.commit() + headers = act_as(client, user, settings) + assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/account' + assert client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False).headers['location'] == '/admin/account' + user.must_change_password = False + db.add(UserAction(user_id=user.id, action='guide_start')) + db.commit() + page = BeautifulSoup(client.get('/admin').text, 'html.parser') + assert page.select_one('noscript a[href="/admin/guide"]') + response = client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False) + assert response.status_code == 303 and response.headers['location'] == '/admin' + assert 'data-guide-pending="false"' in client.get('/admin/account').text + + +def test_guide_dismissal_failure_does_not_mark_it_finished(accounts, monkeypatch): + from sqlalchemy.exc import OperationalError + client, db, settings, _, user, _ = accounts + db.add(UserAction(user_id=user.id, action='guide_start')) + db.commit() + headers = act_as(client, user, settings) + def unavailable(): + raise OperationalError('INSERT', {}, Exception('private database details')) + monkeypatch.setattr(db, 'commit', unavailable) + response = client.post('/admin/guide/dismiss', headers={**headers, 'Accept': 'application/json'}) + assert response.status_code == 503 and 'private database details' not in response.text + assert db.scalar(select(UserAction).where(UserAction.action == 'guide_finish')) is None + assert 'data-guide-pending="true"' in client.get('/admin/account').text def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts): -- 2.54.0 From 8e3ea6eeb208c016c959ad637052a41b8b894e7c Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 12 Oct 2026 11:08:17 +0300 Subject: [PATCH 2/3] docs: record guide handoff and Gitea connection blocker --- .codex-session.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.codex-session.md b/.codex-session.md index 357d55a..a3f2c63 100644 --- a/.codex-session.md +++ b/.codex-session.md @@ -7,11 +7,12 @@ ## Остановились на -- Изменения готовы к коммиту, push и PR в `main`. Открытого PR на момент проверки нет. +- Реализация сохранена коммитом `5128fac` (`feat: add first-login usage guide`). Открытого PR на момент проверки нет. +- Push и повторное обновление `main` заблокированы: DNS разрешается, но TCP 443 не отвечает; Git завершился после 128 секунд с `Failed to connect ... Couldn't connect to server`. Проверка вне sandbox также завершилась таймаутом. Первая команда `git fetch origin` прошла; основа ветки и локальный/удалённый `main` на момент проверки — `378d7ee`. - PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных. - Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя. ## Следующий шаг -- Выполнить коммит и `git push -u origin feature/first-login-guide`, создать единственный PR в `main` через `gitea-api`. +- После восстановления доступа выполнить `git push -u origin feature/first-login-guide`, повторно найти открытый PR через `gitea-api GET /repos/admin/miem_workers/pulls?state=open&base=main` и создать единственный PR в `main`, если отсутствует. - После публикации остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR. -- 2.54.0 From e29775b23855feaa89ddb96a44fc522b1420c8de Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 12 Oct 2026 15:56:32 +0300 Subject: [PATCH 3/3] fix: normalize guide CSS line endings --- .codex-session.md | 14 ++++++++------ app/static/admin.css | 6 +++--- app/version.py | 6 +++--- pyproject.toml | 2 +- tests/test_api.py | 2 +- tests/test_council.py | 2 +- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.codex-session.md b/.codex-session.md index a3f2c63..945ab62 100644 --- a/.codex-session.md +++ b/.codex-session.md @@ -1,18 +1,20 @@ ## Сделано -- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.0`. +- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.1`. - Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД. - Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении. -- Проверки: `pytest -q` — 412 passed, 11 skipped; браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис всех четырёх JS-файлов и двух shell-скриптов CI — passed; `git diff --check` — passed. +- Исправлено неблокирующее замечание независимого ревью PR #71: только три новых правила CSS переведены с CRLF на LF, содержимое правил сохранено. +- После исправления: `pytest -q` — 412 passed, 11 skipped; `git diff --check main` — passed. Предыдущая запись о passed для полного diff коммита `5128fac` была неточной: ревью подтвердило три trailing whitespace, теперь они устранены. +- До этой правки: браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed. Браузерный сценарий повторно не запускался: поведение CSS не менялось. ## Остановились на -- Реализация сохранена коммитом `5128fac` (`feat: add first-login usage guide`). Открытого PR на момент проверки нет. -- Push и повторное обновление `main` заблокированы: DNS разрешается, но TCP 443 не отвечает; Git завершился после 128 секунд с `Failed to connect ... Couldn't connect to server`. Проверка вне sandbox также завершилась таймаутом. Первая команда `git fetch origin` прошла; основа ветки и локальный/удалённый `main` на момент проверки — `378d7ee`. +- Открыт PR #71: https://git.vakanaut.ru/admin/miem_workers/pulls/71. Ревью коммита `5128fac` одобрено с одним неблокирующим замечанием; пользователь поручил исправить его. +- Правка готова к коммиту и push в ту же ветку/PR. Доступ к Gitea восстановился; локальный `main` обновлён, основа — `378d7ee`. - PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных. - Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя. ## Следующий шаг -- После восстановления доступа выполнить `git push -u origin feature/first-login-guide`, повторно найти открытый PR через `gitea-api GET /repos/admin/miem_workers/pulls?state=open&base=main` и создать единственный PR в `main`, если отсутствует. -- После публикации остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR. +- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71. +- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR. diff --git a/app/static/admin.css b/app/static/admin.css index 140d67c..2a69e02 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -967,9 +967,9 @@ .users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; } -.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } -.guide__panel > :not([data-guide-step]) { flex-shrink: 0; } -.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } +.guide__panel > :not([data-guide-step]) { flex-shrink: 0; } +.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } .guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; } .guide__heading { margin-block: 20px 12px; font-size: 18px; } .guide__fields { margin-block: 16px; } diff --git a/app/version.py b/app/version.py index 58c2ce9..10e3afd 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.3.0" -FRONTEND_VERSION = "1.3.0" -BACKEND_VERSION = "1.3.0" +APP_VERSION = "1.3.1" +FRONTEND_VERSION = "1.3.1" +BACKEND_VERSION = "1.3.1" diff --git a/pyproject.toml b/pyproject.toml index bb99ce5..371303f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.3.0" +version = "1.3.1" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 32df934..751f8f3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "1.3.0" + assert response.json()["backend_version"] == "1.3.1" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 0c44d84..7d08317 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -223,7 +223,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.3.0 · Frontend 1.3.0" in response.text + assert "Backend 1.3.1 · Frontend 1.3.1" in response.text assert "Вход в систему" in response.text assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403) assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text -- 2.54.0