Compare commits

..
Author SHA1 Message Date
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
19 changed files with 603 additions and 156 deletions

No files matched your search

+13 -12
View File
@@ -1,20 +1,21 @@
## Сделано
- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.1`.
- Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД.
- Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении.
- Исправлено неблокирующее замечание независимого ревью PR #71: только три новых правила CSS переведены с CRLF на LF, содержимое правил сохранено.
- После исправления: `pytest -q` — 412 passed, 11 skipped; `git diff --check main` — passed. Предыдущая запись о passed для полного diff коммита `5128fac` была неточной: ревью подтвердило три trailing whitespace, теперь они устранены.
- До этой правки: браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed. Браузерный сценарий повторно не запускался: поведение CSS не менялось.
- Ветка `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 #71: https://git.vakanaut.ru/admin/miem_workers/pulls/71. Ревью коммита `5128fac` одобрено с одним неблокирующим замечанием; пользователь поручил исправить его.
- Правка готова к коммиту и push в ту же ветку/PR. Доступ к Gitea восстановился; локальный `main` обновлён, основа — `378d7ee`.
- PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных.
- Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя.
- Открыт PR #72: https://git.vakanaut.ru/admin/miem_workers/pulls/72. Ревью SHA `962e936` требует исправить один P2; пользователь поручил исправление. Оно готово к коммиту и push в ту же ветку/PR.
- PostgreSQL-тесты пропущены без отдельной тестовой БД; production не изменялся. Тестирование Excel проводилось только на синтетическом наборе.
## Следующий шаг
- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71.
- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
- Сохранить исправление коммитом и отправить `git push -u origin feature/council-xlsx-selection` в существующий PR #72.
- После публикации остановиться и ждать независимого ревью в отдельной сессии и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.5.1
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
## 1.5.0
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
## 0.18.1
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
+94 -94
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,7 +52,7 @@ uvicorn app.main:app --reload
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
## Пользователи и личный кабинет
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
@@ -71,44 +71,44 @@ uvicorn app.main:app --reload
Проверки: `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, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
@@ -134,51 +134,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.
## Проверки
+9 -1
View File
@@ -113,6 +113,7 @@ def directory(
started_to: str | None = None,
has_email: str | None = None,
has_academic_degree: str | None = None,
pps: bool = False,
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -134,6 +135,7 @@ def directory(
started_to=parsed_started_to,
has_email=parsed_has_email,
has_academic_degree=parsed_has_academic_degree,
pps=pps,
sort=sort,
direction=direction,
limit=limit,
@@ -154,6 +156,7 @@ def directory(
"started_to": started_to or "",
"has_email": has_email or "",
"has_academic_degree": has_academic_degree or "",
"pps": pps,
"sort": sort,
"direction": direction,
"limit": page["limit"],
@@ -304,7 +307,12 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
content = council_xlsx(result)
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):
+2
View File
@@ -29,6 +29,7 @@ def list_employees(
started_to: date | None = None,
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -45,6 +46,7 @@ def list_employees(
started_to=started_to,
has_email=has_email,
has_academic_degree=has_academic_degree,
pps=pps,
sort=sort,
direction=direction,
limit=limit,
+16 -4
View File
@@ -116,6 +116,7 @@ def list_employees_page(
started_to: date | None = None,
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -131,12 +132,23 @@ 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 pps:
# Match decoded position strings consistently in PostgreSQL and SQLite.
employees = [employee for employee in db.scalars(ordered_stmt).all()
if any("кафедра" in position.casefold() or "департамент" in position.casefold()
for position in _clean_list(_as_dict(employee.current_data).get("positions")))]
total = len(employees)
if offset >= total:
offset = 0
employees = employees[offset:offset + limit]
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,
+1 -1
View File
@@ -255,7 +255,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
branch: str = "технические", employee_id: int | None = None,
previous_specialty: str | None = None) -> dict:
if branch and branch not in BRANCHES:
if branch not in BRANCHES:
raise CouncilInputError("Неизвестная отрасль науки")
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
+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
+23
View File
@@ -281,6 +281,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));
@@ -598,6 +612,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;
+2 -1
View File
@@ -35,6 +35,7 @@
<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>
@@ -112,7 +113,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 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 %}
<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>
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.3.1"
FRONTEND_VERSION = "1.3.1"
BACKEND_VERSION = "1.3.1"
APP_VERSION = "1.5.1"
FRONTEND_VERSION = "1.5.1"
BACKEND_VERSION = "1.5.1"
+1 -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`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "1.3.1"
version = "1.5.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+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.3.1"
assert response.json()["backend_version"] == "1.5.1"
def test_api_employees_and_stats_require_admin_session():
+83 -19
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.3.1 · Frontend 1.3.1" in response.text
assert "Backend 1.5.1 · Frontend 1.5.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
@@ -515,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:
@@ -558,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)
@@ -603,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']['Тип материала'] == 'не указан'
@@ -620,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)
+50 -4
View File
@@ -84,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) + ["Профессор", "Неизвестная квалификация"])
@@ -185,6 +203,33 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
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()
def test_unknown_kind_conflicts_and_safe_source_url(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee)
@@ -323,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":
@@ -370,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
@@ -400,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']
+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()