Compare commits

...
Author SHA1 Message Date
agent d1854ed5e2 Merge pull request 'Добавить роль пользователя диссовета и ограничить доступ к запускам' (#77) from feature/council-user-role into main
CI / test (push) Successful in 10m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-12 11:35:13 +00:00
codex c7940c690b docs: record Workers 2.0.1 release changes
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-12 09:58:18 +00:00
codex 3fb174899d fix: hide council editing controls from read-only viewers
CI / test (pull_request) Successful in 10m44s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-12 09:45:12 +00:00
codex 508aae4f8a feat: add council user role and restrict crawl runs
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m43s
2026-11-12 09:35:26 +00:00
agent 59dcacbaff Merge pull request 'Фильтр по повышению квалификации в справочнике сотрудников' (#76) from feature/employee-training-filter into main
CI / test (push) Successful in 10m36s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 17:01:29 +00:00
codex 43d609b353 docs: record training guidance and workspace alignment
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:29:34 +03:00
codex 9d618bda81 fix: align the workspace and explain training filters
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:17:58 +03:00
codex ae0fc32172 docs: record employee training filters in changelog
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:47:59 +03:00
codex 83431d8f2a fix: distinguish courses from training provider names
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:32:58 +03:00
codex 0aa472412e fix: recognize training courses listed in profile text
CI / test (pull_request) Canceled after 0s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
2026-11-11 18:30:14 +03:00
codex d10690e28b feat: filter employees by professional development courses
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m40s
2026-11-11 18:25:13 +03:00
agent 744abd0063 Merge pull request 'Контекстные подсказки для новых пользователей Workers' (#75) from feature/contextual-onboarding into main
CI / test (push) Successful in 10m36s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 12:56:05 +00:00
codex f9cf9708da docs: record contextual onboarding changes
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:29:58 +03:00
codex 0f2d0db3c7 fix: keep guide accessible when browser storage is blocked
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:15:04 +03:00
codex d9420db50c fix: preserve guide progress across navigation
CI / test (pull_request) Canceled after 0s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
2026-11-11 14:12:07 +03:00
codex c92523f235 feat: show onboarding tips beside Workers fields
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:07:05 +03:00
agent 39894a6712 Merge pull request 'Use the shared MIEM production deployment contract' (#74) from docs/replacement-vps-deployment into main
CI / test (push) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 09:12:32 +00:00
codex 201e4dd299 docs: route production operations through MIEM infra-platform
CI / test (pull_request) Successful in 10m40s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 08:59:34 +00:00
admin 22e1bd3f2e Merge pull request 'Фильтр ППС и сворачиваемый раздел новостей сотрудников' (#73) from feature/pps-employee-filter into main
CI / test (push) Successful in 10m46s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
Reviewed-on: #73
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-29 10:00:50 +00:00
40 changed files with 1048 additions and 295 deletions

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Changelog
## 2.0.1
- Роль «Пользователь диссовета» добавляет раздел «Диссовет» к меню обычного пользователя и разрешает редактировать публикации. Без входа доступен только просмотр диссовета, включая публикации и выгрузку XLSX.
- Страницы запусков, их история и прогресс в API доступны только администраторам.
- Поля фильтров справочника и диссовета выделяются при фокусе контуром толщиной 2 px без отступа.
- При обновлении существующей базы с 1.x применяется миграция 014. Она добавляет новую роль и сохраняет назначения существующих пользователей.
## 1.7.3
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
## 1.7.2
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
## 1.6.2
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
- Обучение сохраняет текущий шаг при работе с фильтрами и доступно повторно из меню «Как пользоваться».
## 1.5.1
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
+101 -95
View File
@@ -1,48 +1,48 @@
# MIEM Employees Server
# MIEM Employees Server
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
## Архитектура
## Архитектура
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
- `postgres`: основная БД.
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
## Переменные окружения
Скопируйте `.env.example` в `.env` и поменяйте секреты:
```bash
cp .env.example .env
```
Основные настройки:
- `DATABASE_URL`: строка подключения SQLAlchemy.
- `postgres`: основная БД.
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
## Переменные окружения
Скопируйте `.env.example` в `.env` и поменяйте секреты:
```bash
cp .env.example .env
```
Основные настройки:
- `DATABASE_URL`: строка подключения SQLAlchemy.
- `SOURCE_URL`: список сотрудников МИЭМ.
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
- `SESSION_SECRET`: секрет подписи cookie.
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
## Локальный запуск
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
Админка: `http://localhost:8000/admin`.
В админке доступны:
- `SESSION_SECRET`: секрет подписи cookie.
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
## Локальный запуск
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
Админка: `http://localhost:8000/admin`.
В админке доступны:
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
@@ -52,10 +52,16 @@ uvicorn app.main:app --reload
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
## Пользователи и личный кабинет
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
`/admin/users` доступен только администратору. Здесь можно создавать учётные записи, назначать роль, включать и отключать доступ, сбрасывать пароль и смотреть историю входов.
- **Пользователь** — обзор, сотрудники, личный кабинет и подсказки по справочнику.
- **Пользователь диссовета** — те же разделы плюс «Диссертационный совет», подсказки по нему и редактирование публикаций.
- **Администратор** — все разделы, включая «Запуски» и управление пользователями. Список, подробности и текущий прогресс запусков ограничены этой ролью в HTML и API. Остальные роли получают в `/api/stats` сводку по сотрудникам.
«Диссертационный совет», просмотр его публикаций и XLSX доступны публично. Открытая интерактивная документация API отключена. Для мониторинга доступен `/api/health`.
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
@@ -67,41 +73,41 @@ uvicorn app.main:app --reload
Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. При переходе с 1.x на 2.x сделайте штатную резервную копию существующей PostgreSQL-БД и примените `migrations/014_system_user_council_role.sql` владельцем таблицы. Миграция расширяет ограничение роли и сохраняет все существующие назначения. Для существующей SQLite-БД остановите приложение, сделайте копию файла БД и примените `sqlite3 -bail <файл БД> < migrations/sqlite/014_system_user_council_role.sql` из POSIX-оболочки. В новой БД ограничение создаётся вместе с таблицей. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
## Docker Compose
```bash
## Docker Compose
```bash
docker compose up -d --build --remove-orphans
```
По умолчанию:
- API и админка: `http://localhost:8000`
```
По умолчанию:
- API и админка: `http://localhost:8000`
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
## Наполнение БД
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
Публикации теперь хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
## Наполнение БД
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
Публикации теперь хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
Новости сотрудников также хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
## Кандидаты в диссертационный совет
@@ -118,7 +124,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Пользователь диссовета или администратор может выбрать карточку статьи мышью либо клавишами Enter или пробел и открыть форму редактирования названия, журнала и квартиля. Та же форма доступна в карточках основного списка.
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
@@ -140,45 +146,45 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
## Парсинг
## Парсинг
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
```bash
```bash
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
```
Алгоритм обновления:
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
- новые сотрудники добавляются в `employees`;
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
- старые URL сохраняются в истории `employee_profile_urls`;
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
```
Алгоритм обновления:
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
- новые сотрудники добавляются в `employees`;
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
- старые URL сохраняются в истории `employee_profile_urls`;
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
- кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в `active` без обновления содержимого профиля;
- каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`;
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
## Обслуживание
```bash
```bash
docker compose logs -f api
docker compose logs -f worker
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down
```
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`, `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
Развёртывание на новом VPS ведётся через общую платформу МИЭМ. Канонический домен `workers.miem.space`. Выбор образа, восстановление PostgreSQL, доступ, healthcheck и откат описаны в [процедуре первого запуска](https://git.vakanaut.ru/admin/infra-platform/src/branch/main/docs/runbooks/REPLACEMENT_VPS_FIRST_ROLLOUT.md). Фактические статусы публикуются в Infrastructure Registry. [Карточка проекта](https://git.vakanaut.ru/admin/infrastructure-registry/src/branch/main/environments/miem/projects/miem_workers/index.md).
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Этот job относится к диагностике ключа. Выбор и публикация production-релиза выполняются в infra-platform.
## Проверки
@@ -288,6 +294,6 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
### Подсказки первого входа
После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
+26 -11
View File
@@ -1,6 +1,7 @@
from copy import deepcopy
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Query, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select, update
@@ -10,8 +11,8 @@ from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal, get_db
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user,
optional_user, sign_session, authenticate, check_csrf)
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, USER_ROLES, require_admin, require_user,
optional_user, sign_session, authenticate, can_use_council, check_csrf)
import secrets
from app.services.admin_data import (
employee_detail_payload,
@@ -30,12 +31,16 @@ from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
templates = Jinja2Templates(directory="app/templates")
templates.env.filters["admin_datetime"] = format_admin_datetime
templates.env.globals["user_roles"] = USER_ROLES
@router.get("", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_user(request, settings, db)
counts = stats_payload(db)
user = require_user(request, settings, db)
is_admin = user.role == "admin"
counts = stats_payload(db, include_runs=is_admin)
if not is_admin:
return _render(request, "dashboard.html", {"counts": counts})
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all()
@@ -114,6 +119,8 @@ def directory(
has_email: str | None = None,
has_academic_degree: str | None = None,
pps: bool = False,
has_training: Literal["", "true", "false"] = "",
training_since_year: str = Query(default="", pattern=r"^([1-9][0-9]{3})?$"),
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -136,6 +143,8 @@ def directory(
has_email=parsed_has_email,
has_academic_degree=parsed_has_academic_degree,
pps=pps,
has_training=has_training == "true" if has_training else None,
training_since_year=int(training_since_year) if training_since_year else None,
sort=sort,
direction=direction,
limit=limit,
@@ -157,6 +166,8 @@ def directory(
"has_email": has_email or "",
"has_academic_degree": has_academic_degree or "",
"pps": pps,
"has_training": has_training,
"training_since_year": training_since_year,
"sort": sort,
"direction": direction,
"limit": page["limit"],
@@ -178,7 +189,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result})
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": can_use_council(authenticated), **result})
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
@@ -195,7 +206,7 @@ def council_employee_articles(employee_id: int, request: Request, specialty: str
db.rollback()
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
"can_edit": authenticated is not None, **result})
"can_edit": can_use_council(authenticated), **result})
class CouncilArticleEdit(BaseModel):
@@ -213,7 +224,7 @@ class CouncilArticleEdit(BaseModel):
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
"can_edit": optional_user(request, settings, db) is not None}
"can_edit": can_use_council(optional_user(request, settings, db))}
try:
result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc")
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
@@ -235,6 +246,8 @@ def council_publications(request: Request, employee: str = "", title: str = "",
def edit_council_publication(request: Request, change: CouncilArticleEdit,
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
actor = require_user(request, settings, db, lock=True)
if not can_use_council(actor):
raise HTTPException(403, "Редактирование доступно пользователям диссовета и администраторам")
username = actor.username
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
if request.headers.get("X-Council-Edit") != "1":
@@ -371,7 +384,7 @@ def refresh_employee_detail(
@router.get("/runs", response_class=HTMLResponse)
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_user(request, settings, db)
require_admin(request, settings, db)
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
items = [run_payload(run) for run in run_models]
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
@@ -385,7 +398,7 @@ def run_detail(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_user(request, settings, db)
require_admin(request, settings, db)
run = db.get(CrawlRun, run_id)
if not run:
return RedirectResponse("/admin/runs", status_code=303)
@@ -450,9 +463,11 @@ def refresh_dismissed(
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
user = getattr(request.state, "user", None)
payload = {
"request": request,
"current_user": getattr(request.state, "user", None),
"current_user": user,
"council_access": can_use_council(user),
"csrf_token": getattr(request.state, "csrf_token", ""),
"backend_version": BACKEND_VERSION,
"frontend_version": FRONTEND_VERSION,
+10 -6
View File
@@ -1,6 +1,6 @@
from datetime import date
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
@@ -30,6 +30,8 @@ def list_employees(
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
has_training: bool | None = None,
training_since_year: int | None = Query(default=None, ge=1000, le=9999),
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -47,6 +49,8 @@ def list_employees(
has_email=has_email,
has_academic_degree=has_academic_degree,
pps=pps,
has_training=has_training,
training_since_year=training_since_year,
sort=sort,
direction=direction,
limit=limit,
@@ -75,7 +79,7 @@ def list_crawl_runs(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(request, settings, db)
runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all()
return {"items": [run_payload(run) for run in runs]}
@@ -86,7 +90,7 @@ def latest_crawl_run(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(request, settings, db)
running = get_running_run(db)
latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
return {"running": run_payload(running), "latest": run_payload(latest)}
@@ -99,7 +103,7 @@ def get_crawl_run(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(request, settings, db)
run = db.get(CrawlRun, run_id)
if not run:
return {"error": "not_found"}
@@ -132,8 +136,8 @@ def stats(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
return stats_payload(db)
user = require_user(request, settings, db)
return stats_payload(db, include_runs=user.role == "admin")
def _employee_summary(employee: Employee) -> dict:
+1 -1
View File
@@ -7,7 +7,7 @@ from app.users import router as users_router
from app.db import init_db
from app.version import BACKEND_VERSION
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(api_router)
app.include_router(users_router)
+1 -1
View File
@@ -258,7 +258,7 @@ class CouncilCorrections(Base):
class SystemUser(Base):
__tablename__ = "system_users"
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),)
__table_args__ = (CheckConstraint("role IN ('admin', 'user', 'council')", name="ck_system_users_role"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+11
View File
@@ -165,6 +165,17 @@ def require_user(request: Request, settings: Settings, db: Session, *, allow_pas
return user
USER_ROLES = {
'user': 'Пользователь',
'council': 'Пользователь диссовета',
'admin': 'Администратор',
}
def can_use_council(user: SystemUser | None) -> bool:
return user is not None and user.role in {'admin', 'council'}
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
user = require_user(request, settings, db)
if user.role != 'admin':
+69 -14
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import date, datetime, time
from math import ceil
from typing import Any
@@ -20,6 +21,44 @@ EMPLOYEE_SORTS = {
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
}
_TRAINING_LABEL = "повышение квалификации"
_TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", re.IGNORECASE)
_TRAINING_MENTION = re.compile(
r"\b(?:повышение квалификации|(?:курс|программ)[а-яё]* (?:повышения|по повышению) квалификации)\b",
re.IGNORECASE,
)
_QUOTED_TEXT = re.compile(r'«[^»]*»|"[^"]*"')
_TRAINING_YEAR = re.compile(r"^(\d{4})(?!\d)|(?<!\d)(\d{4})\s*г(?:\.|од[ау]?\b)", re.IGNORECASE)
def _has_training(data: dict[str, Any], since_year: int | None) -> bool:
for value in _as_list(data.get("sections")):
section = _as_dict(value)
title = str(section.get("title") or "").casefold().strip().rstrip(":")
dedicated = title in {_TRAINING_LABEL, "курсы повышения квалификации"}
if not dedicated and _TRAINING_LABEL not in (part.strip() for part in title.split("/")):
continue
for value in _as_list(section.get("year_entries")):
entry = _as_dict(value)
year = entry.get("year")
text = str(entry.get("text") or "").strip()
if since_year is not None and (type(year) is not int or year < since_year):
continue
if text and text != str(year) and (dedicated or _TRAINING_KIND.search(_QUOTED_TEXT.sub("", text))):
return True
# Profiles may also list completed courses in manually entered paragraphs or lists.
for text in _clean_list(section.get("paragraphs")) + _clean_list(section.get("items")):
description = _QUOTED_TEXT.sub("", text).strip()
if not dedicated and not _TRAINING_MENTION.search(description):
continue
if since_year is None:
return True
match = _TRAINING_YEAR.search(description)
if match and int(match.group(1) or match.group(2)) >= since_year:
return True
return False
def employee_display_payload(employee: Employee) -> dict[str, Any]:
data = _as_dict(employee.current_data)
contacts = _as_dict(data.get("contacts"))
@@ -117,6 +156,8 @@ def list_employees_page(
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
has_training: bool | None = None,
training_since_year: int | None = None,
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -135,15 +176,28 @@ def list_employees_page(
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
order = desc(sort_column) if direction == "desc" else sort_column
ordered_stmt = base_stmt.order_by(order)
if pps:
# Match decoded position strings consistently in PostgreSQL and SQLite.
employees = [employee for employee in db.scalars(ordered_stmt).all()
if any("кафедра" in position.casefold() or "департамент" in position.casefold()
for position in _clean_list(_as_dict(employee.current_data).get("positions")))]
total = len(employees)
if has_training is None and training_since_year is not None:
has_training = True
if pps or has_training is not None:
# Apply profile filters to decoded data before pagination on both databases.
employees = []
first_page = []
total = 0
for employee in db.scalars(ordered_stmt.execution_options(yield_per=100)):
data = _as_dict(employee.current_data)
if pps and not any("кафедра" in position.casefold() or "департамент" in position.casefold()
for position in _clean_list(data.get("positions"))):
continue
if has_training is not None and _has_training(data, training_since_year) != has_training:
continue
if offset and total < limit:
first_page.append(employee)
if offset <= total < offset + limit:
employees.append(employee)
total += 1
if offset >= total:
employees = first_page
offset = 0
employees = employees[offset:offset + limit]
else:
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
if offset >= total:
@@ -159,11 +213,9 @@ def list_employees_page(
}
def stats_payload(db: Session) -> dict[str, Any]:
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
def stats_payload(db: Session, *, include_runs: bool = True) -> dict[str, Any]:
latest_added = db.scalar(select(Employee).order_by(desc(Employee.first_seen_at)).limit(1))
return {
counts = {
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
"verification_required": db.scalar(
@@ -171,11 +223,14 @@ def stats_payload(db: Session) -> dict[str, Any]:
)
or 0,
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
"new_in_last_run": latest_run.new_count if latest_run else 0,
"latest_added": employee_display_payload(latest_added) if latest_added else None,
"latest_run": run_payload(latest_run) if latest_run else None,
"current_running_run": run_payload(running_run) if running_run else None,
}
if include_runs:
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
counts.update(new_in_last_run=latest_run.new_count if latest_run else 0,
latest_run=run_payload(latest_run), current_running_run=run_payload(running_run))
return counts
def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
+97 -15
View File
@@ -1,4 +1,5 @@
.admin {
--admin-gutter: 32px;
margin: 0;
min-height: 100vh;
display: flex;
@@ -27,7 +28,7 @@
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 32px;
padding: 18px var(--admin-gutter);
background: #ffffff;
border-bottom: 1px solid #d9dee7;
flex-wrap: wrap;
@@ -46,6 +47,7 @@
.admin__nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
}
@@ -65,10 +67,14 @@
outline-offset: 2px;
}
:is(.directory__filters, .council__search) :is(input, select):focus {
outline: 2px solid #0f766e;
outline-offset: 0;
}
.admin__main {
flex: 1;
width: min(1180px, calc(100% - 32px));
margin: 28px auto;
margin: 28px var(--admin-gutter);
}
.admin__footer {
@@ -558,9 +564,17 @@
color: #6b7280;
}
.directory__filter-panel > summary {
width: fit-content;
min-height: 44px;
align-content: center;
cursor: pointer;
font-weight: 600;
}
.directory__filters {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
padding: 16px;
background: #ffffff;
@@ -600,6 +614,10 @@
font-weight: 700;
}
.directory__filter-hint {
color: #4b5563;
}
.directory__input {
box-sizing: border-box;
width: 100%;
@@ -783,6 +801,12 @@
font-weight: 700;
}
@media (max-width: 1100px) {
.directory__filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.directory__filters {
grid-template-columns: 1fr 1fr;
@@ -802,6 +826,10 @@
}
@media (max-width: 620px) {
.admin {
--admin-gutter: 16px;
}
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
@@ -812,17 +840,15 @@
}
.admin__header {
align-items: flex-start;
padding: 16px;
padding-block: 16px;
}
.admin__nav {
width: 100%;
flex-wrap: wrap;
}
.admin__main {
width: min(100% - 24px, 1180px);
margin: 20px auto;
margin-block: 20px;
}
.directory__filters {
@@ -990,15 +1016,71 @@
.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 {
margin-block: 16px;
padding: clamp(12px, 2vw, 20px);
border: 1px solid #99d5ce;
border-radius: 12px;
background: #f0fdfa;
color: #164e49;
scroll-margin-block: 16px;
}
.guide__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 20px;
}
.guide__progress { margin: 0 0 4px; color: #35615b; font-size: 13px; font-variant-numeric: tabular-nums; }
.guide__heading { margin: 0; font-size: 18px; }
.guide--page .guide__heading { margin-block: 20px 12px; }
.guide__heading:focus-visible { outline: 2px solid #0f766e; outline-offset: 4px; border-radius: 2px; }
.guide__intro { max-width: 66ch; margin: 8px 0 0; line-height: 1.5; }
.guide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.guide__actions .button { min-height: 44px; }
.guide__field { display: grid; justify-items: start; min-width: 0; }
.guide__tip {
position: relative;
display: block;
box-sizing: border-box;
min-width: 0;
max-width: 44ch;
margin-block: 7px 2px;
padding: 7px 10px;
border-radius: 8px;
background: #e0f5f1;
color: #164e49;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
text-align: start;
overflow-wrap: anywhere;
}
.guide__tip::before {
position: absolute;
inset-block-start: -4px;
inset-inline-start: 13px;
width: 8px;
height: 8px;
background: inherit;
content: "";
transform: rotate(45deg);
}
.council__candidate-count .guide__tip { padding-inline: 6px; }
.council__search:has(.guide__tip) > .directory__field { align-self: start; }
@media (min-width: 701px) {
.council__candidate-summary:has(.guide__tip) {
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) 18px;
}
}
@media (max-width: 420px) {
.council__counts:has(.guide__tip) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.directory__checkbox:has(.guide__tip) { flex-wrap: wrap; }
.directory__checkbox .guide__tip { flex-basis: 100%; }
.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; }
+2 -2
View File
@@ -8,8 +8,7 @@
if (names.includes(previous)) branch.value = previous;
});
const dialog = document.querySelector('[data-council-dialog]');
if (!dialog) return;
const form = dialog.querySelector('[data-edit-form]');
const form = dialog?.querySelector('[data-edit-form]');
const autoSpecialties = form?.querySelector('[data-specialty-auto]');
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
autoSpecialties?.addEventListener('change', () => {
@@ -149,6 +148,7 @@
}, true);
window.addEventListener('resize', repositionTooltips);
if (!dialog) return;
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
button.addEventListener('click', () => { if (!saving) dialog.close(); });
});
+240 -42
View File
@@ -1,69 +1,267 @@
(() => {
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;
const guide = document.querySelector('[data-guide]');
if (!guide) return;
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});
const directory = '/admin/directory';
const council = '/admin/council';
const publications = '/admin/council/publications';
const councilAccess = guide.dataset.guideCouncil === 'true';
const steps = [
{id: 'welcome', title: 'Познакомьтесь с Workers',
intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. '
: 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'},
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
]},
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filter-panel', tips: [
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
]},
{id: 'employee-training', path: directory, title: 'Проверьте повышение квалификации', region: '.directory__filter-panel', tips: [
['[name="has_training"]', 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'],
['[name="training_since_year"]', 'Например, 2024 включает 2024 год и все последующие.'],
]},
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [
['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'],
['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
]},
{id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [
['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'],
['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'],
]},
{id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [
['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'],
['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'],
['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'],
]},
{id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [
['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'],
['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'],
]},
{id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading',
intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [
['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'],
['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'],
['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'],
]},
{id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading',
intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [
['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'],
['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'],
]},
{id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [
['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'],
['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'],
]},
{id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [
['#publications-employee', 'Укажите фамилию или часть ФИО автора.'],
['#publications-article', 'Введите несколько слов из названия статьи.'],
['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'],
]},
{id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles',
intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [
['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'],
['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'],
['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'],
], editorTips: [
['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'],
['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
]},
].filter(step => councilAccess || (step.path !== council && step.path !== publications));
const main = document.querySelector('#main-content');
const trigger = document.querySelector('[data-guide-open]');
const fallback = document.querySelector('[data-guide-fallback]');
const heading = guide.querySelector('#guide-title');
const progress = guide.querySelector('[data-guide-progress]');
const intro = guide.querySelector('[data-guide-intro]');
const reference = guide.querySelector('[data-guide-reference]');
const back = guide.querySelector('[data-guide-back]');
const next = guide.querySelector('[data-guide-next]');
const close = guide.querySelector('[data-guide-close]');
const error = guide.querySelector('[data-guide-error]');
const unsaved = guide.querySelector('[data-guide-unsaved]');
const storageKey = 'workers-guide:' + guide.dataset.guideUser;
let index = 0;
let paused = false;
let saving = false;
let removeTips = [];
function addTip([selector, message, placement], id, allowHidden = false) {
const target = main.querySelector(selector);
if (!target || (!allowHidden && !target.getClientRects().length)) return;
const tip = document.createElement('span');
tip.className = 'guide__tip';
tip.id = 'guide-tip-' + id;
tip.textContent = message;
const description = target.getAttribute('aria-describedby');
target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' '));
const label = target.labels?.[0];
let group;
if (label) {
// The description is announced separately from the label text.
tip.setAttribute('aria-hidden', 'true');
label.append(tip);
} else if (placement === 'inside') {
target.append(tip);
} else if (target.matches('button')) {
group = document.createElement('div');
group.className = 'guide__field';
target.before(group);
group.append(target, tip);
} else {
target.after(tip);
}
removeTips.push(() => {
tip.remove();
if (description === null) target.removeAttribute('aria-describedby');
else target.setAttribute('aria-describedby', description);
if (group) group.replaceWith(target);
});
}
function open() {
index = 0;
error.hidden = true;
unsaved.hidden = true;
dialog.showModal();
function clearTips() {
removeTips.forEach(remove => remove());
removeTips = [];
}
function showStep(focus = true) {
const step = steps[index];
paused = Boolean(step.path && step.path !== location.pathname);
clearTips();
const region = !paused && step.region && main.querySelector(step.region);
if (region instanceof HTMLDetailsElement) region.open = true;
if (region) region.before(guide);
else main.prepend(guide);
heading.textContent = paused ? 'Продолжите обучение' : step.title;
progress.textContent = `Шаг ${index + 1} из ${steps.length}`;
intro.textContent = paused ? step.title + '.' : step.intro || '';
intro.hidden = !intro.textContent;
reference.hidden = index !== 0;
error.hidden = unsaved.hidden = true;
back.hidden = paused;
back.disabled = index === 0;
next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
if (!paused) {
step.tips?.forEach((tip, number) => addTip(tip, number));
step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true));
}
guide.hidden = false;
if (focus) {
heading.focus({preventScroll: true});
guide.scrollIntoView({block: 'start'});
}
}
function goTo(number, path = steps[number].path || '/admin/account') {
if (!saveStep(steps[number].id)) {
location.assign(trigger.href);
return;
}
if (path !== location.pathname) {
location.assign(path);
return;
}
index = number;
showStep();
}
function hide(remember = true) {
clearTips();
guide.hidden = true;
saveStep(remember ? 'closed' : null);
trigger?.focus();
}
function saveStep(step) {
try {
if (step === null) sessionStorage.removeItem(storageKey);
else sessionStorage.setItem(storageKey, step);
return true;
} catch {
return false;
}
}
async function finish() {
if (saving) return;
if (dialog.dataset.guidePending !== 'true') {
dialog.close();
if (guide.dataset.guidePending !== 'true') {
hide();
return;
}
saving = true;
dialog.setAttribute('aria-busy', 'true');
guide.setAttribute('aria-busy', 'true');
close.disabled = next.disabled = back.disabled = true;
error.hidden = true;
error.hidden = unsaved.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),
headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error();
dialog.dataset.guidePending = 'false';
dialog.close();
guide.dataset.guidePending = 'false';
hide();
} catch (_failure) {
error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.';
error.hidden = false;
unsaved.hidden = false;
error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
error.hidden = unsaved.hidden = false;
unsaved.focus();
} finally {
saving = false;
dialog.removeAttribute('aria-busy');
guide.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(); });
trigger?.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (saving) return;
goTo(0, location.pathname);
});
back.addEventListener('click', () => goTo(index - 1));
next.addEventListener('click', () => {
if (index === steps.length - 1) finish();
else { index += 1; showStep(); }
if (paused) goTo(index);
else if (index === steps.length - 1) finish();
else goTo(index + 1);
});
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();
unsaved.addEventListener('click', () => hide(false));
guide.addEventListener('keydown', event => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
finish();
});
function restore(allowAutoStart) {
let active;
try {
active = sessionStorage.getItem(storageKey);
} catch {
return;
}
const requested = steps.findIndex(step => step.id === active);
const autoStart = allowAutoStart && requested < 0 && active !== 'closed' && guide.dataset.guidePending === 'true';
if (autoStart && !saveStep(steps[0].id)) return;
if (fallback) fallback.hidden = true;
if (requested >= 0) {
index = requested;
showStep(!allowAutoStart || index > 0);
} else if (autoStart) {
showStep(false);
} else {
clearTips();
guide.hidden = true;
}
}
window.addEventListener('pageshow', event => {
if (event.persisted) restore(false);
});
restore(true);
})();
+1 -1
View File
@@ -3,7 +3,7 @@
{% block content %}
<section class="panel users">
<h2 class="panel__title">Личный кабинет</h2>
<p>{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}</p>
<p>{{ account_user.full_name or account_user.username }} · {{ user_roles[account_user.role] }}</p>
<dl class="users__account-meta"><div><dt>Первый вход</dt><dd>{{ account_user.first_login_at|admin_datetime }}</dd></div><div><dt>Последний вход</dt><dd>{{ account_user.last_login_at|admin_datetime }}</dd></div><div><dt>Пароль изменён</dt><dd>{{ account_user.password_changed_at|admin_datetime }}</dd></div></dl>
{% if password_changed %}<p role="status">Пароль изменён. Другие сессии завершены.</p>{% endif %}
</section>
+8 -3
View File
@@ -1,3 +1,4 @@
{% set is_admin = current_user and current_user.role == 'admin' %}
<!doctype html>
<html lang="ru">
<head>
@@ -17,9 +18,11 @@
{% else %}
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
{% if council_access %}<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>{% endif %}
{% if is_admin %}
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
{% if current_user and current_user.role == 'admin' %}<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>{% endif %}
<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>
{% endif %}
<a class="admin__link" href="/admin/account"{% if request.url.path == '/admin/account' %} aria-current="page"{% endif %}>Личный кабинет</a>
{% if current_user and not current_user.must_change_password %}<a class="admin__link" href="/admin/guide" data-guide-open>Как пользоваться</a>{% endif %}
<form method="post" action="/admin/logout">
@@ -30,13 +33,15 @@
</nav>
</header>
<main class="admin__main" id="main-content">
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
{% include 'guide_walkthrough.html' %}
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="admin__footer">
Backend {{ backend_version }} · Frontend {{ frontend_version }}
</footer>
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
{% include 'guide_dialog.html' %}
<script src="/static/guide.js?v={{ frontend_version }}" defer></script>
{% endif %}
{% block scripts %}{% endblock %}
+4 -4
View File
@@ -81,12 +81,12 @@
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}</small></span>
<span class="council__counts">
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count" data-guide-count="q1"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
<span class="council__candidate-count" data-guide-count="total"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
<span class="council__candidate-count" data-guide-count="hse"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
<span class="council__candidate-count" data-guide-count="all"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
</span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
@@ -7,9 +7,11 @@
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
{% if can_edit %}
{% set employee_id = candidate.employee_id %}
{% include "council_edit_button.html" %}
{% endif %}
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span>
+2 -5
View File
@@ -1,10 +1,10 @@
{% if can_edit %}
<dialog class="columns-modal" data-council-dialog aria-labelledby="article-edit-title">
<div class="columns-modal__panel">
<div class="columns-modal__header">
<h3 class="columns-modal__title" id="article-edit-title">Редактирование статьи</h3>
<button class="button button--ghost" type="button" data-edit-close aria-label="Закрыть редактирование">✕</button>
</div>
{% if can_edit %}
<form class="council-editor" data-edit-form>
<label class="directory__field" for="article-edit-name">Название статьи
<textarea class="directory__input" id="article-edit-name" name="title" required maxlength="2000" rows="3"></textarea>
@@ -37,9 +37,6 @@
</div>
</form>
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
{% else %}
<p>Для редактирования статей и квартилей войдите в систему.</p>
<a class="button" href="/admin/login">Войти в систему</a>
{% endif %}
</div>
</dialog>
{% endif %}
+4 -5
View File
@@ -18,16 +18,15 @@
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
{% else %}
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в систему</a>.</p>{% endif %}
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
{% for article in articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
{% if can_edit %}{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}{% endif %}
<p class="council__hint">{{ article.employee_name }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span></div>
<p class="council__hint">{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
<p class="council__hint" data-guide-publication="journal">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
<p class="council__hint" data-guide-publication="specialties">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
</li>
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
</ol>
+9 -5
View File
@@ -5,7 +5,7 @@
<a class="metric metric--link" href="/admin/directory"><span class="metric__label">Всего в базе</span><span class="metric__value">{{ counts.total }}</span></a>
<a class="metric metric--link" href="/admin/directory?status=active"><span class="metric__label">Работают</span><span class="metric__value">{{ counts.active }}</span></a>
<a class="metric metric--link" href="/admin/directory?status=verification_required"><span class="metric__label">Требуют проверки</span><span class="metric__value">{{ counts.verification_required }}</span></a>
<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>
{% if is_admin %}<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>{% endif %}
<a class="metric metric--link" href="/admin/directory?status=dismissed"><span class="metric__label">Уволены</span><span class="metric__value">{{ counts.dismissed }}</span></a>
</section>
<section class="stats-strip">
@@ -17,6 +17,7 @@
<span class="stats-strip__value">Сотрудников пока нет</span>
{% endif %}
</div>
{% if is_admin %}
<a class="stats-strip__item stats-strip__item--link" href="/admin/runs">
<span class="stats-strip__label">Запуски</span>
<span class="stats-strip__value">{{ counts.runs }}</span>
@@ -25,19 +26,21 @@
<span class="stats-strip__label">Ошибки</span>
<span class="stats-strip__value">{{ counts.errors }}</span>
</div>
{% endif %}
</section>
{% if is_admin %}
<section class="panel progress-panel" data-progress-panel>
<div class="progress-panel__header">
<h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions">
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
<form method="post" action="/admin/crawl-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button" type="submit">Запустить парсинг</button>
</form>{% endif %}
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/dismissed/refresh">
</form>
<form method="post" action="/admin/dismissed/refresh">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button button--secondary" type="submit">Проверить уволенных</button>
</form>{% endif %}
</form>
</div>
</div>
{% set run = counts.current_running_run or latest_run %}
@@ -66,6 +69,7 @@
</tbody>
</table>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="/static/admin.js"></script>
+17 -1
View File
@@ -12,6 +12,9 @@
<button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
</div>
{% set has_filters = filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to or filters.has_training or filters.training_since_year %}
<details class="directory__filter-panel">
<summary>Фильтры{% if has_filters %} · применены{% endif %}</summary>
<form class="directory__filters" method="get" action="/admin/directory">
<fieldset class="directory__filter-group directory__filter-group--search">
<legend class="directory__filter-legend">Поиск</legend>
@@ -37,6 +40,18 @@
</select></label>
<label class="directory__checkbox"><input type="checkbox" name="pps" value="true" {% if filters.pps %}checked{% endif %}> ППС</label>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Повышение квалификации</legend>
<label class="directory__field">Курсы в профиле<select class="directory__input" name="has_training">
<option value="" {% if not filters.has_training %}selected{% endif %}>Любое</option>
<option value="true" {% if filters.has_training == "true" %}selected{% endif %}>Есть курсы</option>
<option value="false" {% if filters.has_training == "false" %}selected{% endif %}>Нет курсов</option>
</select></label>
<label class="directory__field">С какого года включительно
<input class="directory__input" name="training_since_year" inputmode="numeric" pattern="[1-9][0-9]{3}" maxlength="4" value="{{ filters.training_since_year }}" placeholder="Например, 2024" aria-describedby="training-year-hint">
</label>
<small class="directory__filter-hint" id="training-year-hint">Если указан только год, будут показаны сотрудники с курсами.</small>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Период обнаружения</legend>
<label class="directory__field">От<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}"></label>
@@ -64,6 +79,7 @@
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
</div>
</form>
</details>
<details class="directory__help">
<summary>Что означают даты и статусы</summary>
@@ -113,7 +129,7 @@
{% else %}
<tr><td class="directory-table__empty" colspan="15">
<strong>Сотрудники не найдены</strong>
{% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to %}
{% if has_filters %}
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
{% else %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
{% block content %}
<article class="panel guide guide--page">
<article class="panel guide--page">
<h2 class="panel__title">Как пользоваться сайтом</h2>
{% include 'guide_content.html' %}
<form method="post" action="/admin/guide/dismiss">
+9 -5
View File
@@ -1,20 +1,22 @@
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
<p>Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.</p>
<p>Сайт помогает найти сотрудника и изучить его профиль{% if council_access %}, а также подобрать кандидатов в диссертационный совет{% endif %}.</p>
<dl class="guide__fields">
<dt>Сотрудники</dt><dd>Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.</dd>
<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>
<dt>Обзор и Запуски</dt><dd>Количество сотрудников, состояние загрузки данных и история её запусков.</dd>
{% if council_access %}<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>{% endif %}
<dt>Обзор</dt><dd>Количество сотрудников и их статус.</dd>
{% if is_admin %}<dt>Запуски</dt><dd>Состояние загрузки данных и история запусков.</dd>{% endif %}
<dt>Личный кабинет</dt><dd>Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.</dd>
</dl>
{% if current_user.role == 'admin' %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% else %}<p>Вы можете просматривать данные и исправлять сведения о публикациях. Запуск загрузки данных и управление пользователями доступны администратору.</p>{% endif %}
{% if is_admin %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% endif %}
<p>Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
<p>В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.</p>
<p>В разделе «Сотрудники» раскройте панель «Фильтры». В поле «ФИО или ссылка» введите фамилию, часть ФИО или ссылку на профиль ВШЭ. Например, Иванов.</p>
<dl class="guide__fields">
<dt>Статус, Email, Учёная степень</dt><dd>Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.</dd>
<dt>Повышение квалификации</dt><dd>Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ. Например, «Нет курсов» и 2024 покажут сотрудников без записей о курсах за 2024 год и позже. Если указать только год, отобразятся сотрудники с курсами за этот период.</dd>
<dt>Период обнаружения: От / До</dt><dd>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</dd>
<dt>Сортировка и страница</dt><dd>Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.</dd>
<dt>Настроить колонки</dt><dd>Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.</dd>
@@ -22,6 +24,7 @@
<p>Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.</p>
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.</p>
</section>
{% if council_access %}
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Подберите кандидатов в диссовет</h3>
<dl class="guide__fields">
@@ -69,3 +72,4 @@
</dl>
<p>Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».</p>
</section>
{% endif %}
-24
View File
@@ -1,24 +0,0 @@
<dialog class="columns-modal guide" data-guide-dialog data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title">
<div class="columns-modal__panel guide__panel">
<div class="columns-modal__header">
<h2 class="columns-modal__title" id="guide-title">Как пользоваться сайтом</h2>
<button class="button button--ghost" type="button" data-guide-close>Закрыть подсказки</button>
</div>
<p class="guide__progress" data-guide-progress role="status"></p>
{% include 'guide_content.html' %}
<p class="users__error" data-guide-error role="alert" hidden></p>
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
<div class="guide__actions">
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
<button class="button" type="button" data-guide-next>Далее</button>
</div>
</div>
</dialog>
{% if request.state.guide_pending|default(false) %}
<noscript><aside class="panel guide" aria-label="Подсказки первого входа">
<h2 class="panel__title">Как пользоваться сайтом</h2>
<p>Пароль изменён. Откройте подсказки о поиске сотрудников, подборе диссовета и проверке публикаций.</p>
<a class="button" href="/admin/guide">Открыть подсказки</a>
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
</aside></noscript>
{% endif %}
+25
View File
@@ -0,0 +1,25 @@
<section class="guide" data-guide data-guide-user="{{ current_user.id }}" data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-council="{{ 'true' if council_access else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title" hidden>
<div class="guide__header">
<div>
<p class="guide__progress" data-guide-progress></p>
<h2 class="guide__heading" id="guide-title" tabindex="-1"></h2>
</div>
<div class="guide__actions">
<button class="button button--ghost" type="button" data-guide-close aria-label="Закрыть подсказки">Закрыть</button>
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
<button class="button" type="button" data-guide-next>Далее</button>
</div>
</div>
<p class="guide__intro" data-guide-intro hidden></p>
<a class="admin__link" href="/admin/guide" target="_blank" rel="noopener" data-guide-reference hidden>Подробная справка ↗</a>
<p class="users__error" data-guide-error role="alert" hidden></p>
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
</section>
{% if request.state.guide_pending|default(false) %}
<aside class="panel guide--page" aria-label="Подсказки первого входа" data-guide-fallback>
<h2 class="panel__title">Как пользоваться сайтом</h2>
<p>{{ 'Откройте справку о сотрудниках, диссовете и публикациях.' if council_access else 'Откройте справку о поиске сотрудников и работе со справочником.' }}</p>
<a class="button" href="/admin/guide">Открыть подсказки</a>
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
</aside>
{% endif %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% macro change_value(field, value) %}
{% if field == 'specialty_codes' %}{% if value is none or not value.manual %}по справочникам{% else %}{{ value.codes|join(', ') or 'связь исключена' }}{% endif %}
{% elif field == 'role' %}{{ {'admin': 'Администратор', 'user': 'Пользователь'}.get(value, 'не задано') }}
{% elif field == 'role' %}{{ user_roles.get(value, 'не задано') }}
{% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }}
{% else %}{{ value if value else 'не указано' }}{% endif %}
{% endmacro %}
+2 -2
View File
@@ -15,7 +15,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="form__label">Логин<input class="form__input" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" autocomplete="off"></label>
<label class="form__label">Имя<input class="form__input" name="full_name" maxlength="255" autocomplete="off"></label>
<label class="form__label">Роль<select class="form__input" name="role"><option value="user">Пользователь</option><option value="admin">Администратор</option></select></label>
<label class="form__label">Роль<select class="form__input" name="role">{% for role, label in user_roles.items() %}<option value="{{ role }}">{{ label }}</option>{% endfor %}</select></label>
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
<button class="button" type="submit">Создать пользователя</button>
</form>
@@ -29,7 +29,7 @@
<td class="table__cell">{% if user.id == current_user.id %}Администратор · ваша учётная запись{% else %}
<form class="users__controls" method="post" action="/admin/users/{{ user.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Роль {{ user.username }}<select class="form__input" name="role"><option value="user"{% if user.role == 'user' %} selected{% endif %}>Пользователь</option><option value="admin"{% if user.role == 'admin' %} selected{% endif %}>Администратор</option></select></label>
<label>Роль {{ user.username }}<select class="form__input" name="role">{% for role, label in user_roles.items() %}<option value="{{ role }}"{% if user.role == role %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>Доступ {{ user.username }}<select class="form__input" name="is_active"><option value="true"{% if user.is_active %} selected{% endif %}>Включён</option><option value="false"{% if not user.is_active %} selected{% endif %}>Отключён</option></select></label>
<button class="button button--secondary" type="submit">Сохранить доступ</button>
</form>{% endif %}
+4 -4
View File
@@ -9,7 +9,7 @@ from app.admin import _render
from app.config import Settings, get_settings
from app.db import get_db
from app.models import SystemUser, UserAction, utcnow
from app.security import (SESSION_COOKIE, SESSION_SECONDS, check_csrf, hash_password,
from app.security import (SESSION_COOKIE, SESSION_SECONDS, USER_ROLES, check_csrf, hash_password,
require_admin, require_user, sign_session, temporary_password, verify_password)
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
@@ -56,8 +56,8 @@ def create_user(request: Request, username: str = Form(..., max_length=64), full
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
actor = require_admin(request, settings, db)
username = username.strip().casefold()
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in {'admin', 'user'}:
return _users_page(request, db, error='Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Выберите одну из двух ролей', status=422)
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in USER_ROLES:
return _users_page(request, db, error='Логин должен содержать от 3 до 64 латинских букв, цифр, точек, дефисов или подчёркиваний. Выберите роль из списка', status=422)
try:
actor = _lock_admin(request, db, settings)
password = temporary_password()
@@ -106,7 +106,7 @@ def reset_password(user_id: int, request: Request, db: Session = Depends(get_db)
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
actor = require_admin(request, settings, db)
if role not in {'admin', 'user'} or is_active not in {'true', 'false'}:
if role not in USER_ROLES or is_active not in {'true', 'false'}:
raise HTTPException(422, 'Выберите роль и состояние доступа')
try:
actor = _lock_admin(request, db, settings)
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.5.1"
FRONTEND_VERSION = "1.5.1"
BACKEND_VERSION = "1.5.1"
APP_VERSION = "2.0.1"
FRONTEND_VERSION = "2.0.1"
BACKEND_VERSION = "2.0.1"
@@ -0,0 +1,6 @@
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE public.system_users
DROP CONSTRAINT ck_system_users_role,
ADD CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council'));
COMMIT;
@@ -0,0 +1,31 @@
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE system_users_new (
id INTEGER PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council')),
is_active BOOLEAN NOT NULL,
password_hash TEXT NOT NULL,
must_change_password BOOLEAN NOT NULL,
session_version INTEGER NOT NULL,
first_login_at DATETIME,
last_login_at DATETIME,
password_changed_at DATETIME,
password_reset_at DATETIME,
failed_login_attempts INTEGER NOT NULL,
locked_until DATETIME,
created_at DATETIME NOT NULL
);
INSERT INTO system_users_new
SELECT id, username, full_name, role, is_active, password_hash, must_change_password, session_version,
first_login_at, last_login_at, password_changed_at, password_reset_at, failed_login_attempts,
locked_until, created_at
FROM system_users;
DROP TABLE system_users;
ALTER TABLE system_users_new RENAME TO system_users;
CREATE TEMP TABLE check_user_action_references (violations INTEGER CHECK (violations = 0));
INSERT INTO check_user_action_references SELECT count(*) FROM pragma_foreign_key_check('user_actions');
DROP TABLE check_user_action_references;
COMMIT;
PRAGMA foreign_keys = ON;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "1.5.1"
version = "2.0.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+25 -1
View File
@@ -75,8 +75,30 @@ def main():
page.keyboard.press("Tab")
assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'")
for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320):
filters = page.locator(".directory__filter-panel")
expect(filters).not_to_have_attribute("open", "")
expect(page.get_by_label("ФИО или ссылка")).to_be_hidden()
filters.locator("summary").focus()
page.keyboard.press("Enter")
expect(page.get_by_label("ФИО или ссылка")).to_be_visible()
assert page.locator('.directory__filters :is(input, select)').evaluate_all("""controls =>
controls.every(control => {
control.focus();
const style = getComputedStyle(control);
return style.outlineWidth === '2px' && style.outlineOffset === '0px';
})
""")
for width in (1920, 1440, 1180, 1024, 921, 920, 768, 621, 620, 390, 320):
page.set_viewport_size({"width": width, "height": 844})
assert page.locator("main").evaluate("""main => {
const header = document.querySelector('.admin__header');
const style = getComputedStyle(header);
const headerBox = header.getBoundingClientRect();
const mainBox = main.getBoundingClientRect();
return Math.abs(mainBox.left - headerBox.left - parseFloat(style.paddingLeft)) < 1
&& Math.abs(headerBox.right - mainBox.right - parseFloat(style.paddingRight)) < 1
&& document.documentElement.scrollWidth <= document.documentElement.clientWidth;
}""")
assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth")
assert page.locator(".directory__input").evaluate_all("""controls => controls.every(control => {
const field = control.closest('.directory__field').getBoundingClientRect();
@@ -88,6 +110,7 @@ def main():
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1")
assert "offset=" not in page.url
expect(filters).not_to_have_attribute("open", "")
trigger = page.get_by_role("button", name="Настроить колонки")
trigger.focus()
page.keyboard.press("Enter")
@@ -116,6 +139,7 @@ def main():
assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth")
page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True)
page.keyboard.press("Escape")
filters.locator("summary").click()
page.get_by_label("ФИО или ссылка").fill("Нет совпадений")
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
+3 -8
View File
@@ -66,16 +66,11 @@ def main():
page.on('pageerror', lambda error: errors.append(str(error)))
page.goto('http://miem.test/admin/council')
page.locator('.council__candidate-summary').click()
expect(page.locator('.council__article strong')).to_have_text('Исследование передачи данных')
expect(page.locator('[data-council-edit]')).to_have_count(0)
expect(page.locator('[data-council-dialog]')).to_have_count(0)
article = page.locator('[data-council-article]')
pencil = article.locator('[data-council-edit]')
expect(pencil).to_be_hidden()
article.locator('strong').click()
expect(pencil).to_be_visible()
pencil.click()
expect(page.get_by_role('dialog')).to_be_visible()
expect(page.get_by_role('link', name='Войти в систему', exact=True)).to_be_visible()
page.keyboard.press('Escape')
expect(pencil).to_be_focused()
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
page.reload()
page.locator('.council__candidate-summary').click()
+114 -19
View File
@@ -71,6 +71,7 @@ def main():
creation = page.locator('form[action="/admin/users"]')
creation.get_by_label('Логин', exact=True).fill('reader')
creation.get_by_label('Имя', exact=True).fill('Тестовый читатель')
creation.get_by_role('combobox', name='Роль', exact=True).select_option('council')
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
temporary = page.locator('[data-temporary-password]')
expect(temporary).to_be_visible()
@@ -80,6 +81,7 @@ def main():
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('combobox', name='Роль', exact=True).select_option('user')
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()
@@ -100,16 +102,23 @@ 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()
guide = page.get_by_role('dialog', name='Как пользоваться сайтом')
guide = page.locator('[data-guide]')
expect(guide).to_be_visible()
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused()
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
restricted = context.new_page()
restricted.add_init_script("""Object.defineProperty(window, 'sessionStorage', {
get() { throw new DOMException('Storage is disabled', 'SecurityError'); },
});""")
restricted.goto(base_url + '/admin/account')
expect(restricted.get_by_role('link', name='Открыть подсказки', exact=True)).to_be_visible()
restricted.get_by_role('link', name='Как пользоваться', exact=True).click()
expect(restricted.get_by_role('heading', name='Как пользоваться сайтом')).to_be_visible()
restricted.close()
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()
@@ -119,6 +128,7 @@ def main():
page.unroute('**/admin/guide/dismiss')
page.reload()
expect(guide).to_be_visible()
guide.get_by_role('heading').focus()
page.keyboard.press('Escape')
expect(guide).not_to_be_visible()
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
@@ -126,20 +136,103 @@ def main():
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()
go_next = guide.get_by_role('button', name='Далее', exact=True)
go_back = guide.get_by_role('button', name='Назад', exact=True)
tips = page.locator('.guide__tip:visible')
go_next.click()
expect(guide.get_by_role('heading', name='Найдите сотрудника', exact=True)).to_be_focused()
assert page.url.endswith('/admin/directory')
expect(tips).to_have_count(3)
search = page.get_by_role('textbox', name='ФИО или ссылка', exact=True)
expect(search).to_have_accessible_description('Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.')
search.fill('Сотрудник')
page.get_by_role('button', name='Применить фильтры', exact=True).click()
expect(tips).to_have_count(3)
expect(search).to_have_value('Сотрудник')
page.get_by_role('link', name='Сбросить', exact=True).click()
expect(search).to_have_value('')
expect(tips).to_have_count(3)
go_back.click()
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
for _ in range(3):
go_next.click()
expect(guide.get_by_role('heading', name='Проверьте повышение квалификации')).to_be_visible()
expect(tips).to_have_count(2)
expect(page.get_by_role('combobox', name='Курсы в профиле', exact=True)).to_have_accessible_description(
'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'
)
expect(page.get_by_role('textbox', name='С какого года включительно', exact=True)).to_have_accessible_description(
'Если указан только год, будут показаны сотрудники с курсами. '
'Например, 2024 включает 2024 год и все последующие.'
)
for width in (1440, 320):
page.set_viewport_size({'width': width, 'height': 900})
assert tips.evaluate_all('tips => tips.every(tip => tip.scrollWidth <= tip.clientWidth)')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.set_viewport_size({'width': 1440, 'height': 900})
for _ in range(3):
go_next.click()
assert page.url.endswith('/admin/council')
expect(tips).to_have_count(3)
for width in (1440, 320):
page.set_viewport_size({'width': width, 'height': 900})
assert page.evaluate('''() => {
const tips = [...document.querySelectorAll('.guide__tip')];
const boxes = tips.map(tip => tip.getBoundingClientRect());
const fields = [...document.querySelectorAll('.council__search input:not([type=hidden]), .council__search select')];
const overlaps = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
return tips.every(tip => tip.scrollWidth <= tip.clientWidth && tip.scrollHeight <= tip.clientHeight)
&& boxes.every((box, i) => boxes.slice(i + 1).every(other => !overlaps(box, other))
&& fields.every(field => !overlaps(box, field.getBoundingClientRect())))
&& document.documentElement.scrollWidth <= innerWidth;
}''')
guide.evaluate("node => node.scrollIntoView({block: 'start'})")
page.screenshot(path=str(output / f'guide-council-{width}.png'))
go_back.click()
expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible()
profile = page.get_by_role('link', name='Сотрудник для проверки', exact=True)
expect(profile).to_have_accessible_description('Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.')
profile.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='Откройте карточку сотрудника')).to_be_visible()
go_next.click()
go_next.click()
go_next.click()
expect(tips).to_have_count(3)
expect(page.locator('[data-guide-count="total"] .guide__tip')).to_contain_text('Уникальные работы')
page.screenshot(path=str(output / 'guide-counts-mobile.png'))
go_next.click()
expect(tips).to_have_count(2)
expect(page.locator('[data-guide-count="hse"] .guide__tip')).to_contain_text('личного кабинета')
go_next.click()
expect(tips).to_have_count(2)
go_next.click()
assert page.url.endswith('/admin/council/publications')
go_next.click()
expect(tips).to_have_count(3)
warning = page.locator('[data-warning] button').first
warning.focus()
expect(warning).to_have_attribute('aria-expanded', 'true')
page.keyboard.press('Escape')
expect(warning).to_have_attribute('aria-expanded', 'false')
expect(guide).to_be_visible()
page.locator('[data-council-article]').click()
page.get_by_role('button', name='Редактировать статью: Статья для проверки', exact=True).click()
guide_editor = page.get_by_role('dialog', name='Редактирование статьи')
expect(guide_editor.locator('.guide__tip')).to_have_count(3)
expect(guide_editor.get_by_role('combobox', name='Квартиль', exact=True)).to_have_accessible_description(
'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'
)
page.keyboard.press('Escape')
expect(guide_editor).not_to_be_visible()
expect(guide).to_be_visible()
guide.get_by_role('button', name='Завершить подсказки', exact=True).click()
expect(guide).not_to_be_visible()
expect(tips).to_have_count(0)
page.go_back()
expect(guide).not_to_be_visible()
expect(tips).to_have_count(0)
no_js = browser.new_context(java_script_enabled=False)
nojs_page = no_js.new_page()
nojs_page.goto(base_url + '/admin/login')
@@ -151,13 +244,15 @@ def main():
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()
expect(nojs_page.locator('[data-guide-step]')).to_have_count(2)
expect(nojs_page.get_by_text('Повышение квалификации', exact=True)).to_be_visible()
expect(nojs_page.get_by_role('link', name='Диссертационный совет', exact=True)).to_have_count(0)
expect(nojs_page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0)
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)
expect(page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0)
page.get_by_role('link', name='Обзор', exact=True).click()
expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0)
page.get_by_role('link', name='Диссертационный совет', exact=True).click()
+3 -1
View File
@@ -6,7 +6,9 @@ from jinja2 import Environment, FileSystemLoader
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
request=SimpleNamespace(url=SimpleNamespace(path="/admin")),
current_user=SimpleNamespace(role="admin", must_change_password=True),
council_access=True,
)
assert "Обзор" in template
+1 -1
View File
@@ -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.5.1"
assert response.json()["backend_version"] == "2.0.1"
def test_api_employees_and_stats_require_admin_session():
+1 -1
View File
@@ -230,7 +230,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.5.1 · Frontend 1.5.1" in response.text
assert "Backend 2.0.1 · Frontend 2.0.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
+8 -1
View File
@@ -82,8 +82,15 @@ def test_editor_permissions_csrf_validation_and_missing_article(editor, db_sessi
assert db_session.get(CouncilCorrections, 1) is None
client.cookies.clear()
assert save(client, payload).status_code == 401
original_title = db_session.scalar(select(EmployeePublication.title).where(
EmployeePublication.employee_id == payload['employee_id']))
response = client.get('/admin/council/publications')
assert response.status_code == 200 and 'Для редактирования' in response.text
assert response.status_code == 200
assert original_title in response.text
assert 'data-council-edit' not in response.text and 'data-council-dialog' not in response.text
fragment = client.get(f"/admin/council/employees/{payload['employee_id']}/articles")
assert fragment.status_code == 200 and original_title in fragment.text
assert 'data-council-article' not in fragment.text and 'data-council-edit' not in fragment.text
def test_editor_journal_reassignment_year_scope_and_unknown_year(editor, db_session):
+27
View File
@@ -1,6 +1,12 @@
from pathlib import Path
import sqlite3
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.dialects.sqlite import dialect
from sqlalchemy.schema import CreateTable
from app.db import _ensure_runtime_schema
from app.models import SystemUser
def test_runtime_schema_adds_skipped_count_to_existing_crawl_runs_table(monkeypatch):
@@ -142,3 +148,24 @@ def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
columns = {column["name"] for column in inspector.get_columns("employees")}
assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
assert "employee_profile_urls" in inspector.get_table_names()
def test_sqlite_council_role_migration_preserves_users_and_history():
original = str(CreateTable(SystemUser.__table__).compile(dialect=dialect())).replace(", 'council'", "")
with sqlite3.connect(":memory:") as connection:
connection.execute(original)
connection.execute("CREATE TABLE user_actions (user_id INTEGER REFERENCES system_users(id))")
connection.execute("""
INSERT INTO system_users
(id, username, full_name, role, is_active, password_hash, must_change_password,
session_version, failed_login_attempts, created_at)
VALUES (17, 'reader', '', 'user', 1, 'fixture', 0, 4, 0, '2026-11-12')
""")
connection.execute("INSERT INTO user_actions VALUES (17)")
before = connection.execute("SELECT * FROM system_users").fetchall()
migration = Path("migrations/sqlite/014_system_user_council_role.sql").read_text(encoding="utf-8")
connection.executescript(migration)
assert connection.execute("SELECT * FROM system_users").fetchall() == before
assert connection.execute("SELECT * FROM user_actions").fetchall() == [(17,)]
connection.execute("UPDATE system_users SET role = 'council' WHERE id = 17")
assert connection.execute("SELECT role FROM system_users").fetchone() == ("council",)
+113
View File
@@ -0,0 +1,113 @@
from bs4 import BeautifulSoup
from fastapi.testclient import TestClient
import pytest
from app.config import Settings, get_settings
from app.db import get_db
from app.main import app
from app.models import Employee
from app.parser.profile import extract_sections
from app.security import SESSION_COOKIE, sign_session
from app.services.admin_data import list_employees_page
@pytest.mark.parametrize(
("html", "has_course", "has_recent_course"),
[
('<p>2024 (11 декабря), НИУ ВШЭ. Программа повышения квалификации «ИИ».</p>', True, True),
('<ul><li>Программа повышения квалификации «ДПО», октябрь 2024 года.</li></ul>', True, True),
('<p>Курс повышения квалификации «ДПО», 7 сентября 2024 год.</p>', True, True),
('<p>Курсы повышения квалификации: «Технологии 2025 г.», 2023г.</p>', True, False),
('<p>Программа повышения квалификации «Технологии 2025 г.»</p>', True, False),
('<p>2025. Стажировка «Технологии».</p>', False, False),
('<p>2025. Стажировка «Повышение квалификации педагогов».</p>', False, False),
('<p>2025. Стажировка в Институте повышения квалификации.</p>', False, False),
],
)
def test_training_filter_reads_courses_from_profile_paragraphs_and_lists(
db_session, html, has_course, has_recent_course,
):
url = "https://example.test/employee"
soup = BeautifulSoup(
f"<h2>Дополнительное образование / Повышение квалификации / Стажировки</h2><div>{html}</div>",
"html.parser",
)
db_session.add(Employee(
profile_key="training:text", canonical_url=url,
current_data={"sections": extract_sections(soup, url)},
))
db_session.commit()
assert list_employees_page(db_session, has_training=True)["total"] == int(has_course)
assert list_employees_page(db_session, training_since_year=2024)["total"] == int(has_recent_course)
assert list_employees_page(db_session, training_since_year=2024, has_training=False)["total"] == int(
not has_recent_course
)
def test_training_filter_uses_course_year_and_kind_before_pagination(db_session):
title = "Дополнительное образование / Повышение квалификации / Стажировки"
profiles = [
(title, "2023", "«Курс 2024», повышение квалификации"),
(title, "2024", "«Курс», повышение квалификации, НИУ ВШЭ"),
(title, "2025", "«Курс», Повышение квалификации"),
(title, "2025", "«Стажировка», стажировка"),
("Конференции", "2025", "«Доклад», повышение квалификации"),
(title, "", "«Курс без года», повышение квалификации"),
]
for index, (section_title, year, text) in enumerate(profiles):
soup = BeautifulSoup(
f'<h2>{section_title}</h2><div><div class="with-indent">'
f'<div class="person-list-hangover">{year}</div><span>{text}</span></div></div>',
"html.parser",
)
url = f"https://example.test/{index}"
db_session.add(Employee(
profile_key=f"training:{index}", canonical_url=url, full_name=f"Person {index}",
current_data={
"sections": extract_sections(soup, url),
"positions": ["Доцент: кафедра"] if index == 1 else ["Инженер"],
},
))
db_session.add(Employee(profile_key="training:6", canonical_url="https://example.test/6", full_name="Person 6"))
db_session.commit()
def names(**filters):
return [item["full_name"] for item in list_employees_page(db_session, **filters)["employees"]]
assert names(has_training=True) == ["Person 0", "Person 1", "Person 2", "Person 5"]
assert names(has_training=False) == ["Person 3", "Person 4", "Person 6"]
assert names(training_since_year=2024) == ["Person 1", "Person 2"]
assert names(training_since_year=2024, has_training=False) == [
"Person 0", "Person 3", "Person 4", "Person 5", "Person 6",
]
assert names(training_since_year=2024, pps=True) == ["Person 1"]
assert names(training_since_year=2024, offset=1) == ["Person 2"]
assert names(training_since_year=2024, offset=25) == ["Person 1", "Person 2"]
settings = Settings(_env_file=None, admin_username="admin", admin_password="password", session_secret="test-training")
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
with TestClient(app) as client:
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
response = client.get("/api/employees?has_training=false&training_since_year=2024")
assert response.status_code == 200
assert response.json()["total"] == 5
response = client.get("/admin/directory?has_training=true&training_since_year=2024&offset=1")
assert response.status_code == 200
page = BeautifulSoup(response.text, "html.parser")
panel = page.select_one(".directory__filter-panel")
assert not panel.has_attr("open")
assert panel.summary.get_text(strip=True) == "Фильтры · применены"
assert panel.select_one('select[name="has_training"] option[selected]')["value"] == "true"
assert panel.select_one('input[name="training_since_year"]')["value"] == "2024"
assert "Person 1" not in response.text
assert "Person 2" in response.text
previous = page.select_one(".directory__pagination a")["href"]
assert "has_training=true" in previous
assert "training_since_year=2024" in previous
assert client.get("/admin/directory?has_training=&training_since_year=").status_code == 200
assert client.get("/admin/directory?training_since_year=bad").status_code == 422
finally:
app.dependency_overrides.clear()
+42 -10
View File
@@ -126,13 +126,19 @@ def test_regular_user_cannot_start_scraping(accounts, path):
assert client.get('/api/stats').status_code == 200
def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
def test_council_user_edit_audit_and_personal_history_isolation(accounts):
client, db, settings, admin, user, employee = accounts
act_as(client, user, settings)
candidate = council.council_candidates(db)['candidates'][0]
article = candidate['articles'][0]
data = {'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0,
'title': 'Изменённая статья', 'journal': 'Новый журнал', 'quartile': 'Q2', 'specialty_codes': ['1.2.2']}
act_as(client, user, settings)
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 403
assert client.post(f'/admin/users/{user.id}', data={'role': 'council', 'is_active': 'true'},
headers=act_as(client, admin, settings), follow_redirects=False).status_code == 303
db.refresh(user)
assert user.role == 'council' and user.session_version == 1
act_as(client, user, settings)
response = client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'})
assert response.status_code == 200
action = db.scalar(select(UserAction).where(UserAction.action == 'card_edit'))
@@ -150,6 +156,29 @@ def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
def test_navigation_and_run_access_match_role(accounts, role):
client, db, settings, _, user, _ = accounts
user.role = role
db.commit()
act_as(client, user, settings)
dashboard = BeautifulSoup(client.get('/admin').text, 'html.parser')
links = {link['href'] for link in dashboard.select('nav a[href]')}
assert {'/admin', '/admin/directory', '/admin/account', '/admin/guide'} <= links
assert ('/admin/council' in links) == (role != 'user')
assert ('/admin/runs' in links) == (role == 'admin')
assert ('/admin/users' in links) == (role == 'admin')
assert bool(dashboard.select('[data-progress-panel]')) == (role == 'admin')
assert ('latest_run' in client.get('/api/stats').json()) == (role == 'admin')
for path in ('/admin/runs', '/api/crawl-runs', '/api/crawl-runs/latest', '/api/crawl-runs/1'):
assert client.get(path).status_code == (200 if role == 'admin' else 403)
assert client.get('/admin/runs/1', follow_redirects=False).status_code == (303 if role == 'admin' else 403)
client.cookies.clear()
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/login'
assert client.get('/admin/council').status_code == 200
assert client.get('/docs').status_code == 404
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
client, db, settings, admin, user, _ = accounts
user.password_changed_at = utcnow()
@@ -248,6 +277,8 @@ def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
client, db, settings, _, user, employee = accounts
user.role = 'council'
db.commit()
headers = act_as(client, user, settings)
before = user.password_hash
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
@@ -290,7 +321,7 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
@pytest.mark.parametrize('role', ['user', 'admin'])
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
client, db, settings, _, user, _ = accounts
user.role = role
@@ -306,12 +337,13 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
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 len(parsed.select('[data-guide-step]')) == (2 if role == 'user' else 6)
assert not parsed.select('[data-guide]')
assert ('Прочитайте показатели сотрудника' in guide.text) == (role != 'user')
assert ('Проверьте строку публикации' in guide.text) == (role != 'user')
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 ('не ограничивает «ВСЕГО»' in guide.text) == (role != 'user')
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide]')['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):
@@ -331,7 +363,7 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
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 not BeautifulSoup(client.get('/admin/council').text, 'html.parser').select('[data-guide]')
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login'
user.must_change_password = True
db.commit()
@@ -342,7 +374,7 @@ def test_guide_access_and_no_javascript_completion(accounts):
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"]')
assert page.select_one('[data-guide-fallback] 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