Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201e4dd299 | ||
|
|
22e1bd3f2e | ||
|
|
caae2270dc | ||
|
|
cdf879fdf5 | ||
|
|
fd3008dfde | ||
|
|
87429e463c | ||
|
|
ac9b1cebd1 | ||
|
|
1c52b2a2c6 | ||
|
|
962e936ad1 | ||
|
|
2baa2e1aa1 | ||
|
|
7c02b0825f | ||
|
|
e29775b238 | ||
|
|
8e3ea6eeb2 | ||
|
|
5128fac6e5 | ||
|
|
378d7ee96e | ||
|
|
9d788b0663 | ||
|
|
3359ea2f86 | ||
|
|
ccebaddfda | ||
|
|
96987e90c9 | ||
|
|
a82f63d6cb | ||
|
|
03ac2f5ee8 | ||
|
|
a7de80de3b | ||
|
|
e08c30848f | ||
|
|
2554cbf39a | ||
|
|
c58bb8d626 | ||
|
|
f4fb966b78 | ||
|
|
9910fc77d6 | ||
|
|
f45c66e069 | ||
|
|
2e0c71367d |
No files matched your search
@@ -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.
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pytest -q
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
|
||||
|
||||
## 0.18.1
|
||||
|
||||
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
|
||||
|
||||
@@ -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`: опциональный лимит профилей для тестового запуска.
|
||||
- `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`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `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`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
@@ -52,97 +52,133 @@ uvicorn app.main:app --reload
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
|
||||
## Пользователи и личный кабинет
|
||||
|
||||
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
|
||||
|
||||
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
|
||||
|
||||
При первом успешном входе и после сброса требуется задать свой пароль (15–128 символов, можно фразу), указав текущий пароль. Попап нельзя закрыть Escape или нажатием на фон; можно сменить пароль либо выйти. Без JavaScript доступна обычная форма, а серверные операции до смены пароля заблокированы. Начальный администратор также меняет пароль при первом входе после обновления.
|
||||
|
||||
`/admin/account` показывает первый/последний вход, смену пароля и историю действий. Каждый видит только свои действия и события, относящиеся к его учётной записи; администратор на странице пользователей видит общую историю или фильтр по учётной записи. Журнал фиксирует успешные входы, смену/сброс пароля, изменения ролей/доступа и реальные правки карточек: сотрудник, статья, год, прежние/новые значения названия, журнала, квартиля и тематической связи. Сохранение правки и запись истории — одна транзакция; отклонённые и неизменённые сохранения не создают запись о редактировании. История разбита на страницы по 30 записей.
|
||||
|
||||
Сессия ограничена 12 часами. Смена/сброс пароля, изменение роли/доступа и выход отзывают прежние сессии. Собственную роль/доступ и пароль через административный сброс менять нельзя: свой пароль меняется в личном кабинете. Изменения доступа сериализуются, чтобы параллельные администраторы не лишили систему всех администраторов. После пяти неверных входов учётная запись блокирует новые входы на 15 минут; административный сброс снимает это ограничение.
|
||||
|
||||
Все 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`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
|
||||
|
||||
Проверки: `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 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 настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
|
||||
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422. Явно пустая отрасль (`branch=`) также отклоняется для списка, карточки и XLSX; отсутствие параметра сохраняет значение по умолчанию «технические».
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
Начальный список диссовета содержит только сотрудников и счётчики. Публикации загружаются при раскрытии карточки через `/admin/council/employees/{id}/articles` с сохранением отрасли и специальности; повторное раскрытие использует уже загруженные данные текущей страницы. При ошибке доступна кнопка повтора, без JavaScript — ссылка на публикации. Экспорт XLSX продолжает включать все найденные записи.
|
||||
|
||||
До загрузки публикаций применяется поиск по ФИО и выбор конкретного сотрудника. Проверка одинаковых ФИО остаётся глобальной, а общий счётчик сотрудников не зависит от поиска. PostgreSQL возвращает только разделы профиля об образовании/степенях и необходимые поля публикаций; аннотации, авторы и остальной профиль не загружаются. Plain-text описания журналов не проходят через HTML-парсер; HTML и сущности обрабатываются как прежде.
|
||||
|
||||
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
|
||||
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.
|
||||
|
||||
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
|
||||
|
||||
Расчёт использует пять календарных лет, включая текущий год Москвы: в 2026 году — 2022–2026. Точные день и месяц для периода не нужны; конфликт годов остаётся причиной проверки. В форме фильтров выбирается отрасль оценки, которая задаёт порог для доктора (9 для технических, 11 для физико-математических и т. д.); для кандидата/PhD минимум 20. Выбранная отрасль сохраняется при переходах страниц и выгрузке XLSX, а все пороги вынесены в пояснения над списком.
|
||||
|
||||
Статья с известным видом материала, журналом и квартилем Q1/Q2 из источника, применимым к году публикации, автоматически входит в «Подтверждено», если год входит в период и нет конфликтов идентичности, вида и годов. Неизвестный вид, отсутствующий журнал, квартиль другого года или ручная замена квартиля без источника не дают автоматического зачёта. Подтверждение степени и рассмотрение содержания статьи по специальности оцениваются отдельно.
|
||||
|
||||
Редактор позволяет заменить связь журнала с одной или несколькими из доступных специальностей. Ручная связь применяется ко всем статьям журнала независимо от года; в фильтр попадают допустимые публикации за календарный период. Пустой ручной список исключает связь, переключатель «Использовать связь из справочников» восстанавливает справочные данные. Связь тематики журнала не заменяет рассмотрение содержания статьи. Ручные специальности сохраняются в той же таблице, в разделе `specialties`; дополнительная миграция не нужна.
|
||||
|
||||
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
|
||||
|
||||
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
|
||||
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, пограничные даты и неподтверждённые основания не создают положительного заключения.
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
|
||||
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы 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/RSCI, для доктора наук также К2. Для кандидата/PhD самостоятельный К2 исключается; работа с другим подходящим основанием учитывается один раз. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
|
||||
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
## Парсинг
|
||||
|
||||
## Парсинг
|
||||
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `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` не считаются подтверждением увольнения;
|
||||
|
||||
```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` не считаются подтверждением увольнения;
|
||||
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
|
||||
- кнопка «Проверить уволенных» сверяет только их 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.
|
||||
|
||||
## Проверки
|
||||
|
||||
@@ -174,8 +210,7 @@ node --check app/static/admin.js
|
||||
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
|
||||
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
|
||||
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
|
||||
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. Число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации в компактный XLSX не включаются.
|
||||
|
||||
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
|
||||
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
|
||||
@@ -250,3 +285,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
|
||||
### Подсказки первого входа
|
||||
|
||||
После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
|
||||
|
||||
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+196
-33
@@ -1,14 +1,18 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from copy import deepcopy
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||
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
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
||||
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)
|
||||
import secrets
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
format_admin_datetime,
|
||||
@@ -19,17 +23,18 @@ from app.services.admin_data import (
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.services.council import CouncilInputError, council_candidates
|
||||
from app.services.council import CouncilInputError, council_candidates, _journal_key
|
||||
from app.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.filters["admin_datetime"] = format_admin_datetime
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
counts = stats_payload(db)
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
@@ -40,25 +45,48 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
response = _render(request, "login.html", {"error": None, "csrf_token": csrf})
|
||||
response.set_cookie(LOGIN_CSRF_COOKIE, csrf, httponly=True, samesite="strict", max_age=600, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
username: str = Form(..., max_length=64),
|
||||
password: str = Form(..., max_length=1024),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
try:
|
||||
user = authenticate(db, username, password, settings)
|
||||
if user is None:
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль, либо вход временно недоступен",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=401)
|
||||
db.add(UserAction(user_id=user.id, action="login"))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "login.html", {"error": "Вход временно недоступен. Попробуйте ещё раз",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=503)
|
||||
redirect = RedirectResponse("/admin/account" if user.must_change_password else "/admin", status_code=303)
|
||||
redirect.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')
|
||||
redirect.delete_cookie(LOGIN_CSRF_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
def logout(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = optional_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if user is not None:
|
||||
try:
|
||||
user.session_version += 1
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось завершить сессии. Попробуйте ещё раз")
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
@@ -69,9 +97,10 @@ def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@@ -84,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,
|
||||
@@ -91,7 +121,7 @@ def directory(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
@@ -105,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,
|
||||
@@ -125,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"],
|
||||
@@ -135,26 +167,152 @@ def directory(
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
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, **result})
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
||||
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
|
||||
if not result["candidates"]:
|
||||
raise HTTPException(404, "Сотрудник не найден или не соответствует фильтру. Обновите список")
|
||||
except CouncilInputError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
|
||||
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
|
||||
"can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
class CouncilArticleEdit(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True)
|
||||
employee_id: int = Field(gt=0)
|
||||
edit_key: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
revision: int = Field(ge=0)
|
||||
title: str = Field(min_length=1, max_length=2000)
|
||||
journal: str = Field(min_length=1, max_length=500)
|
||||
quartile: str = Field(pattern=r"^(Q[1-4])?$")
|
||||
specialty_codes: list[str] | None = Field(default=None, max_length=12)
|
||||
|
||||
|
||||
@router.get("/council/publications", response_class=HTMLResponse)
|
||||
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}
|
||||
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"]}
|
||||
for candidate in result["candidates"] for article in candidate["articles"]
|
||||
if employee.casefold().strip() in candidate["name"].casefold()
|
||||
and title.casefold().strip() in article["title"].casefold()
|
||||
and journal.casefold().strip() in " ".join(article["journals"]).casefold()]
|
||||
total, limit = len(articles), 50
|
||||
offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0
|
||||
return _render(request, "council_publications.html", {**context, "error": False,
|
||||
"articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit,
|
||||
"revision": result["revision"], "journal_names": result["journal_names"], "specialties": result["specialties"]})
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council_publications.html", {**context, "error": True}, status_code=503)
|
||||
|
||||
|
||||
@router.post("/council/publications")
|
||||
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)
|
||||
username = actor.username
|
||||
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
|
||||
if request.headers.get("X-Council-Edit") != "1":
|
||||
raise HTTPException(403, "Недопустимый запрос на изменение")
|
||||
if not _journal_key(change.journal):
|
||||
raise HTTPException(422, "Укажите название журнала")
|
||||
try:
|
||||
result = council_candidates(db, employee_id=change.employee_id, paginate=False)
|
||||
article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id
|
||||
for a in c["articles"] if a["edit_key"] == change.edit_key), None)
|
||||
if not article:
|
||||
raise HTTPException(404, "Статья не найдена. Обновите страницу")
|
||||
if article["year"] is None and change.quartile:
|
||||
raise HTTPException(422, "Для квартиля необходим однозначный год публикации")
|
||||
if change.specialty_codes is not None and not set(change.specialty_codes) <= {item["code"] for item in result["specialties"]}:
|
||||
raise HTTPException(422, "Неизвестная специальность ВАК")
|
||||
record = db.get(CouncilCorrections, 1)
|
||||
revision = record.revision if record else 0
|
||||
if revision != change.revision:
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
data = deepcopy(record.data) if record else {"articles": {}, "journals": {}}
|
||||
if change.title != article["title"] or change.journal != article["edit_journal"]:
|
||||
data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal}
|
||||
specialty_data = data.setdefault("specialties", {})
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
if change.specialty_codes is None:
|
||||
specialty_data.pop(_journal_key(change.journal), None)
|
||||
else:
|
||||
specialty_data[_journal_key(change.journal)] = sorted(set(change.specialty_codes))
|
||||
if article["year"] is not None and (change.quartile != article["edit_quartile"] or change.journal != article["edit_journal"]):
|
||||
data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile
|
||||
if record:
|
||||
count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1,
|
||||
CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1,
|
||||
updated_by=username)).rowcount
|
||||
if not count:
|
||||
db.rollback()
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
else:
|
||||
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
|
||||
changes = {}
|
||||
for field, before, after in (("title", article["title"], change.title),
|
||||
("journal", " / ".join(article["journals"]), change.journal),
|
||||
("quartile", article["edit_quartile"], change.quartile)):
|
||||
if before != after:
|
||||
changes[field] = {"before": before, "after": after}
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
before = {"manual": article["manual_specialties"], "codes": article["edit_specialty_codes"] if article["manual_specialties"] else None}
|
||||
after = {"manual": change.specialty_codes is not None,
|
||||
"codes": sorted(set(change.specialty_codes or [])) if change.specialty_codes is not None else None}
|
||||
if before != after:
|
||||
changes["specialty_codes"] = {"before": before, "after": after}
|
||||
if changes:
|
||||
candidate = next(c for c in result["candidates"] if c["employee_id"] == change.employee_id)
|
||||
db.add(UserAction(user_id=actor.id, action="card_edit", employee_id=change.employee_id,
|
||||
employee_name=candidate["name"], publication_year=article["year"], publication_key=change.edit_key, article_title=change.title, changes=changes))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз")
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
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):
|
||||
@@ -171,7 +329,7 @@ def employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
@@ -202,7 +360,7 @@ def refresh_employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
@@ -213,7 +371,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_admin(request, settings)
|
||||
require_user(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()
|
||||
@@ -227,7 +385,7 @@ def run_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
@@ -241,7 +399,7 @@ def trigger_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
@@ -260,7 +418,7 @@ def crawl_now(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -279,7 +437,7 @@ def refresh_dismissed(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -294,11 +452,16 @@ def refresh_dismissed(
|
||||
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
|
||||
payload = {
|
||||
"request": request,
|
||||
"current_user": getattr(request.state, "user", None),
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
response = templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
if getattr(request.state, "user", None) is not None or template == "login.html":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
|
||||
+11
-9
@@ -7,12 +7,12 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlRun, Employee
|
||||
from app.security import require_admin
|
||||
from app.security import require_admin, require_user, check_csrf
|
||||
from app.services.admin_data import employee_display_payload, list_employees_page, run_detail_payload, run_payload, stats_payload
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -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,
|
||||
@@ -36,7 +37,7 @@ def list_employees(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
@@ -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,
|
||||
@@ -59,7 +61,7 @@ def get_employee(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return {"error": "not_found"}
|
||||
@@ -73,7 +75,7 @@ def list_crawl_runs(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(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 +86,7 @@ def latest_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(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 +99,7 @@ def get_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return {"error": "not_found"}
|
||||
@@ -111,7 +113,7 @@ def trigger_crawl(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
if running:
|
||||
return {"status": "already_running", "run": run_payload(running)}
|
||||
@@ -130,7 +132,7 @@ def stats(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return stats_payload(db)
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -26,6 +26,9 @@ def init_db() -> None:
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_runtime_schema()
|
||||
from app.security import ensure_bootstrap_admin
|
||||
with SessionLocal() as db:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
|
||||
|
||||
def _ensure_runtime_schema() -> None:
|
||||
|
||||
@@ -3,12 +3,14 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
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.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
|
||||
+51
-1
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy import CheckConstraint, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -243,3 +243,53 @@ class ParseResourceCache(Base):
|
||||
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class CouncilCorrections(Base):
|
||||
"""Manual council data, outside crawler-owned publications."""
|
||||
__tablename__ = "council_corrections"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
data: Mapped[dict] = mapped_column(json_type, nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
class SystemUser(Base):
|
||||
__tablename__ = "system_users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", 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)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
session_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
first_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class UserAction(Base):
|
||||
__tablename__ = "user_actions"
|
||||
__table_args__ = (Index("ix_user_actions_user_created", "user_id", "created_at"),
|
||||
Index("ix_user_actions_target_created", "target_user_id", "created_at"))
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("system_users.id"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("system_users.id"))
|
||||
employee_id: Mapped[int | None] = mapped_column(Integer)
|
||||
employee_name: Mapped[str | None] = mapped_column(Text)
|
||||
publication_year: Mapped[int | None] = mapped_column(Integer)
|
||||
publication_key: Mapped[str | None] = mapped_column(String(64))
|
||||
article_title: Mapped[str | None] = mapped_column(Text)
|
||||
changes: Mapped[dict] = mapped_column(json_type, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
+181
-25
@@ -2,45 +2,201 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
SESSION_SECONDS = 12 * 60 * 60
|
||||
PASSWORD_ITERATIONS = 600_000
|
||||
|
||||
|
||||
def verify_admin(username: str, password: str, settings: Settings) -> bool:
|
||||
return hmac.compare_digest(username, settings.admin_username) and hmac.compare_digest(
|
||||
password, settings.admin_password
|
||||
)
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": username, "iat": int(time.time())}, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
signature = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}.{signature}"
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt, expected = encoded.split('$')
|
||||
if scheme != 'pbkdf2_sha256' or int(iterations) != PASSWORD_ITERATIONS:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), PASSWORD_ITERATIONS)
|
||||
return hmac.compare_digest(actual, bytes.fromhex(expected))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY_HASH = hash_password('dummy password for unknown account timing')
|
||||
|
||||
|
||||
def temporary_password() -> str:
|
||||
groups = ('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnpqrstuvwxyz', '23456789', '!@#$%+-_')
|
||||
characters = [secrets.choice(group) for group in groups]
|
||||
characters.extend(secrets.choice(''.join(groups)) for _ in range(4))
|
||||
secrets.SystemRandom().shuffle(characters)
|
||||
return ''.join(characters)
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(db: Session, settings: Settings) -> SystemUser:
|
||||
username = settings.admin_username.strip().casefold()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
if user is not None:
|
||||
return user
|
||||
user = SystemUser(username=username, role='admin', password_hash=hash_password(settings.admin_password),
|
||||
must_change_password=False)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
return user
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings, version: int = 0) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({'sub': username, 'iat': int(time.time()),
|
||||
'v': version, 'csrf': secrets.token_urlsafe(24)}, separators=(',', ':')).encode()).decode().rstrip('=')
|
||||
signature = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return f'{payload}.{signature}'
|
||||
|
||||
|
||||
def _session_data(token: str | None, settings: Settings) -> dict | None:
|
||||
if not token or len(token) > 2048 or '.' not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit('.', 1)
|
||||
expected = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode((payload + '=' * (-len(payload) % 4)).encode()))
|
||||
now = int(time.time())
|
||||
if (not isinstance(data, dict) or not isinstance(data.get('sub'), str)
|
||||
or type(data.get('iat')) is not int or not now - SESSION_SECONDS <= data['iat'] <= now + 60
|
||||
or type(data.get('v')) is not int or not isinstance(data.get('csrf'), str)):
|
||||
return None
|
||||
return data
|
||||
except (ValueError, TypeError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_session(token: str | None, settings: Settings) -> str | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit(".", 1)
|
||||
expected = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
data = _session_data(token, settings)
|
||||
return data['sub'] if data else None
|
||||
|
||||
|
||||
def session_csrf(request: Request, settings: Settings) -> str:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
return data['csrf'] if data else ''
|
||||
|
||||
|
||||
async def check_csrf(request: Request, settings: Settings = Depends(get_settings)):
|
||||
if request.method != 'POST':
|
||||
return
|
||||
origin = request.headers.get('origin')
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
raise HTTPException(403, 'Недопустимый источник запроса')
|
||||
if parsed.scheme not in {'http', 'https'} or parsed.netloc.casefold() != request.url.netloc.casefold():
|
||||
raise HTTPException(403, 'Запрос должен исходить со страницы этой системы')
|
||||
# JSON with a custom header cannot be submitted by a cross-site HTML form; CORS stays disabled.
|
||||
if request.headers.get('X-Council-Edit') == '1' and request.headers.get('content-type', '').startswith('application/json'):
|
||||
return
|
||||
expected = (request.cookies.get(LOGIN_CSRF_COOKIE, '') if request.url.path == '/admin/login'
|
||||
else session_csrf(request, settings))
|
||||
if request.url.path == '/admin/logout' and not expected:
|
||||
return
|
||||
supplied = request.headers.get('X-CSRF-Token', '')
|
||||
if not supplied:
|
||||
form = await request.form()
|
||||
supplied = form.get('csrf_token', '')
|
||||
if not expected or not isinstance(supplied, str) or not hmac.compare_digest(supplied.encode(), expected.encode()):
|
||||
raise HTTPException(403, 'Обновите страницу и повторите действие')
|
||||
|
||||
|
||||
def optional_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser | None:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not data:
|
||||
return None
|
||||
query = select(SystemUser).where(SystemUser.username == data['sub'].casefold())
|
||||
if lock:
|
||||
query = query.with_for_update().execution_options(populate_existing=True)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
except Exception:
|
||||
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, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
if user is None or not user.is_active or user.session_version != data['v']:
|
||||
return None
|
||||
return data.get("sub")
|
||||
request.state.user = user
|
||||
request.state.csrf_token = data['csrf']
|
||||
if user.must_change_password and not allow_password_change:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(403, 'Сначала смените временный пароль в личном кабинете')
|
||||
raise HTTPException(303, headers={'Location': '/admin/account'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings) -> str:
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
|
||||
return username
|
||||
def require_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser:
|
||||
user = optional_user(request, settings, db, allow_password_change=allow_password_change, lock=lock)
|
||||
if user is None:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(401, 'Для этого действия войдите в систему')
|
||||
raise HTTPException(303, headers={'Location': '/admin/login'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
|
||||
user = require_user(request, settings, db)
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(403, 'Это действие доступно только администратору')
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str, settings: Settings) -> SystemUser | None:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username.strip().casefold()).with_for_update())
|
||||
valid = verify_password(password, user.password_hash if user else _DUMMY_HASH)
|
||||
if user is None:
|
||||
return None
|
||||
now = utcnow()
|
||||
locked_until = user.locked_until
|
||||
if locked_until and locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if not user.is_active or (locked_until and locked_until > now):
|
||||
return None
|
||||
if not valid:
|
||||
if locked_until:
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts = 0
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= 5:
|
||||
user.locked_until = now + timedelta(minutes=15)
|
||||
db.commit()
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
if user.first_login_at is None:
|
||||
user.first_login_at = now
|
||||
user.must_change_password = True
|
||||
user.last_login_at = now
|
||||
return user
|
||||
@@ -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,
|
||||
|
||||
+170
-38
@@ -1,6 +1,5 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import calendar
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
@@ -11,14 +10,15 @@ from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from sqlalchemy import select, func, case, cast
|
||||
from sqlalchemy.dialects.postgresql import JSONPATH
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Employee
|
||||
from app.models import Employee, EmployeePublication, CouncilCorrections, json_type
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.council_source import _doi, _journal_names, _name, load_reference, _description_journals
|
||||
from app.services.council_catalog import catalog, publication_catalog
|
||||
from app.services.publication_quartiles import labels as quartile_labels, identity_issues, hse_entries, merge as merge_quartiles
|
||||
from app.services.publication_quartiles import qualifying_entries, identity_issues, hse_entries, merge as merge_quartiles
|
||||
from app.services.council_assessment import (
|
||||
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
|
||||
publication_type_allowed, sourced,
|
||||
@@ -50,8 +50,7 @@ def _safe_url(value: str | None) -> str | None:
|
||||
|
||||
|
||||
def period(today: date) -> tuple[date, date]:
|
||||
year = today.year - 5
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
return date(today.year - 4, 1, 1), date(today.year, 12, 31)
|
||||
|
||||
|
||||
def _journal_key(value: str) -> str:
|
||||
@@ -103,7 +102,7 @@ def _articles_by_author() -> dict[str, list[dict]]:
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
imported = raw.get("csv_enrichment")
|
||||
from_site = not imported or bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
|
||||
from_site = not imported or bool(publication.publication_id or publication.url or getattr(publication, "raw_has_metadata", any(key != "csv_enrichment" for key in raw)))
|
||||
origins = ["ЛК сотрудника ВШЭ"] if from_site else []
|
||||
if imported:
|
||||
origins.append("Данные ВШЭ")
|
||||
@@ -122,10 +121,12 @@ def _database_article(publication) -> dict:
|
||||
if isinstance(imported, dict) and not imported_issues and (not imported_doi or imported_doi == doi):
|
||||
journals.extend(_journal_names(imported))
|
||||
issues = list(dict.fromkeys([*issues, *imported_issues]))
|
||||
quartile_entries = [*qualifying_entries(publication.quartiles, publication.year, doi, current_journals, identifiers),
|
||||
*qualifying_entries(imported_quartiles, publication.year, doi, current_journals, identifiers)]
|
||||
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
|
||||
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
|
||||
"quartiles": list(dict.fromkeys([*quartile_labels(publication.quartiles, publication.year, doi, current_journals, identifiers),
|
||||
*quartile_labels(imported_quartiles, publication.year, doi, current_journals, identifiers)])),
|
||||
"quartiles": list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in quartile_entries)),
|
||||
"quartile_entries": quartile_entries,
|
||||
"quartile_issues": issues,
|
||||
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)],
|
||||
"source_hashes": [publication.source_hash],
|
||||
@@ -133,7 +134,7 @@ def _database_article(publication) -> dict:
|
||||
"url": _safe_url(publication.url)}
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
def _unique_articles(articles: list[dict], start: date, end: date, corrections=None, employee_id=None) -> list[dict]:
|
||||
k1_journals = _k1_journals()
|
||||
specialties = _k1_specialties()["by_journal"]
|
||||
title_years = defaultdict(set)
|
||||
@@ -171,12 +172,41 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
merged["in_csv"] |= article["in_csv"]
|
||||
merged["in_db"] |= article["in_db"]
|
||||
merged["url"] = merged["url"] or article["url"]
|
||||
for article in unique.values():
|
||||
for identity, article in unique.items():
|
||||
evidence = article.pop("evidence")
|
||||
article["edit_key"] = hashlib.sha256(json.dumps(
|
||||
identity,
|
||||
ensure_ascii=False).encode()).hexdigest()
|
||||
correction = (corrections or {}).get("articles", {}).get(f"{employee_id}:{article['edit_key']}")
|
||||
article["manually_corrected"] = bool(correction)
|
||||
article["journal_corrected"] = bool(correction and article["journals"] != [correction["journal"]])
|
||||
if correction:
|
||||
journal_changed = article["journal_corrected"]
|
||||
article["title"] = correction["title"]
|
||||
article["journals"] = [correction["journal"]]
|
||||
if journal_changed:
|
||||
article["issns"] = []
|
||||
article["quartiles"] = []
|
||||
article["quartile_issues"] = []
|
||||
evidence = [{**item, "journals": article["journals"],
|
||||
"quartiles": [] if journal_changed else item["quartiles"]} for item in evidence]
|
||||
years = sorted({item["year"] for item in evidence if item["year"] is not None})
|
||||
# Never pick a conflicting source year merely because that row came first.
|
||||
year = article["year"] = years[0] if len(years) == 1 else None
|
||||
article["source_years"] = years
|
||||
article["automatic_quartile_bases"] = []
|
||||
if len(article["journals"]) == 1 and not article["quartile_issues"] and not article["journal_corrected"]:
|
||||
for item in evidence:
|
||||
if item["year"] != year:
|
||||
continue
|
||||
details = item.get("quartile_entries")
|
||||
if details is None:
|
||||
# Prepared CSV evidence has no category or metric metadata.
|
||||
details = [{"quartile": label[-2:], "system": label[:-3],
|
||||
"source": " / ".join(item["origins"]), "source_url": item.get("url")}
|
||||
for label in item["quartiles"] if label.endswith((" Q1", " Q2"))]
|
||||
article["automatic_quartile_bases"].extend(
|
||||
{**entry, "basis": entry["quartile"], "applicable_year": year} for entry in details)
|
||||
article["criteria"] = [*article["quartiles"], *article["quartile_issues"]]
|
||||
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals))
|
||||
@@ -209,10 +239,9 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
elif year is None:
|
||||
article["criteria"].append("Год не указан — период не подтверждён")
|
||||
elif start.year <= year <= end.year:
|
||||
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
|
||||
else "В пределах пяти лет")
|
||||
article["criteria"].append(f"В пределах пятилетнего периода {start.year}–{end.year}")
|
||||
else:
|
||||
article["criteria"].append("Вне пятилетнего периода")
|
||||
article["criteria"].append(f"Вне пятилетнего периода {start.year}–{end.year}")
|
||||
if not article["quartiles"] and not matches:
|
||||
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
|
||||
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
|
||||
@@ -224,35 +253,87 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
|
||||
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 = "технические") -> 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), "")
|
||||
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
|
||||
degree_sections = Employee.current_data["sections"]
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
degree_sections = func.jsonb_path_query_array(Employee.current_data,
|
||||
cast('$.sections[*] ? (@.title like_regex "образован|степен|academic degree|education" flag "i")', JSONPATH), type_=json_type)
|
||||
employees = db.execute(select(Employee.id, Employee.profile_key, Employee.full_name, Employee.status,
|
||||
Employee.canonical_url, degree_sections.label("degree_sections"))).all()
|
||||
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
|
||||
by_author = _articles_by_author()
|
||||
verified = evidence()
|
||||
candidates = []
|
||||
corrections_record = db.get(CouncilCorrections, 1)
|
||||
corrections = corrections_record.data if corrections_record else {}
|
||||
revision = corrections_record.revision if corrections_record else 0
|
||||
eligible = []
|
||||
degrees_by_employee = {}
|
||||
for employee in employees:
|
||||
try:
|
||||
degrees = academic_degrees({"sections": employee.degree_sections})
|
||||
except (AttributeError, TypeError):
|
||||
degrees = []
|
||||
if any(degree_level(degree) for degree in degrees) or verified["employees"].get(employee.profile_key, {}).get("degrees"):
|
||||
eligible.append(employee)
|
||||
degrees_by_employee[employee.id] = degrees
|
||||
total = len(eligible)
|
||||
query = _name(q)
|
||||
eligible = [employee for employee in eligible if query in _name(employee.full_name or "ФИО не указано")
|
||||
and (employee_id is None or employee.id == employee_id)]
|
||||
publication_fields = [EmployeePublication.employee_id, EmployeePublication.title, EmployeePublication.year,
|
||||
EmployeePublication.publication_type, EmployeePublication.publication_id, EmployeePublication.url,
|
||||
EmployeePublication.doi_url, EmployeePublication.source_hash, EmployeePublication.quartiles]
|
||||
raw_data, description = EmployeePublication.raw_data, EmployeePublication.description
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
# Only council metadata crosses the DB boundary; abstracts/authors stay in PostgreSQL.
|
||||
description = case((func.jsonb_typeof(description) == "object", func.jsonb_build_object(
|
||||
"short", description["short"], "shortLeft", description["shortLeft"], type_=json_type)),
|
||||
else_=description)
|
||||
raw = EmployeePublication.raw_data
|
||||
raw_has_metadata = case((func.jsonb_typeof(raw) == "object",
|
||||
raw != func.jsonb_build_object("csv_enrichment", raw["csv_enrichment"])), else_=False)
|
||||
raw_keys = ("csv_enrichment", "journal", "Журнал", "Название журнала", "issn", "eissn", "ISSN", "EISSN")
|
||||
raw_data = case((func.jsonb_typeof(raw) == "object", func.jsonb_build_object(
|
||||
*[value for key in raw_keys for value in (key, raw[key])],
|
||||
"description", case((func.jsonb_typeof(EmployeePublication.description) == "object", None),
|
||||
else_=raw["description"]), type_=json_type)), else_=None)
|
||||
publication_fields.append(raw_has_metadata.label("raw_has_metadata"))
|
||||
publications_by_employee = defaultdict(list)
|
||||
if eligible:
|
||||
publications = db.execute(select(*publication_fields, raw_data.label("raw_data"), description.label("description"))
|
||||
.where(EmployeePublication.employee_id.in_([e.id for e in eligible]))).all()
|
||||
for publication in publications:
|
||||
publications_by_employee[publication.employee_id].append(publication)
|
||||
candidates = []
|
||||
for employee in eligible:
|
||||
author = _name(employee.full_name or "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
data = employee.current_data if isinstance(employee.current_data, dict) else {}
|
||||
try:
|
||||
degrees = academic_degrees(data)
|
||||
except (AttributeError, TypeError):
|
||||
degrees = []
|
||||
degrees = degrees_by_employee[employee.id]
|
||||
employee_evidence = verified["employees"].get(employee.profile_key, {})
|
||||
if not any(degree_level(degree) for degree in degrees) and not employee_evidence.get("degrees"):
|
||||
continue
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in publications_by_employee[employee.id])], start, end, corrections, employee.id)
|
||||
for article in articles:
|
||||
proofs = publication_proofs(employee_evidence, article)
|
||||
article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and
|
||||
f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {}))
|
||||
proofs = [] if article["manual_review"] else publication_proofs(employee_evidence, article)
|
||||
kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
|
||||
kind = next(iter(kinds)) if len(kinds) == 1 else None
|
||||
preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed(
|
||||
@@ -260,7 +341,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
if not preliminary_type_allowed:
|
||||
for flag in ("q1", "q2", "k1", "k2", "rsci"):
|
||||
article[flag] = False
|
||||
identifiers = [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
|
||||
identifiers = [] if article["journal_corrected"] else [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
|
||||
for name in article["journals"]:
|
||||
canonical = _k1_journals().get(_journal_key(name))
|
||||
identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, []))
|
||||
@@ -278,13 +359,62 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
|
||||
if key:
|
||||
article[key] = True
|
||||
assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end)
|
||||
if not article["catalog_issues"] and not article["journal_corrected"]:
|
||||
article["automatic_quartile_bases"].extend({"basis": metric["quartile"], "system": metric["system"],
|
||||
"metric": metric["metric"], "category": metric["category"], "metric_year": metric["year"],
|
||||
"applicable_year": article["year"], "source": metric["source"], "source_url": metric["source_url"]}
|
||||
for record in article["catalog_records"] for metric in record["metrics"]
|
||||
if article["year"] == metric["year"] and metric["quartile"] in {"Q1", "Q2"})
|
||||
journal_key = _journal_key(article["journals"][0]) if len(article["journals"]) == 1 else ""
|
||||
manual_quartile = corrections.get("journals", {}).get(f"{journal_key}:{article['year']}")
|
||||
if manual_quartile is not None:
|
||||
article["quartiles"] = [f"Ручная корректировка {manual_quartile}"] if manual_quartile else []
|
||||
article["q1"] = bool(manual_quartile == "Q1" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
|
||||
article["q2"] = bool(manual_quartile == "Q2" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
|
||||
article["criteria"] = [c for c in article["criteria"] if not c.endswith((" Q1", " Q2", " Q3", " Q4")) and c != "Q1/Q2 и К1 не подтверждены доступными данными"]
|
||||
article["criteria"].append(f"Квартиль журнала за {article['year']}: {manual_quartile or 'не указан'} · ручная корректировка, требует подтверждения")
|
||||
if article["manual_review"]:
|
||||
article["automatic_quartile_bases"] = []
|
||||
manual_specialties = corrections.get("specialties", {}).get(journal_key) if journal_key else None
|
||||
article["manual_specialties"] = manual_specialties is not None
|
||||
default_specialties = sorted(set().union(
|
||||
*(_k1_specialties()["by_journal"].get(_k1_journals().get(_journal_key(name)), []) for name in article["journals"]),
|
||||
*(record["specialty_codes"] for record in article["catalog_records"])))
|
||||
article["edit_specialty_codes"] = manual_specialties if manual_specialties is not None else default_specialties
|
||||
if manual_specialties is not None:
|
||||
eligible = preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year
|
||||
article["specialty_codes"] = manual_specialties if eligible else []
|
||||
for record in article["catalog_records"]:
|
||||
record["thematic"] = (["Связь журнала со специальностью: ручная корректировка"] if specialty in manual_specialties
|
||||
else ["Связь журнала с выбранной специальностью исключена вручную"] if specialty else [])
|
||||
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
|
||||
labels = {q[-2:] for q in article["quartiles"] if q.endswith((" Q1", " Q2", " Q3", " Q4"))}
|
||||
if manual_quartile is None:
|
||||
labels.update(metric["quartile"] for record in article["catalog_records"] for metric in record["metrics"]
|
||||
if metric["year"] == article["year"] and metric["quartile"] in {"Q1", "Q2", "Q3", "Q4"})
|
||||
labels.update(basis["basis"] for proof in proofs for basis in proof.get("bases", [])
|
||||
if isinstance(basis, dict) and basis.get("applicable_year") == article["year"] and basis.get("basis") in {"Q1", "Q2", "Q3", "Q4"})
|
||||
article["edit_quartile"] = manual_quartile if manual_quartile is not None else next(iter(labels)) if len(labels) == 1 else ""
|
||||
article["edit_journal"] = article["journals"][0] if len(article["journals"]) == 1 else ""
|
||||
changed = [a for a in articles if a["manual_review"]]
|
||||
adjusted_evidence = {**employee_evidence, "publications": [p for p in employee_evidence.get("publications", [])
|
||||
if not any((a["doi"] and p.get("doi") == a["doi"]) or p.get("source_hash") in a["source_hashes"] for a in changed)]}
|
||||
assessment = assess_candidate(degrees, articles, adjusted_evidence, branch, specialty, start, end)
|
||||
for article in changed:
|
||||
message = "Журнал или квартиль исправлен вручную. Для зачёта требуется источник, подтверждающий эти сведения за год статьи"
|
||||
article["confirmation_reasons"].append(message)
|
||||
article["warnings"]["quartile"].append(message)
|
||||
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
|
||||
for article in articles:
|
||||
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
|
||||
if article["matches_specialty"]:
|
||||
article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
|
||||
candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано",
|
||||
article["criteria"].append(f"По специальности {specialty}: {specialty_name} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})")
|
||||
for article in articles:
|
||||
if specialty:
|
||||
article["warnings"]["specialty"].extend(text for record in article["catalog_records"]
|
||||
for text in record["thematic"] if not text.startswith(("Паспорт ", "Аналитическая связь журнала:")))
|
||||
article["messages"] = list(dict.fromkeys([*article["criteria"], *article["confirmation_reasons"]]))
|
||||
candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id,
|
||||
"count": len(articles),
|
||||
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")},
|
||||
"total_count": sum(a["qualifies"] for a in articles),
|
||||
@@ -296,17 +426,19 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
|
||||
if sort != "full_name":
|
||||
candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
|
||||
total = len(candidates)
|
||||
query = _name(q)
|
||||
candidates = [item for item in candidates if query in _name(item["name"])
|
||||
and (not specialty or item["specialty_count"] > 0)]
|
||||
candidates = [item for item in candidates if not specialty or item["specialty_count"] > 0]
|
||||
matched = len(candidates)
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
criteria_groups = {}
|
||||
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,
|
||||
"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)
|
||||
|
||||
@@ -127,48 +127,54 @@ def publication_proofs(employee_evidence, article):
|
||||
|
||||
def assess_article(article, proofs, start, end, level, branch, specialty, policy):
|
||||
reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])]
|
||||
warnings = {"journal": list(dict.fromkeys(reasons)), "year": [], "type": [], "quartile": [], "specialty": []}
|
||||
def report(field, message):
|
||||
reasons.append(message)
|
||||
warnings[field].append(message)
|
||||
years = article["source_years"]
|
||||
dates = set()
|
||||
date_conflict = False
|
||||
for proof in proofs:
|
||||
if proof.get("publication_date"):
|
||||
if proof.get("publication_date") and sourced(proof.get("date_evidence")):
|
||||
try:
|
||||
value = iso_date(proof["publication_date"])
|
||||
if sourced(proof.get("date_evidence")):
|
||||
dates.add(value)
|
||||
else:
|
||||
reasons.append("Источник точной даты не подтверждён")
|
||||
dates.add(iso_date(proof["publication_date"]))
|
||||
except ValueError:
|
||||
reasons.append("Некорректная точная дата")
|
||||
date_conflict = True
|
||||
if len(years) > 1 or len(dates) > 1 or (dates and years and next(iter(dates)).year not in years):
|
||||
reasons.append("Даты публикации в источниках расходятся")
|
||||
date_conflict = True
|
||||
pass # Exact dates are optional; the calendar-year window uses the publication year.
|
||||
date_years = {value.year for value in dates}
|
||||
date_conflict = len(years) > 1 or len(date_years) > 1 or bool(date_years and years and not date_years <= set(years))
|
||||
if date_conflict:
|
||||
report("year", "Источники указывают разные годы публикации. Уточните год статьи")
|
||||
exact = next(iter(dates)) if len(dates) == 1 else None
|
||||
in_period = start < exact < end if exact else len(years) == 1 and start.year < years[0] < end.year
|
||||
if exact and exact in {start, end}:
|
||||
boundary = policy.get("boundary_dates", {})
|
||||
in_period = sourced(boundary) and boundary.get("value") == "inclusive"
|
||||
if not in_period:
|
||||
reasons.append("Включение граничной даты не подтверждено")
|
||||
publication_year = article["year"] if article["year"] is not None else next(iter(date_years)) if len(date_years) == 1 else None
|
||||
in_period = publication_year is not None and start.year <= publication_year <= end.year
|
||||
if not in_period:
|
||||
reasons.append("Пятилетний период не подтверждён")
|
||||
report("year", f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None
|
||||
else "Год не указан — период не подтверждён")
|
||||
types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
|
||||
if len(types) != 1:
|
||||
reasons.append("Вид материала не подтверждён или расходится")
|
||||
kind = next(iter(types)) if len(types) == 1 else None
|
||||
automatic_bases = [b for b in article.get("automatic_quartile_bases", [])
|
||||
if b["applicable_year"] == (exact.year if exact else article["year"])
|
||||
and not article.get("catalog_issues") and not article.get("quartile_issues")]
|
||||
if not types and not (article["types"] == ["Статья"] and automatic_bases):
|
||||
if len(article["types"]) > 1:
|
||||
report("type", "Источники называют разные виды работы: " + ", ".join(article["types"]) + ". Уточните тип материала")
|
||||
elif not article["types"]:
|
||||
report("type", "В источниках не указан тип материала. В карточке показано «Статья», но для зачёта нужно уточнить вид работы")
|
||||
elif article["types"] == ["Статья"]:
|
||||
report("quartile", "Тип «Статья» указан в источнике, но для зачёта по К1/К2/RSCI отдельная запись проверки работы не сохранена. Проверьте основание зачёта")
|
||||
else:
|
||||
report("type", f"Источник указывает вид «{article['types'][0]}». Для включения такого материала в минимум нужно отдельное подтверждение")
|
||||
kind = next(iter(types)) if len(types) == 1 else "article" if not types and article["types"] == ["Статья"] and automatic_bases else None
|
||||
type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)])
|
||||
if type_conflict:
|
||||
reasons.append("Виды материала в источниках расходятся")
|
||||
report("type", "Источники называют разные виды работы. Уточните, является ли публикация статьёй")
|
||||
eligible_kind = publication_type_allowed(article["types"], kind, policy)
|
||||
if kind and kind != "article" and not eligible_kind:
|
||||
reasons.append("Зачёт этого вида материала в общий минимум не подтверждён")
|
||||
report("type", "Это другой вид работы, а не статья. Его включение в публикационный минимум требует отдельного основания")
|
||||
bases = []
|
||||
basis_year = exact.year if exact else article["year"]
|
||||
for proof in proofs:
|
||||
for basis in proof.get("bases", []):
|
||||
if not isinstance(basis, dict):
|
||||
reasons.append("Некорректное подтверждение основания")
|
||||
report("quartile", "Запись об основании зачёта повреждена. Проверьте источник категории журнала")
|
||||
continue
|
||||
category = basis.get("basis")
|
||||
valid = sourced(basis) and type(basis.get("applicable_year")) is int and basis["applicable_year"] == basis_year
|
||||
@@ -179,28 +185,30 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
|
||||
if valid:
|
||||
bases.append(basis)
|
||||
else:
|
||||
reasons.append("Источник или год применимости основания не подтверждены")
|
||||
report("quartile", "Для категории журнала не подтверждён источник или год применимости. Нельзя переносить категорию автоматически на год статьи")
|
||||
if kind == "article":
|
||||
bases.extend(automatic_bases)
|
||||
# Different categories may legitimately have different quartiles; conflicting
|
||||
# claims for the same system/category/year must not be resolved by ordering.
|
||||
claims = {}
|
||||
basis_conflict = False
|
||||
for basis in bases:
|
||||
key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else (
|
||||
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric_year"), basis_year)
|
||||
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric"), basis.get("metric_year"), basis_year)
|
||||
if key in claims and claims[key] != basis["basis"]:
|
||||
reasons.append("Подтверждённые основания источников расходятся")
|
||||
report("quartile", "Подтверждённые основания источников расходятся: для одной системы, категории, метрики и года указаны разные значения")
|
||||
basis_conflict = True
|
||||
claims[key] = basis["basis"]
|
||||
accepted = [b for b in bases if level == 2 or b["basis"] != "К2"]
|
||||
if not accepted:
|
||||
reasons.append("Нет подтверждённого допустимого основания выбранного пункта")
|
||||
report("quartile", f"Нет подтверждённого основания за {basis_year or 'год публикации'}: Q1/Q2, К1{'/К2' if level == 2 else ''} или RSCI. Проверьте категорию журнала, год и источник")
|
||||
# Missing optional evidence is not a veto on another sufficient independent
|
||||
# basis. Identity/date/type conflicts, however, invalidate the whole work.
|
||||
identity_conflict = any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
|
||||
identity_conflict = bool(article.get("quartile_issues")) or any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
|
||||
for reason in article.get("catalog_issues", []))
|
||||
if article.get("proof_identity_conflict"):
|
||||
identity_conflict = True
|
||||
reasons.append("Идентификаторы ручного подтверждения указывают на разные работы")
|
||||
report("journal", "DOI и идентификатор в ручном подтверждении указывают на разные работы. Проверьте привязку источника к этой статье")
|
||||
conflicts = date_conflict or type_conflict or basis_conflict or identity_conflict
|
||||
article["confirmed"] = bool(in_period and eligible_kind and accepted and not conflicts)
|
||||
mono_policy = policy.get("monograph_period", {})
|
||||
@@ -208,6 +216,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
|
||||
and (mono_policy.get("value") == "all_years" or
|
||||
(mono_policy.get("value") == "five_years" and in_period)))
|
||||
article["confirmation_reasons"] = list(dict.fromkeys(reasons))
|
||||
article["warnings"] = warnings
|
||||
article["display_type"] = article["type_label"] if len(article["types"]) == 1 else "Статья"
|
||||
article["publication_date"] = exact
|
||||
article["confirmed_bases"] = [{**b, "source_url": safe_url(b.get("source_url"))} for b in accepted]
|
||||
article["thematic_status"] = "Рассмотрение статьи по паспорту не подтверждено" if specialty else "Специальность для рассмотрения статьи не выбрана"
|
||||
@@ -228,6 +238,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
|
||||
f"пункты {review['point_numbers']}; {review['checked_on']}")
|
||||
if len({r["result"] for r in valid_reviews}) > 1:
|
||||
article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка"
|
||||
if specialty and (not valid_reviews or any(r["result"] != "confirmed" for r in valid_reviews)):
|
||||
warnings["specialty"].append("Связь журнала со специальностью не доказывает соответствие содержания каждой статьи. Рассмотрение этой работы по паспорту специальности не подтверждено")
|
||||
|
||||
|
||||
def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end):
|
||||
|
||||
@@ -149,7 +149,7 @@ def publication_catalog(identifiers, names, specialty=""):
|
||||
return [], [*issues, "ISSN указывают на разные журналы — требуется проверка"]
|
||||
records = data["journals"].get(next(iter(matches)), []) if matches else []
|
||||
if records and names and not any(str(name).casefold().strip() == r["name"].casefold() for name in names for r in records):
|
||||
issues.append("Название журнала и ISSN требуют подтверждения идентичности")
|
||||
issues.append("Название журнала в публикации и запись по ISSN расходятся. Проверьте, относятся ли они к одному журналу")
|
||||
output = []
|
||||
for record in records:
|
||||
link = record["links"].get(specialty)
|
||||
@@ -166,6 +166,7 @@ def publication_catalog(identifiers, names, specialty=""):
|
||||
output.append({"id": record["id"], "name": record["name"], "issn": record.get("issn"), "eissn": record.get("eissn"),
|
||||
"catalog": record["catalog"], "metrics": record["metrics"], "thematic": thematic,
|
||||
"match_level": link["match_level"] if link else None,
|
||||
"specialty_codes": list(record["links"]),
|
||||
"passport_urls": [safe_url(data["passports"][r["passport_id"]]["source_url"]) for r in link["passport_references"]] if link else []})
|
||||
# A conflict is preserved; it never resolves by load order.
|
||||
if len(records) > 1:
|
||||
|
||||
+221
-51
@@ -1,11 +1,22 @@
|
||||
"""XLSX report containing the same public evidence as the council page."""
|
||||
"""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
|
||||
@@ -26,66 +41,59 @@ def council_xlsx(result: dict) -> bytes:
|
||||
def specialties_text(codes):
|
||||
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
|
||||
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
|
||||
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
|
||||
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "ЛК сотрудника ВШЭ", "DOI", "Ссылка на сайте", "Строки выгрузки ВШЭ", "Специальности ВАК", "По выбранной специальности",
|
||||
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
|
||||
"Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
note = "Совпадение ФИО: данные ВШЭ не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
_append(employees, ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего",
|
||||
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
|
||||
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
|
||||
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
|
||||
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"]}
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
|
||||
candidate["k1_count"], candidate["total_count"],
|
||||
candidate["db_count"], candidate["count"],
|
||||
candidate["profile_url"], note, specialties_text(specialty_codes),
|
||||
candidate["specialty_count"] if result["specialty"] else None,
|
||||
candidate["selected_degree"], "Да" if candidate["degree_confirmed"] else "Нет", candidate["clause"],
|
||||
result["branch"], candidate["minimum"], candidate["confirmed_count"],
|
||||
candidate["k2_count"], candidate["rsci_count"],
|
||||
candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
|
||||
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"]])
|
||||
for article in candidate["articles"]:
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
|
||||
"; ".join(article["origins"]), "; ".join(article["criteria"]),
|
||||
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
|
||||
article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
|
||||
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None,
|
||||
*["Да" if article[key] else "Нет" for key in ("k2", "rsci", "confirmed")], article["publication_date"],
|
||||
"; ".join(article["confirmation_reasons"]),
|
||||
"; ".join(f"{b['basis']} · {b.get('system', '')} · {b.get('category', '')} · {b.get('metric', '')} · год метрики {b.get('metric_year', '')} · применимо к {b['applicable_year']} · {b['source']} · {b.get('source_url') or ''}" for b in article["confirmed_bases"]),
|
||||
"; ".join(f"{r['name']} · {r['id']} · ISSN {r['issn']} / {r['eissn']} · {r['catalog']}" for r in article["catalog_records"]),
|
||||
"; ".join(f"{m['system']} · {m['metric']} · {m['category']} · {m['edition'] or ''} · {m['quartile']} · год метрики {m['year']} · выпуск {m['release_year'] or ''} · {m['source']} · {m['source_url'] or ''} · строка {m['source_row'] or ''} · страница {m['source_pdf_page'] or ''}" for r in article["catalog_records"] for m in r["metrics"]),
|
||||
"; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
|
||||
criteria = list(dict.fromkeys("В пределах пяти лет" if value.startswith(
|
||||
("В пределах пяти лет", "В пределах пятилетнего периода")) else value for value in article["criteria"]))
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"],
|
||||
article["type_label"] if article["types"] else "не указан", "; ".join(criteria),
|
||||
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "k2", "rsci")],
|
||||
specialties_text(article["specialty_codes"]), article["doi_url"], article["url"]])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Отрасль оценки", result["branch"]],
|
||||
["Период оценки", "Пять лет относительно текущей даты Москвы"],
|
||||
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
|
||||
["Период оценки", "Пять календарных лет, включая текущий год Москвы; день и месяц не учитываются"],
|
||||
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI показывают наличие соответствующего основания у уникальной работы в календарном периоде. Это не заключение о включении сотрудника в совет; подтверждённый зачёт и причины проверки доступны на сайте."],
|
||||
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
|
||||
["Границы и виды", "Спорные граничные даты и допустимость иных видов требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
|
||||
["Границы и виды", "Год публикации должен быть согласован между источниками; допустимость иных видов требует подтверждения. Без него соответствующие работы не подтверждают минимум."],
|
||||
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
|
||||
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
|
||||
["Подбор по специальности", "Хотя бы одна публикация за пять календарных лет в журнале К1 по Перечню ВАК на 15.09.2026 либо с ручной тематической связью; содержание статьи не проверяется."],
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
["Сортировать по", {"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"]]],
|
||||
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Отбор по степени", "Высшая степень из истории: доктор выше кандидата/PhD; отсутствие подтверждения доктора не переводит оценку на пункт 6. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Сотрудников в выгрузке", len(all_candidates)], ["Найдено с исходными фильтрами", result["matched"]],
|
||||
["Всего сотрудников в списке", result["total"]],
|
||||
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
|
||||
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
|
||||
["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
|
||||
["ЛК сотрудника ВШЭ / Все публикации", "Все годы и категории. ЛК сотрудника ВШЭ — публикации из личного кабинета. "
|
||||
"Все публикации объединяет записи личного кабинета и выгрузку из базы ВШЭ без дублей; источники указаны на листе Публикации."],
|
||||
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Всего", "Как на сайте: уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Для кандидата/PhD одного К2 недостаточно. Пересечения учитываются один раз. Это предварительный счётчик; подтверждённый зачёт показан отдельно."],
|
||||
["Интерактивный подбор", "Первый лист содержит всех сотрудников с учёной степенью на момент выгрузки. Специальность, отрасль, поиск, степень, статус и сортировка меняются в Excel без макросов. Начальные фильтры взяты с сайта; подробные листы содержат полный набор сотрудников и публикаций."],
|
||||
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
|
||||
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
|
||||
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
|
||||
["Автоматический зачёт на сайте", "Известная статья с журналом и Q1/Q2 из источника за применимый год автоматически засчитывается при согласованном годе в периоде и отсутствии конфликтов. Подтверждение степени и тематическое рассмотрение проверяются отдельно."],
|
||||
["Ручные корректировки", "Учитываются правки статьи, журнала, квартиля и специальностей из редактора. Квартиль относится к году публикации; тематическая связь журнала действует независимо от года. Ручной квартиль без источника требует проверки."],
|
||||
["Совпадение ФИО", "При одинаковом ФИО нескольких сотрудников данные выгрузки ВШЭ не присваиваются автоматически; используются записи личного кабинета каждого сотрудника."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
_append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
|
||||
for group in result["criteria_groups"]:
|
||||
_append(parameters, ["Доктор · " + group["names"], f"{group['minimum']} публикаций; К1/К2/RSCI/Q1/Q2; обязательных монографий: {group['monographs']}"])
|
||||
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
|
||||
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
|
||||
for meta in result["catalog_metadata"]:
|
||||
@@ -94,8 +102,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
_append(parameters, ["Проверка каталога", issue])
|
||||
for meta in result["category_metadata"]:
|
||||
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 12, 16, 16, 16, 50, 70]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28, 10, 10, 20, 18, 70, 75, 60, 90, 75, 60]),
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 10, 12, 18, 28, 70, 45]),
|
||||
(publications, [7, 34, 65, 12, 24, 80, 10, 10, 10, 10, 10, 70, 45, 45]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
@@ -103,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
|
||||
@@ -47,7 +47,7 @@ def _description_journals(description: dict) -> list[str]:
|
||||
values = value.values() if isinstance(value, dict) else [value]
|
||||
for text in values:
|
||||
if isinstance(text, str):
|
||||
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
|
||||
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) if "<" in text or "&" in text else text.strip()
|
||||
# HSE short descriptions start with the journal, followed by the year.
|
||||
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
|
||||
return names
|
||||
|
||||
@@ -118,7 +118,7 @@ def binding_issue(item, payload, doi="", journals=(), identifiers=()):
|
||||
source_name = _name(item.get("journal") or "")
|
||||
names_match = bool(source_name and source_name in {_name(name) for name in journals if name})
|
||||
if source_name and journals and not names_match and not ids_match:
|
||||
return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
return f"Квартиль сохранён для журнала «{item.get('journal')}», а статья отнесена к «{' / '.join(journals)}». Совпадение по ISSN не подтверждено. Уточните журнал статьи и источник квартиля"
|
||||
if not (source_doi and source_doi == current_doi) and not ids_match and not names_match:
|
||||
return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка"
|
||||
return None
|
||||
@@ -129,13 +129,18 @@ def identity_issues(payload, doi="", journals=(), identifiers=()):
|
||||
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
|
||||
|
||||
|
||||
def qualifying_entries(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return [item for item in entries(payload)
|
||||
if item["quartile"] in {"Q1", "Q2"}
|
||||
and item["system"] in SYSTEM_FIELDS
|
||||
and item.get("publication_year") == publication_year
|
||||
and item.get("metric_year") in {None, publication_year}
|
||||
and not binding_issue(item, payload, doi, journals, identifiers)]
|
||||
|
||||
|
||||
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in entries(payload)
|
||||
if item["quartile"] in {"Q1", "Q2"}
|
||||
and item["system"] in SYSTEM_FIELDS
|
||||
and item.get("publication_year") == publication_year
|
||||
and item.get("metric_year") in {None, publication_year}
|
||||
and not binding_issue(item, payload, doi, journals, identifiers)))
|
||||
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in
|
||||
qualifying_entries(payload, publication_year, doi, journals, identifiers)))
|
||||
|
||||
|
||||
def index_sjr(rankings):
|
||||
|
||||
@@ -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;
|
||||
@@ -913,3 +936,69 @@
|
||||
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
|
||||
.council__article-heading { flex-direction: column; gap: 4px; }
|
||||
}
|
||||
|
||||
.council__article[data-council-article] { position: relative; padding-inline-end: 60px; cursor: pointer; }
|
||||
.council__article[data-council-article]:hover { background: #f3f7f5; }
|
||||
.council__article--active { background: #e7f0eb; }
|
||||
.council__article[data-council-article]:focus-visible { outline: 3px solid #0f766e; outline-offset: 2px; }
|
||||
.council__edit { position: absolute; inset-inline-end: 4px; top: 12px; min-width: 44px; min-height: 44px; visibility: hidden; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.council__article--active > .council__edit,
|
||||
.council__article:focus-within > .council__edit { visibility: visible; }
|
||||
.council__articles--editor { padding: 20px 20px 20px 42px; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__articles--editor .council__hint { margin-block: 8px; }
|
||||
.council-editor { display: grid; gap: 16px; margin-top: 20px; }
|
||||
.council-editor .directory__field { min-width: 0; }
|
||||
.council-editor textarea { resize: vertical; min-height: 90px; font: inherit; }
|
||||
.council-editor__actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.council-editor__error { margin: 0; color: #991b1b; line-height: 1.5; }
|
||||
@media (max-width: 600px) {
|
||||
.council__articles--editor { padding-inline: 28px 12px; }
|
||||
.council-editor .directory__input { font-size: 16px; }
|
||||
}
|
||||
|
||||
.council-editor__specialties { min-width: 0; }
|
||||
.council-editor__specialties summary { cursor: pointer; min-height: 44px; align-content: center; }
|
||||
.council-editor__specialties fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid #d6dddc; border-radius: 8px; }
|
||||
.council-editor__specialties .columns-modal__grid { grid-template-columns: 1fr; margin-block: 12px; }
|
||||
.council__criteria-list { line-height: 1.55; }
|
||||
|
||||
.council__warning { display: inline-block; vertical-align: middle; }
|
||||
.council__warning-button { display: inline-flex; justify-content: center; align-items: center; min-width: 40px; min-height: 40px; padding: 0; border: 0; border-radius: 4px; color: #991b1b; background: transparent; cursor: pointer; vertical-align: middle; }
|
||||
.council__warning-button:hover { background: #fee2e2; }
|
||||
.council__tooltip { position: fixed; z-index: 30; width: min(360px, calc(100vw - 24px)); padding: 12px; box-sizing: border-box; border: 1px solid #d6dddc; border-radius: 8px; color: #1f2937; background: #fff; box-shadow: 0 6px 20px rgba(15, 23, 42, .18); font: 14px/1.5 system-ui, sans-serif; max-height: min(360px, calc(100vh - 24px)); overflow: auto; text-align: start; overflow-wrap: anywhere; }
|
||||
.council__tooltip[hidden] { display: none; }
|
||||
.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } }
|
||||
|
||||
.users__table-wrap { overflow-x: auto; margin-block: 20px; }
|
||||
.users__table-wrap .table { min-width: 720px; }
|
||||
.users__table-wrap .table__cell { vertical-align: top; overflow-wrap: anywhere; max-width: 420px; }
|
||||
.users__controls { display: grid; gap: 8px; min-width: 160px; }
|
||||
.users__controls label { display: grid; gap: 4px; }
|
||||
.users__create { margin-block: 24px; }
|
||||
.users__create summary { cursor: pointer; padding-block: 10px; font-weight: 600; }
|
||||
.users__create-form, .users__password-form { max-width: 520px; }
|
||||
.users__hint { color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.users__error { color: #991b1b; line-height: 1.5; }
|
||||
.users__temporary { padding: 16px; background: #e7f0eb; border: 1px solid #8fa8a5; border-radius: 8px; }
|
||||
.users__temporary .form__input { max-width: 240px; font-family: monospace; font-size: 20px; }
|
||||
.users__account-meta { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.users__account-meta dt { color: #4c5b64; font-size: 14px; }
|
||||
.users__account-meta dd { margin: 4px 0 0; }
|
||||
.users__change { margin-top: 6px; }
|
||||
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
|
||||
|
||||
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
|
||||
|
||||
.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.guide__panel > :not([data-guide-step]) { flex-shrink: 0; }
|
||||
.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; }
|
||||
.guide__heading { margin-block: 20px 12px; font-size: 18px; }
|
||||
.guide__fields { margin-block: 16px; }
|
||||
.guide__fields dt { margin-block-start: 14px; font-weight: 600; }
|
||||
.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; }
|
||||
.guide--page { max-width: 75ch; }
|
||||
.guide--page .guide__step + .guide__step { margin-block-start: 32px; }
|
||||
.guide .columns-modal__header { flex-wrap: wrap; }
|
||||
@@ -0,0 +1,201 @@
|
||||
(() => {
|
||||
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 autoSpecialties = form?.querySelector('[data-specialty-auto]');
|
||||
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
|
||||
autoSpecialties?.addEventListener('change', () => {
|
||||
specialtyCheckboxes.forEach(input => { input.disabled = autoSpecialties.checked; });
|
||||
});
|
||||
let selected;
|
||||
let trigger;
|
||||
let saving = false;
|
||||
const openKey = 'miem.council.openCandidates';
|
||||
|
||||
try {
|
||||
const opened = JSON.parse(sessionStorage.getItem(openKey) || '[]');
|
||||
document.querySelectorAll('[data-employee-id]').forEach(node => {
|
||||
if (opened.includes(node.dataset.employeeId)) node.open = true;
|
||||
});
|
||||
sessionStorage.removeItem(openKey);
|
||||
} catch (_error) { /* Storage is optional. */ }
|
||||
|
||||
function activate(article) {
|
||||
document.querySelectorAll('.council__article--active').forEach(node => {
|
||||
node.classList.remove('council__article--active');
|
||||
});
|
||||
article.classList.add('council__article--active');
|
||||
}
|
||||
|
||||
function positionTooltip(button, tip) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
tip.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - tip.offsetWidth - 12))}px`;
|
||||
tip.style.top = `${Math.max(12, rect.bottom + tip.offsetHeight + 8 < innerHeight ? rect.bottom + 8 : rect.top - tip.offsetHeight - 8)}px`;
|
||||
}
|
||||
function repositionTooltips() {
|
||||
document.querySelectorAll('[data-warning]').forEach(warning => {
|
||||
const tip = warning.querySelector('[role="tooltip"]');
|
||||
if (!tip.hidden) positionTooltip(warning.querySelector('button'), tip);
|
||||
});
|
||||
}
|
||||
function setupArticles(root) {
|
||||
root.querySelectorAll('[data-council-article]').forEach(article => {
|
||||
article.addEventListener('click', event => {
|
||||
if (!event.target.closest('a, button')) activate(article);
|
||||
});
|
||||
article.addEventListener('keydown', event => {
|
||||
if (event.target === article && ['Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
activate(article);
|
||||
article.querySelector('[data-council-edit]').focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-council-edit]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
trigger = button;
|
||||
selected = JSON.parse(button.dataset.article);
|
||||
if (form) {
|
||||
form.elements.title.value = selected.title;
|
||||
form.elements.journal.value = selected.journal;
|
||||
form.elements.quartile.value = selected.quartile;
|
||||
autoSpecialties.checked = !selected.manual_specialties;
|
||||
form.querySelector("[data-edit-specialties]").open = selected.manual_specialties;
|
||||
specialtyCheckboxes.forEach(input => {
|
||||
input.checked = selected.specialty_codes.includes(input.value);
|
||||
input.disabled = autoSpecialties.checked;
|
||||
});
|
||||
form.elements.quartile.disabled = selected.year === null;
|
||||
form.querySelector('[data-edit-error]').hidden = true;
|
||||
form.querySelector('[data-edit-note]').textContent = selected.year === null
|
||||
? 'Год публикации не указан или неоднозначен. Можно изменить название статьи и журнал; квартиль недоступен.'
|
||||
: `Изменение квартиля применяется ко всем статьям выбранного журнала за ${selected.year} год. Проверьте квартиль при смене журнала.`;
|
||||
}
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-warning]').forEach(warning => {
|
||||
const button = warning.querySelector('button');
|
||||
const tip = warning.querySelector('[role="tooltip"]');
|
||||
let pinned = false;
|
||||
function show(visible) {
|
||||
tip.hidden = !visible;
|
||||
button.setAttribute('aria-expanded', String(visible));
|
||||
if (!visible) return;
|
||||
positionTooltip(button, tip);
|
||||
}
|
||||
warning.addEventListener('mouseenter', () => show(true));
|
||||
warning.addEventListener('mouseleave', () => { if (!pinned && document.activeElement !== button) show(false); });
|
||||
button.addEventListener('focus', () => show(true));
|
||||
button.addEventListener('blur', () => { pinned = false; show(false); });
|
||||
button.addEventListener('click', () => { pinned = !pinned; show(pinned); });
|
||||
warning.addEventListener('council-tooltip-close', () => { pinned = false; show(false); });
|
||||
});
|
||||
}
|
||||
setupArticles(document);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape') return;
|
||||
document.querySelectorAll('[data-warning] button[aria-expanded="true"]').forEach(button => {
|
||||
button.closest('[data-warning]').dispatchEvent(new Event('council-tooltip-close'));
|
||||
});
|
||||
});
|
||||
|
||||
async function loadArticles(details) {
|
||||
if (details.dataset.loaded || details.dataset.loading) return;
|
||||
const panel = details.querySelector('[data-council-content]');
|
||||
const status = panel.querySelector('[data-articles-status]');
|
||||
const content = panel.querySelector('[data-articles-content]');
|
||||
details.dataset.loading = '1';
|
||||
panel.setAttribute('aria-busy', 'true');
|
||||
status.textContent = 'Загружаем публикации…';
|
||||
try {
|
||||
const response = await fetch(panel.dataset.articlesUrl, {credentials: 'same-origin', signal: AbortSignal.timeout(15000)});
|
||||
if (!response.ok) throw new Error('Не удалось загрузить публикации. Попробуйте ещё раз.');
|
||||
content.innerHTML = await response.text();
|
||||
setupArticles(content);
|
||||
details.dataset.loaded = '1';
|
||||
status.textContent = 'Публикации загружены';
|
||||
status.classList.add('council__load-status--done');
|
||||
} catch (_error) {
|
||||
status.textContent = 'Не удалось загрузить публикации. Проверьте подключение и повторите загрузку.';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'button button--secondary';
|
||||
retry.textContent = 'Повторить загрузку публикаций';
|
||||
retry.addEventListener('click', () => loadArticles(details));
|
||||
content.replaceChildren(retry);
|
||||
} finally {
|
||||
delete details.dataset.loading;
|
||||
panel.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('[data-employee-id]').forEach(details => {
|
||||
details.addEventListener('toggle', () => { if (details.open) loadArticles(details); });
|
||||
if (details.open) loadArticles(details);
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target instanceof Element && event.target.closest('.council__tooltip')) return;
|
||||
repositionTooltips();
|
||||
}, true);
|
||||
window.addEventListener('resize', repositionTooltips);
|
||||
|
||||
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
|
||||
button.addEventListener('click', () => { if (!saving) dialog.close(); });
|
||||
});
|
||||
dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); });
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog && !saving) dialog.close(); });
|
||||
dialog.addEventListener('close', () => trigger?.focus());
|
||||
|
||||
form?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
const error = form.querySelector('[data-edit-error]');
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
const payload = {
|
||||
employee_id: selected.employee_id, edit_key: selected.edit_key, revision: selected.revision,
|
||||
title: form.elements.title.value.trim(), journal: form.elements.journal.value.trim(),
|
||||
quartile: selected.year === null ? '' : form.elements.quartile.value,
|
||||
specialty_codes: autoSpecialties.checked ? null : specialtyCheckboxes.filter(input => input.checked).map(input => input.value),
|
||||
};
|
||||
saving = true;
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Сохраняем…';
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
error.hidden = true;
|
||||
try {
|
||||
const response = await fetch('/admin/council/publications', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-Council-Edit': '1'},
|
||||
body: JSON.stringify(payload), signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(typeof data.detail === 'string' ? data.detail : 'Проверьте название статьи, журнал и квартиль.');
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(openKey, JSON.stringify(Array.from(document.querySelectorAll('[data-employee-id][open]')).map(node => node.dataset.employeeId)));
|
||||
} catch (_error) { /* Storage is optional. */ }
|
||||
window.location.reload();
|
||||
} catch (failure) {
|
||||
error.textContent = failure.name === 'TimeoutError'
|
||||
? 'Не получен ответ сервера. Обновите страницу, чтобы проверить сохранение.'
|
||||
: failure.message === 'Failed to fetch' ? 'Нет связи с сервером. Проверьте подключение и попробуйте ещё раз.' : failure.message;
|
||||
error.hidden = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
submit.disabled = false;
|
||||
submit.textContent = 'Сохранить';
|
||||
form.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
(() => {
|
||||
const dialog = document.querySelector('[data-guide-dialog]');
|
||||
if (!dialog) return;
|
||||
const trigger = document.querySelector('[data-guide-open]');
|
||||
const steps = Array.from(dialog.querySelectorAll('[data-guide-step]'));
|
||||
const back = dialog.querySelector('[data-guide-back]');
|
||||
const next = dialog.querySelector('[data-guide-next]');
|
||||
const close = dialog.querySelector('[data-guide-close]');
|
||||
const error = dialog.querySelector('[data-guide-error]');
|
||||
const unsaved = dialog.querySelector('[data-guide-unsaved]');
|
||||
let index = 0;
|
||||
let saving = false;
|
||||
|
||||
function showStep() {
|
||||
steps.forEach((step, number) => { step.hidden = number !== index; });
|
||||
dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`;
|
||||
back.disabled = index === 0;
|
||||
next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
|
||||
steps[index].scrollTop = 0;
|
||||
steps[index].querySelector('h3').focus({preventScroll: true});
|
||||
}
|
||||
function open() {
|
||||
index = 0;
|
||||
error.hidden = true;
|
||||
unsaved.hidden = true;
|
||||
dialog.showModal();
|
||||
showStep();
|
||||
}
|
||||
async function finish() {
|
||||
if (saving) return;
|
||||
if (dialog.dataset.guidePending !== 'true') {
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
dialog.setAttribute('aria-busy', 'true');
|
||||
close.disabled = next.disabled = back.disabled = true;
|
||||
error.hidden = true;
|
||||
try {
|
||||
const response = await fetch('/admin/guide/dismiss', {
|
||||
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
||||
headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
dialog.dataset.guidePending = 'false';
|
||||
dialog.close();
|
||||
} catch (_failure) {
|
||||
error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.';
|
||||
error.hidden = false;
|
||||
unsaved.hidden = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
dialog.removeAttribute('aria-busy');
|
||||
close.disabled = next.disabled = false;
|
||||
back.disabled = index === 0;
|
||||
}
|
||||
}
|
||||
trigger?.addEventListener('click', event => { event.preventDefault(); open(); });
|
||||
back.addEventListener('click', () => { index -= 1; showStep(); });
|
||||
next.addEventListener('click', () => {
|
||||
if (index === steps.length - 1) finish();
|
||||
else { index += 1; showStep(); }
|
||||
});
|
||||
close.addEventListener('click', finish);
|
||||
unsaved.addEventListener('click', () => { if (!saving) dialog.close(); });
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); });
|
||||
dialog.addEventListener('close', () => trigger?.focus());
|
||||
if (dialog.dataset.guidePending === 'true') open();
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const required = document.querySelector('[data-required-password]');
|
||||
if (required) {
|
||||
const form = document.querySelector('[data-password-form]');
|
||||
required.querySelector('[data-password-dialog-body]').append(form);
|
||||
required.addEventListener('cancel', event => event.preventDefault());
|
||||
required.showModal();
|
||||
(form.querySelector('[aria-invalid="true"]') || form.querySelector('input[type="password"]')).focus();
|
||||
}
|
||||
document.querySelectorAll('[data-password-reset]').forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!window.confirm('Сбросить пароль? Текущие сессии пользователя завершатся. При следующем входе потребуется сменить временный пароль.')) event.preventDefault();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Личный кабинет · MIEM Employees{% endblock %}
|
||||
{% 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>
|
||||
<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>
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Сменить пароль</h2>
|
||||
<p class="users__hint">Смена пароля или выход из системы завершает все ваши сессии.</p>
|
||||
<form class="form users__password-form" method="post" action="/admin/account/password" data-password-form>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if error %}<p class="users__error" id="password-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<label class="form__label">Текущий пароль<input class="form__input" name="current_password" type="password" required maxlength="1024" autocomplete="current-password"{% if error_field == 'current_password' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<label class="form__label">Новый пароль<input class="form__input" name="new_password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" aria-describedby="password-hint{% if error_field == 'new_password' %} password-error{% endif %}"{% if error_field == 'new_password' %} aria-invalid="true"{% endif %}></label>
|
||||
<label class="form__label">Повторите новый пароль<input class="form__input" name="confirmation" type="password" required minlength="15" maxlength="128" autocomplete="new-password"{% if error_field == 'confirmation' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<p class="users__hint" id="password-hint">От 15 до 128 символов. Можно использовать фразу. Новый пароль должен отличаться от текущего.</p>
|
||||
<button class="button" type="submit">Сохранить новый пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">Последние действия</h2>{% include 'user_activity.html' %}</section>
|
||||
{% if account_user.must_change_password %}
|
||||
<dialog class="columns-modal" data-required-password aria-labelledby="required-password-title" aria-describedby="required-password-note">
|
||||
<div class="columns-modal__panel">
|
||||
<h2 class="columns-modal__title" id="required-password-title">Смените временный пароль</h2>
|
||||
<p id="required-password-note">Прежде чем пользоваться учётной записью, задайте свой пароль. Закрыть это окно без смены пароля нельзя.</p>
|
||||
<div data-password-dialog-body></div>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Выйти из системы</button></form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+11
-3
@@ -11,16 +11,20 @@
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/admin' }}">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
{% if public_page %}
|
||||
{% if public_page and not current_user %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в админку</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в систему</a>
|
||||
{% 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>
|
||||
<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/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">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -31,6 +35,10 @@
|
||||
<footer class="admin__footer">
|
||||
Backend {{ backend_version }} · Frontend {{ frontend_version }}
|
||||
</footer>
|
||||
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
|
||||
{% include 'guide_dialog.html' %}
|
||||
<script src="/static/guide.js?v={{ frontend_version }}" defer></script>
|
||||
{% endif %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
+30
-51
@@ -5,10 +5,12 @@
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за последние пять лет сопоставляются с данными о квартилях и категориях журналов. Минимум — 9 работ для доктора наук и 20 для кандидата наук или PhD. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a class="button button--secondary" href="/admin/council/publications" target="_blank" rel="noopener noreferrer">Статьи, журналы и квартили ↗</a>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>{% if input_error %}Проверьте параметры оценки{% elif export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
|
||||
@@ -20,20 +22,29 @@
|
||||
{% if catalog_issues %}<div class="council__message" role="alert"><p>{{ catalog_issues|join('; ') }}. Повреждённые сведения не используются для зачёта.</p></div>{% endif %}
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как рассчитываются показатели</strong></summary>
|
||||
<p>Период оценки: {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}, последние пять лет относительно текущей даты Москвы. Для доктора наук применяется минимум 9 публикаций по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
|
||||
<p>Период оценки: {{ start.year }}–{{ end.year }}, пять календарных лет, включая текущий год Москвы; день и месяц публикации не влияют на зачёт. Для доктора наук минимум зависит от отрасли науки по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
|
||||
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
|
||||
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Для пограничных лет нужно уточнить дату публикации; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, даты или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
|
||||
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</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>
|
||||
<p>«ЛК сотрудника ВШЭ» — количество публикаций из личного кабинета сотрудника за все годы. «Данные ВШЭ» — выгрузка из базы публикаций ВШЭ, которая дополняет сведения из личного кабинета. «Все публикации» объединяет оба источника без дублей за все годы и независимо от категории. При совпадении ФИО нескольких сотрудников данные ВШЭ не присваиваются автоматически. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
</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 %}
|
||||
</select>
|
||||
</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>
|
||||
@@ -55,7 +66,7 @@
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
@@ -66,9 +77,9 @@
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
<div class="council__list">
|
||||
{% for candidate in candidates %}
|
||||
<details class="council__candidate">
|
||||
<details class="council__candidate" data-employee-id="{{ candidate.employee_id }}">
|
||||
<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 }}{% if candidate.minimum is not none %} из {{ candidate.minimum }}{% endif %}</small></span>
|
||||
<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"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
|
||||
@@ -79,63 +90,31 @@
|
||||
</span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
|
||||
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
|
||||
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}<li>{{ reason }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
|
||||
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
|
||||
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
|
||||
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article">
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year or 'Год не указан' }}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
|
||||
<span>{{ article.type_label }}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
</div>
|
||||
<ul class="council__evidence" aria-label="Критерии и подтверждения">
|
||||
<li>Зачёт в подтверждённое число: {{ 'да' if article.confirmed else 'требует проверки' }}{% if article.publication_date %}; точная дата: {{ article.publication_date.strftime('%d.%m.%Y') }}{% endif %}</li>
|
||||
<li>{{ article.thematic_status }}</li>
|
||||
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
|
||||
{% for reason in article.confirmation_reasons %}<li>{{ reason }}</li>{% endfor %}
|
||||
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for record in article.catalog_records %}
|
||||
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
|
||||
<ul>{% for metric in record.metrics %}<li>{{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for text in record.thematic %}<li>{{ text }}</li>{% endfor %}
|
||||
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="council__candidate-body" data-council-content
|
||||
data-articles-url="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&branch={{ branch|urlencode }}">
|
||||
<p class="council__hint" role="status" data-articles-status>Публикации загрузятся при раскрытии карточки.</p>
|
||||
<div data-articles-content><noscript><a class="admin__link" href="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&branch={{ branch|urlencode }}">Открыть публикации сотрудника</a></noscript></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not error %}{% include "council_edit_dialog.html" %}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
|
||||
<p>Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
|
||||
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}{% if not reason.startswith("Подтверждено ") %}<li>{{ reason }}</li>{% endif %}{% endfor %}</ul>{% endif %}
|
||||
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
|
||||
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
|
||||
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
|
||||
{% 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 }}. Нажмите, чтобы показать редактирование">
|
||||
{% set employee_id = candidate.employee_id %}
|
||||
{% include "council_edit_button.html" %}
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
|
||||
<span>{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
</div>
|
||||
<p class="council__hint">Журнал: {{ article.journals|join(" / ") or "не указан" }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %}</p>
|
||||
<p class="council__hint">Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
|
||||
{% if specialty %}<p class="council__hint">Специальность: {{ specialty }} — {{ specialty_name }}{% set field = 'specialty' %}{% set label = 'специальность статьи' %}{% include 'council_warning.html' %}</p>{% endif %}
|
||||
<ul class="council__evidence" aria-label="Источники сведений">
|
||||
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for record in article.catalog_records %}
|
||||
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
|
||||
<ul>{% for metric in record.metrics %}<li>{{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button class="button button--ghost council__edit" type="button" aria-label="Редактировать статью: {{ article.title }}" aria-haspopup="dialog" data-council-edit data-article='{{ {"employee_id": employee_id, "edit_key": article.edit_key, "revision": revision, "title": article.title, "journal": article.edit_journal, "quartile": article.edit_quartile, "year": article.year, "specialty_codes": article.edit_specialty_codes, "manual_specialties": article.manual_specialties}|tojson|forceescape }}'>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-12 12-6 1 1-6Z"/><path d="m14 5 5 5"/></svg>
|
||||
</button>
|
||||
@@ -0,0 +1,45 @@
|
||||
<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>
|
||||
</label>
|
||||
<label class="directory__field" for="article-edit-journal">Название журнала
|
||||
<input class="directory__input" id="article-edit-journal" name="journal" required maxlength="500" list="council-journals">
|
||||
</label>
|
||||
<label class="directory__field" for="article-edit-quartile">Квартиль
|
||||
<select class="directory__input" id="article-edit-quartile" name="quartile">
|
||||
<option value="">Не указан</option>{% for q in ['Q1', 'Q2', 'Q3', 'Q4'] %}<option value="{{ q }}">{{ q }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<details class="council-editor__specialties" data-edit-specialties>
|
||||
<summary>Специальности журнала</summary>
|
||||
<fieldset>
|
||||
<legend>Специальности журнала</legend>
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_auto" data-specialty-auto> Использовать связь из справочников</label>
|
||||
<div class="columns-modal__grid">{% for item in specialties %}
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_codes" value="{{ item.code }}"> {{ item.code }} — {{ item.name }}</label>
|
||||
{% endfor %}</div>
|
||||
<p class="council__hint">Ручная связь заменяет справочную для всех статей выбранного журнала, независимо от года. Без отмеченных специальностей связь исключается.</p>
|
||||
</fieldset>
|
||||
</details>
|
||||
<p class="council__hint" data-edit-note></p>
|
||||
<p class="council__hint">Ручная корректировка требует повторного подтверждения источниками для зачёта работы.</p>
|
||||
<p class="council-editor__error" data-edit-error role="alert" hidden></p>
|
||||
<div class="council-editor__actions">
|
||||
<button class="button" type="submit">Сохранить</button>
|
||||
<button class="button button--secondary" type="button" data-edit-close>Отмена</button>
|
||||
</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>
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Статьи, журналы и квартили · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council" aria-labelledby="publications-title">
|
||||
<header class="council__hero"><div class="council__hero-copy">
|
||||
<h2 class="council__title" id="publications-title">Статьи, журналы и квартили</h2>
|
||||
<p class="council__intro">Публикации сотрудников из списка диссовета. Квартиль журнала относится к году публикации.</p>
|
||||
</div></header>
|
||||
<a class="admin__link" href="/admin/council">Вернуться к диссовету</a>
|
||||
<form class="council__search" method="get" role="search">
|
||||
<label class="directory__field" for="publications-employee">ФИО сотрудника<input class="directory__input" type="search" id="publications-employee" name="employee" value="{{ employee }}"></label>
|
||||
<label class="directory__field" for="publications-article">Название статьи<input class="directory__input" type="search" id="publications-article" name="title" value="{{ title }}"></label>
|
||||
<label class="directory__field" for="publications-journal">Название журнала<input class="directory__input" type="search" id="publications-journal" name="journal" value="{{ journal }}"></label>
|
||||
<button class="button" type="submit">Найти</button>
|
||||
{% if employee or title or journal %}<a class="admin__link" href="/admin/council/publications">Сбросить фильтры</a>{% endif %}
|
||||
</form>
|
||||
{% if error %}
|
||||
<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" %}
|
||||
<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>
|
||||
</li>
|
||||
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
|
||||
</ol>
|
||||
{% if matched > limit %}<nav class="directory__pagination" aria-label="Страницы статей">
|
||||
{% set filters = 'employee=' ~ (employee|urlencode) ~ '&title=' ~ (title|urlencode) ~ '&journal=' ~ (journal|urlencode) %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?{{ filters }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span>{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?{{ filters }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
{% include "council_edit_dialog.html" %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% if article.warnings[field] %}
|
||||
<span class="council__warning" data-warning>
|
||||
{% set warning_id = 'warning-' ~ employee_id ~ '-' ~ article.edit_key ~ '-' ~ field %}
|
||||
<button class="council__warning-button" type="button" aria-label="Уточнение: {{ label }}" aria-expanded="false" aria-describedby="{{ warning_id }}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 6v8"/><circle cx="12" cy="18" r=".5" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<span class="council__tooltip" role="tooltip" id="{{ warning_id }}" hidden>{{ article.warnings[field]|unique|join('. ') }}</span>
|
||||
<noscript><span>{{ article.warnings[field]|unique|join('. ') }}</span></noscript>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -30,12 +30,14 @@
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
</form>{% endif %}
|
||||
{% if current_user and current_user.role == 'admin' %}<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>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||
</div>
|
||||
<div class="employee-card__actions">
|
||||
<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--compact" type="submit">Обновить данные</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,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">
|
||||
@@ -120,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>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<article class="panel guide 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 %}
|
||||
@@ -0,0 +1,71 @@
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
|
||||
<p>Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Сотрудники</dt><dd>Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.</dd>
|
||||
<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>
|
||||
<dt>Обзор и Запуски</dt><dd>Количество сотрудников, состояние загрузки данных и история её запусков.</dd>
|
||||
<dt>Личный кабинет</dt><dd>Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.</dd>
|
||||
</dl>
|
||||
{% if current_user.role == 'admin' %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% else %}<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>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</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>
|
||||
<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>
|
||||
@@ -0,0 +1,24 @@
|
||||
<dialog class="columns-modal guide" data-guide-dialog data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title">
|
||||
<div class="columns-modal__panel guide__panel">
|
||||
<div class="columns-modal__header">
|
||||
<h2 class="columns-modal__title" id="guide-title">Как пользоваться сайтом</h2>
|
||||
<button class="button button--ghost" type="button" data-guide-close>Закрыть подсказки</button>
|
||||
</div>
|
||||
<p class="guide__progress" data-guide-progress role="status"></p>
|
||||
{% include 'guide_content.html' %}
|
||||
<p class="users__error" data-guide-error role="alert" hidden></p>
|
||||
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
|
||||
<div class="guide__actions">
|
||||
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
|
||||
<button class="button" type="button" data-guide-next>Далее</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% if request.state.guide_pending|default(false) %}
|
||||
<noscript><aside class="panel guide" aria-label="Подсказки первого входа">
|
||||
<h2 class="panel__title">Как пользоваться сайтом</h2>
|
||||
<p>Пароль изменён. Откройте подсказки о поиске сотрудников, подборе диссовета и проверке публикаций.</p>
|
||||
<a class="button" href="/admin/guide">Открыть подсказки</a>
|
||||
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
|
||||
</aside></noscript>
|
||||
{% endif %}
|
||||
@@ -9,13 +9,15 @@
|
||||
<body class="admin">
|
||||
<main class="admin__main">
|
||||
<section class="panel">
|
||||
<h1 class="panel__title">Вход в админку</h1>
|
||||
<h1 class="panel__title">Вход в систему</h1>
|
||||
{% if error %}<p>{{ error }}</p>{% endif %}
|
||||
<form class="form" method="post" action="/admin/login">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" required maxlength="64" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" required maxlength="1024" autocomplete="current-password"></label>
|
||||
<button class="button" type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Если забыли пароль, обратитесь к администратору системы.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<section class="panel">
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Запуски парсинга</h2>
|
||||
<form method="post" action="/admin/runs"><button class="button" type="submit">Запустить парсинг</button></form>
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/runs">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button" type="submit">Запустить парсинг</button></form>{% endif %}
|
||||
</div>
|
||||
{% set run = runs[0] if runs else none %}
|
||||
{% if run %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% 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 == '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': 'Изменение роли или доступа', '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">
|
||||
<thead><tr><th class="table__head" scope="col">Когда</th><th class="table__head" scope="col">Кто</th><th class="table__head" scope="col">Действие</th><th class="table__head" scope="col">Что изменено</th></tr></thead>
|
||||
<tbody>{% for action in actions %}<tr class="table__row">
|
||||
<td class="table__cell">{{ action.created_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{{ user_names.get(action.user_id, '—') }}</td>
|
||||
<td class="table__cell">{{ action_labels.get(action.action, action.action) }}{% if action.target_user_id %}<br>{{ user_names.get(action.target_user_id, '') }}{% endif %}</td>
|
||||
<td class="table__cell">{% if action.employee_name %}<a class="admin__link" href="/admin/council?q={{ action.employee_name|urlencode }}">{{ action.employee_name }}</a><br>{{ action.article_title }}{% if action.publication_year %} · {{ action.publication_year }}{% endif %}{% endif %}
|
||||
{% for field, change in action.changes.items() %}<div class="users__change"><strong>{{ field_labels.get(field, field) }}:</strong> {{ change_value(field, change.before) }} → {{ change_value(field, change.after) }}</div>{% endfor %}
|
||||
{% if not action.changes and not action.employee_name %}—{% endif %}
|
||||
</td>
|
||||
</tr>{% else %}<tr><td class="table__cell" colspan="4">Действий пока нет. Здесь появятся входы, смена пароля и редактирование карточек.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if history_total > history_limit %}<nav class="directory__pagination" aria-label="История действий">
|
||||
{% if history_offset > 0 %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset - history_limit }}">Назад</a>{% endif %}
|
||||
<span>{{ history_offset // history_limit + 1 }} / {{ (history_total + history_limit - 1) // history_limit }}</span>
|
||||
{% if history_offset + history_limit < history_total %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset + history_limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Управление пользователями</h2>
|
||||
<p>Пользователь может просматривать данные и редактировать карточки. Запуск парсинга и управление пользователями доступны администратору.</p>
|
||||
{% if error %}<p class="users__error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if temporary %}<div class="users__temporary" role="status">
|
||||
<p>Временный пароль для <strong>{{ temporary.username }}</strong>. Показывается только сейчас — передайте его пользователю. При входе потребуется смена пароля.</p>
|
||||
<label class="form__label">Временный пароль<input class="form__input" type="text" value="{{ temporary.password }}" readonly autocomplete="off" data-temporary-password></label>
|
||||
</div>{% endif %}
|
||||
<details class="users__create" open>
|
||||
<summary>Создать пользователя</summary>
|
||||
<form class="form users__create-form" method="post" action="/admin/users">
|
||||
<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>
|
||||
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
|
||||
<button class="button" type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="users__table-wrap"><table class="table">
|
||||
<thead><tr>{% for title in ['Пользователь', 'Первый вход', 'Последний вход', 'Пароль изменён', 'Временный пароль выдан', 'Роль и доступ', 'Действия'] %}<th class="table__head" scope="col">{{ title }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>{% for user in users %}<tr class="table__row">
|
||||
<td class="table__cell"><a class="admin__link" href="?user_id={{ user.id }}">{{ user.username }}</a>{% if user.full_name %}<br>{{ user.full_name }}{% endif %}{% if user.must_change_password %}<br>Требуется смена пароля{% endif %}{% if user.locked_until %}<br>Ограничение входа до {{ user.locked_until|admin_datetime }}{% endif %}</td>
|
||||
<td class="table__cell">{{ user.first_login_at|admin_datetime if user.first_login_at else 'Ещё не входил' }}</td><td class="table__cell">{{ user.last_login_at|admin_datetime if user.last_login_at else 'Ещё не входил' }}</td>
|
||||
<td class="table__cell">{{ user.password_changed_at|admin_datetime }}</td><td class="table__cell">{{ user.password_reset_at|admin_datetime }}</td>
|
||||
<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="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 %}
|
||||
</td>
|
||||
<td class="table__cell">{% if user.id != current_user.id %}<form method="post" action="/admin/users/{{ user.id }}/reset-password" data-password-reset>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--secondary" type="submit">Сбросить пароль {{ user.username }}</button>
|
||||
</form>{% else %}<a class="admin__link" href="/admin/account">Сменить свой пароль</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">{% if selected_user %}Действия {{ selected_user.username }}{% else %}Последние действия пользователей{% endif %}</h2>
|
||||
{% if selected_user %}<a class="admin__link" href="/admin/users">Показать всех пользователей</a>{% endif %}
|
||||
{% include 'user_activity.html' %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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,
|
||||
require_admin, require_user, sign_session, temporary_password, verify_password)
|
||||
|
||||
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
def _history(db, user_id=None, offset=0):
|
||||
query = select(UserAction)
|
||||
if user_id is not None:
|
||||
query = query.where(or_(UserAction.user_id == user_id, UserAction.target_user_id == user_id))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
offset = min(max(0, offset), ((total - 1) // 30) * 30) if total else 0
|
||||
items = db.scalars(query.order_by(desc(UserAction.created_at), desc(UserAction.id)).offset(offset).limit(30)).all()
|
||||
return {'actions': items, 'history_total': total, 'history_offset': offset, 'history_limit': 30}
|
||||
|
||||
|
||||
def _users_page(request, db, *, user_id=None, offset=0, error=None, temporary=None, status=200):
|
||||
users = db.scalars(select(SystemUser).order_by(SystemUser.username)).all()
|
||||
selected = next((u for u in users if u.id == user_id), None)
|
||||
if user_id is not None and selected is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
response = _render(request, 'users.html', {'users': users, 'selected_user': selected,
|
||||
'user_names': {u.id: u.username for u in users}, 'error': error, 'temporary': temporary,
|
||||
**_history(db, user_id, offset)}, status_code=status)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
def _lock_admin(request, db, settings):
|
||||
# Serialize role/availability changes so concurrent admins cannot disable each other.
|
||||
db.scalars(select(SystemUser).order_by(SystemUser.id).with_for_update()
|
||||
.execution_options(populate_existing=True)).all()
|
||||
return require_admin(request, settings, db)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def users(request: Request, user_id: int | None = None, offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings, db)
|
||||
return _users_page(request, db, user_id=user_id, offset=offset)
|
||||
|
||||
|
||||
@router.post('/users')
|
||||
def create_user(request: Request, username: str = Form(..., max_length=64), full_name: str = Form('', max_length=255),
|
||||
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)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
password = temporary_password()
|
||||
user = SystemUser(username=username, full_name=full_name.strip(), role=role,
|
||||
password_hash=hash_password(password), password_reset_at=utcnow())
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(UserAction(user_id=actor.id, action='user_create', target_user_id=user.id,
|
||||
changes={'role': {'before': None, 'after': role}}))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Этот логин уже занят. Выберите другой', status=409)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось создать пользователя. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}/reset-password')
|
||||
def reset_password(user_id: int, request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Свой пароль измените в личном кабинете')
|
||||
password = temporary_password()
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = True
|
||||
user.password_reset_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=actor.id, action='password_reset', target_user_id=user.id))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось сбросить пароль. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}')
|
||||
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'}:
|
||||
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Собственную роль и доступ изменить нельзя')
|
||||
changes = {}
|
||||
for field, value in (('role', role), ('is_active', is_active == 'true')):
|
||||
old = getattr(user, field)
|
||||
if old != value:
|
||||
changes[field] = {'before': old, 'after': value}
|
||||
setattr(user, field, value)
|
||||
if changes:
|
||||
user.session_version += 1
|
||||
db.add(UserAction(user_id=actor.id, action='user_update', target_user_id=user.id, changes=changes))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось изменить доступ. Попробуйте ещё раз', status=503)
|
||||
return RedirectResponse('/admin/users', status_code=303)
|
||||
|
||||
|
||||
@router.get('/account')
|
||||
def account(request: Request, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True)
|
||||
return _account_page(request, db, user, offset=offset)
|
||||
|
||||
|
||||
def _account_page(request, db, user, *, offset=0, error=None, error_field=None, status=200):
|
||||
history = _history(db, user.id, offset)
|
||||
ids = {a.user_id for a in history['actions']} | {a.target_user_id for a in history['actions'] if a.target_user_id is not None}
|
||||
names = dict(db.execute(select(SystemUser.id, SystemUser.username).where(SystemUser.id.in_(ids))).all()) if ids else {}
|
||||
return _render(request, 'account.html', {'account_user': user, 'user_names': names, 'error': error,
|
||||
'error_field': error_field, 'password_changed': request.query_params.get('password_changed') == '1', **history}, status_code=status)
|
||||
|
||||
|
||||
@router.post('/account/password')
|
||||
def change_password(request: Request, current_password: str = Form(..., max_length=1024),
|
||||
new_password: str = Form(..., max_length=128), confirmation: str = Form(..., max_length=128),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return _account_page(request, db, user, error='Текущий пароль неверен. Введите пароль, с которым вошли', error_field='current_password', status=422)
|
||||
if len(new_password) < 15 or new_password == current_password:
|
||||
return _account_page(request, db, user, error='Выберите новый пароль от 15 до 128 символов, отличающийся от текущего. Можно использовать фразу', error_field='new_password', status=422)
|
||||
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()
|
||||
user.session_version += 1
|
||||
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()
|
||||
return _account_page(request, db, user, error='Не удалось сохранить пароль. Попробуйте ещё раз', status=503)
|
||||
response = RedirectResponse('/admin/account?password_changed=1', status_code=303)
|
||||
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
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.18.1"
|
||||
FRONTEND_VERSION = "0.18.1"
|
||||
BACKEND_VERSION = "0.18.1"
|
||||
APP_VERSION = "1.5.1"
|
||||
FRONTEND_VERSION = "1.5.1"
|
||||
BACKEND_VERSION = "1.5.1"
|
||||
@@ -1,12 +1,12 @@
|
||||
# Подтверждения публикационных критериев совета
|
||||
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX настроены на **технические науки**: для доктора — 9 публикаций, для кандидата/PhD — 20. Пятилетний период рассчитывается относительно текущей даты Москвы. Дата ходатайства, монографии и связанные причины проверки не отображаются; подтверждение степени, вида, периода и основания работ по-прежнему требуется. Внутренний сервис сохраняет правила других отраслей для проверки исходной инструкции.
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX используют выбранную **отрасль оценки**, по умолчанию технические науки: для доктора порог зависит от отрасли, для кандидата/PhD — 20. Период — пять календарных лет, включая текущий год Москвы (2022–2026 в 2026 году); день и месяц не учитываются. Пороги и требования по отраслям показаны в шапке. Статья с известным видом, журналом и применимым к её году квартилем Q1/Q2 засчитывается автоматически без отдельной ручной записи, если нет конфликтов. Подтверждение степени и тематическое рассмотрение статьи остаются отдельными проверками.
|
||||
|
||||
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, данные ВШЭ и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
|
||||
|
||||
## Хранение и привязка
|
||||
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице и в XLSX.
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `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`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точную дату можно опустить: единственный согласованный внутренний год пятилетнего периода подтверждает период; оба пограничных года требуют точной даты. Некорректная дата и расхождения источников не разрешаются порядком записей. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
|
||||
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точная дата для периода не требуется: любой согласованный год из пяти календарных лет, включая текущий, подтверждает период. Разные дни одного года не создают конфликт; разные годы источников требуют проверки. Некорректная необязательная точная дата не отменяет известный год. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
|
||||
|
||||
`bases[].basis` — `К1`, `К2`, `RSCI`, `Q1` или `Q2`. `applicable_year` должен совпадать с подтверждаемым годом публикации. Для Q1/Q2 обязательны система, метрика, категория и год метрики. Допустимые системы: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef. Заполнение названия системы не устанавливает наличие квартильной методики в ней: администратор должен проверить применимость конкретной метрики и основания по инструкции. Простая индексация, РИНЦ, `hse_list` и числовой CiteScore не подтверждают основание. Если год метрики отличается от года публикации, `source` должен обосновывать применимость: сервис не распространяет снимок автоматически.
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
|
||||
## Нормативные уточнения
|
||||
|
||||
До получения источника спорные границы, иные виды работ и период зачёта монографии не считаются подтверждёнными. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
|
||||
Период сайта задан как пять календарных лет по продуктовому правилу. Иные виды работ и период зачёта монографии требуют подтверждения источником. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
|
||||
|
||||
- `boundary_dates`: `value: "inclusive"` разрешает обе точные граничные даты. По умолчанию подтверждаются только даты строго между ними. Если источник устанавливает разные режимы начала и конца, этот формат не подходит: требуется отдельная правка, границы пока оставляют на проверке.
|
||||
- `boundary_dates`: устаревшее поле; календарный период не использует точные граничные даты, запись не влияет на подсчёт.
|
||||
- `monograph_period`: `value: "five_years"` либо `"all_years"` определяет период отдельного зачёта подтверждённых монографий. Без него монографический минимум не подтверждается.
|
||||
- `publication_types`: `values: ["monograph", "chapter", ...]` разрешает явно проверенные дополнительные виды в общий публикационный минимум. Статьи учитываются при подтверждённом виде и основании. Не считайте исключение других видов нормативным запретом: допустимость пока не установлена. Монография отдельно и в общем числе может учитываться одновременно только после такого уточнения.
|
||||
|
||||
@@ -76,3 +76,10 @@
|
||||
Проверяются уникальность ID и пар, источники, паспорта, пункты, SHA-256 локальных PDF и безопасность относительных путей. Повреждённый снимок не даёт оснований и показывается как требующий проверки; второй исправный снимок сохраняется. Связь К1 со справочником специальностей проверяется по SHA-256. К1 и К2 остаются предварительными снимками категории; ручное основание должно иметь свой применимый год и источник. Подбор специальности по К1 остаётся вспомогательным и может скрывать сотрудников с иными основаниями; он не является условием пунктов 5–6.
|
||||
|
||||
Для обновления К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,8 @@
|
||||
-- Additive storage: original employee publications remain unchanged.
|
||||
CREATE TABLE IF NOT EXISTS council_corrections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_by VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS system_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user' CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
first_login_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
password_reset_at TIMESTAMPTZ,
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES system_users(id),
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id INTEGER REFERENCES system_users(id),
|
||||
employee_id INTEGER,
|
||||
employee_name TEXT,
|
||||
publication_year INTEGER,
|
||||
publication_key VARCHAR(64),
|
||||
article_title TEXT,
|
||||
changes JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_user_created ON user_actions(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_target_created ON user_actions(target_user_id, created_at);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.18.1"
|
||||
version = "1.5.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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)))
|
||||
@@ -66,8 +66,15 @@ def main():
|
||||
output = Path(".impeccable/review")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
requests = {"articles": 0, "fail": False}
|
||||
|
||||
def serve(route):
|
||||
url = urlsplit(route.request.url)
|
||||
if '/council/employees/' in url.path:
|
||||
requests['articles'] += 1
|
||||
if requests['fail']:
|
||||
route.fulfill(status=503, body='Temporary failure')
|
||||
return
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={key: value for key, value in response.headers.items()
|
||||
@@ -81,20 +88,70 @@ def main():
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.goto("http://miem.test/admin/council")
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28")
|
||||
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()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
assert requests['articles'] == 0
|
||||
requests['fail'] = True
|
||||
summary.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_role('button', name='Повторить загрузку публикаций')).to_be_visible()
|
||||
requests['fail'] = False
|
||||
page.get_by_role('button', name='Повторить загрузку публикаций').click()
|
||||
expect(page.locator(".council__source").filter(has_text="ЛК сотрудника ВШЭ").first).to_be_visible()
|
||||
expect(page.get_by_text("Q1/Q2 и К1 не подтверждены доступными данными").first).to_be_visible()
|
||||
before = requests['articles']
|
||||
summary.click()
|
||||
summary.click()
|
||||
assert requests['articles'] == before
|
||||
warning = page.get_by_role('button', name='Уточнение: тип материала').first
|
||||
summary.focus()
|
||||
warning.hover()
|
||||
expect(summary).to_be_focused()
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(page.get_by_role('tooltip')).to_have_count(0)
|
||||
expect(warning).to_have_attribute('aria-expanded', 'false')
|
||||
assert warning.evaluate("button => button.matches(':hover')")
|
||||
expect(summary).to_be_focused()
|
||||
page.mouse.move(5, 5)
|
||||
warning.hover()
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
expect(page.get_by_role('tooltip')).to_contain_text('тип материала')
|
||||
page.screenshot(path=str(output / 'warning-desktop.png'))
|
||||
page.set_viewport_size({'width': 320, 'height': 844})
|
||||
warning.focus()
|
||||
page.keyboard.press('Enter')
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output / 'warning-mobile.png'))
|
||||
page.keyboard.press('Escape')
|
||||
expect(page.get_by_role('tooltip')).to_have_count(0)
|
||||
page.set_viewport_size({'width': 1440, 'height': 1000})
|
||||
page.get_by_role("button", name="Уточнение: квартиль и основание зачёта").first.click()
|
||||
expect(page.get_by_role("tooltip").first).to_contain_text("Нет подтверждённого основания")
|
||||
page.keyboard.press("Escape")
|
||||
assert page.locator(".council__candidate").first.evaluate("e => e.open")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False)
|
||||
@@ -131,7 +188,7 @@ def main():
|
||||
assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27"
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 27")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
with page.expect_download() as pending_download:
|
||||
@@ -142,16 +199,16 @@ def main():
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
page.get_by_role("link", name="Сбросить поиск").first.click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
page.get_by_label("Специальность ВАК").select_option("1.2.2")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27")
|
||||
page.locator(".council__candidate-summary").click()
|
||||
expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible()
|
||||
expect(page.get_by_text("По специальности 1.2.2:", exact=False)).to_be_visible()
|
||||
expect(page.get_by_text("Специальность: 1.2.2", exact=False).first).to_be_visible()
|
||||
for width in (1440, 768, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
@@ -163,7 +220,7 @@ def main():
|
||||
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
|
||||
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
|
||||
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
|
||||
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
|
||||
assert "Работ по выбранной специальности" not in headers
|
||||
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
|
||||
page.get_by_label("Специальность ВАК").select_option("")
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 25")
|
||||
@@ -175,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
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Offline editor UX check: python tests/browser_council_editor.py."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
person = Employee(profile_key='editor', full_name='Иванов Иван Иванович',
|
||||
canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title='Исследование передачи данных',
|
||||
year=2024, publication_type='ARTICLE', raw_data={'journal': 'Журнал связи'}, source_hash='editor'))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
settings = Settings(_env_file=None, session_secret='browser-editor-test', admin_username='admin', admin_password='test')
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
output = Path('.impeccable/review/council-editor')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def serve(route):
|
||||
request = route.request
|
||||
url = urlsplit(request.url)
|
||||
response = client.request(request.method, url.path + ('?' + url.query if url.query else ''),
|
||||
content=request.post_data_buffer,
|
||||
headers={k: v for k, v in request.headers.items() if k in {'content-type', 'x-council-edit'}})
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={k: v for k, v in response.headers.items() if k in {'content-type', 'content-disposition'}})
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width': 1440, 'height': 1000})
|
||||
context.route('**/*', serve)
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto('http://miem.test/admin/council')
|
||||
page.locator('.council__candidate-summary').click()
|
||||
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()
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
expect(pencil).to_be_focused()
|
||||
page.keyboard.press('Enter')
|
||||
expect(page.get_by_label('Название статьи', exact=True)).to_have_value('Исследование передачи данных')
|
||||
page.get_by_label('Название статьи', exact=True).fill('Уточнённое исследование')
|
||||
page.get_by_label('Название журнала', exact=True).fill('Новый журнал')
|
||||
page.get_by_role('dialog').get_by_label('Квартиль').select_option('Q1')
|
||||
page.screenshot(path=str(output / 'edit-desktop.png'))
|
||||
page.set_viewport_size({'width': 320, 'height': 844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
assert page.get_by_role('dialog').evaluate('e => e.scrollWidth <= e.clientWidth')
|
||||
page.screenshot(path=str(output / 'edit-mobile.png'))
|
||||
page.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Уточнённое исследование')
|
||||
expect(page.locator('.council__candidate')).to_have_attribute('open', '')
|
||||
with context.expect_page() as opened:
|
||||
page.get_by_role('link', name='Статьи, журналы и квартили').click()
|
||||
window = opened.value
|
||||
window.wait_for_load_state()
|
||||
window.on('pageerror', lambda error: errors.append(str(error)))
|
||||
window.get_by_label('ФИО сотрудника').fill('Иванов')
|
||||
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('уточнённое')
|
||||
window.get_by_role('searchbox', name='Название журнала', exact=True).fill('новый')
|
||||
window.get_by_role('button', name='Найти', exact=True).click()
|
||||
expect(window.locator('[data-council-article]')).to_have_count(1)
|
||||
for width in (1440, 390, 320):
|
||||
window.set_viewport_size({'width': width, 'height': 900})
|
||||
assert window.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
if width in (1440, 320):
|
||||
window.screenshot(path=str(output / f'list-{width}.png'), full_page=True)
|
||||
window.locator('[data-council-article] strong').click()
|
||||
window.locator('[data-council-edit]').click()
|
||||
window.get_by_role('dialog').get_by_label('Квартиль').select_option('Q2')
|
||||
window.locator('[data-edit-specialties] summary').click()
|
||||
window.get_by_label('Использовать связь из справочников').uncheck()
|
||||
window.locator('[name="specialty_codes"][value="1.2.2"]').check()
|
||||
window.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(window.get_by_text('Журнал: Новый журнал · Квартиль: Q2', exact=False)).to_be_visible()
|
||||
expect(window.get_by_text('Специальности журнала: 1.2.2 · ручная связь', exact=True)).to_be_visible()
|
||||
# The original tab now carries a stale revision.
|
||||
article.locator('strong').click()
|
||||
pencil.click()
|
||||
page.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(page.get_by_role('alert')).to_contain_text('Данные уже изменены')
|
||||
page.keyboard.press('Escape')
|
||||
page.reload()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
expect(page.get_by_text('Квартиль: Q2', exact=False)).to_be_visible()
|
||||
page.get_by_label('Отрасль оценки').select_option('физико-математические')
|
||||
page.get_by_role('button', name='Применить', exact=True).click()
|
||||
expect(page.get_by_label('Отрасль оценки')).to_have_value('физико-математические')
|
||||
page.get_by_label('Специальность ВАК').select_option('1.2.2')
|
||||
page.get_by_role('button', name='Применить', exact=True).click()
|
||||
expect(page.locator('.council__candidate')).to_have_count(1)
|
||||
page.locator('.council__candidate-summary').click()
|
||||
expect(page.get_by_text('Публикаций по выбранной специальности: 1', exact=True)).to_be_visible()
|
||||
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('Нет совпадений')
|
||||
window.get_by_role('button', name='Найти', exact=True).click()
|
||||
expect(window.get_by_text('Статьи не найдены.', exact=False)).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f'Council editor browser checks passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Offline users/roles/password/audit browser scenario; synthetic SQLite data only."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uvicorn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication, utcnow
|
||||
from app.security import ensure_bootstrap_admin
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='Browser initial admin', session_secret='browser-users-secret')
|
||||
with factory() as db:
|
||||
admin = ensure_bootstrap_admin(db, settings)
|
||||
admin.first_login_at = utcnow()
|
||||
employee = Employee(profile_key='browser-users', full_name='Сотрудник для проверки', canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=employee.id, title='Статья для проверки', year=2024, publication_type='ARTICLE',
|
||||
source_hash='browser-users', raw_data={'journal': 'Журнал для проверки'}))
|
||||
db.commit()
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
output = Path('.impeccable/review/users')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
listener = socket.socket()
|
||||
listener.bind(('127.0.0.1', 0))
|
||||
server = uvicorn.Server(uvicorn.Config(app, lifespan='off', access_log=False, log_level='critical'))
|
||||
thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(1000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(.01)
|
||||
assert server.started
|
||||
base_url = f'http://127.0.0.1:{listener.getsockname()[1]}'
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width':1440,'height':1000})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_be_visible()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
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('button', name='Создать пользователя', exact=True).click()
|
||||
temporary = page.locator('[data-temporary-password]')
|
||||
expect(temporary).to_be_visible()
|
||||
password = temporary.input_value()
|
||||
page.screenshot(path=str(output/'users-desktop.png'), mask=[temporary], full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
|
||||
creation.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
nojs_password = page.locator('[data-temporary-password]').input_value()
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('reader')
|
||||
page.get_by_label('Пароль', exact=True).fill(password)
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
modal = page.get_by_role('dialog', name='Смените временный пароль')
|
||||
expect(modal).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.get_by_label('Текущий пароль', exact=True)).to_be_focused()
|
||||
assert modal.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
page.screenshot(path=str(output/'required-mobile.png'))
|
||||
page.set_viewport_size({'width':1440,'height':1000})
|
||||
page.screenshot(path=str(output/'required-desktop.png'))
|
||||
modal.get_by_label('Текущий пароль', exact=True).fill(password)
|
||||
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
guide = page.get_by_role('dialog', name='Как пользоваться сайтом')
|
||||
expect(guide).to_be_visible()
|
||||
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused()
|
||||
for width in (1440, 320):
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
assert guide.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output / f'guide-first-{width}.png'))
|
||||
page.keyboard.press('Tab')
|
||||
assert page.evaluate('document.querySelector("[data-guide-dialog]").contains(document.activeElement)')
|
||||
page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure'))
|
||||
guide.get_by_role('button', name='Закрыть подсказки', exact=True).click()
|
||||
expect(guide.locator('[data-guide-error]')).to_be_visible()
|
||||
guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.unroute('**/admin/guide/dismiss')
|
||||
page.reload()
|
||||
expect(guide).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.reload()
|
||||
expect(guide).not_to_be_visible()
|
||||
page.get_by_role('link', name='Как пользоваться', exact=True).click()
|
||||
expect(guide).to_be_visible()
|
||||
guide.get_by_role('button', name='Далее', exact=True).click()
|
||||
expect(guide.get_by_role('heading', name='Найдите сотрудника и откройте профиль')).to_be_visible()
|
||||
guide.get_by_role('button', name='Назад', exact=True).click()
|
||||
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_visible()
|
||||
for number in range(1, 7):
|
||||
expect(guide.locator('[data-guide-progress]')).to_have_text(f'Шаг {number} из 6')
|
||||
expect(guide.locator('[data-guide-step]:visible')).to_have_count(1)
|
||||
if number == 4:
|
||||
expect(guide).to_contain_text('ВСЕГО (предв.)')
|
||||
page.screenshot(path=str(output / 'guide-counts-mobile.png'))
|
||||
if number < 6:
|
||||
guide.get_by_role('button', name='Далее', exact=True).click()
|
||||
guide.get_by_role('button', name='Завершить подсказки', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
no_js = browser.new_context(java_script_enabled=False)
|
||||
nojs_page = no_js.new_page()
|
||||
nojs_page.goto(base_url + '/admin/login')
|
||||
nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_role('button', name='Войти', exact=True).click()
|
||||
nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click()
|
||||
expect(nojs_page.locator('[data-guide-step]')).to_have_count(6)
|
||||
expect(nojs_page.get_by_role('heading', name='Проверьте строку публикации')).to_be_visible()
|
||||
nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click()
|
||||
expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0)
|
||||
no_js.close()
|
||||
expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Обзор', exact=True).click()
|
||||
expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Диссертационный совет', exact=True).click()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
article = page.locator('[data-council-article]')
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
page.keyboard.press('Enter')
|
||||
editor = page.get_by_role('dialog', name='Редактирование статьи')
|
||||
editor.get_by_label('Название статьи', exact=True).fill('Статья изменена читателем')
|
||||
editor.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Статья изменена читателем')
|
||||
page.get_by_role('link', name='Личный кабинет', exact=True).click()
|
||||
expect(page.get_by_text('Редактирование статьи', exact=True)).to_be_visible()
|
||||
expect(page.get_by_text('Статья изменена читателем', exact=False)).to_be_visible()
|
||||
page.screenshot(path=str(output/'account-desktop.png'), full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'account-mobile.png'), full_page=True)
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Сбросить пароль reader', exact=True).click()
|
||||
expect(page.locator('[data-temporary-password]')).to_be_visible()
|
||||
page.locator('[data-temporary-password]').evaluate("input => input.value = '[скрыто]'")
|
||||
expect(page.locator('.table__cell').filter(has_text='Требуется смена пароля')).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
listener.close()
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
print(f'Users browser scenario passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+4
-3
@@ -11,14 +11,14 @@ from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.security import SESSION_COOKIE, sign_session, _session_data
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.18.1"
|
||||
assert response.json()["backend_version"] == "1.5.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
@@ -127,7 +127,8 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)["csrf"]
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", headers={"X-CSRF-Token": csrf}, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
|
||||
+207
-41
@@ -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()
|
||||
@@ -83,7 +90,7 @@ def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
assert imported["origins"] == ["Данные ВШЭ"]
|
||||
assert imported["qualifies"] and imported["q2"]
|
||||
assert "Тип не указан" in imported["type_label"]
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2020, 1, 1)
|
||||
|
||||
|
||||
def test_all_degree_levels_and_missing_publications_remain_visible(db_session, export):
|
||||
@@ -215,12 +222,16 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
|
||||
assert "Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно." in response.text
|
||||
assert "CSV по критериям" not in response.text and "ВСЕГО" in response.text and "Все публикации" in response.text
|
||||
assert "Книга" in response.text and "Тип не указан" in response.text
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert 'class="council__article"' not in response.text
|
||||
fragment = client.get(f"/admin/council/employees/{person.id}/articles")
|
||||
assert fragment.status_code == 200
|
||||
assert "Книга" in fragment.text and "не указан тип материала" in fragment.text
|
||||
assert "<script>" in fragment.text and "<script>" not in fragment.text
|
||||
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 0.18.1 · Frontend 0.18.1" in response.text
|
||||
assert "Вход в админку" 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
|
||||
finally:
|
||||
@@ -260,30 +271,35 @@ 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]][:13] == [
|
||||
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
|
||||
assert [cell.value for cell in people[1]] == [
|
||||
"№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего", "Всего из ЛК",
|
||||
"Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"
|
||||
]
|
||||
assert people["J2"].value == "https://www.hse.ru/staff/test"
|
||||
assert people["K2"].value is None
|
||||
assert people.max_column == 23
|
||||
assert works["L1"].value == "ЛК сотрудника ВШЭ"
|
||||
assert [cell.value for cell in works[1]] == [
|
||||
"№", "ФИО", "Название", "Год", "Тип материала", "Критерии", "Q1", "Q2", "К1", "К2",
|
||||
"RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"
|
||||
]
|
||||
assert people["L2"].value == "https://www.hse.ru/staff/test"
|
||||
assert people.max_column == 12 and works.max_column == 14
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
assert [people.cell(2, n).value for n in [4, 5, 6, 7, 8, 9]] == [1, 1, 0, 1, 1, 1]
|
||||
assert [people.cell(2, n).value for n in range(4, 11)] == [1, 1, 0, 0, 1, 1, 1]
|
||||
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
|
||||
assert "Данные ВШЭ" in works["F2"].value and "ЛК сотрудника ВШЭ" in works["F2"].value
|
||||
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
|
||||
assert works["M2"].value == "https://doi.org/10.1234/shared" and works["O2"].value == "2, 3"
|
||||
assert "Scopus Q1" in works["F2"].value and "Web of Science Q2" in works["F2"].value
|
||||
assert works["M2"].value == "https://doi.org/10.1234/shared"
|
||||
assert works["N2"].value == "https://publications.hse.ru/view/27"
|
||||
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
|
||||
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()
|
||||
|
||||
@@ -301,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")
|
||||
@@ -314,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")
|
||||
@@ -386,9 +458,8 @@ 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)))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 7)] == [1, 1]
|
||||
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]
|
||||
|
||||
|
||||
def test_k1_rename_note_is_preserved_separately():
|
||||
@@ -447,13 +518,12 @@ 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:7] == ["Q1", "Q2", "К1", "ВСЕГО"]
|
||||
assert headers[3:8] == ["Q1", "Q2", "К1", "К2", "Всего"]
|
||||
assert "CSV по критериям" not in headers
|
||||
anna_row = 3 if direction == "asc" else 2
|
||||
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 8)] == [1, 1, 1, 1]
|
||||
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 9)] == [1, 1, 1, 0, 1]
|
||||
|
||||
|
||||
def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_session, export):
|
||||
@@ -495,9 +565,11 @@ 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 response.text
|
||||
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
|
||||
assert "Не подходит" not in response.text
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
@@ -507,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["Публикации"]["G2"].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:
|
||||
@@ -550,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)
|
||||
@@ -561,18 +633,17 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
|
||||
assert 'Математическое моделирование' in candidate['Специальности ВАК']
|
||||
assert '2.3.5.' in candidate['Специальности ВАК']
|
||||
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
|
||||
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
|
||||
assert 'Работ по выбранной специальности' not in staff[0]
|
||||
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
|
||||
assert '1.2.2.' in records['Matching']['Специальности ВАК']
|
||||
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
|
||||
assert '1.2.2.' in records['Matching']['Специальность ВАК']
|
||||
assert 'По выбранной специальности' not in works[0]
|
||||
for title in ('Book', 'Unknown journal', 'Old journal'):
|
||||
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
|
||||
assert records[title]['Специальность ВАК'] == 'Не подтверждены'
|
||||
if not specialty:
|
||||
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
|
||||
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'W{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'L{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'N{len(works)}')
|
||||
|
||||
|
||||
def test_employee_without_degree_publications_are_not_parsed(db_session, export, monkeypatch):
|
||||
@@ -583,3 +654,98 @@ def test_employee_without_degree_publications_are_not_parsed(db_session, export,
|
||||
pytest.fail('Ineligible employee publications must not be parsed')
|
||||
monkeypatch.setattr(council, '_database_article', unexpected)
|
||||
assert council.council_candidates(db_session)['total'] == 0
|
||||
|
||||
|
||||
def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, export):
|
||||
export([row(**{'Название журнала': 'Бизнес-информатика', 'Квартиль wos': 'Q2'})])
|
||||
person = employee(db_session)
|
||||
person.current_data = {'sections': [{'title': 'Учёные степени', 'items': [
|
||||
'Кандидат технических наук', 'Доктор технических наук']} ]}
|
||||
publication(db_session, person, title='Shared work', doi='https://doi.org/10.1234/shared', kind='ARTICLE')
|
||||
publication(db_session, person, title='Other publication', kind=None)
|
||||
db_session.commit()
|
||||
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')
|
||||
# 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(['В пределах пяти лет', 'В пределах пяти лет по году; точную дату нужно проверить'])
|
||||
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'] == 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']['Тип материала'] == 'не указан'
|
||||
assert rows['Other publication']['К2'] == rows['Other publication']['RSCI'] == 'Да'
|
||||
assert rows['Shared work']['Критерии'].count('В пределах пяти лет') == 1
|
||||
assert 'точную дату' not in rows['Shared work']['Критерии']
|
||||
params = dict(book['Параметры'].values)
|
||||
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)
|
||||
|
||||
|
||||
def test_council_lazy_loading_selects_only_matching_publications(db_session, export, monkeypatch):
|
||||
from sqlalchemy import event
|
||||
first = employee(db_session, name='Первый сотрудник', key='first')
|
||||
second = employee(db_session, name='Второй сотрудник', key='second')
|
||||
publication(db_session, first, title='Первая статья', kind='ARTICLE')
|
||||
publication(db_session, second, title='Вторая статья', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
parsed = []
|
||||
original = council._database_article
|
||||
def tracked(publication):
|
||||
parsed.append(publication.employee_id)
|
||||
return original(publication)
|
||||
monkeypatch.setattr(council, '_database_article', tracked)
|
||||
result = council.council_candidates(db_session, q='первый')
|
||||
assert result['total'] == 2 and result['matched'] == 1
|
||||
assert parsed == [first.id]
|
||||
parsed.clear()
|
||||
statements = []
|
||||
def sql(connection, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
event.listen(db_session.bind, 'before_cursor_execute', sql)
|
||||
try:
|
||||
result = council.council_candidates(db_session, q='нет совпадений')
|
||||
assert result['total'] == 2 and result['matched'] == 0
|
||||
assert not any('employee_publications' in statement for statement in statements)
|
||||
finally:
|
||||
event.remove(db_session.bind, 'before_cursor_execute', sql)
|
||||
assert parsed == []
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
page = client.get('/admin/council')
|
||||
assert 'data-articles-url' in page.text and 'class="council__article"' not in page.text
|
||||
detail = client.get(f'/admin/council/employees/{first.id}/articles')
|
||||
assert detail.status_code == 200
|
||||
assert 'Первая статья' in detail.text and 'Вторая статья' not in detail.text
|
||||
assert parsed == [first.id, second.id, first.id]
|
||||
assert client.get('/admin/council/employees/99999/articles').status_code == 404
|
||||
assert client.get(f'/admin/council/employees/{first.id}/articles?specialty=wrong').status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_lazy_loading_preserves_global_duplicate_name_check(db_session, export):
|
||||
export([row(author='Одно ФИО', title='Не присваивать автоматически')])
|
||||
first = employee(db_session, name='Одно ФИО', key='first')
|
||||
employee(db_session, name='Одно ФИО', key='second')
|
||||
publication(db_session, first, title='Своя статья')
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, employee_id=first.id)
|
||||
assert result['total'] == 2 and result['matched'] == 1
|
||||
assert result['candidates'][0]['ambiguous']
|
||||
assert [a['title'] for a in result['candidates'][0]['articles']] == ['Своя статья']
|
||||
|
||||
|
||||
def test_plain_description_fast_path_and_html_entities():
|
||||
from app.services.council_source import _description_journals
|
||||
description = {'short': {'ru': ' Journal Name. 2024. № 1. ', 'en': '<b>Journal & Name</b>. 2024.'},
|
||||
'shortLeft': {'en': 'Journal & Name. 2024.'}}
|
||||
assert _description_journals(description) == ['Journal Name', 'Journal & Name', 'Journal & Name']
|
||||
@@ -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,18 +125,18 @@ 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("Соответствует")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("year,exact,expected", [(2021, None, False), (2026, None, False),
|
||||
(2022, None, True), (2021, "2021-10-09", True), (2021, "2021-10-08", False),
|
||||
(2026, "2026-10-07", True), (2026, "2026-10-08", False), (2026, "2026-10-09", False),
|
||||
(2024, "2025-01-01", False), (2024, "invalid", False)])
|
||||
@pytest.mark.parametrize("year,exact,expected", [(2021, None, False), (2026, None, True),
|
||||
(2022, None, True), (2021, "2021-10-09", False), (2021, "2021-10-08", False),
|
||||
(2026, "2026-10-07", True), (2026, "2026-10-08", True), (2026, "2026-10-09", True),
|
||||
(2024, "2025-01-01", False), (2024, "invalid", True)])
|
||||
def test_boundary_dates_and_conflicting_year(db_session, proofs, year, exact, expected):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee, year=year)
|
||||
@@ -160,7 +183,7 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
|
||||
app.dependency_overrides[get_db] = database
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for query in ["specialty=oops"]:
|
||||
for query in ["specialty=oops", "branch=oops"]:
|
||||
assert client.get("/admin/council?" + query).status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
|
||||
query = "?sort=confirmed_count"
|
||||
@@ -169,15 +192,40 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx" + query).content))
|
||||
headers = [c.value for c in book["Сотрудники"][1]]
|
||||
row = dict(zip(headers, [c.value for c in book["Сотрудники"][2]]))
|
||||
assert row["Подтверждено"] == 9 and row["Минимум"] == 9
|
||||
assert row["Итог"] in page.text
|
||||
assert row["Отрасль оценки"] == "технические"
|
||||
assert not any("ходатайств" in str(c.value).lower() or "монограф" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
|
||||
assert row["Всего"] == 9 and row["Научная степень"] == degree
|
||||
assert dict(book["Параметры"].values)["Отрасль оценки"] == "технические"
|
||||
assert not any("ходатайств" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
|
||||
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 legacy_book["Сотрудники"]["Q2"].value == "технические"
|
||||
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()
|
||||
|
||||
@@ -198,7 +246,7 @@ def test_no_degree_or_only_title_does_not_form_conclusion(db_session, proofs):
|
||||
assert council.council_candidates(db_session)["total"] == 0
|
||||
|
||||
|
||||
def test_exact_dates_conflict_and_bases_intersection_count_once(db_session, proofs):
|
||||
def test_days_within_same_year_do_not_conflict_and_bases_count_once(db_session, proofs):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee)
|
||||
record["bases"].append({**SOURCE, "basis": "RSCI", "applicable_year": 2024})
|
||||
@@ -206,7 +254,7 @@ def test_exact_dates_conflict_and_bases_intersection_count_once(db_session, proo
|
||||
assert result(db_session)["confirmed_count"] == 1
|
||||
proofs(publications=[{**record, "publication_date": "2024-01-01", "date_evidence": SOURCE},
|
||||
{**record, "publication_date": "2024-02-01", "date_evidence": SOURCE}])
|
||||
assert result(db_session)["confirmed_count"] == 0
|
||||
assert result(db_session)["confirmed_count"] == 1
|
||||
|
||||
|
||||
def test_exact_date_can_confirm_missing_year_without_losing_preliminary_count(db_session, proofs):
|
||||
@@ -285,13 +333,13 @@ def test_technical_assessment_uses_current_period_without_petition(db_session, p
|
||||
assert value["assessment_reasons"] == []
|
||||
|
||||
|
||||
def test_boundary_dates_require_sourced_policy(db_session, proofs):
|
||||
def test_calendar_years_ignore_legacy_boundary_policy(db_session, proofs):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee, year=2021, publication_date="2021-10-08", date_evidence=SOURCE)
|
||||
proofs(publications=[record], policy={"boundary_dates": {"value": "inclusive"}})
|
||||
assert result(db_session)["confirmed_count"] == 0
|
||||
proofs(publications=[record], policy={"boundary_dates": {**SOURCE, "value": "inclusive"}})
|
||||
assert result(db_session)["confirmed_count"] == 1
|
||||
assert result(db_session)["confirmed_count"] == 0
|
||||
|
||||
|
||||
def test_invalid_evidence_schema_is_rejected(monkeypatch, tmp_path):
|
||||
@@ -320,11 +368,14 @@ 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)
|
||||
assert exported[{"К1": "К1", "К2": "К2 предварительно", "RSCI": "RSCI предварительно", "Q1": "Q1", "Q2": "Q2"}[basis]] == int(admitted)
|
||||
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":
|
||||
assert exported[basis] == int(admitted)
|
||||
|
||||
|
||||
def test_admitted_type_needs_sourced_policy_and_consistent_material(db_session, proofs):
|
||||
@@ -361,18 +412,195 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
|
||||
article = candidate["articles"][0]
|
||||
flag = "q2" if broken_index == 0 else "q1"
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == 1
|
||||
assert candidate["confirmed_count"] == 0
|
||||
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
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 200 and "целостность каталога" in response.text
|
||||
assert journal["name"] in response.text
|
||||
assert journal["name"] in TestClient(app).get(f"/admin/council/employees/{employee.id}/articles").text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
finally:
|
||||
catalogs.catalog.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('year,kind,expected', [(2021, 'ARTICLE', False), (2022, 'ARTICLE', True),
|
||||
(2026, 'ARTICLE', True), (2027, 'ARTICLE', False), (2024, None, False), (2024, 'BOOK', False)])
|
||||
def test_automatic_source_quartile_calendar_years(db_session, proofs, year, kind, expected):
|
||||
employee = person(db_session, ['Доктор технических наук'])
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Automatic', year=year,
|
||||
publication_type=kind, source_hash='automatic', raw_data={'journal': 'Журнал',
|
||||
'csv_enrichment': {'Название журнала': 'Журнал', 'Квартиль scopus': 'Q2', 'Год': str(year)}}))
|
||||
candidate = result(db_session)
|
||||
assert candidate['confirmed_count'] == int(expected)
|
||||
assert not candidate['degree_confirmed']
|
||||
assert candidate['assessment_status'] == 'Требует проверки'
|
||||
article = candidate['articles'][0]
|
||||
assert len([m for m in article['messages'] if 'период' in m.lower()]) == 1
|
||||
if expected:
|
||||
assert article['confirmed_bases'][0]['basis'] == 'Q2'
|
||||
assert not any('Вид материала не подтверждён' in m for m in article['messages'])
|
||||
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']
|
||||
assert exported['Всего'] == candidate['q2_count']
|
||||
|
||||
|
||||
def test_automatic_quartile_identity_conflict_and_messages(db_session, proofs):
|
||||
employee = person(db_session, ['Доктор технических наук'])
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Conflict', year=2024,
|
||||
publication_type='ARTICLE', source_hash='conflict', doi_url='https://doi.org/10.1234/conflict',
|
||||
raw_data={'journal': 'Текущий журнал', 'csv_enrichment': {'Название журнала': 'Текущий журнал', 'Квартиль scopus': 'Q2'}},
|
||||
quartiles={'schema_version': 1, 'entries': [{'system': 'Scopus', 'quartile': 'Q1',
|
||||
'doi': '10.1234/conflict', 'journal': 'Другой журнал', 'publication_year': 2024}]}))
|
||||
article = result(db_session)['articles'][0]
|
||||
assert not article['confirmed']
|
||||
assert len(article['messages']) == len(set(article['messages']))
|
||||
issue = article['warnings']['journal'][0]
|
||||
assert 'Другой журнал' in issue and 'Текущий журнал' in issue
|
||||
assert article['messages'].count(issue) == 1
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get(f'/admin/council/employees/{employee.id}/articles')
|
||||
from bs4 import BeautifulSoup
|
||||
card = BeautifulSoup(response.text, 'html.parser').select_one('li.council__article')
|
||||
assert sum(tip.get_text().count(issue) for tip in card.select('[role=tooltip]')) == 1
|
||||
assert 'Зачёт в подтверждённое число' not in card.get_text()
|
||||
assert 'Специальность для рассмотрения статьи не выбрана' not in card.get_text()
|
||||
assert 'Пункт:' not in response.text and 'Минимум:' not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_branch_selection_threshold_and_export(db_session, proofs):
|
||||
employee = person(db_session, ['Доктор технических наук'])
|
||||
proofs([{**SOURCE, 'degree': 'Доктор технических наук'}], [work(db_session, employee, n) for n in range(10)])
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for branch, expected in [('технические', 'Соответствует'), ('физико-математические', 'Требует проверки')]:
|
||||
response = client.get('/admin/council', params={'branch': branch})
|
||||
assert response.status_code == 200
|
||||
from bs4 import BeautifulSoup
|
||||
parsed = BeautifulSoup(response.text, 'html.parser')
|
||||
assert parsed.select_one('#council-branch option[selected]').get('value') == branch
|
||||
assert expected in parsed.select_one('.council__candidate').get_text()
|
||||
book = load_workbook(BytesIO(client.get('/admin/council/export.xlsx', params={'branch': branch}).content))
|
||||
headers = [cell.value for cell in book['Сотрудники'][1]]
|
||||
row = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
|
||||
assert row['Всего'] == 10
|
||||
parameters = dict(book['Параметры'].values)
|
||||
assert parameters['Отрасль оценки'] == branch
|
||||
threshold = next(value for key, value in parameters.items() if key.startswith('Доктор · ') and branch in key.split(' · ')[1].split(', '))
|
||||
assert threshold.startswith('9 ' if branch == 'технические' else '11 ')
|
||||
assert parameters['Начало периода'] == datetime(2022, 1, 1)
|
||||
assert parameters['Конец периода'] == datetime(2026, 12, 31)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('category,metric,expected', [
|
||||
('Applied Mathematics', 'CiteScore', True),
|
||||
('Analysis', 'CiteScore', False),
|
||||
('Analysis', 'SJR', True),
|
||||
])
|
||||
def test_automatic_quartile_categories_and_metrics_preserve_conflicts(db_session, proofs, category, metric, expected):
|
||||
employee = person(db_session, ['Доктор технических наук'])
|
||||
first = {'system': 'Scopus', 'quartile': 'Q1', 'category': 'Analysis', 'metric': 'CiteScore',
|
||||
'metric_year': 2024, 'publication_year': 2024, 'doi': '10.1234/categories',
|
||||
'journal': 'Journal of Mathematics', 'source': 'Journal metrics 2024',
|
||||
'source_url': 'https://example.org/metrics', 'checked_on': '2026-10-08'}
|
||||
second = {**first, 'quartile': 'Q2', 'category': category, 'metric': metric}
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Categorized article', year=2024,
|
||||
publication_type='ARTICLE', source_hash='categories', doi_url='https://doi.org/10.1234/categories',
|
||||
raw_data={'journal': first['journal']}, quartiles={'schema_version': 1, 'entries': [first, second]}))
|
||||
candidate = result(db_session)
|
||||
assert candidate['q1_count'] == candidate['q2_count'] == candidate['total_count'] == 1
|
||||
assert candidate['confirmed_count'] == int(expected)
|
||||
article = candidate['articles'][0]
|
||||
assert any(reason.startswith('Подтверждённые основания источников расходятся') for reason in article['confirmation_reasons']) == (not expected)
|
||||
assert [(basis.get('category'), basis.get('metric'), basis.get('metric_year'), basis['source'], basis['source_url'])
|
||||
for basis in article['automatic_quartile_bases']] == [
|
||||
('Analysis', 'CiteScore', 2024, first['source'], first['source_url']),
|
||||
(category, metric, 2024, second['source'], second['source_url'])]
|
||||
|
||||
|
||||
def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_session, proofs):
|
||||
employee = person(db_session, ['Доктор технических наук'])
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Missing kind', year=2024,
|
||||
source_hash='missing', raw_data={'journal': 'Journal', 'csv_enrichment': {'Название журнала': 'Journal', 'Квартиль scopus': 'Q1'}}))
|
||||
candidate = result(db_session)
|
||||
article = candidate['articles'][0]
|
||||
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()
|
||||
@@ -0,0 +1,195 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import CouncilCorrections, Employee, EmployeePublication
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services import council
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def editor(db_session, monkeypatch):
|
||||
monkeypatch.setattr(council, '_articles_by_author', lambda: {})
|
||||
for number in range(2):
|
||||
person = Employee(profile_key=f'editor:{number}', full_name=f'Иванов {number}',
|
||||
canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db_session.add(person)
|
||||
db_session.flush()
|
||||
db_session.add(EmployeePublication(employee_id=person.id, title=f'Статья {number}', year=2024,
|
||||
publication_type='ARTICLE', source_hash=f'editor:{number}', raw_data={'journal': 'Общий журнал'}))
|
||||
db_session.commit()
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='test', session_secret='editor-test')
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
|
||||
candidate = council.council_candidates(db_session)['candidates'][0]
|
||||
article = candidate['articles'][0]
|
||||
payload = {'employee_id': candidate['employee_id'], 'edit_key': article['edit_key'], 'revision': 0,
|
||||
'title': 'Исправленная статья', 'journal': 'Общий журнал', 'quartile': 'Q1'}
|
||||
try:
|
||||
yield client, payload
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def save(client, payload):
|
||||
return client.post('/admin/council/publications', json=payload, headers={'X-Council-Edit': '1'})
|
||||
|
||||
|
||||
def test_editor_shared_quartile_persistence_filter_and_export(editor, db_session):
|
||||
client, payload = editor
|
||||
assert save(client, payload).status_code == 200
|
||||
db_session.expire_all()
|
||||
result = council.council_candidates(db_session, today=date(2026, 10, 9))
|
||||
assert result['revision'] == 1
|
||||
assert [c['q1_count'] for c in result['candidates']] == [1, 1]
|
||||
articles = [a for c in result['candidates'] for a in c['articles']]
|
||||
assert all(a['manual_review'] and not a['confirmed'] for a in articles)
|
||||
assert next(a for a in articles if a['edit_key'] == payload['edit_key'] and a['title'] == payload['title'])
|
||||
assert db_session.scalars(select(EmployeePublication.title)).all() == ['Статья 0', 'Статья 1']
|
||||
for filters in ({'title': 'исправленная'}, {'employee': 'иванов 0'}, {'journal': 'общий ЖУРНАЛ'}):
|
||||
response = client.get('/admin/council/publications', params=filters)
|
||||
assert response.status_code == 200
|
||||
assert payload['title'] in response.text
|
||||
assert 'Статьи не найдены' in client.get('/admin/council/publications?title=absent').text
|
||||
main = client.get('/admin/council')
|
||||
assert 'data-council-edit' not in main.text and payload['title'] not in main.text
|
||||
fragment = client.get(f"/admin/council/employees/{payload['employee_id']}/articles")
|
||||
assert 'data-council-edit' in fragment.text and payload['title'] in fragment.text
|
||||
from io import BytesIO
|
||||
from openpyxl import load_workbook
|
||||
workbook = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
|
||||
assert any(payload['title'] in str(row) for row in workbook['Публикации'].values)
|
||||
# A stale window must not overwrite another save.
|
||||
assert save(client, {**payload, 'title': 'Устаревшая правка'}).status_code == 409
|
||||
assert db_session.get(CouncilCorrections, 1).revision == 1
|
||||
|
||||
|
||||
def test_editor_permissions_csrf_validation_and_missing_article(editor, db_session):
|
||||
client, payload = editor
|
||||
assert client.post('/admin/council/publications', json=payload).status_code == 403
|
||||
for values in ({'title': ' '}, {'journal': ' '}, {'journal': '!!!'}, {'quartile': 'Q5'}, {'quartile': '<script>'}, {'revision': -1}):
|
||||
assert save(client, {**payload, **values}).status_code == 422
|
||||
assert save(client, {**payload, 'edit_key': '0' * 64}).status_code == 404
|
||||
assert db_session.get(CouncilCorrections, 1) is None
|
||||
client.cookies.clear()
|
||||
assert save(client, payload).status_code == 401
|
||||
response = client.get('/admin/council/publications')
|
||||
assert response.status_code == 200 and 'Для редактирования' in response.text
|
||||
|
||||
|
||||
def test_editor_journal_reassignment_year_scope_and_unknown_year(editor, db_session):
|
||||
client, payload = editor
|
||||
old = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id != payload['employee_id']))
|
||||
old.year = 2023
|
||||
db_session.commit()
|
||||
assert save(client, {**payload, 'journal': 'Новый журнал', 'quartile': 'Q2'}).status_code == 200
|
||||
db_session.expire_all()
|
||||
result = council.council_candidates(db_session)
|
||||
corrected = next(c for c in result['candidates'] if c['employee_id'] == payload['employee_id'])
|
||||
assert corrected['q2_count'] == 1
|
||||
assert corrected['articles'][0]['journals'] == ['Новый журнал']
|
||||
untouched = next(c for c in result['candidates'] if c['employee_id'] != payload['employee_id'])
|
||||
assert untouched['q2_count'] == 0
|
||||
assert untouched['articles'][0]['journals'] == ['Общий журнал']
|
||||
old.year = None
|
||||
db_session.commit()
|
||||
article = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == old.employee_id)['articles'][0]
|
||||
unknown = {**payload, 'employee_id': old.employee_id, 'edit_key': article['edit_key'], 'revision': 1}
|
||||
assert save(client, unknown).status_code == 422
|
||||
assert save(client, {**unknown, 'quartile': ''}).status_code == 200
|
||||
|
||||
|
||||
def test_editor_csv_only_and_escaping(editor, db_session, monkeypatch):
|
||||
client, payload = editor
|
||||
monkeypatch.setattr(council, '_articles_by_author', lambda: {'иванов 0': [{
|
||||
'title': 'Выгрузка', 'year': 2024, 'doi': '10.1234/csv', 'types': [], 'journals': [],
|
||||
'quartiles': [], 'origins': ['Данные ВШЭ'], 'rows': [2], 'in_csv': True, 'in_db': False, 'url': None}]})
|
||||
article = next(a for c in council.council_candidates(db_session)['candidates']
|
||||
for a in c['articles'] if a['doi'] == '10.1234/csv')
|
||||
payload.update(edit_key=article['edit_key'], title='<script>alert(1)</script>', journal='Журнал', quartile='Q4')
|
||||
assert save(client, payload).status_code == 200
|
||||
page = client.get('/admin/council/publications')
|
||||
assert '<script>alert(1)</script>' not in page.text
|
||||
assert '<script>' in page.text
|
||||
|
||||
|
||||
def test_editor_reassignment_clears_old_journal_evidence(editor, db_session):
|
||||
client, payload = editor
|
||||
source = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id == payload['employee_id']))
|
||||
source.raw_data = {'journal': 'Бизнес-информатика', 'issn': '1998-0663'}
|
||||
db_session.commit()
|
||||
before = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
|
||||
assert before['k1_count'] == 1
|
||||
assert save(client, {**payload, 'journal': 'Неизвестный журнал', 'quartile': 'Q4'}).status_code == 200
|
||||
after = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
|
||||
assert (after['k1_count'], after['q1_count'], after['q2_count']) == (0, 0, 0)
|
||||
assert after['articles'][0]['catalog_records'] == []
|
||||
assert not after['articles'][0]['specialty_codes']
|
||||
assert source.raw_data['issn'] == '1998-0663'
|
||||
|
||||
|
||||
def test_editor_pagination_preserves_filters(editor, db_session):
|
||||
client, payload = editor
|
||||
for number in range(55):
|
||||
db_session.add(EmployeePublication(employee_id=payload['employee_id'], title=f'Пагинация {number:02}',
|
||||
year=2024, source_hash=f'page:{number}', raw_data={'journal': 'Тестовый журнал'}))
|
||||
db_session.commit()
|
||||
response = client.get('/admin/council/publications?employee=Иванов&title=Пагинация&journal=Тестовый&offset=999')
|
||||
assert response.status_code == 200
|
||||
assert 'Показаны 51–55' in response.text
|
||||
assert response.text.count('data-council-article') == 5
|
||||
assert 'offset=0' in response.text
|
||||
assert 'name="employee" value="Иванов"' in response.text
|
||||
|
||||
|
||||
def test_editor_database_failure_preserves_data(editor, db_session, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, payload = editor
|
||||
def unavailable():
|
||||
raise OperationalError('UPDATE', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db_session, 'commit', unavailable)
|
||||
response = save(client, payload)
|
||||
assert response.status_code == 503
|
||||
assert 'private database details' not in response.text
|
||||
assert db_session.get(CouncilCorrections, 1) is None
|
||||
|
||||
|
||||
def test_manual_specialty_shared_filter_remove_and_reset(editor, db_session):
|
||||
client, payload = editor
|
||||
assert save(client, {**payload, 'specialty_codes': ['<script>']}).status_code == 422
|
||||
assert save(client, {**payload, 'specialty_codes': ['1.2.2', '1.2.2']}).status_code == 200
|
||||
result = council.council_candidates(db_session, specialty='1.2.2')
|
||||
assert result['matched'] == 2
|
||||
assert all(c['specialty_count'] == 1 and c['articles'][0]['manual_specialties'] for c in result['candidates'])
|
||||
assert result['candidates'][0]['articles'][0]['edit_specialty_codes'] == ['1.2.2']
|
||||
# Empty selection removes the journal's thematic association.
|
||||
assert save(client, {**payload, 'revision': 1, 'specialty_codes': []}).status_code == 200
|
||||
assert council.council_candidates(db_session, specialty='1.2.2')['matched'] == 0
|
||||
# The automatic option restores the source catalog association.
|
||||
assert save(client, {**payload, 'revision': 2, 'specialty_codes': None}).status_code == 200
|
||||
assert 'общий журнал' not in db_session.get(CouncilCorrections, 1).data['specialties']
|
||||
|
||||
|
||||
def test_specialty_only_edit_preserves_automatic_confirmation(editor, db_session):
|
||||
client, payload = editor
|
||||
source = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id == payload['employee_id']))
|
||||
source.raw_data = {'journal': 'Общий журнал', 'csv_enrichment': {'Название журнала': 'Общий журнал', 'Квартиль scopus': 'Q1'}}
|
||||
db_session.commit()
|
||||
candidate = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
|
||||
assert candidate['confirmed_count'] == 1
|
||||
payload.update(title=source.title, specialty_codes=['1.2.2'])
|
||||
assert save(client, payload).status_code == 200
|
||||
updated = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
|
||||
assert updated['confirmed_count'] == 1
|
||||
assert updated['articles'][0]['manual_specialties']
|
||||
assert not updated['articles'][0]['manual_review']
|
||||
assert db_session.get(CouncilCorrections, 1).data['journals'] == {}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -233,7 +233,7 @@ def test_crawler_doi_or_journal_change_excludes_preserved_quartile(new_doi, new_
|
||||
article = _database_article(publication)
|
||||
assert publication.quartiles == payload
|
||||
assert article["quartiles"] == [] and article["quartile_issues"]
|
||||
assert all("перепроверка" in issue for issue in article["quartile_issues"])
|
||||
assert all("перепроверка" in issue or "Уточните журнал статьи" in issue for issue in article["quartile_issues"])
|
||||
|
||||
|
||||
def test_updating_lookup_freezes_legacy_binding_instead_of_rebinding_old_entries():
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import re
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.db import Base
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import SystemUser, UserAction, Employee, EmployeePublication, CouncilCorrections, utcnow
|
||||
from app.security import (SESSION_COOKIE, _session_data, ensure_bootstrap_admin, hash_password,
|
||||
sign_session, temporary_password, verify_password)
|
||||
from app.services import council
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def accounts(db_session, monkeypatch):
|
||||
test_engine = None
|
||||
native_url = os.getenv('USERS_TEST_DATABASE_URL')
|
||||
if native_url:
|
||||
assert make_url(native_url).database == 'miem_users_test', 'Use a disposable miem_users_test database only'
|
||||
test_engine = create_engine(native_url)
|
||||
Base.metadata.create_all(test_engine)
|
||||
db_session = sessionmaker(bind=test_engine)()
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='initial-admin-test', session_secret='users-test-secret')
|
||||
admin = ensure_bootstrap_admin(db_session, settings)
|
||||
user = SystemUser(username='reader', full_name='Тестовый пользователь', role='user',
|
||||
password_hash=hash_password('Initial user password'), must_change_password=False,
|
||||
first_login_at=utcnow())
|
||||
db_session.add(user)
|
||||
employee = Employee(profile_key='users:employee', canonical_url='https://www.hse.ru/staff/test', full_name='Тестовый сотрудник',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db_session.add(employee)
|
||||
db_session.flush()
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Исходная статья', year=2024,
|
||||
publication_type='ARTICLE', source_hash='users-publication', raw_data={'journal': 'Журнал'}))
|
||||
db_session.commit()
|
||||
monkeypatch.setattr(council, '_articles_by_author', lambda: {})
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
try:
|
||||
yield client, db_session, settings, admin, user, employee
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
if test_engine is not None:
|
||||
db_session.close()
|
||||
Base.metadata.drop_all(test_engine)
|
||||
test_engine.dispose()
|
||||
|
||||
|
||||
def act_as(client, user, settings):
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(user.username, settings, user.session_version))
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
return {'X-CSRF-Token': csrf}
|
||||
|
||||
|
||||
def login(client, username, password):
|
||||
page = client.get('/admin/login')
|
||||
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[name=csrf_token]')['value']
|
||||
return client.post('/admin/login', data={'username': username, 'password': password, 'csrf_token': csrf}, follow_redirects=False)
|
||||
|
||||
|
||||
def test_password_hash_and_eight_character_generator():
|
||||
for _ in range(100):
|
||||
password = temporary_password()
|
||||
assert len(password) == 8
|
||||
assert all(re.search(pattern, password) for pattern in (r'[A-Z]', r'[a-z]', r'[0-9]', r'[!@#$%+_\-]'))
|
||||
encoded = hash_password('Секретная фраза с пробелами')
|
||||
assert verify_password('Секретная фраза с пробелами', encoded)
|
||||
assert not verify_password('Другой пароль', encoded)
|
||||
assert not verify_password('anything', 'broken hash')
|
||||
assert hash_password('same password') != hash_password('same password')
|
||||
|
||||
|
||||
def test_create_user_first_login_forces_password_change(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
response = client.post('/admin/users', data={'username': 'new.reader', 'full_name': 'Новый пользователь', 'role': 'user'}, headers=headers)
|
||||
assert response.status_code == 200 and response.headers['cache-control'] == 'no-store'
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == 'new.reader'))
|
||||
assert verify_password(password, user.password_hash) and user.must_change_password
|
||||
assert password not in str(db.scalars(select(UserAction.changes)).all())
|
||||
client.cookies.clear()
|
||||
signed_in = login(client, user.username, password)
|
||||
assert signed_in.status_code == 303 and signed_in.headers['location'] == '/admin/account'
|
||||
db.refresh(user)
|
||||
assert user.first_login_at and user.last_login_at and user.password_changed_at is None
|
||||
first_login = user.first_login_at
|
||||
assert 'data-required-password' in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 403
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
csrf = _session_data(old_token, settings)['csrf']
|
||||
changed = client.post('/admin/account/password', data={'current_password': password,
|
||||
'new_password': 'A new personal passphrase', 'confirmation': 'A new personal passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False)
|
||||
assert changed.status_code == 303
|
||||
db.refresh(user)
|
||||
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()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['/admin/crawl-now', '/admin/runs', '/admin/dismissed/refresh', '/admin/employees/1/refresh', '/api/crawl-runs'])
|
||||
def test_regular_user_cannot_start_scraping(accounts, path):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
response = client.post(path, headers=act_as(client, user, settings), follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
assert 'Запустить парсинг' not in client.get('/admin').text
|
||||
assert 'Обновить данные' not in client.get('/admin/employees/1').text
|
||||
assert client.get('/api/stats').status_code == 200
|
||||
|
||||
|
||||
def test_regular_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']}
|
||||
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'))
|
||||
assert action.user_id == user.id and action.employee_id == employee.id and action.publication_year == 2024
|
||||
assert action.changes['title'] == {'before': 'Исходная статья', 'after': 'Изменённая статья'}
|
||||
assert action.changes['quartile'] == {'before': '', 'after': 'Q2'}
|
||||
assert action.changes['specialty_codes']['after']['codes'] == ['1.2.2']
|
||||
db.add(UserAction(user_id=admin.id, action='card_edit', article_title='Чужое действие', changes={}))
|
||||
db.commit()
|
||||
profile = client.get(f'/admin/account?user_id={admin.id}')
|
||||
assert 'Изменённая статья' in profile.text and 'Исходная статья' in profile.text
|
||||
assert 'Чужое действие' not in profile.text
|
||||
assert client.get(f'/admin/users?user_id={admin.id}').status_code == 403
|
||||
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 409
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
|
||||
|
||||
|
||||
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
user.password_changed_at = utcnow()
|
||||
db.commit()
|
||||
previous_changed = user.password_changed_at
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
response = client.post(f'/admin/users/{user.id}/reset-password', headers=act_as(client, admin, settings))
|
||||
assert response.status_code == 200
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
db.refresh(user)
|
||||
assert user.must_change_password and user.password_reset_at and verify_password(password, user.password_hash)
|
||||
assert user.password_changed_at == previous_changed
|
||||
assert password not in client.get('/admin/users').text
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
assert login(client, user.username, password).headers['location'] == '/admin/account'
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_reset')).target_user_id == user.id
|
||||
|
||||
|
||||
def test_role_changes_disable_and_protection_against_locking_out_self(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{admin.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{admin.id}/reset-password', headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'admin', 'is_active': 'true'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.role == 'admin'
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/admin/users', follow_redirects=False).status_code == 303
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
client.cookies.clear()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
assert not user.is_active
|
||||
|
||||
|
||||
def test_csrf_duplicate_user_validation_and_login_throttling(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'unprotected'}).status_code == 403
|
||||
assert client.post('/admin/account/password', data={'current_password': 'anything'}).status_code == 403
|
||||
assert client.post('/admin/login', data={'username': user.username, 'password': 'anything'}).status_code == 403
|
||||
for username, role, code in [('reader', 'user', 409), ('bad login', 'user', 422), ('newuser', 'superuser', 422)]:
|
||||
assert client.post('/admin/users', data={'username': username, 'role': role}, headers=headers).status_code == code
|
||||
client.cookies.clear()
|
||||
for _ in range(5):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.locked_until and user.failed_login_attempts == 5
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
for attempt in range(1, 6):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == attempt
|
||||
assert bool(user.locked_until) == (attempt == 5)
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 5
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 0 and user.locked_until is None
|
||||
|
||||
|
||||
def test_bootstrap_admin_first_login_and_changed_password_no_env_fallback(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
assert login(client, admin.username, 'initial-admin-test').headers['location'] == '/admin/account'
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
assert client.post('/admin/account/password', data={'current_password': 'initial-admin-test',
|
||||
'new_password': 'New administrator passphrase', 'confirmation': 'New administrator passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False).status_code == 303
|
||||
client.cookies.clear()
|
||||
assert login(client, admin.username, 'initial-admin-test').status_code == 401
|
||||
assert login(client, admin.username, 'New administrator passphrase').status_code == 303
|
||||
|
||||
|
||||
def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
|
||||
import time
|
||||
from app import security
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'https://evil.example'}).status_code == 403
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'http://['}).status_code == 403
|
||||
assert db.scalar(select(SystemUser).where(SystemUser.username == 'attacker')) is None
|
||||
token = sign_session(user.username, settings, user.session_version)
|
||||
now = int(time.time())
|
||||
monkeypatch.setattr(security.time, 'time', lambda: now + security.SESSION_SECONDS + 1)
|
||||
assert security.read_session(token, settings) is None
|
||||
|
||||
|
||||
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
|
||||
client, db, settings, _, user, employee = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
|
||||
('Initial user password', 'short', 'short'),
|
||||
('Initial user password', 'New valid personal password', 'Different valid password')]:
|
||||
response = client.post('/admin/account/password', headers=headers,
|
||||
data={'current_password': current, 'new_password': new, 'confirmation': confirmation})
|
||||
assert response.status_code == 422 and user.password_hash == before
|
||||
article = council.council_candidates(db)['candidates'][0]['articles'][0]
|
||||
response = client.post('/admin/council/publications', headers={'X-Council-Edit': '1'}, json={
|
||||
'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0, 'title': article['title'],
|
||||
'journal': article['edit_journal'], 'quartile': article['edit_quartile'], 'specialty_codes': None})
|
||||
assert response.status_code == 200
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == []
|
||||
|
||||
|
||||
def test_logout_revokes_replayed_cookie(accounts):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
assert client.post('/admin/logout', headers=headers, follow_redirects=False).status_code == 303
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
def unavailable():
|
||||
raise OperationalError('UPDATE', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/account/password', data={'current_password': 'Initial user password',
|
||||
'new_password': 'A different personal passphrase', 'confirmation': 'A different personal passphrase'}, headers=headers)
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
db.refresh(user)
|
||||
assert user.password_hash == before and user.session_version == 0
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['user', 'admin'])
|
||||
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
user.role = role
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
data = {'current_password': 'Initial user password', 'new_password': 'New onboarding passphrase',
|
||||
'confirmation': 'New onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
page = client.get('/admin/account')
|
||||
assert 'data-guide-pending="true"' in page.text
|
||||
assert 'data-required-password' not in page.text
|
||||
assert page.headers['Cache-Control'] == 'no-store'
|
||||
guide = client.get('/admin/guide')
|
||||
parsed = BeautifulSoup(guide.text, 'html.parser')
|
||||
assert len(parsed.select('[data-guide-step]')) == 6
|
||||
assert not parsed.select('[data-guide-dialog]')
|
||||
assert 'Прочитайте показатели сотрудника' in guide.text and 'Проверьте строку публикации' in guide.text
|
||||
assert ('управлять доступом' in guide.text) == (role == 'admin')
|
||||
assert 'не ограничивает «ВСЕГО»' in guide.text
|
||||
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide-dialog]')['data-guide-csrf']
|
||||
assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/directory').text
|
||||
for _ in range(2):
|
||||
response = client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': csrf, 'Accept': 'application/json'}, follow_redirects=False)
|
||||
assert response.status_code == 204
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_finish')).all()) == 1
|
||||
client.cookies.clear()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin').text
|
||||
data = {'current_password': data['new_password'], 'new_password': 'Second onboarding passphrase',
|
||||
'confirmation': 'Second onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
assert client.get('/admin/guide').status_code == 200
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_start')).all()) == 1
|
||||
|
||||
|
||||
def test_guide_access_and_no_javascript_completion(accounts):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
assert 'data-guide-dialog' not in client.get('/admin/council').text
|
||||
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login'
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False).headers['location'] == '/admin/account'
|
||||
user.must_change_password = False
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
page = BeautifulSoup(client.get('/admin').text, 'html.parser')
|
||||
assert page.select_one('noscript a[href="/admin/guide"]')
|
||||
response = client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers['location'] == '/admin'
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
|
||||
|
||||
def test_guide_dismissal_failure_does_not_mark_it_finished(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
def unavailable():
|
||||
raise OperationalError('INSERT', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/guide/dismiss', headers={**headers, 'Accept': 'application/json'})
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_finish')) is None
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/account').text
|
||||
|
||||
|
||||
def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
if db.get_bind().dialect.name != 'postgresql':
|
||||
pytest.skip('Requires the disposable PostgreSQL test database')
|
||||
second = SystemUser(username='admin-two', role='admin', password_hash=hash_password('Second administrator passphrase'),
|
||||
must_change_password=False, first_login_at=utcnow())
|
||||
db.add(second)
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind())
|
||||
def sessions():
|
||||
with factory() as session:
|
||||
yield session
|
||||
app.dependency_overrides[get_db] = sessions
|
||||
clients = [TestClient(app), TestClient(app)]
|
||||
actors = [admin, second]
|
||||
targets = [second.id, admin.id]
|
||||
def demote(index):
|
||||
headers = act_as(clients[index], actors[index], settings)
|
||||
response = clients[index].post(f'/admin/users/{targets[index]}',
|
||||
data={'role': 'user', 'is_active': 'true'}, headers=headers, follow_redirects=False)
|
||||
return response.status_code, response.headers.get('location')
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
responses = list(executor.map(demote, [0, 1]))
|
||||
assert sum(location == '/admin/users' for status, location in responses) == 1
|
||||
assert all(status in {303, 403} for status, location in responses)
|
||||
remaining = db.scalars(select(SystemUser).where(SystemUser.role == 'admin', SystemUser.is_active.is_(True))).all()
|
||||
assert len(remaining) == 1
|
||||
finally:
|
||||
for other in clients:
|
||||
other.close()
|
||||
Reference in new issue
Block a user