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
codex caae2270dc docs: document collapsed employee news section
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:32:23 +03:00
codex cdf879fdf5 fix: show employee news once in a collapsed section
CI / test (pull_request) Successful in 10m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:31:15 +03:00
codex fd3008dfde docs: document PPS employee filter in changelog
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:27:17 +03:00
codex 87429e463c feat: add PPS employee directory filter
CI / test (pull_request) Successful in 10m47s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:21:25 +03:00
admin ac9b1cebd1 Merge pull request 'Интерактивный подбор диссовета в XLSX и единый расчёт «Всего»' (#72) from feature/council-xlsx-selection into main
CI / test (push) Successful in 10m32s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #72
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-13 07:48:57 +00:00
codex 1c52b2a2c6 fix: reject empty council assessment branch
CI / test (pull_request) Successful in 10m33s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 18:54:26 +03:00
codex 962e936ad1 feat: explain council selection rules on the first XLSX sheet
CI / test (pull_request) Successful in 10m32s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 17:51:43 +03:00
codex 2baa2e1aa1 feat: add offline council selection to XLSX and align totals
CI / test (pull_request) Successful in 10m29s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 16:57:09 +03:00
admin 7c02b0825f Merge pull request 'Подсказки первого входа и руководство по работе с сайтом' (#71) from feature/first-login-guide into main
CI / test (push) Successful in 10m21s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #71
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-12 13:09:11 +00:00
codex e29775b238 fix: normalize guide CSS line endings
CI / test (pull_request) Successful in 10m21s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 15:56:32 +03:00
codex 8e3ea6eeb2 docs: record guide handoff and Gitea connection blocker 2026-10-12 11:08:17 +03:00
codex 5128fac6e5 feat: add first-login usage guide
CI / test (pull_request) Successful in 10m22s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 11:05:04 +03:00
admin 378d7ee96e Merge pull request 'Ограничить отрасли оценки специальностями ВАК' (#70) from fix/council-specialty-branches into main
CI / test (push) Successful in 10m17s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #70
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 22:51:43 +00:00
codex 9d788b0663 fix: handle council specialty changes without JavaScript
CI / test (pull_request) Successful in 10m18s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 01:48:34 +03:00
codex 3359ea2f86 fix: restrict council assessment branches by VAK specialty
CI / test (pull_request) Successful in 10m18s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 01:39:44 +03:00
admin ccebaddfda Merge pull request 'Пользователи системы: роли, временные пароли, личный кабинет и история правок' (#69) from feature/user-management into main
CI / test (push) Successful in 10m12s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #69
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-10 18:11:56 +00:00
51 changed files with 2024 additions and 261 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
## Сделано
- Ветка `feature/council-xlsx-selection`, создана от обновлённого `main` (`7c02b08`) после слияния PR #71. Версия приложения/frontend/backend `1.4.2`.
- «Всего» в XLSX использует `candidate.total_count`, как сайт: Q1/Q2/К1/RSCI, у доктора также К2; самостоятельный К2 у кандидата/PhD исключается, пересечения не увеличивают итог.
- Первый лист «Подбор диссовета»: специальность ВАК, допустимая отрасль с автоматическим выбором совместимой, поиск, степень, статус, сортировка, счётчики, подтверждённый зачёт, минимум и профиль ВШЭ. Без макросов и новых зависимостей.
- Для переключения фильтров выгружаются все сотрудники с учёной степенью и их публикации. Начальные фильтры берутся с сайта; подробные листы сохраняют названия и столбцы. Данные являются снимком; изменения Excel не отправляются на сайт.
- Исходные строки остаются текстом; формулы записываются отдельно. Формат и поведение описаны в README и docs/council-evidence.md.
- Исправлено замечание P2 ревью SHA `962e936`: общий сервис отклоняет явно пустую отрасль до построения результата. Список, карточка и экспорт возвращают 422; отсутствие параметра сохраняет техническую отрасль. Регрессии охватывают пустую/непустую БД и параметры специальности/предыдущего выбора.
- На первом листе добавлена памятка под списком и ссылки к ней/обратно к фильтрам. Памятка поясняет работу с листом, счётчики, К2, подтверждения и пороги, ограничения специальностей, источников, снимка и Excel; правила расчёта и адреса фильтров не менялись.
- Проверки: после исправления P2 полный pytest — 418 passed, 11 skipped; синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed; `git diff --check main` — passed.
- До исправления P2 в установленном Windows Excel 16.0 проверены 14 сценариев на синтетических данных и пустой файл: результаты, сортировка, поиск (включая буквальный *), специальности, совместимость отраслей, степень, статусы и пороги совпали с сервисом сайта. Дополнительно проверены переходы к памятке и к фильтрам; памятка экспортирована в PDF, проверено наличие разделов и завершающей ссылки. Артефакты: `.impeccable/review/council-xlsx/sample-checked.xlsx`, `memo-preview.pdf`, `cases.json`, `check.ps1` (игнорируются Git).
## Остановились на
- Открыт PR #72: https://git.vakanaut.ru/admin/miem_workers/pulls/72. Ревью SHA `962e936` требует исправить один P2; пользователь поручил исправление. Оно готово к коммиту и push в ту же ветку/PR.
- PostgreSQL-тесты пропущены без отдельной тестовой БД; production не изменялся. Тестирование Excel проводилось только на синтетическом наборе.
## Следующий шаг
- Сохранить исправление коммитом и отправить `git push -u origin feature/council-xlsx-selection` в существующий PR #72.
- После публикации остановиться и ждать независимого ревью в отдельной сессии и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
+1
View File
@@ -30,6 +30,7 @@ jobs:
- run: node --check app/static/admin.js
- run: node --check app/static/council.js
- run: node --check app/static/users.js
- run: node --check app/static/guide.js
- run: sh -n infra/postgres-backup.sh
- run: sh -n infra/install-postgres-backup-cron.sh
+30
View File
@@ -1,5 +1,35 @@
# 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`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
## 1.5.0
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
## 0.18.1
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
+109 -97
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,48 +73,48 @@ 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 не создает дубликаты.
## Кандидаты в диссертационный совет
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422. Явно пустая отрасль (`branch=`) также отклоняется для списка, карточки и XLSX; отсутствие параметра сохраняет значение по умолчанию «технические».
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
@@ -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 или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
@@ -134,51 +140,51 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX. Первый лист «Подбор диссовета» работает в Excel Microsoft 365 / Excel 2021 и новее без макросов: выбор специальности ВАК, допустимой отрасли, поиск по ФИО, фильтры по степени и статусу, сортировка по ФИО и счётчикам. Начальные фильтры взяты с сайта. На первом листе ссылка «Открыть памятку по расчёту и использованию» ведёт к инструкции под списком: определения счётчиков и статусов, правило К2, ограничения специальностей и источников, различия снимка XLSX и сайта, работа с формулами. Из памятки можно вернуться к фильтрам. При несовместимой отрасли после смены специальности применяется первая допустимая; она показана рядом. Список показывает Q1/Q2/К1/К2/RSCI, «Всего», подтверждённый зачёт, минимум и статус; ссылки ведут на публичные профили ВШЭ. Для переключения фильтров в файл включены все сотрудники с учёной степенью, даже если страница сайта была отфильтрована. Компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов) также содержат полный набор данных, а правила и исходные фильтры вынесены в «Параметры». Служебные данные и справочники находятся на скрытых листах. Внешние ссылки проверяются; исходные строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся вспомогательным подбором, а не условием соответствия пунктам 5–6. XLSX хранит снимок на момент выгрузки: новые данные и ручные подтверждения требуют повторной выгрузки; изменения фильтров в Excel не отправляются на сайт.
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» совпадает с предварительным счётчиком сайта: уникальное объединение Q1/Q2/К1/RSCI, для доктора наук также К2. Для кандидата/PhD самостоятельный К2 исключается; работа с другим подходящим основанием учитывается один раз. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
После изменения 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.
## Проверки
@@ -285,3 +291,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
файлы с LF.
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
публикаций из БД продолжают отображаться без перезапуска.
### Подсказки первого входа
После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
+37 -14
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()
@@ -113,6 +118,9 @@ def directory(
started_to: str | None = None,
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,
@@ -134,6 +142,9 @@ def directory(
started_to=parsed_started_to,
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,
@@ -154,6 +165,9 @@ def directory(
"started_to": started_to or "",
"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"],
@@ -169,13 +183,13 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
authenticated = optional_user(request, settings, db)
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
branch=branch)
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
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)
@@ -192,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):
@@ -210,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"]}
@@ -232,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":
@@ -303,8 +319,13 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
branch=branch)
content = council_xlsx(result)
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
selection_results = [
result if b == result["branch"] and not result["q"] and not result["specialty"] else
council_candidates(db, sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
for b in branches]
content = council_xlsx(result, selection_results)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
@@ -363,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()
@@ -377,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)
@@ -442,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,
+12 -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
@@ -29,6 +29,9 @@ def list_employees(
started_to: date | None = None,
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,
@@ -45,6 +48,9 @@ def list_employees(
started_to=started_to,
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,
@@ -73,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]}
@@ -84,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)}
@@ -97,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"}
@@ -130,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:
+58 -12
View File
@@ -9,51 +9,97 @@
"specialties": [
{
"code": "1.2.2",
"name": "Математическое моделирование, численные методы и комплексы программ"
"name": "Математическое моделирование, численные методы и комплексы программ",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.2.2",
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.6",
"name": "Методы и системы защиты информации, информационная безопасность"
"name": "Методы и системы защиты информации, информационная безопасность",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.1",
"name": "Системный анализ, управление и обработка информации, статистика"
"name": "Системный анализ, управление и обработка информации, статистика",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.7",
"name": "Компьютерное моделирование и автоматизация проектирования"
"name": "Компьютерное моделирование и автоматизация проектирования",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "1.3.8",
"name": "Физика конденсированного состояния"
"name": "Физика конденсированного состояния",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.2.15",
"name": "Системы, сети и устройства телекоммуникаций"
"name": "Системы, сети и устройства телекоммуникаций",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "1.1.2",
"name": "Дифференциальные уравнения и математическая физика"
"name": "Дифференциальные уравнения и математическая физика",
"branches": [
"физико-математические"
]
},
{
"code": "2.2.13",
"name": "Радиотехника, в том числе системы и устройства телевидения"
"name": "Радиотехника, в том числе системы и устройства телевидения",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.2",
"name": "Вычислительные системы и их элементы"
"name": "Вычислительные системы и их элементы",
"branches": [
"технические"
]
},
{
"code": "2.2.14",
"name": "Антенны, СВЧ-устройства и их технологии"
"name": "Антенны, СВЧ-устройства и их технологии",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.5",
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей",
"branches": [
"технические",
"физико-математические"
]
}
],
"journals": [
+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)
+16 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import SystemUser, utcnow
from app.models import SystemUser, UserAction, utcnow
SESSION_COOKIE = "miem_admin_session"
LOGIN_CSRF_COOKIE = "miem_login_csrf"
@@ -138,6 +138,10 @@ def optional_user(request: Request, settings: Settings, db: Session, *, allow_pa
user = db.scalar(query)
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
user = ensure_bootstrap_admin(db, settings)
if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password
and request.method == 'GET' and request.url.path.startswith('/admin')):
request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start'
except SQLAlchemyError:
db.rollback()
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
@@ -161,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':
+78 -11
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"))
@@ -116,6 +155,9 @@ def list_employees_page(
started_to: date | None = None,
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,
@@ -131,12 +173,36 @@ def list_employees_page(
has_email=has_email,
has_academic_degree=has_academic_degree,
)
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
if offset >= total:
offset = 0
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
order = desc(sort_column) if direction == "desc" else sort_column
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
ordered_stmt = base_stmt.order_by(order)
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
else:
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
if offset >= total:
offset = 0
employees = db.scalars(ordered_stmt.limit(limit).offset(offset)).all()
return {
"employees": [employee_display_payload(employee) for employee in employees],
"total": total,
@@ -147,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(
@@ -159,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:
+15 -4
View File
@@ -253,14 +253,24 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
branch: str = "технические", employee_id: int | None = None) -> dict:
if branch and branch not in BRANCHES:
branch: str = "технические", employee_id: int | None = None,
previous_specialty: str | None = None) -> dict:
if branch not in BRANCHES:
raise CouncilInputError("Неизвестная отрасль науки")
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
specialties = _k1_specialties()["specialties"]
if specialty and specialty not in {s["code"] for s in specialties}:
raise CouncilInputError("Неизвестная специальность ВАК")
branches = list(dict.fromkeys(name for s in specialties if not specialty or s["code"] == specialty
for name in s["branches"]))
if branch and branch not in branches:
previous_branches = {name for s in specialties if previous_specialty == "" or s["code"] == previous_specialty
for name in s["branches"]}
if previous_specialty is not None and previous_specialty != specialty and branch in previous_branches:
branch = branches[0]
else:
raise CouncilInputError("Отрасль оценки не относится к выбранным специальностям ВАК")
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
degree_sections = Employee.current_data["sections"]
if db.get_bind().dialect.name == "postgresql":
@@ -421,13 +431,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
limit = 25
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
criteria_groups = {}
for name, (minimum, monographs) in BRANCHES.items():
for name in branches:
minimum, monographs = BRANCHES[name]
criteria_groups.setdefault((minimum, monographs), []).append(name)
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"branch": branch, "branches": list(BRANCHES),
"branch": branch, "branches": branches,
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
for path in (K1_JOURNALS, K2_JOURNALS)
+189 -9
View File
@@ -1,11 +1,22 @@
"""Compact XLSX report from council data, with an explicit Q1/Q2/K1 total."""
"""Offline council selection and detailed XLSX report from the site's assessment."""
from io import BytesIO
from textwrap import fill
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.hyperlink import Hyperlink
from openpyxl.workbook.defined_name import DefinedName
from app.version import BACKEND_VERSION, FRONTEND_VERSION
from app.services.council_source import _name
SORT_LABELS = {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
"k2_count": "К2", "rsci_count": "RSCI", "total_count": "Всего",
"confirmed_count": "Подтверждено", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}
def _append(sheet, values):
@@ -13,9 +24,13 @@ def _append(sheet, values):
if any(isinstance(value, str) and len(value) > 32767 for value in values):
raise ValueError("Text exceeds Excel cell limit")
sheet.append(values)
for column, value in enumerate(values, 1):
if isinstance(value, str):
# Source text is never executable; formulas are written separately.
sheet.cell(sheet._current_row, column).data_type = "s"
def council_xlsx(result: dict) -> bytes:
def council_xlsx(result: dict, selection_results: list[dict]) -> bytes:
workbook = Workbook()
workbook.properties.creator = "MIEM Employees"
employees = workbook.active
@@ -30,9 +45,10 @@ def council_xlsx(result: dict) -> bytes:
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
for number, candidate in enumerate(result["candidates"], 1):
all_candidates = next(data["candidates"] for data in selection_results if data["branch"] == result["branch"])
for number, candidate in enumerate(all_candidates, 1):
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
total = sum(article["q1"] or article["q2"] or article["k1"] for article in candidate["articles"])
total = candidate["total_count"]
_append(employees, [number, candidate["name"], candidate["selected_degree"],
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
@@ -56,7 +72,8 @@ def council_xlsx(result: dict) -> bytes:
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
["Сотрудников в выгрузке", len(all_candidates)], ["Найдено с исходными фильтрами", result["matched"]],
["Всего сотрудников в списке", result["total"]],
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
@@ -65,7 +82,8 @@ def council_xlsx(result: dict) -> bytes:
"Без названия журнала К1 не подтверждён."],
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Всего", "Число уникальных работ с хотя бы одним основанием Q1 ИЛИ Q2 ИЛИ К1. Пересечения учитываются один раз. К2 и RSCI самостоятельно не увеличивают этот столбец; итог может отличаться от общего счётчика на сайте."],
["Всего", "Как на сайте: уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Для кандидата/PhD одного К2 недостаточно. Пересечения учитываются один раз. Это предварительный счётчик; подтверждённый зачёт показан отдельно."],
["Интерактивный подбор", "Первый лист содержит всех сотрудников с учёной степенью на момент выгрузки. Специальность, отрасль, поиск, степень, статус и сортировка меняются в Excel без макросов. Начальные фильтры взяты с сайта; подробные листы содержат полный набор сотрудников и публикаций."],
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
@@ -93,13 +111,175 @@ def council_xlsx(result: dict) -> bytes:
sheet.column_dimensions[get_column_letter(column)].width = width
for row in sheet:
for cell in row:
if isinstance(cell.value, str):
# CSV/profile content is text, never an Excel formula or error code.
cell.data_type = "s"
cell.alignment = Alignment(vertical="top", wrap_text=True)
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="263640")
_selection(workbook, result, selection_results)
output = BytesIO()
workbook.save(output)
return output.getvalue()
def _selection(workbook, result, selection_results):
sheet = workbook.create_sheet("Подбор диссовета", 0)
source = workbook.create_sheet("_Данные подбора")
options = workbook.create_sheet("_Справочники подбора")
specialties = result["specialties"]
labels = ["Все специальности", *[f"{s['code']} — {s['name']}" for s in specialties]]
branches = [data["branch"] for data in selection_results]
_append(options, ["Специальность", "Список отраслей", "", "Отрасль 1", "Отрасль 2"])
for row, label in enumerate(labels, 2):
allowed = branches if row == 2 else [b for b in specialties[row - 3]["branches"] if b in branches]
name = f"SelectionBranches{row}"
_append(options, [label, name, "", *allowed])
workbook.defined_names.add(DefinedName(name, attr_text=f"'{options.title}'!$D${row}:${get_column_letter(3 + len(allowed))}${row}"))
workbook.defined_names.add(DefinedName("SelectionSpecialties", attr_text=f"'{options.title}'!$A$2:$A${len(labels) + 1}"))
workbook.defined_names.add(DefinedName("SelectionOptions", attr_text=f"'{options.title}'!$A$2:$B${len(labels) + 1}"))
headers = ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "RSCI", "Всего", "Подтверждено",
"Минимум", "Статус", "Степень подтверждена", "ЛК сотрудника ВШЭ", "Все публикации", "Специальности ВАК", "Профиль ВШЭ"]
flag_end = get_column_letter(18 + len(specialties))
score, rank, matches, level, search_name = [get_column_letter(19 + len(specialties) + i) for i in range(5)]
_append(source, [*headers, "Отрасль", *[s["code"] for s in specialties], "Сортировка", "Порядок", "Подходит", "Уровень степени", "ФИО для поиска"])
numbers = {c["employee_id"]: n for n, c in enumerate(selection_results[0]["candidates"], 1)}
for data in selection_results:
for candidate in sorted(data["candidates"], key=lambda c: _name(c["name"])):
codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
_append(source, [numbers[candidate["employee_id"]], candidate["name"], candidate["selected_degree"],
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2", "rsci")], candidate["total_count"],
candidate["confirmed_count"], candidate["minimum"], candidate["assessment_status"],
"Да" if candidate["degree_confirmed"] else "Нет", candidate["db_count"], candidate["count"],
"\n".join(label for s, label in zip(specialties, labels[1:]) if s["code"] in codes) or "Не подтверждены",
candidate["profile_url"] or "", data["branch"], *[int(s["code"] in codes) for s in specialties],
None, None, None, candidate["degree_level"], _name(candidate["name"])])
for address, value in {"A1": "Подбор кандидатов в диссертационный совет", "A3": "Специальность ВАК",
"A4": "Отрасль оценки", "E4": "Применяется", "A5": "Поиск по ФИО", "A6": "Учёная степень",
"J3": "Сортировать по", "J4": "Направление", "J5": "Статус", "J6": "Найдено",
"C3": next((label for s, label in zip(specialties, labels[1:]) if s["code"] == result["specialty"]), labels[0]),
"C4": result["branch"], "C5": result["q"], "C6": "Все степени", "K3": SORT_LABELS[result["sort"]],
"K4": "По убыванию" if result["direction"] == "desc" else "По возрастанию", "K5": "Все статусы",
"A2": f"Данные на момент выгрузки · период {result['start'].year}–{result['end'].year} · Backend {BACKEND_VERSION} · Frontend {FRONTEND_VERSION}",
"A7": "Всего: Q1/Q2/К1/RSCI; у доктора также К2. Статус и подтверждённый зачёт оцениваются отдельно.",
"A8": "Подбор по связи журнала со специальностью не доказывает тематику каждой статьи или включение в совет. Подробности — на листах Сотрудники и Публикации."}.items():
sheet[address] = value
sheet[address].data_type = "s"
for area in ("A1:Q1", "A2:Q2", "A3:B3", "A4:B4", "A5:B5", "A6:B6", "C3:I3", "C4:D4", "F4:I4", "C5:I5", "C6:I6", "K3:Q3", "K4:Q4", "K5:Q5", "A7:Q7", "A8:Q8"):
sheet.merge_cells(area)
allowed = 'INDIRECT(VLOOKUP($C$3,SelectionOptions,2,FALSE))'
sheet["F4"] = f'=IFERROR(IF(COUNTIF({allowed},$C$4)>0,$C$4,INDEX({allowed},1)),"Недопустимая специальность")'
for address, formula in {"C3": "=SelectionSpecialties", "C4": f"={allowed}",
"C6": '"Все степени,Доктор наук,Кандидат / PhD"', "K3": '"' + ','.join(SORT_LABELS.values()) + '"',
"K4": '"По возрастанию,По убыванию"', "K5": '"Все статусы,Соответствует критериям,Требует проверки"'}.items():
validation = DataValidation(type="list", formula1=formula, allow_blank=False, showErrorMessage=True,
errorStyle="stop", errorTitle="Выберите значение из списка", error="Используйте доступные варианты.")
sheet.add_data_validation(validation)
validation.add(sheet[address])
last = max(2, source._current_row)
sheet["K6"] = f'=COUNTIF(\'{source.title}\'!${matches}$2:${matches}${last},1)'
controls = "'Подбор диссовета'!"
search = f'SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(TRIM(LOWER({controls}$C$5)),"ё","е"),"~","~~"),"*","~*"),"?","~?")'
for row in range(2, source._current_row + 1):
source[f"{score}{row}"] = f'=INDEX($A{row}:$Q{row},1,MATCH({controls}$K$3,{controls}$A$9:$Q$9,0))'
comparison = f'IF({controls}$K$4="По убыванию",">","<")'
scores, counts = f'${score}$2:${score}${last}', f'$O$2:$O${last}'
source[f"{rank}{row}"] = (f'=IF({controls}$K$3="ФИО",IF({controls}$K$4="По убыванию",{last + 1}-ROW(),ROW()-1),'
f'COUNTIF({scores},{comparison}&{score}{row})+COUNTIFS({scores},{score}{row},{counts},{comparison}&O{row})'
f'+COUNTIFS(${score}$2:${score}{row},{score}{row},$O$2:$O{row},O{row}))')
source[f"{matches}{row}"] = (f'=--AND($R{row}={controls}$F$4,'
f'OR({controls}$C$3="Все специальности",IFERROR(INDEX($S{row}:${flag_end}{row},1,MATCH({controls}$C$3,'
f"'{options.title}'!$A$3:$A${len(labels) + 1},0)),0)=1),"
f'ISNUMBER(SEARCH({search},${search_name}{row})),'
f'OR({controls}$K$5="Все статусы",IF({controls}$K$5="Соответствует критериям",LEFT($L{row},13)="Соответствует",$L{row}="Требует проверки")),'
f'OR({controls}$C$6="Все степени",${level}{row}=IF({controls}$C$6="Доктор наук",2,1)))')
for column, header in enumerate(headers, 1):
sheet.cell(9, column, header)
ranks = f"'{source.title}'!${rank}$2:${rank}${last}"
match_range = f"'{source.title}'!${matches}$2:${matches}${last}"
for row in range(10, 10 + max(1, len(numbers))):
sheet[f"S{row}"] = f'=IFERROR(MATCH(_xlfn.AGGREGATE(15,6,{ranks}/({match_range}=1),ROWS($S$10:S{row})),{ranks},0),"")'
for column in range(1, 18):
value = f"INDEX('{source.title}'!$A$2:$Q${last},$S{row},{column})"
if column == 17:
value = f'IF({value}="","",HYPERLINK({value},"Профиль ВШЭ"))'
sheet.cell(row, column, f'=IF($S{row}="","",{value})')
sheet.column_dimensions["S"].hidden = True
widths = [9, 34, 35, 9, 9, 9, 9, 9, 12, 16, 12, 42, 20, 22, 22, 65, 22]
for column, width in enumerate(widths, 1):
sheet.column_dimensions[get_column_letter(column)].width = width
for row in sheet.iter_rows(max_col=17):
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
cell.font = Font(name="Calibri", size=11, color="263640")
for row in (1, 9):
for cell in sheet[row][:17]:
cell.fill = PatternFill("solid", fgColor="263640")
cell.font = Font(name="Calibri", size=15 if row == 1 else 11, bold=True, color="FFFFFF")
for address in ("C3", "C4", "C5", "C6", "K3", "K4", "K5"):
sheet[address].fill = PatternFill("solid", fgColor="E7F0EB")
for row in range(1, 10):
sheet.row_dimensions[row].height = 32
sheet.freeze_panes = "D10"
sheet.sheet_view.zoomScale = 85
workbook.active = 0
workbook.calculation.fullCalcOnLoad = True
workbook.calculation.forceFullCalc = True
workbook.calculation.calcMode = "auto"
source.sheet_state = options.sheet_state = "hidden"
memo_row = 12 + max(1, len(numbers))
sheet["A7"] = "Открыть памятку по расчёту и использованию"
sheet["A7"].hyperlink = Hyperlink(ref="A7", location=f"'Подбор диссовета'!A{memo_row}")
sheet["A7"].font = Font(name="Calibri", size=11, color="0563C1", underline="single")
memo = [
("Памятка: как пользоваться подбором",
"1. Меняйте зелёные поля: специальность ВАК и отрасль, поиск по ФИО, степень, статус, сортировку и направление. Список обновляется автоматически.\n"
"2. После смены специальности посмотрите поле «Применяется»: если прежняя отрасль несовместима, используется первая допустимая.\n"
"3. Для просмотра всех сотрудников выберите «Все специальности», «Все степени» и «Все статусы», очистите поиск. Чтобы искать потенциальных кандидатов, оставьте «Все статусы»: сведения могут требовать проверки.\n"
"4. «Найдено» показывает число строк текущего подбора. «Профиль ВШЭ» открывает внешний сайт и требует подключения к Интернету."),
("Какие данные включены",
"Файл содержит всех сотрудников с обнаруженной или документально подтверждённой степенью доктора, кандидата наук или PhD на момент выгрузки. Отсутствие публикаций не исключает сотрудника. Из истории степеней выбирается высшая текущая: доктор выше кандидата/PhD. Уволенные сотрудники также могут присутствовать; актуальность работы и возможность участия проверяйте на сайте и по документам."),
("Период и виды работ",
f"Оценка охватывает пять календарных лет: {result['start'].year}–{result['end'].year}. День и месяц не учитываются. Границы зафиксированы при выгрузке и не сдвигаются при открытии файла в следующем году. Известные другие виды материалов по умолчанию исключаются из счётчиков; для их допуска нужно отдельное проверенное основание. Неизвестный вид может попадать в предварительный счётчик, но для подтверждённого зачёта нужно установить вид работы."),
("Q1, Q2, К1, К2 и RSCI",
"Каждый счётчик показывает уникальные работы с соответствующим основанием в пятилетнем периоде. Q1/Q2 — применимые сведения о квартиле из источников и каталогов; К1/К2 — сопоставление журнала со справочником ВАК либо проверенная запись. RSCI требует отдельного основания: отметка РИНЦ его не заменяет. Одна работа может одновременно входить в несколько счётчиков; складывать их для получения итога нельзя."),
("Как считается «Всего»",
"Предварительный итог, как на сайте: уникальные работы Q1 ИЛИ Q2 ИЛИ К1 ИЛИ RSCI; для доктора наук также К2. Для кандидата/PhD работа с одним К2 не входит в итог. Если у неё есть другое допустимое основание, она учитывается по нему. Пересечения учитываются один раз. Само попадание в «Всего» не подтверждает выполнение требований к составу совета."),
("«Подтверждено» и подтверждение степени",
"«Подтверждено» — работы с допустимым видом, согласованным годом в периоде и проверенным применимым основанием без существенных конфликтов источников. Известная статья с журналом и Q1/Q2 за её год может засчитываться автоматически. Для К1/К2/RSCI требуется отдельная запись проверки работы. Ручная правка журнала или квартиля сама по себе не подтверждает зачёт. «Степень подтверждена» проверяется отдельно; обнаруженная степень в профиле не заменяет документальное подтверждение."),
("«Минимум» и «Статус»",
"Для доктора минимум: 9 подтверждённых публикаций по технической отрасли, 11 по физико-математической. Для кандидата/PhD — 20. Положительный статус требует достаточного подтверждённого зачёта и подтверждения высшей текущей степени. «Требует проверки» может означать нехватку подтверждений или найденных работ; это не доказательство отсутствия публикаций. Статус оценивает публикационные критерии и не является решением о включении в совет."),
("«ЛК сотрудника ВШЭ» и «Все публикации»",
"Эти счётчики охватывают все годы и категории. «ЛК сотрудника ВШЭ» — уникальные работы из личного кабинета. «Все публикации» — объединение личного кабинета и выгрузки ВШЭ без дублей. Они не равны пятилетнему «Всего» и не сравниваются с публикационным минимумом."),
("Специальности ВАК и ручная связь",
f"Подбор ограничен {len(specialties)} специальностями сайта. Сотрудник отбирается, если хотя бы одна подходящая работа за пять лет имеет справочную связь журнала К1 или ручную связь с выбранной специальностью. Числовые счётчики при этом включают все работы сотрудника по своим правилам, а не только работы этой специальности. «Не подтверждены» означает отсутствие кодов по этим правилам, а не отсутствие научной специальности у сотрудника. Ручная связь задаётся на сайте для журнала и применяется ко всем его статьям; она не подтверждает содержание каждой статьи по паспорту специальности."),
("Что не проверяется автоматически",
"Полнота публикаций, содержание каждой статьи по паспорту, актуальность работы сотрудника и все остальные условия включения в диссовет требуют отдельной проверки. Подробные причины и источники смотрите на сайте. Текущая категория ВАК не доказывает категорию в год статьи. Каталоги имеют ограниченный охват и годы метрик; они не подтверждают текущую индексацию или отсутствие журнала в других базах."),
("Почему цифры могут расходиться",
"Сравнивайте одинаковые период, фильтры и снимок данных. Неполные профили, совпадающие ФИО, отсутствие DOI, разные названия журналов и конфликты годов могут мешать присвоению или объединению работ. При одинаковых ФИО данные выгрузки ВШЭ не присваиваются автоматически. Необычные символы и пробелы поиска в Excel могут обрабатываться иначе, чем на сайте. «Сотрудники» и «Публикации» содержат полный набор и не фильтруются вместе с первым листом; «Параметры» фиксируют исходные настройки выгрузки."),
("Обновление данных и работа в Excel",
"XLSX — снимок: обновления сайта и ручные подтверждения попадут в него только при новой выгрузке. Изменения в Excel не отправляются на сайт и не создают подтверждений. Используйте Excel Microsoft 365 / Excel 2021 или новее с автоматическим пересчётом формул; макросы для подбора не нужны. Если список не обновился, включите автоматический расчёт в настройках формул. Меняйте зелёные поля, сохраняйте формулы таблицы и скрытые служебные листы, иначе подбор может перестать работать."),
]
for title, text in memo:
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
cell = sheet.cell(memo_row, 1, title)
cell.font = Font(name="Calibri", size=12, bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="263640")
cell.alignment = Alignment(vertical="center")
sheet.row_dimensions[memo_row].height = 28
memo_row += 1
text = "\n".join(fill(paragraph, width=90, break_on_hyphens=False) for paragraph in text.splitlines())
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
cell = sheet.cell(memo_row, 1, text)
cell.font = Font(name="Calibri", size=11, color="263640")
cell.alignment = Alignment(vertical="top", wrap_text=True)
sheet.row_dimensions[memo_row].height = (text.count("\n") + 1) * 15 + 10
memo_row += 3
cell = sheet.cell(memo_row, 1, "Вернуться к фильтрам")
cell.hyperlink = Hyperlink(ref=cell.coordinate, location="'Подбор диссовета'!A1")
cell.font = Font(name="Calibri", size=11, color="0563C1", underline="single")
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
sheet.row_dimensions[memo_row].height = 28
+126 -8
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 {
@@ -281,6 +287,20 @@
border-radius: 8px;
}
.employee-card__news-toggle {
min-height: 44px;
align-content: center;
cursor: pointer;
}
.employee-card__news-toggle .employee-section__title {
display: inline;
}
.employee-card__section--news[open] .employee-card__news-toggle {
margin-bottom: 12px;
}
.employee-card__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -544,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;
@@ -586,6 +614,10 @@
font-weight: 700;
}
.directory__filter-hint {
color: #4b5563;
}
.directory__input {
box-sizing: border-box;
width: 100%;
@@ -598,6 +630,15 @@
border-radius: 6px;
}
.directory__checkbox {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-size: 14px;
cursor: pointer;
}
.directory__filter-actions {
display: flex;
align-items: end;
@@ -760,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;
@@ -779,6 +826,10 @@
}
@media (max-width: 620px) {
.admin {
--admin-gutter: 16px;
}
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
@@ -789,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 {
@@ -966,3 +1015,72 @@
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
.guide {
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--page { max-width: 75ch; }
.guide--page .guide__step + .guide__step { margin-block-start: 32px; }
+10 -2
View File
@@ -1,7 +1,14 @@
(() => {
const specialty = document.querySelector('#council-specialty');
const branch = document.querySelector('#council-branch');
specialty?.addEventListener('change', () => {
const names = JSON.parse(specialty.selectedOptions[0].dataset.branches);
const previous = branch.value;
branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name)));
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', () => {
@@ -141,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(); });
});
+267
View File
@@ -0,0 +1,267 @@
(() => {
const guide = document.querySelector('[data-guide]');
if (!guide) return;
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 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 (guide.dataset.guidePending !== 'true') {
hide();
return;
}
saving = true;
guide.setAttribute('aria-busy', 'true');
close.disabled = next.disabled = back.disabled = 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': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error();
guide.dataset.guidePending = 'false';
hide();
} catch (_failure) {
error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
error.hidden = unsaved.hidden = false;
unsaved.focus();
} finally {
saving = false;
guide.removeAttribute('aria-busy');
close.disabled = next.disabled = false;
back.disabled = index === 0;
}
}
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 (paused) goTo(index);
else if (index === steps.length - 1) finish();
else goTo(index + 1);
});
close.addEventListener('click', finish);
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>
+12 -2
View File
@@ -1,3 +1,4 @@
{% set is_admin = current_user and current_user.role == 'admin' %}
<!doctype html>
<html lang="ru">
<head>
@@ -17,10 +18,13 @@
{% 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">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
@@ -29,11 +33,17 @@
</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) %}
<script src="/static/guide.js?v={{ frontend_version }}" defer></script>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
+9 -8
View File
@@ -5,7 +5,7 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
</div>
</header>
@@ -26,7 +26,7 @@
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.</p>
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.</p>
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
@@ -34,6 +34,7 @@
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<input type="hidden" name="previous_specialty" value="{{ specialty }}">
<label class="directory__field" for="council-branch">Отрасль оценки
<select class="directory__input" id="council-branch" name="branch">
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
@@ -41,9 +42,9 @@
</label>
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
<option value="" data-branches='{{ specialties|map(attribute="branches")|sum(start=[])|unique|list|tojson }}'>Все специальности</option>
{% for item in specialties %}
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
<option value="{{ item.code }}" data-branches='{{ item.branches|tojson }}'{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
{% endfor %}
</select>
</label>
@@ -80,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>
+18 -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>
@@ -35,6 +38,19 @@
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
</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>
@@ -63,6 +79,7 @@
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
</div>
</form>
</details>
<details class="directory__help">
<summary>Что означают даты и статусы</summary>
@@ -112,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.started_from or filters.started_to %}
{% if has_filters %}
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
{% else %}
+6 -5
View File
@@ -106,8 +106,8 @@
{% endif %}
{% if employee_view.news_links %}
<section class="employee-card__section">
<h3 class="employee-section__title">В новостях</h3>
<details class="employee-card__section employee-card__section--news">
<summary class="employee-card__news-toggle"><h3 class="employee-section__title">В новостях</h3></summary>
<ul class="employee-card__list">
{% for news in employee_view.news_links %}
<li class="employee-card__list-item">
@@ -121,14 +121,15 @@
</li>
{% endfor %}
</ul>
</section>
</details>
{% endif %}
<section class="employee-card__section">
<h3 class="employee-section__title">Разделы профиля</h3>
{% if employee_view.sections %}
{% set profile_sections = employee_view.sections | rejectattr('type', 'equalto', 'news') | list %}
{% if profile_sections %}
<div class="employee-card__sections">
{% for section in employee_view.sections %}
{% for section in profile_sections %}
<article class="employee-section">
<div class="employee-section__header">
<h4 class="employee-section__title">{{ section.title }}</h4>
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
{% block content %}
<article class="panel guide--page">
<h2 class="panel__title">Как пользоваться сайтом</h2>
{% include 'guide_content.html' %}
<form method="post" action="/admin/guide/dismiss">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button" type="submit">Завершить подсказки</button>
</form>
</article>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
<p>Сайт помогает найти сотрудника и изучить его профиль{% if council_access %}, а также подобрать кандидатов в диссертационный совет{% endif %}.</p>
<dl class="guide__fields">
<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 is_admin %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% endif %}
<p>Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
<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>
</dl>
<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">
<dt>Специальность ВАК</dt><dd>Выберите код и название либо «Все специальности». Например, 2.3.2 — вычислительные системы и их элементы.</dd>
<dt>Отрасль оценки</dt><dd>Выберите отрасль предполагаемого включения в совет. Для 1.1.2 доступны только физико-математические науки, для 2.3.2 — технические, для остальных специальностей сайта — обе отрасли.</dd>
<dt>Поиск по ФИО</dt><dd>Введите фамилию или часть полного имени, чтобы сузить список.</dd>
<dt>Сортировать по / Направление</dt><dd>Например, выберите «Подтверждено» и «По убыванию», затем нажмите «Применить». Получится список сотрудников в заданном порядке.</dd>
</dl>
<p>Фильтр специальности ищет сотрудников с подходящей связью журнала. Он не доказывает тематику каждой статьи и не ограничивает «ВСЕГО» только работами по выбранной специальности.</p>
<p>Показатели рассчитаны за пять календарных лет, включая текущий год. Для подготовки ходатайства отдельно проверьте требуемый период и документальные основания. Подробности доступны в блоке «Как рассчитываются показатели».</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Прочитайте показатели сотрудника</h3>
<p>В строке сотрудника показаны ФИО, обнаруженные степени, отметка об увольнении при наличии, результат оценки и счётчики. Откройте строку, чтобы загрузить публикации и причины дополнительной проверки.</p>
<dl class="guide__fields">
<dt>Q1, Q2, К1</dt><dd>Количество работ с соответствующим основанием. Одна работа может попадать в несколько этих счётчиков.</dd>
<dt>ВСЕГО (предв.)</dt><dd>Уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Одна работа учитывается один раз. Для кандидата наук или PhD одной категории К2 недостаточно.</dd>
<dt>Подтверждено</dt><dd>Работы с проверенными видом материала, периодом и основанием зачёта. Именно это число сравнивается с минимумом: 9 для доктора по технической отрасли, 11 по физико-математической, 20 для кандидата наук или PhD.</dd>
<dt>ЛК сотрудника ВШЭ</dt><dd>Публикации из личного кабинета сотрудника за все годы.</dd>
<dt>Все публикации</dt><dd>Работы из личного кабинета и выгрузки ВШЭ, объединённые без дублей за все годы и независимо от категории.</dd>
</dl>
<p>При раскрытии также показаны К2, RSCI, подтверждение степени, причины статуса и ссылка на профиль ВШЭ. «Требует проверки» означает нехватку подтверждений; выполнение публикационного минимума не гарантирует включение в совет.</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Проверьте строку публикации</h3>
<dl class="guide__fields">
<dt>Название и год</dt><dd>Помогают определить работу и период. Неизвестный или спорный год отмечен подсказкой.</dd>
<dt>Источник и тип материала</dt><dd>«ЛК сотрудника ВШЭ» или «Данные ВШЭ» показывают происхождение записи; тип отличает статью от книги и других материалов.</dd>
<dt>Журнал и квартиль</dt><dd>Название издания и Q1–Q4 при наличии. Квартиль должен быть применим к году статьи; К1/К2 — отдельные категории ВАК, а не квартили.</dd>
<dt>DOI и Публикация на сайте</dt><dd>Ссылки на исходную работу, если они доступны.</dd>
<dt>Основания и специальность</dt><dd>В раскрытой карточке сотрудника приведены источники зачёта, годы метрик, данные каталогов, ISSN и ссылки на паспорта при наличии. При выборе специальности показаны сведения о тематическом соответствии.</dd>
</dl>
<p>Кнопка «Уточнение» рядом с полем объясняет, что требует проверки. Её можно открыть мышью или клавишей Enter. Предупреждение само по себе не означает, что публикации нет.</p>
<p>В разделе «Статьи, журналы и квартили» показана сокращённая строка: сотрудник, название, год, тип, журнал, квартиль и специальности журнала. Там можно искать по ФИО, названию статьи и журналу.</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Исправьте сведения и выгрузите результат</h3>
<p>Войдя в систему, выберите строку публикации и откройте «Редактировать статью». С клавиатуры: перейдите на строку, нажмите Enter, затем активируйте кнопку редактирования.</p>
<dl class="guide__fields">
<dt>Название статьи / Название журнала</dt><dd>Введите проверенные названия. Для журнала можно выбрать предложение из списка.</dd>
<dt>Квартиль</dt><dd>Выберите подтверждённый Q1–Q4. Если год неизвестен, поле недоступно. Изменение квартиля затронет все статьи этого журнала за тот же год.</dd>
<dt>Специальности журнала</dt><dd>Оставьте связь из справочников или задайте ручную. Ручной выбор заменяет связь для всех статей журнала за все годы; снятие всех специальностей исключает связь.</dd>
<dt>Сохранить / Отмена</dt><dd>Сохранение обновит показатели и запишет действие в историю. Ручная правка не заменяет подтверждение источником для зачёта работы.</dd>
<dt>Скачать XLSX</dt><dd>В разделе «Диссертационный совет» выгружает параметры оценки, всех найденных сотрудников и их публикации, включая другие страницы списка.</dd>
</dl>
<p>Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».</p>
</section>
{% 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 %}
+2 -2
View File
@@ -1,10 +1,10 @@
{% 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 %}
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа'} %}
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа', 'guide_start': 'Подсказки первого входа доступны', 'guide_finish': 'Подсказки первого входа закрыты'} %}
{% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
<div class="users__table-wrap">
<table class="table users__history-table">
+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 %}
+31 -5
View File
@@ -1,6 +1,6 @@
import re
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import desc, func, or_, select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session
@@ -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)
@@ -157,6 +157,7 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
if new_password != confirmation:
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
try:
first_password_change = user.password_changed_at is None
user.password_hash = hash_password(new_password)
user.must_change_password = False
user.password_changed_at = utcnow()
@@ -164,6 +165,8 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
user.failed_login_attempts = 0
user.locked_until = None
db.add(UserAction(user_id=user.id, action='password_change'))
if first_password_change:
db.add(UserAction(user_id=user.id, action='guide_start'))
db.commit()
except SQLAlchemyError:
db.rollback()
@@ -172,3 +175,26 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
return response
@router.get('/guide')
def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_user(request, settings, db)
return _render(request, 'guide.html', {'guide_page': True})
@router.post('/guide/dismiss')
def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
user = require_user(request, settings, db, lock=True)
try:
latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1))
if latest == 'guide_start':
db.add(UserAction(user_id=user.id, action='guide_finish'))
db.commit()
except SQLAlchemyError:
db.rollback()
raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз')
if request.headers.get('accept') == 'application/json':
return Response(status_code=204)
return RedirectResponse('/admin', status_code=303)
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.2.1"
FRONTEND_VERSION = "1.2.1"
BACKEND_VERSION = "1.2.1"
APP_VERSION = "2.0.1"
FRONTEND_VERSION = "2.0.1"
BACKEND_VERSION = "2.0.1"
+6 -1
View File
@@ -6,7 +6,7 @@
## Хранение и привязка
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. Компактный XLSX содержит только выбранные столбцы; подробные подтверждения и причины проверки в него не включаются. Экспортный «Всего» считает уникальное объединение Q1/Q2/К1 без самостоятельных К2/RSCI.
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. XLSX содержит первый лист интерактивного подбора и компактные подробные листы. Для переключения специальности и отрасли файл включает полный набор сотрудников, а исходные фильтры сайта задают начальный подбор. Для обеих доступных отраслей используются результаты того же сервиса оценки: показаны подтверждённый зачёт, минимум и статус. Подробные записи подтверждений и непубличные ссылки в файл не включаются. Экспортный «Всего» совпадает с сайтом: уникальное объединение Q1/Q2/К1/RSCI, для доктора также К2; одного К2 у кандидата/PhD недостаточно. XLSX — снимок на момент выгрузки; ручное подтверждение на сайте требует новой выгрузки.
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
@@ -78,3 +78,8 @@
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX.
Без JavaScript форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
@@ -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.2.1"
version = "2.0.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+5 -1
View File
@@ -170,7 +170,11 @@ if __name__ == "__main__":
result = {"schema_version": 1, "source": args.source.name,
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
"specialties": [{"code": code, "name": name,
"branches": ["физико-математические"] if code == "1.1.2" else
["технические"] if code == "2.3.2" else
["технические", "физико-математические"]}
for code, name in SPECIALTIES], "journals": entries}
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
from collections import Counter
print(len(entries), dict(Counter(j['status'] for j in entries)))
+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()
+36
View File
@@ -92,6 +92,21 @@ def main():
assert "Без степени" not in page.content() and "Только звание" not in page.content()
expect(page.locator("#council-date")).to_have_count(0)
assert "Монографии" not in page.content()
branches = page.locator('#council-branch')
specialty = page.locator('#council-specialty')
expect(branches.locator('option')).to_have_count(2)
specialty.select_option('1.1.2')
expect(branches.locator('option')).to_have_count(1)
expect(branches).to_have_value('физико-математические')
specialty.select_option('2.3.2')
expect(branches.locator('option')).to_have_count(1)
expect(branches).to_have_value('технические')
specialty.select_option('1.2.2')
expect(branches.locator('option')).to_have_count(2)
branches.select_option('физико-математические')
specialty.select_option('')
expect(branches).to_have_value('физико-математические')
branches.select_option('технические')
page.get_by_role("button", name="Применить", exact=True).click()
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
page.get_by_role("link", name="Далее").click()
@@ -217,6 +232,27 @@ def main():
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
page.screenshot(path=str(output / f"catalog-{width}.png"), full_page=True)
assert not errors, errors
no_js = browser.new_context(java_script_enabled=False)
no_js.route("**/*", serve)
fallback = no_js.new_page()
for previous, specialty, old_branch, new_branch in [
("2.3.2", "1.1.2", "технические", "физико-математические"),
("1.1.2", "2.3.2", "физико-математические", "технические"),
]:
url = f"http://miem.test/admin/council?specialty={previous}&branch={old_branch}&q=Тест&sort=full_name&direction=asc"
fallback.goto(url)
fallback.get_by_label("Специальность ВАК").select_option(specialty)
with fallback.expect_download() as pending_download:
fallback.get_by_role("button", name="Скачать XLSX", exact=True).click()
book = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
fallback.get_by_role("button", name="Применить", exact=True).click()
expect(fallback.get_by_label("Отрасль оценки")).to_have_value(new_branch)
expect(fallback.get_by_label("Специальность ВАК")).to_have_value(specialty)
expect(fallback.get_by_label("Поиск по ФИО")).to_have_value("Тест")
expect(fallback.get_by_label("Сортировать по")).to_have_value("full_name")
expect(fallback.get_by_label("Направление")).to_have_value("asc")
no_js.close()
browser.close()
finally:
council._articles_by_author = original
+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()
+155 -2
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()
@@ -79,6 +80,10 @@ def main():
page.set_viewport_size({'width':320,'height':844})
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
creation.get_by_label('Логин', exact=True).fill('nojs.reader')
creation.get_by_role('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()
page.goto(base_url + '/admin/login')
page.get_by_label('Логин', exact=True).fill('reader')
@@ -97,9 +102,157 @@ def main():
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
expect(page.get_by_role('dialog')).to_have_count(0)
expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible()
guide = page.locator('[data-guide]')
expect(guide).to_be_visible()
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.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure'))
guide.get_by_role('button', name='Закрыть подсказки', exact=True).click()
expect(guide.locator('[data-guide-error]')).to_be_visible()
guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click()
expect(guide).not_to_be_visible()
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
page.unroute('**/admin/guide/dismiss')
page.reload()
expect(guide).to_be_visible()
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()
page.reload()
expect(guide).not_to_be_visible()
page.get_by_role('link', name='Как пользоваться', exact=True).click()
expect(guide).to_be_visible()
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')
nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader')
nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password)
nojs_page.get_by_role('button', name='Войти', exact=True).click()
nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password)
nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123')
nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123')
nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click()
expect(nojs_page.locator('[data-guide-step]')).to_have_count(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_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.2.1"
assert response.json()["backend_version"] == "2.0.1"
def test_api_employees_and_stats_require_admin_session():
+86 -20
View File
@@ -13,7 +13,14 @@ from app.db import get_db
from app.main import app
from app.models import Employee, EmployeePublication
from app.services import council
from app.services.council_export import _append
from app.services.council_export import _append, council_xlsx
def selection_results(db, result):
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
return [result if b == result["branch"] and not result["q"] and not result["specialty"] else
council.council_candidates(db, result["end"], sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
for b in branches]
@pytest.fixture()
@@ -223,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.2.1 · Frontend 1.2.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
@@ -264,7 +271,7 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
book = load_workbook(BytesIO(response.content))
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
assert book.sheetnames == ["Подбор диссовета", "Сотрудники", "Публикации", "Параметры", "_Данные подбора", "_Справочники подбора"]
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]] == [
"№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего", "Всего из ЛК",
@@ -287,9 +294,12 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
assert works.auto_filter.ref == works.dimensions
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
assert "private@example.org" not in text and "Без степени" not in text
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
focused = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))
assert focused["Сотрудники"].max_row == 29
assert focused["Подбор диссовета"]["C5"].value == "Петров 27"
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
assert empty["Сотрудники"].max_row == 29 and empty["Публикации"].max_row == 29
assert empty["Подбор диссовета"]["C5"].value == "missing"
finally:
app.dependency_overrides.clear()
@@ -307,10 +317,12 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
book = load_workbook(BytesIO(response.content))
assert book["Сотрудники"]["B2"].value == person.full_name
assert book["Сотрудники"]["B2"].data_type == "s"
assert book["_Данные подбора"]["B2"].value == person.full_name
assert book["_Данные подбора"]["B2"].data_type == "s"
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
assert titles == {"=1+1": "s", "#N/A": "s"}
with ZipFile(BytesIO(response.content)) as archive:
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
assert all(b"<f>" not in archive.read(f"xl/worksheets/sheet{n}.xml") for n in (2, 3, 4))
person.full_name = "Long " + "x" * 32768
db_session.commit()
failure = client.get("/admin/council/export.xlsx")
@@ -320,6 +332,60 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
app.dependency_overrides.clear()
def test_xlsx_selection_contains_full_data_and_preserves_initial_filters(db_session, export):
matching = employee(db_session)
publication(db_session, matching, kind="ARTICLE", raw_data={"journal": "Бизнес-информатика"})
other = employee(db_session, "Другой сотрудник", key="other")
publication(db_session, other, title="Other", kind="ARTICLE")
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get('/admin/council/export.xlsx?specialty=1.2.2&q=Иванов&sort=confirmed_count&direction=asc')
assert response.status_code == 200
book = load_workbook(BytesIO(response.content))
dashboard, source, options = book.worksheets[0], book['_Данные подбора'], book['_Справочники подбора']
assert dashboard.title == 'Подбор диссовета' and book.active == dashboard
assert dashboard['C3'].value.startswith('1.2.2 —')
assert dashboard['C4'].value == 'технические' and dashboard['C5'].value == 'Иванов'
assert dashboard['K3'].value == 'Подтверждено' and dashboard['K4'].value == 'По возрастанию'
assert source.max_row == 5 and source.sheet_state == options.sheet_state == 'hidden'
assert set(source.cell(row, 2).value for row in range(2, 6)) == {matching.full_name, other.full_name}
assert {source.cell(row, 18).value for row in range(2, 6)} == {'технические', 'физико-математические'}
assert {source.cell(row, 11).value for row in range(2, 6)} == {9, 11}
assert book['Сотрудники'].max_row == book['Публикации'].max_row == 3
assert len(dashboard.data_validations.dataValidation) == 6
for row in range(3, options.max_row + 1):
code = options.cell(row, 1).value.split(' ')[0]
expected = ['физико-математические'] if code == '1.1.2' else ['технические'] if code == '2.3.2' else ['технические', 'физико-математические']
assert [options.cell(row, column).value for column in (4, 5) if options.cell(row, column).value] == expected
assert options.cell(row, 2).value in book.defined_names
assert '_xlfn.AGGREGATE' in dashboard['S10'].value
assert '$C$3' in dashboard['F4'].value and 'IFERROR' in dashboard['F4'].value
memo_address = dashboard['A7'].hyperlink.location.split('!', 1)[1]
assert dashboard[memo_address].value == 'Памятка: как пользоваться подбором'
assert dashboard[memo_address].row > 11
memo_text = ' '.join(str(dashboard.cell(row, 1).value or '').replace('\n', ' ')
for row in range(dashboard[memo_address].row, dashboard.max_row + 1))
for explanation in ('Для кандидата/PhD', 'Подтверждено', '9 подтверждённых',
'11 по физико-математической', '20', 'Не подтверждены', 'все работы сотрудника',
'Уволенные', 'автоматический расчёт', 'не отправляются на сайт'):
assert explanation in memo_text
assert any(cell.hyperlink and cell.hyperlink.location == "'Подбор диссовета'!A1"
for row in dashboard for cell in row)
assert book.calculation.calcMode == 'auto' and book.calculation.forceFullCalc
with ZipFile(BytesIO(response.content)) as archive:
assert not any('vbaProject' in name or 'externalLinks' in name for name in archive.namelist())
db_session.query(EmployeePublication).delete()
db_session.query(Employee).delete()
db_session.commit()
empty = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
assert empty['Сотрудники'].max_row == 1 and empty['Публикации'].max_row == 1
assert empty['_Данные подбора'].max_row == 1
finally:
app.dependency_overrides.clear()
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
def fail_on_scan(sheet):
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
@@ -392,8 +458,7 @@ def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_sess
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
assert [book["Сотрудники"].cell(2, column).value for column in (6, 8)] == [1, 1]
@@ -453,8 +518,7 @@ def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
assert [candidate["name"] for candidate in result["candidates"]] == expected
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
headers = [cell.value for cell in book["Сотрудники"][1]]
assert headers[3:8] == ["Q1", "Q2", "К1", "К2", "Всего"]
assert "CSV по критериям" not in headers
@@ -501,7 +565,9 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
client = TestClient(app)
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
assert response.status_code == 200
assert '<option value="1.2.2" selected>' in response.text
from bs4 import BeautifulSoup
selected = BeautifulSoup(response.text, "html.parser").select_one('#council-specialty option[selected]')
assert selected["value"] == "1.2.2"
assert response.text.count('<option value="2.3.5"') == 1
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
assert "Не подходит" not in response.text
@@ -513,10 +579,11 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
assert "Показаны 1–1 из 1" in filtered.text
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
assert book["Сотрудники"].max_row == 29
assert book["Сотрудники"].max_row == 30
assert book["Подбор диссовета"]["C3"].value.startswith("1.2.2 —")
params = dict(book["Параметры"].values)
assert params["Специальность ВАК"].startswith("1.2.2.")
assert "По специальности 1.2.2" in book["Публикации"]["F2"].value
assert "Перечень ВАК" in book["Публикации"]["F2"].value or "справочник ВАК" in book["Публикации"]["F2"].value
empty = client.get("/admin/council?specialty=2.2.14")
assert "Сотрудники не найдены" in empty.text
finally:
@@ -556,10 +623,9 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
other = employee(db_session, name='Без соответствия', key='other')
publication(db_session, other, title='Unknown', kind='ARTICLE')
db_session.commit()
from app.services.council_export import council_xlsx
for specialty in ('', '1.2.2'):
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
book = load_workbook(BytesIO(council_xlsx(result)))
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
staff = list(book['Сотрудники'].values)
works = list(book['Публикации'].values)
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
@@ -601,16 +667,16 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
result = council.council_candidates(db_session, date(2026, 10, 8))
candidate = result['candidates'][0]
other = next(a for a in candidate['articles'] if a['title'] == 'Other publication')
# Isolate the export rule: other qualifying council bases do not add to its total.
# Export the same total as the site, including doctor-only K2 and RSCI.
other.update(k2=True, rsci=True, qualifies=True)
candidate.update(k2_count=1, rsci_count=1, total_count=2)
shared = next(a for a in candidate['articles'] if a['title'] == 'Shared work')
shared['criteria'].extend(['В пределах пяти лет', 'В пределах пяти лет по году; точную дату нужно проверить'])
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
staff = dict(zip([c.value for c in book['Сотрудники'][1]], [c.value for c in book['Сотрудники'][2]]))
assert staff['Научная степень'] == 'Доктор технических наук'
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == staff['Всего'] == 1
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == 1
assert staff['Всего'] == candidate['total_count'] == 2
assert staff['Всего из ЛК'] == staff['Общее количество всех публикаций без дублей'] == 2
rows = {values[2]: dict(zip(next(book['Публикации'].values), values)) for values in list(book['Публикации'].values)[1:]}
assert rows['Other publication']['Тип материала'] == 'не указан'
@@ -618,7 +684,7 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
assert rows['Shared work']['Критерии'].count('В пределах пяти лет') == 1
assert 'точную дату' not in rows['Shared work']['Критерии']
params = dict(book['Параметры'].values)
assert 'Q1 ИЛИ Q2 ИЛИ К1' in params['Всего']
assert 'Q1/Q2/К1/RSCI' in params['Всего'] and 'для доктора наук также К2' in params['Всего']
assert 'источники указаны на листе' not in ' '.join(str(v) for v in params.values())
assert not any('Работ / По выбранной специальности' == key for key in params)
+123 -11
View File
@@ -48,9 +48,14 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
"bases": [{**SOURCE, "basis": basis, "applicable_year": year}], **proof_fields}
def result(db, branch="технические", **kwargs):
def result(db, branch="технические", assessment_branch=None, **kwargs):
db.commit()
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
value = council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
if assessment_branch:
return assessment.assess_candidate(value["degree"].split("; "), value["articles"],
assessment.evidence()["employees"]["test"], assessment_branch, "",
date(2022, 1, 1), date(2026, 12, 31))
return value
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
@@ -61,7 +66,7 @@ def test_each_doctor_threshold_and_monograph_requirement(db_session, proofs, bra
employee = person(db_session, [degree])
records = [work(db_session, employee, n) for n in range(minimum + delta)]
proofs([{**SOURCE, "degree": degree}], records)
value = result(db_session, branch)
value = result(db_session, assessment_branch=branch)
assert value["minimum"] == minimum and value["monograph_minimum"] == mono
assert value["confirmed_count"] == minimum + delta
assert value["assessment_status"].startswith("Соответствует") == (delta >= 0 and not mono)
@@ -79,6 +84,24 @@ def test_candidate_phd_20_and_k2_exclusion(db_session, proofs, degree, count, ba
assert value["assessment_status"].startswith("Соответствует") == (basis != "К2" and count >= 20)
@pytest.mark.parametrize("degree,total", [("Доктор технических наук", 3), ("Кандидат технических наук", 2), ("Ph.D.", 2)])
def test_xlsx_total_matches_site_for_k2_rsci_and_overlapping_bases(db_session, proofs, degree, total):
employee = person(db_session, [degree])
records = [work(db_session, employee, number, basis=basis) for number, basis in enumerate(("К2", "RSCI", "К1"))]
records[-1]["bases"].append({**SOURCE, "basis": "К2", "applicable_year": 2024})
proofs([{**SOURCE, "degree": degree}], records)
db_session.commit()
selections = [council.council_candidates(db_session, date(2026, 10, 8), branch=branch, paginate=False)
for branch in ("технические", "физико-математические")]
candidate = selections[0]["candidates"][0]
assert candidate["k2_count"] == 2 and candidate["rsci_count"] == 1
assert candidate["total_count"] == total
book = load_workbook(BytesIO(council_xlsx(selections[0], selections)))
assert book["Сотрудники"]["H2"].value == total
assert book["_Данные подбора"]["I2"].value == total
assert book["_Данные подбора"]["I3"].value == total
@pytest.mark.parametrize("degrees", list(itertools.permutations(["Кандидат технических наук", "PhD", "Доктор технических наук"])))
def test_history_always_chooses_doctor_without_fallback(db_session, proofs, degrees):
person(db_session, list(degrees) + ["Профессор", "Неизвестная квалификация"])
@@ -102,10 +125,10 @@ def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branc
records = [work(db_session, employee, n) for n in range(8)]
records.append(work(db_session, employee, 8, kind="BOOK", kind_evidence=SOURCE))
proofs([{**SOURCE, "degree": degree}], records)
assert result(db_session, branch)["monograph_count"] == 0
assert result(db_session, assessment_branch=branch)["monograph_count"] == 0
records[-1]["kind"] = "monograph"
proofs([{**SOURCE, "degree": degree}], records, {"monograph_period": {**SOURCE, "value": "five_years"}})
value = result(db_session, branch)
value = result(db_session, assessment_branch=branch)
assert value["confirmed_count"] == 8 and value["monograph_count"] == 1
assert value["assessment_status"].startswith("Соответствует")
@@ -175,8 +198,34 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
assert "council-date" not in page.text and "Монографии" not in page.text
assert "Дата ходатайства" not in page.text
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
legacy_book = load_workbook(BytesIO(legacy.content))
assert dict(legacy_book["Параметры"].values)["Отрасль оценки"] == "экономические"
assert legacy.status_code == 422
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize("populated", [False, True])
def test_empty_branch_is_rejected_before_export_and_omitted_branch_keeps_default(db_session, proofs, populated):
if populated:
employee = person(db_session, ["Доктор технических наук"])
work(db_session, employee)
db_session.commit()
with pytest.raises(council.CouncilInputError, match="Неизвестная отрасль науки"):
council.council_candidates(db_session, branch="")
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
for query in ("branch=", "branch=&specialty=1.1.2", "branch=&specialty=1.1.2&previous_specialty="):
for path in ("/admin/council", "/admin/council/export.xlsx", "/admin/council/employees/1/articles"):
response = client.get(path + "?" + query)
assert response.status_code == 422
assert "Неизвестная отрасль науки" in response.text
assert "content-disposition" not in response.headers
assert client.get("/admin/council").status_code == 200
response = client.get("/admin/council/export.xlsx")
assert response.status_code == 200
book = load_workbook(BytesIO(response.content))
assert book["Подбор диссовета"]["C4"].value == "технические"
assert book["Сотрудники"].max_row == (2 if populated else 1)
finally:
app.dependency_overrides.clear()
@@ -319,10 +368,10 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
# The policy permits a type; it does not itself confirm a quartile.
assert candidate["confirmed_count"] == (int(admitted) if basis not in {"Q1", "Q2"} else 0)
book = load_workbook(BytesIO(council_xlsx(full_result)))
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
headers = [cell.value for cell in book["Сотрудники"][1]]
exported = dict(zip(headers, [cell.value for cell in book["Сотрудники"][2]]))
assert exported["Всего"] == (int(admitted) if basis in {"Q1", "Q2", "К1"} else 0)
assert exported["Всего"] == candidate["total_count"] == int(admitted)
publication = dict(zip([cell.value for cell in book["Публикации"][1]], [cell.value for cell in book["Публикации"][2]]))
assert publication[basis] == ("Да" if admitted else "Нет")
if basis != "RSCI":
@@ -366,7 +415,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
assert candidate["confirmed_count"] == 1
assert full_result["catalog_issues"] and not article["catalog_issues"]
assert {r["catalog"] for r in article["catalog_records"]} == {valid_path.parent.name}
book = load_workbook(BytesIO(council_xlsx(full_result)))
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
assert book["Сотрудники"].cell(2, 5 if flag == "q2" else 4).value == 1
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
app.dependency_overrides[get_db] = lambda: db_session
@@ -396,7 +445,8 @@ def test_automatic_source_quartile_calendar_years(db_session, proofs, year, kind
if expected:
assert article['confirmed_bases'][0]['basis'] == 'Q2'
assert not any('Вид материала не подтверждён' in m for m in article['messages'])
book = load_workbook(BytesIO(council_xlsx(council.council_candidates(db_session, today=date(2026, 10, 8)))))
full_result = council.council_candidates(db_session, today=date(2026, 10, 8))
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
headers = [cell.value for cell in book['Сотрудники'][1]]
exported = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
assert exported['Q2'] == candidate['q2_count']
@@ -492,3 +542,65 @@ def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_se
assert article['display_type'] == 'Статья' and article['types'] == []
assert article['warnings']['type'] and 'не указан тип материала' in article['warnings']['type'][0]
assert not article['confirmed'] and candidate['confirmed_count'] == 0
@pytest.mark.parametrize("specialty,branches", [
("", ["технические", "физико-математические"]),
("1.1.2", ["физико-математические"]),
("2.3.2", ["технические"]),
*[(code, ["технические", "физико-математические"]) for code in
["1.2.2", "1.3.8", "2.2.2", "2.2.13", "2.2.14", "2.2.15", "2.3.1", "2.3.5", "2.3.6", "2.3.7"]],
])
def test_specialty_limits_branch_options_and_rejects_invalid_requests(db_session, proofs, specialty, branches):
from bs4 import BeautifulSoup
employee = person(db_session, ["Доктор технических наук"])
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
for branch in assessment.BRANCHES:
params = {"specialty": specialty, "branch": branch}
page = client.get("/admin/council", params=params)
if branch in branches:
assert page.status_code == 200
options = BeautifulSoup(page.text, "html.parser").select("#council-branch option")
assert [option["value"] for option in options] == branches
assert next(option["value"] for option in options if "selected" in option.attrs) == branch
else:
assert page.status_code == 422
assert client.get("/admin/council/export.xlsx", params=params).status_code == 422
assert client.get(f"/admin/council/employees/{employee.id}/articles", params=params).status_code == 422
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize("previous,specialty,old_branch,new_branch", [
("2.3.2", "1.1.2", "технические", "физико-математические"),
("1.1.2", "2.3.2", "физико-математические", "технические"),
("", "1.1.2", "технические", "физико-математические"),
])
def test_specialty_change_without_js_uses_compatible_branch(db_session, proofs, previous, specialty, old_branch, new_branch):
from bs4 import BeautifulSoup
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
params = {"previous_specialty": previous, "specialty": specialty, "branch": old_branch,
"q": "Тест", "sort": "full_name", "direction": "asc"}
page = client.get("/admin/council", params=params)
assert page.status_code == 200
parsed = BeautifulSoup(page.text, "html.parser")
assert parsed.select_one('#council-branch option[selected]')["value"] == new_branch
assert parsed.select_one('[name=previous_specialty]')["value"] == specialty
assert parsed.select_one('[name=q]')["value"] == "Тест"
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx", params=params).content))
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
for invalid_previous in [None, specialty, "oops"]:
invalid = {**params, "previous_specialty": invalid_previous}
if invalid_previous is None:
del invalid["previous_specialty"]
assert client.get("/admin/council", params=invalid).status_code == 422
assert client.get("/admin/council/export.xlsx", params=invalid).status_code == 422
invalid = {**params, "branch": "экономические"}
assert client.get("/admin/council", params=invalid).status_code == 422
finally:
app.dependency_overrides.clear()
+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",)
+46
View File
@@ -1,5 +1,13 @@
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
from jinja2 import Environment, FileSystemLoader
from starlette.requests import Request
from app.models import Employee, EmployeeNewsLink
from app.services.admin_data import employee_detail_payload
def test_employee_detail_template_is_human_readable():
template = Path("app/templates/employee_detail.html").read_text(encoding="utf-8")
@@ -33,3 +41,41 @@ def test_employee_detail_template_is_human_readable():
assert "Обновить данные" in template
assert 'action="/admin/employees/{{ employee.id }}/refresh"' in template
assert "Снапшоты" in template
@pytest.mark.parametrize("news_source", [None, "snapshot", "stored"])
@pytest.mark.parametrize("has_other_section", [False, True])
def test_employee_news_render_once_in_closed_disclosure(db_session, news_source, has_other_section):
sections = [{"title": "Биография", "type": "generic", "paragraphs": ["Текст биографии"]}] if has_other_section else []
if news_source:
sections.append({"title": "В новостях", "type": "news",
"news_links": [{"title": "Новость из профиля", "url": "https://example.test/snapshot"}],
"paragraphs": ["Дублирующий текст новостей"]})
employee = Employee(profile_key="staff:news", canonical_url="https://example.test/profile",
full_name="Тестовый сотрудник", status="active", current_data={"sections": sections})
db_session.add(employee)
db_session.flush()
if news_source == "stored":
db_session.add(EmployeeNewsLink(employee_id=employee.id, title="Сохранённая новость",
url="https://example.test/stored", summary="Описание новости", source_hash="a" * 64))
db_session.flush()
template = Environment(loader=FileSystemLoader("app/templates"), autoescape=True).get_template("employee_detail.html")
rendered = template.render(employee=employee, employee_view=employee_detail_payload(employee), snapshots=[],
request=Request({"type": "http", "scheme": "http", "headers": [],
"path": f"/admin/employees/{employee.id}"}))
soup = BeautifulSoup(rendered, "html.parser")
assert not any(badge.get_text(strip=True) == "news" for badge in soup.select(".employee-section__type"))
assert "Дублирующий текст новостей" not in rendered
assert ("Текст биографии" in rendered) == has_other_section
assert ("Разделы профиля не найдены." in rendered) == (not has_other_section)
news = soup.select("details.employee-card__section--news")
assert len(news) == (1 if news_source else 0)
if news_source:
assert not news[0].has_attr("open")
assert news[0].summary.h3.get_text(strip=True) == "В новостях"
expected_url = f"https://example.test/{'stored' if news_source == 'stored' else 'snapshot'}"
assert [link["href"] for link in news[0].select("a")] == [expected_url]
assert len(soup.select(f'a[href="{expected_url}"]')) == 1
if news_source == "stored":
assert "Описание новости" in news[0].get_text()
assert "Новость из профиля" not in rendered
+55
View File
@@ -0,0 +1,55 @@
from fastapi.testclient import TestClient
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.security import SESSION_COOKIE, sign_session
from app.services.admin_data import list_employees_page
def test_pps_filter_matches_positions_before_pagination_and_preserves_state(db_session):
settings = Settings(admin_username="admin", admin_password="password", session_secret="test-pps")
db = db_session
data = [
{"positions": ["Доцент: Кафедра физики"]},
{"positions": ["Инженер", "ДЕПАРТАМЕНТ информатики"]},
{"positions": ["кафедра математики"]},
{"positions": ["Инженер"], "address": "Кафедра физики"},
{"positions": []}, {}, None,
]
for index, current_data in enumerate(data):
db.add(Employee(profile_key=f"pps:{index}", canonical_url=f"https://example.test/{index}",
full_name=f"Person {index}", status="dismissed" if index == 2 else "active",
current_data=current_data))
db.commit()
assert list_employees_page(db)["total"] == 7
assert list_employees_page(db, pps=False)["total"] == 7
page = list_employees_page(db, pps=True, status="active", offset=1)
assert page["total"] == 2
assert [item["full_name"] for item in page["employees"]] == ["Person 1"]
assert list_employees_page(db, pps=True)["total"] == 3
assert list_employees_page(db, pps=True, q="Person 3")["total"] == 0
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
response = client.get("/api/employees?pps=true&status=active")
assert response.status_code == 200
assert response.json()["total"] == 2
response = client.get("/admin/directory?pps=true&offset=1&limit=25")
assert response.status_code == 200
assert 'value="true" checked' in response.text
assert "Person 0" not in response.text
assert "Person 1" in response.text
assert "pps=true" in response.text.split('>Назад</a>')[0].rsplit('href="', 1)[1]
empty = client.get("/admin/directory?pps=true&q=Person+3")
assert "Сбросить фильтры" in empty.text
assert "Запустите парсинг" not in empty.text
reset = client.get("/admin/directory")
assert 'value="true" checked' not in reset.text
assert "Показаны 1–7 из 7" in reset.text
finally:
app.dependency_overrides.clear()
+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()
+109 -2
View File
@@ -108,6 +108,7 @@ def test_create_user_first_login_forces_password_change(accounts):
assert not user.must_change_password and user.password_changed_at and user.session_version == 1
assert user.first_login_at == first_login
assert 'data-required-password' not in client.get('/admin/account').text
assert 'data-guide-pending="true"' in client.get('/admin/account').text
assert client.get('/admin/directory').status_code == 200
assert client.get('/admin/users').status_code == 403
client.cookies.clear()
@@ -125,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'))
@@ -149,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()
@@ -247,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'),
@@ -286,6 +318,81 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
db.refresh(user)
assert user.password_hash == before and user.session_version == 0
assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
client, db, settings, _, user, _ = accounts
user.role = role
db.commit()
headers = act_as(client, user, settings)
assert 'data-guide-pending="false"' in client.get('/admin/account').text
data = {'current_password': 'Initial user password', 'new_password': 'New onboarding passphrase',
'confirmation': 'New onboarding passphrase'}
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
page = client.get('/admin/account')
assert 'data-guide-pending="true"' in page.text
assert 'data-required-password' not in page.text
assert page.headers['Cache-Control'] == 'no-store'
guide = client.get('/admin/guide')
parsed = BeautifulSoup(guide.text, 'html.parser')
assert len(parsed.select('[data-guide-step]')) == (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) == (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):
response = client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': csrf, 'Accept': 'application/json'}, follow_redirects=False)
assert response.status_code == 204
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_finish')).all()) == 1
client.cookies.clear()
headers = act_as(client, user, settings)
assert 'data-guide-pending="false"' in client.get('/admin').text
data = {'current_password': data['new_password'], 'new_password': 'Second onboarding passphrase',
'confirmation': 'Second onboarding passphrase'}
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
assert 'data-guide-pending="false"' in client.get('/admin/account').text
assert client.get('/admin/guide').status_code == 200
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_start')).all()) == 1
def test_guide_access_and_no_javascript_completion(accounts):
client, db, settings, _, user, _ = accounts
assert 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()
headers = act_as(client, user, settings)
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/account'
assert client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False).headers['location'] == '/admin/account'
user.must_change_password = False
db.add(UserAction(user_id=user.id, action='guide_start'))
db.commit()
page = BeautifulSoup(client.get('/admin').text, 'html.parser')
assert page.select_one('[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
def test_guide_dismissal_failure_does_not_mark_it_finished(accounts, monkeypatch):
from sqlalchemy.exc import OperationalError
client, db, settings, _, user, _ = accounts
db.add(UserAction(user_id=user.id, action='guide_start'))
db.commit()
headers = act_as(client, user, settings)
def unavailable():
raise OperationalError('INSERT', {}, Exception('private database details'))
monkeypatch.setattr(db, 'commit', unavailable)
response = client.post('/admin/guide/dismiss', headers={**headers, 'Accept': 'application/json'})
assert response.status_code == 503 and 'private database details' not in response.text
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_finish')) is None
assert 'data-guide-pending="true"' in client.get('/admin/account').text
def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts):