Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1854ed5e2 | ||
|
|
c7940c690b | ||
|
|
3fb174899d | ||
|
|
508aae4f8a | ||
|
|
59dcacbaff | ||
|
|
43d609b353 | ||
|
|
9d618bda81 | ||
|
|
ae0fc32172 | ||
|
|
83431d8f2a | ||
|
|
0aa472412e | ||
|
|
d10690e28b | ||
|
|
744abd0063 | ||
|
|
f9cf9708da | ||
|
|
0f2d0db3c7 | ||
|
|
d9420db50c | ||
|
|
c92523f235 | ||
|
|
39894a6712 | ||
|
|
201e4dd299 | ||
|
|
22e1bd3f2e | ||
|
|
caae2270dc | ||
|
|
cdf879fdf5 | ||
|
|
fd3008dfde | ||
|
|
87429e463c | ||
|
|
ac9b1cebd1 | ||
|
|
1c52b2a2c6 | ||
|
|
962e936ad1 | ||
|
|
2baa2e1aa1 | ||
|
|
7c02b0825f | ||
|
|
e29775b238 | ||
|
|
8e3ea6eeb2 | ||
|
|
5128fac6e5 | ||
|
|
378d7ee96e | ||
|
|
9d788b0663 | ||
|
|
3359ea2f86 | ||
|
|
ccebaddfda |
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.
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
- run: node --check app/static/admin.js
|
||||
- run: node --check app/static/council.js
|
||||
- run: node --check app/static/users.js
|
||||
- run: node --check app/static/guide.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.1
|
||||
|
||||
- Роль «Пользователь диссовета» добавляет раздел «Диссовет» к меню обычного пользователя и разрешает редактировать публикации. Без входа доступен только просмотр диссовета, включая публикации и выгрузку XLSX.
|
||||
- Страницы запусков, их история и прогресс в API доступны только администраторам.
|
||||
- Поля фильтров справочника и диссовета выделяются при фокусе контуром толщиной 2 px без отступа.
|
||||
- При обновлении существующей базы с 1.x применяется миграция 014. Она добавляет новую роль и сохраняет назначения существующих пользователей.
|
||||
|
||||
## 1.7.3
|
||||
|
||||
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
|
||||
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
|
||||
|
||||
## 1.7.2
|
||||
|
||||
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
|
||||
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
|
||||
|
||||
## 1.6.2
|
||||
|
||||
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
|
||||
- Обучение сохраняет текущий шаг при работе с фильтрами и доступно повторно из меню «Как пользоваться».
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
|
||||
|
||||
## 0.18.1
|
||||
|
||||
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
# MIEM Employees Server
|
||||
|
||||
# MIEM Employees Server
|
||||
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
|
||||
|
||||
## Архитектура
|
||||
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
@@ -52,10 +52,16 @@ uvicorn app.main:app --reload
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
|
||||
## Пользователи и личный кабинет
|
||||
|
||||
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
|
||||
`/admin/users` доступен только администратору. Здесь можно создавать учётные записи, назначать роль, включать и отключать доступ, сбрасывать пароль и смотреть историю входов.
|
||||
|
||||
- **Пользователь** — обзор, сотрудники, личный кабинет и подсказки по справочнику.
|
||||
- **Пользователь диссовета** — те же разделы плюс «Диссертационный совет», подсказки по нему и редактирование публикаций.
|
||||
- **Администратор** — все разделы, включая «Запуски» и управление пользователями. Список, подробности и текущий прогресс запусков ограничены этой ролью в HTML и API. Остальные роли получают в `/api/stats` сводку по сотрудникам.
|
||||
|
||||
«Диссертационный совет», просмотр его публикаций и XLSX доступны публично. Открытая интерактивная документация API отключена. Для мониторинга доступен `/api/health`.
|
||||
|
||||
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
|
||||
|
||||
@@ -67,48 +73,48 @@ uvicorn app.main:app --reload
|
||||
|
||||
Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.
|
||||
|
||||
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
|
||||
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. При переходе с 1.x на 2.x сделайте штатную резервную копию существующей PostgreSQL-БД и примените `migrations/014_system_user_council_role.sql` владельцем таблицы. Миграция расширяет ограничение роли и сохраняет все существующие назначения. Для существующей SQLite-БД остановите приложение, сделайте копию файла БД и примените `sqlite3 -bail <файл БД> < migrations/sqlite/014_system_user_council_role.sql` из POSIX-оболочки. В новой БД ограничение создаётся вместе с таблицей. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
|
||||
|
||||
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --remove-orphans
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
|
||||
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
## Наполнение БД
|
||||
|
||||
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
|
||||
|
||||
Публикации теперь хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
|
||||
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
|
||||
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
## Наполнение БД
|
||||
|
||||
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
|
||||
|
||||
Публикации теперь хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
|
||||
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
|
||||
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
|
||||
|
||||
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.
|
||||
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422. Явно пустая отрасль (`branch=`) также отклоняется для списка, карточки и XLSX; отсутствие параметра сохраняет значение по умолчанию «технические».
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
@@ -118,7 +124,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
|
||||
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Пользователь диссовета или администратор может выбрать карточку статьи мышью либо клавишами Enter или пробел и открыть форму редактирования названия, журнала и квартиля. Та же форма доступна в карточках основного списка.
|
||||
|
||||
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
|
||||
|
||||
@@ -134,51 +140,51 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
|
||||
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX. Первый лист «Подбор диссовета» работает в Excel Microsoft 365 / Excel 2021 и новее без макросов: выбор специальности ВАК, допустимой отрасли, поиск по ФИО, фильтры по степени и статусу, сортировка по ФИО и счётчикам. Начальные фильтры взяты с сайта. На первом листе ссылка «Открыть памятку по расчёту и использованию» ведёт к инструкции под списком: определения счётчиков и статусов, правило К2, ограничения специальностей и источников, различия снимка XLSX и сайта, работа с формулами. Из памятки можно вернуться к фильтрам. При несовместимой отрасли после смены специальности применяется первая допустимая; она показана рядом. Список показывает Q1/Q2/К1/К2/RSCI, «Всего», подтверждённый зачёт, минимум и статус; ссылки ведут на публичные профили ВШЭ. Для переключения фильтров в файл включены все сотрудники с учёной степенью, даже если страница сайта была отфильтрована. Компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов) также содержат полный набор данных, а правила и исходные фильтры вынесены в «Параметры». Служебные данные и справочники находятся на скрытых листах. Внешние ссылки проверяются; исходные строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся вспомогательным подбором, а не условием соответствия пунктам 5–6. XLSX хранит снимок на момент выгрузки: новые данные и ручные подтверждения требуют повторной выгрузки; изменения фильтров в Excel не отправляются на сайт.
|
||||
|
||||
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» совпадает с предварительным счётчиком сайта: уникальное объединение Q1/Q2/К1/RSCI, для доктора наук также К2. Для кандидата/PhD самостоятельный К2 исключается; работа с другим подходящим основанием учитывается один раз. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
|
||||
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
## Парсинг
|
||||
|
||||
## Парсинг
|
||||
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
|
||||
- новые сотрудники добавляются в `employees`;
|
||||
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
|
||||
- старые URL сохраняются в истории `employee_profile_urls`;
|
||||
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
|
||||
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
|
||||
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
|
||||
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
|
||||
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
|
||||
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
|
||||
- новые сотрудники добавляются в `employees`;
|
||||
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
|
||||
- старые URL сохраняются в истории `employee_profile_urls`;
|
||||
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
|
||||
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
|
||||
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
|
||||
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
|
||||
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
|
||||
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
|
||||
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
|
||||
- кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в `active` без обновления содержимого профиля;
|
||||
- каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`;
|
||||
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
|
||||
|
||||
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
|
||||
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`, `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
|
||||
Развёртывание на новом VPS ведётся через общую платформу МИЭМ. Канонический домен `workers.miem.space`. Выбор образа, восстановление PostgreSQL, доступ, healthcheck и откат описаны в [процедуре первого запуска](https://git.vakanaut.ru/admin/infra-platform/src/branch/main/docs/runbooks/REPLACEMENT_VPS_FIRST_ROLLOUT.md). Фактические статусы публикуются в Infrastructure Registry. [Карточка проекта](https://git.vakanaut.ru/admin/infrastructure-registry/src/branch/main/environments/miem/projects/miem_workers/index.md).
|
||||
|
||||
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
|
||||
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Этот job относится к диагностике ключа. Выбор и публикация production-релиза выполняются в infra-platform.
|
||||
|
||||
## Проверки
|
||||
|
||||
@@ -285,3 +291,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
|
||||
### Подсказки первого входа
|
||||
|
||||
После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
|
||||
|
||||
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+37
-14
@@ -1,6 +1,7 @@
|
||||
from copy import deepcopy
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Query, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select, update
|
||||
@@ -10,8 +11,8 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction
|
||||
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user,
|
||||
optional_user, sign_session, authenticate, check_csrf)
|
||||
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, USER_ROLES, require_admin, require_user,
|
||||
optional_user, sign_session, authenticate, can_use_council, check_csrf)
|
||||
import secrets
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
@@ -30,12 +31,16 @@ from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.filters["admin_datetime"] = format_admin_datetime
|
||||
templates.env.globals["user_roles"] = USER_ROLES
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_user(request, settings, db)
|
||||
counts = stats_payload(db)
|
||||
user = require_user(request, settings, db)
|
||||
is_admin = user.role == "admin"
|
||||
counts = stats_payload(db, include_runs=is_admin)
|
||||
if not is_admin:
|
||||
return _render(request, "dashboard.html", {"counts": counts})
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all()
|
||||
@@ -113,6 +118,9 @@ def directory(
|
||||
started_to: str | None = None,
|
||||
has_email: str | None = None,
|
||||
has_academic_degree: str | None = None,
|
||||
pps: bool = False,
|
||||
has_training: Literal["", "true", "false"] = "",
|
||||
training_since_year: str = Query(default="", pattern=r"^([1-9][0-9]{3})?$"),
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -134,6 +142,9 @@ def directory(
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
pps=pps,
|
||||
has_training=has_training == "true" if has_training else None,
|
||||
training_since_year=int(training_since_year) if training_since_year else None,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
@@ -154,6 +165,9 @@ def directory(
|
||||
"started_to": started_to or "",
|
||||
"has_email": has_email or "",
|
||||
"has_academic_degree": has_academic_degree or "",
|
||||
"pps": pps,
|
||||
"has_training": has_training,
|
||||
"training_since_year": training_since_year,
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
@@ -169,13 +183,13 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
branch=branch)
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result})
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": can_use_council(authenticated), **result})
|
||||
|
||||
|
||||
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
||||
@@ -192,7 +206,7 @@ def council_employee_articles(employee_id: int, request: Request, specialty: str
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
|
||||
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
|
||||
"can_edit": authenticated is not None, **result})
|
||||
"can_edit": can_use_council(authenticated), **result})
|
||||
|
||||
|
||||
class CouncilArticleEdit(BaseModel):
|
||||
@@ -210,7 +224,7 @@ class CouncilArticleEdit(BaseModel):
|
||||
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
|
||||
"can_edit": optional_user(request, settings, db) is not None}
|
||||
"can_edit": can_use_council(optional_user(request, settings, db))}
|
||||
try:
|
||||
result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc")
|
||||
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
|
||||
@@ -232,6 +246,8 @@ def council_publications(request: Request, employee: str = "", title: str = "",
|
||||
def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_user(request, settings, db, lock=True)
|
||||
if not can_use_council(actor):
|
||||
raise HTTPException(403, "Редактирование доступно пользователям диссовета и администраторам")
|
||||
username = actor.username
|
||||
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
|
||||
if request.headers.get("X-Council-Edit") != "1":
|
||||
@@ -303,8 +319,13 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
branch=branch)
|
||||
content = council_xlsx(result)
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
|
||||
selection_results = [
|
||||
result if b == result["branch"] and not result["q"] and not result["specialty"] else
|
||||
council_candidates(db, sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
|
||||
for b in branches]
|
||||
content = council_xlsx(result, selection_results)
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
@@ -363,7 +384,7 @@ def refresh_employee_detail(
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_user(request, settings, db)
|
||||
require_admin(request, settings, db)
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||
items = [run_payload(run) for run in run_models]
|
||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||
@@ -377,7 +398,7 @@ def run_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_user(request, settings, db)
|
||||
require_admin(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
@@ -442,9 +463,11 @@ def refresh_dismissed(
|
||||
|
||||
|
||||
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
|
||||
user = getattr(request.state, "user", None)
|
||||
payload = {
|
||||
"request": request,
|
||||
"current_user": getattr(request.state, "user", None),
|
||||
"current_user": user,
|
||||
"council_access": can_use_council(user),
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
|
||||
+12
-6
@@ -1,6 +1,6 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -29,6 +29,9 @@ def list_employees(
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
pps: bool = False,
|
||||
has_training: bool | None = None,
|
||||
training_since_year: int | None = Query(default=None, ge=1000, le=9999),
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -45,6 +48,9 @@ def list_employees(
|
||||
started_to=started_to,
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
pps=pps,
|
||||
has_training=has_training,
|
||||
training_since_year=training_since_year,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
@@ -73,7 +79,7 @@ def list_crawl_runs(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_user(request, settings, db)
|
||||
require_admin(request, settings, db)
|
||||
runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all()
|
||||
return {"items": [run_payload(run) for run in runs]}
|
||||
|
||||
@@ -84,7 +90,7 @@ def latest_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_user(request, settings, db)
|
||||
require_admin(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
return {"running": run_payload(running), "latest": run_payload(latest)}
|
||||
@@ -97,7 +103,7 @@ def get_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_user(request, settings, db)
|
||||
require_admin(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return {"error": "not_found"}
|
||||
@@ -130,8 +136,8 @@ def stats(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_user(request, settings, db)
|
||||
return stats_payload(db)
|
||||
user = require_user(request, settings, db)
|
||||
return stats_payload(db, include_runs=user.role == "admin")
|
||||
|
||||
|
||||
def _employee_summary(employee: Employee) -> dict:
|
||||
|
||||
@@ -9,51 +9,97 @@
|
||||
"specialties": [
|
||||
{
|
||||
"code": "1.2.2",
|
||||
"name": "Математическое моделирование, численные методы и комплексы программ"
|
||||
"name": "Математическое моделирование, численные методы и комплексы программ",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.2",
|
||||
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"
|
||||
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.6",
|
||||
"name": "Методы и системы защиты информации, информационная безопасность"
|
||||
"name": "Методы и системы защиты информации, информационная безопасность",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.1",
|
||||
"name": "Системный анализ, управление и обработка информации, статистика"
|
||||
"name": "Системный анализ, управление и обработка информации, статистика",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.7",
|
||||
"name": "Компьютерное моделирование и автоматизация проектирования"
|
||||
"name": "Компьютерное моделирование и автоматизация проектирования",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "1.3.8",
|
||||
"name": "Физика конденсированного состояния"
|
||||
"name": "Физика конденсированного состояния",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.15",
|
||||
"name": "Системы, сети и устройства телекоммуникаций"
|
||||
"name": "Системы, сети и устройства телекоммуникаций",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "1.1.2",
|
||||
"name": "Дифференциальные уравнения и математическая физика"
|
||||
"name": "Дифференциальные уравнения и математическая физика",
|
||||
"branches": [
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.13",
|
||||
"name": "Радиотехника, в том числе системы и устройства телевидения"
|
||||
"name": "Радиотехника, в том числе системы и устройства телевидения",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.2",
|
||||
"name": "Вычислительные системы и их элементы"
|
||||
"name": "Вычислительные системы и их элементы",
|
||||
"branches": [
|
||||
"технические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.14",
|
||||
"name": "Антенны, СВЧ-устройства и их технологии"
|
||||
"name": "Антенны, СВЧ-устройства и их технологии",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.5",
|
||||
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"
|
||||
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
}
|
||||
],
|
||||
"journals": [
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ from app.users import router as users_router
|
||||
from app.db import init_db
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION, openapi_url=None)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(users_router)
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ class CouncilCorrections(Base):
|
||||
|
||||
class SystemUser(Base):
|
||||
__tablename__ = "system_users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),)
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user', 'council')", name="ck_system_users_role"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
|
||||
+16
-1
@@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, utcnow
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
@@ -138,6 +138,10 @@ def optional_user(request: Request, settings: Settings, db: Session, *, allow_pa
|
||||
user = db.scalar(query)
|
||||
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
|
||||
user = ensure_bootstrap_admin(db, settings)
|
||||
if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password
|
||||
and request.method == 'GET' and request.url.path.startswith('/admin')):
|
||||
request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start'
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
@@ -161,6 +165,17 @@ def require_user(request: Request, settings: Settings, db: Session, *, allow_pas
|
||||
return user
|
||||
|
||||
|
||||
USER_ROLES = {
|
||||
'user': 'Пользователь',
|
||||
'council': 'Пользователь диссовета',
|
||||
'admin': 'Администратор',
|
||||
}
|
||||
|
||||
|
||||
def can_use_council(user: SystemUser | None) -> bool:
|
||||
return user is not None and user.role in {'admin', 'council'}
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
|
||||
user = require_user(request, settings, db)
|
||||
if user.role != 'admin':
|
||||
|
||||
+78
-11
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, time
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
@@ -20,6 +21,44 @@ EMPLOYEE_SORTS = {
|
||||
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
|
||||
}
|
||||
|
||||
_TRAINING_LABEL = "повышение квалификации"
|
||||
_TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", re.IGNORECASE)
|
||||
_TRAINING_MENTION = re.compile(
|
||||
r"\b(?:повышение квалификации|(?:курс|программ)[а-яё]* (?:повышения|по повышению) квалификации)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QUOTED_TEXT = re.compile(r'«[^»]*»|"[^"]*"')
|
||||
_TRAINING_YEAR = re.compile(r"^(\d{4})(?!\d)|(?<!\d)(\d{4})\s*г(?:\.|од[ау]?\b)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _has_training(data: dict[str, Any], since_year: int | None) -> bool:
|
||||
for value in _as_list(data.get("sections")):
|
||||
section = _as_dict(value)
|
||||
title = str(section.get("title") or "").casefold().strip().rstrip(":")
|
||||
dedicated = title in {_TRAINING_LABEL, "курсы повышения квалификации"}
|
||||
if not dedicated and _TRAINING_LABEL not in (part.strip() for part in title.split("/")):
|
||||
continue
|
||||
for value in _as_list(section.get("year_entries")):
|
||||
entry = _as_dict(value)
|
||||
year = entry.get("year")
|
||||
text = str(entry.get("text") or "").strip()
|
||||
if since_year is not None and (type(year) is not int or year < since_year):
|
||||
continue
|
||||
if text and text != str(year) and (dedicated or _TRAINING_KIND.search(_QUOTED_TEXT.sub("", text))):
|
||||
return True
|
||||
# Profiles may also list completed courses in manually entered paragraphs or lists.
|
||||
for text in _clean_list(section.get("paragraphs")) + _clean_list(section.get("items")):
|
||||
description = _QUOTED_TEXT.sub("", text).strip()
|
||||
if not dedicated and not _TRAINING_MENTION.search(description):
|
||||
continue
|
||||
if since_year is None:
|
||||
return True
|
||||
match = _TRAINING_YEAR.search(description)
|
||||
if match and int(match.group(1) or match.group(2)) >= since_year:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
data = _as_dict(employee.current_data)
|
||||
contacts = _as_dict(data.get("contacts"))
|
||||
@@ -116,6 +155,9 @@ def list_employees_page(
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
pps: bool = False,
|
||||
has_training: bool | None = None,
|
||||
training_since_year: int | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -131,12 +173,36 @@ def list_employees_page(
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
||||
if offset >= total:
|
||||
offset = 0
|
||||
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
|
||||
order = desc(sort_column) if direction == "desc" else sort_column
|
||||
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
|
||||
ordered_stmt = base_stmt.order_by(order)
|
||||
if has_training is None and training_since_year is not None:
|
||||
has_training = True
|
||||
if pps or has_training is not None:
|
||||
# Apply profile filters to decoded data before pagination on both databases.
|
||||
employees = []
|
||||
first_page = []
|
||||
total = 0
|
||||
for employee in db.scalars(ordered_stmt.execution_options(yield_per=100)):
|
||||
data = _as_dict(employee.current_data)
|
||||
if pps and not any("кафедра" in position.casefold() or "департамент" in position.casefold()
|
||||
for position in _clean_list(data.get("positions"))):
|
||||
continue
|
||||
if has_training is not None and _has_training(data, training_since_year) != has_training:
|
||||
continue
|
||||
if offset and total < limit:
|
||||
first_page.append(employee)
|
||||
if offset <= total < offset + limit:
|
||||
employees.append(employee)
|
||||
total += 1
|
||||
if offset >= total:
|
||||
employees = first_page
|
||||
offset = 0
|
||||
else:
|
||||
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
||||
if offset >= total:
|
||||
offset = 0
|
||||
employees = db.scalars(ordered_stmt.limit(limit).offset(offset)).all()
|
||||
return {
|
||||
"employees": [employee_display_payload(employee) for employee in employees],
|
||||
"total": total,
|
||||
@@ -147,11 +213,9 @@ def list_employees_page(
|
||||
}
|
||||
|
||||
|
||||
def stats_payload(db: Session) -> dict[str, Any]:
|
||||
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
def stats_payload(db: Session, *, include_runs: bool = True) -> dict[str, Any]:
|
||||
latest_added = db.scalar(select(Employee).order_by(desc(Employee.first_seen_at)).limit(1))
|
||||
return {
|
||||
counts = {
|
||||
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
|
||||
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
|
||||
"verification_required": db.scalar(
|
||||
@@ -159,11 +223,14 @@ def stats_payload(db: Session) -> dict[str, Any]:
|
||||
)
|
||||
or 0,
|
||||
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
|
||||
"new_in_last_run": latest_run.new_count if latest_run else 0,
|
||||
"latest_added": employee_display_payload(latest_added) if latest_added else None,
|
||||
"latest_run": run_payload(latest_run) if latest_run else None,
|
||||
"current_running_run": run_payload(running_run) if running_run else None,
|
||||
}
|
||||
if include_runs:
|
||||
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
counts.update(new_in_last_run=latest_run.new_count if latest_run else 0,
|
||||
latest_run=run_payload(latest_run), current_running_run=run_payload(running_run))
|
||||
return counts
|
||||
|
||||
|
||||
def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
|
||||
|
||||
+15
-4
@@ -253,14 +253,24 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
|
||||
branch: str = "технические", employee_id: int | None = None) -> dict:
|
||||
if branch and branch not in BRANCHES:
|
||||
branch: str = "технические", employee_id: int | None = None,
|
||||
previous_specialty: str | None = None) -> dict:
|
||||
if branch not in BRANCHES:
|
||||
raise CouncilInputError("Неизвестная отрасль науки")
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
specialties = _k1_specialties()["specialties"]
|
||||
if specialty and specialty not in {s["code"] for s in specialties}:
|
||||
raise CouncilInputError("Неизвестная специальность ВАК")
|
||||
branches = list(dict.fromkeys(name for s in specialties if not specialty or s["code"] == specialty
|
||||
for name in s["branches"]))
|
||||
if branch and branch not in branches:
|
||||
previous_branches = {name for s in specialties if previous_specialty == "" or s["code"] == previous_specialty
|
||||
for name in s["branches"]}
|
||||
if previous_specialty is not None and previous_specialty != specialty and branch in previous_branches:
|
||||
branch = branches[0]
|
||||
else:
|
||||
raise CouncilInputError("Отрасль оценки не относится к выбранным специальностям ВАК")
|
||||
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
|
||||
degree_sections = Employee.current_data["sections"]
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
@@ -421,13 +431,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
criteria_groups = {}
|
||||
for name, (minimum, monographs) in BRANCHES.items():
|
||||
for name in branches:
|
||||
minimum, monographs = BRANCHES[name]
|
||||
criteria_groups.setdefault((minimum, monographs), []).append(name)
|
||||
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
|
||||
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
|
||||
"branch": branch, "branches": list(BRANCHES),
|
||||
"branch": branch, "branches": branches,
|
||||
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
|
||||
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
|
||||
for path in (K1_JOURNALS, K2_JOURNALS)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""Compact XLSX report from council data, with an explicit Q1/Q2/K1 total."""
|
||||
"""Offline council selection and detailed XLSX report from the site's assessment."""
|
||||
|
||||
from io import BytesIO
|
||||
from textwrap import fill
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.hyperlink import Hyperlink
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
from app.services.council_source import _name
|
||||
|
||||
SORT_LABELS = {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
|
||||
"k2_count": "К2", "rsci_count": "RSCI", "total_count": "Всего",
|
||||
"confirmed_count": "Подтверждено", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
@@ -13,9 +24,13 @@ def _append(sheet, values):
|
||||
if any(isinstance(value, str) and len(value) > 32767 for value in values):
|
||||
raise ValueError("Text exceeds Excel cell limit")
|
||||
sheet.append(values)
|
||||
for column, value in enumerate(values, 1):
|
||||
if isinstance(value, str):
|
||||
# Source text is never executable; formulas are written separately.
|
||||
sheet.cell(sheet._current_row, column).data_type = "s"
|
||||
|
||||
|
||||
def council_xlsx(result: dict) -> bytes:
|
||||
def council_xlsx(result: dict, selection_results: list[dict]) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
@@ -30,9 +45,10 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
|
||||
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
|
||||
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
all_candidates = next(data["candidates"] for data in selection_results if data["branch"] == result["branch"])
|
||||
for number, candidate in enumerate(all_candidates, 1):
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
total = sum(article["q1"] or article["q2"] or article["k1"] for article in candidate["articles"])
|
||||
total = candidate["total_count"]
|
||||
_append(employees, [number, candidate["name"], candidate["selected_degree"],
|
||||
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
|
||||
candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
|
||||
@@ -56,7 +72,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
|
||||
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Сотрудников в выгрузке", len(all_candidates)], ["Найдено с исходными фильтрами", result["matched"]],
|
||||
["Всего сотрудников в списке", result["total"]],
|
||||
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
|
||||
@@ -65,7 +82,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Всего", "Число уникальных работ с хотя бы одним основанием Q1 ИЛИ Q2 ИЛИ К1. Пересечения учитываются один раз. К2 и RSCI самостоятельно не увеличивают этот столбец; итог может отличаться от общего счётчика на сайте."],
|
||||
["Всего", "Как на сайте: уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Для кандидата/PhD одного К2 недостаточно. Пересечения учитываются один раз. Это предварительный счётчик; подтверждённый зачёт показан отдельно."],
|
||||
["Интерактивный подбор", "Первый лист содержит всех сотрудников с учёной степенью на момент выгрузки. Специальность, отрасль, поиск, степень, статус и сортировка меняются в Excel без макросов. Начальные фильтры взяты с сайта; подробные листы содержат полный набор сотрудников и публикаций."],
|
||||
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
|
||||
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
|
||||
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
|
||||
@@ -93,13 +111,175 @@ def council_xlsx(result: dict) -> bytes:
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
if isinstance(cell.value, str):
|
||||
# CSV/profile content is text, never an Excel formula or error code.
|
||||
cell.data_type = "s"
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
_selection(workbook, result, selection_results)
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _selection(workbook, result, selection_results):
|
||||
sheet = workbook.create_sheet("Подбор диссовета", 0)
|
||||
source = workbook.create_sheet("_Данные подбора")
|
||||
options = workbook.create_sheet("_Справочники подбора")
|
||||
specialties = result["specialties"]
|
||||
labels = ["Все специальности", *[f"{s['code']} — {s['name']}" for s in specialties]]
|
||||
branches = [data["branch"] for data in selection_results]
|
||||
_append(options, ["Специальность", "Список отраслей", "", "Отрасль 1", "Отрасль 2"])
|
||||
for row, label in enumerate(labels, 2):
|
||||
allowed = branches if row == 2 else [b for b in specialties[row - 3]["branches"] if b in branches]
|
||||
name = f"SelectionBranches{row}"
|
||||
_append(options, [label, name, "", *allowed])
|
||||
workbook.defined_names.add(DefinedName(name, attr_text=f"'{options.title}'!$D${row}:${get_column_letter(3 + len(allowed))}${row}"))
|
||||
workbook.defined_names.add(DefinedName("SelectionSpecialties", attr_text=f"'{options.title}'!$A$2:$A${len(labels) + 1}"))
|
||||
workbook.defined_names.add(DefinedName("SelectionOptions", attr_text=f"'{options.title}'!$A$2:$B${len(labels) + 1}"))
|
||||
|
||||
headers = ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "RSCI", "Всего", "Подтверждено",
|
||||
"Минимум", "Статус", "Степень подтверждена", "ЛК сотрудника ВШЭ", "Все публикации", "Специальности ВАК", "Профиль ВШЭ"]
|
||||
flag_end = get_column_letter(18 + len(specialties))
|
||||
score, rank, matches, level, search_name = [get_column_letter(19 + len(specialties) + i) for i in range(5)]
|
||||
_append(source, [*headers, "Отрасль", *[s["code"] for s in specialties], "Сортировка", "Порядок", "Подходит", "Уровень степени", "ФИО для поиска"])
|
||||
numbers = {c["employee_id"]: n for n, c in enumerate(selection_results[0]["candidates"], 1)}
|
||||
for data in selection_results:
|
||||
for candidate in sorted(data["candidates"], key=lambda c: _name(c["name"])):
|
||||
codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
_append(source, [numbers[candidate["employee_id"]], candidate["name"], candidate["selected_degree"],
|
||||
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2", "rsci")], candidate["total_count"],
|
||||
candidate["confirmed_count"], candidate["minimum"], candidate["assessment_status"],
|
||||
"Да" if candidate["degree_confirmed"] else "Нет", candidate["db_count"], candidate["count"],
|
||||
"\n".join(label for s, label in zip(specialties, labels[1:]) if s["code"] in codes) or "Не подтверждены",
|
||||
candidate["profile_url"] or "", data["branch"], *[int(s["code"] in codes) for s in specialties],
|
||||
None, None, None, candidate["degree_level"], _name(candidate["name"])])
|
||||
|
||||
for address, value in {"A1": "Подбор кандидатов в диссертационный совет", "A3": "Специальность ВАК",
|
||||
"A4": "Отрасль оценки", "E4": "Применяется", "A5": "Поиск по ФИО", "A6": "Учёная степень",
|
||||
"J3": "Сортировать по", "J4": "Направление", "J5": "Статус", "J6": "Найдено",
|
||||
"C3": next((label for s, label in zip(specialties, labels[1:]) if s["code"] == result["specialty"]), labels[0]),
|
||||
"C4": result["branch"], "C5": result["q"], "C6": "Все степени", "K3": SORT_LABELS[result["sort"]],
|
||||
"K4": "По убыванию" if result["direction"] == "desc" else "По возрастанию", "K5": "Все статусы",
|
||||
"A2": f"Данные на момент выгрузки · период {result['start'].year}–{result['end'].year} · Backend {BACKEND_VERSION} · Frontend {FRONTEND_VERSION}",
|
||||
"A7": "Всего: Q1/Q2/К1/RSCI; у доктора также К2. Статус и подтверждённый зачёт оцениваются отдельно.",
|
||||
"A8": "Подбор по связи журнала со специальностью не доказывает тематику каждой статьи или включение в совет. Подробности — на листах Сотрудники и Публикации."}.items():
|
||||
sheet[address] = value
|
||||
sheet[address].data_type = "s"
|
||||
for area in ("A1:Q1", "A2:Q2", "A3:B3", "A4:B4", "A5:B5", "A6:B6", "C3:I3", "C4:D4", "F4:I4", "C5:I5", "C6:I6", "K3:Q3", "K4:Q4", "K5:Q5", "A7:Q7", "A8:Q8"):
|
||||
sheet.merge_cells(area)
|
||||
allowed = 'INDIRECT(VLOOKUP($C$3,SelectionOptions,2,FALSE))'
|
||||
sheet["F4"] = f'=IFERROR(IF(COUNTIF({allowed},$C$4)>0,$C$4,INDEX({allowed},1)),"Недопустимая специальность")'
|
||||
for address, formula in {"C3": "=SelectionSpecialties", "C4": f"={allowed}",
|
||||
"C6": '"Все степени,Доктор наук,Кандидат / PhD"', "K3": '"' + ','.join(SORT_LABELS.values()) + '"',
|
||||
"K4": '"По возрастанию,По убыванию"', "K5": '"Все статусы,Соответствует критериям,Требует проверки"'}.items():
|
||||
validation = DataValidation(type="list", formula1=formula, allow_blank=False, showErrorMessage=True,
|
||||
errorStyle="stop", errorTitle="Выберите значение из списка", error="Используйте доступные варианты.")
|
||||
sheet.add_data_validation(validation)
|
||||
validation.add(sheet[address])
|
||||
|
||||
last = max(2, source._current_row)
|
||||
sheet["K6"] = f'=COUNTIF(\'{source.title}\'!${matches}$2:${matches}${last},1)'
|
||||
controls = "'Подбор диссовета'!"
|
||||
search = f'SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(TRIM(LOWER({controls}$C$5)),"ё","е"),"~","~~"),"*","~*"),"?","~?")'
|
||||
for row in range(2, source._current_row + 1):
|
||||
source[f"{score}{row}"] = f'=INDEX($A{row}:$Q{row},1,MATCH({controls}$K$3,{controls}$A$9:$Q$9,0))'
|
||||
comparison = f'IF({controls}$K$4="По убыванию",">","<")'
|
||||
scores, counts = f'${score}$2:${score}${last}', f'$O$2:$O${last}'
|
||||
source[f"{rank}{row}"] = (f'=IF({controls}$K$3="ФИО",IF({controls}$K$4="По убыванию",{last + 1}-ROW(),ROW()-1),'
|
||||
f'COUNTIF({scores},{comparison}&{score}{row})+COUNTIFS({scores},{score}{row},{counts},{comparison}&O{row})'
|
||||
f'+COUNTIFS(${score}$2:${score}{row},{score}{row},$O$2:$O{row},O{row}))')
|
||||
source[f"{matches}{row}"] = (f'=--AND($R{row}={controls}$F$4,'
|
||||
f'OR({controls}$C$3="Все специальности",IFERROR(INDEX($S{row}:${flag_end}{row},1,MATCH({controls}$C$3,'
|
||||
f"'{options.title}'!$A$3:$A${len(labels) + 1},0)),0)=1),"
|
||||
f'ISNUMBER(SEARCH({search},${search_name}{row})),'
|
||||
f'OR({controls}$K$5="Все статусы",IF({controls}$K$5="Соответствует критериям",LEFT($L{row},13)="Соответствует",$L{row}="Требует проверки")),'
|
||||
f'OR({controls}$C$6="Все степени",${level}{row}=IF({controls}$C$6="Доктор наук",2,1)))')
|
||||
|
||||
for column, header in enumerate(headers, 1):
|
||||
sheet.cell(9, column, header)
|
||||
ranks = f"'{source.title}'!${rank}$2:${rank}${last}"
|
||||
match_range = f"'{source.title}'!${matches}$2:${matches}${last}"
|
||||
for row in range(10, 10 + max(1, len(numbers))):
|
||||
sheet[f"S{row}"] = f'=IFERROR(MATCH(_xlfn.AGGREGATE(15,6,{ranks}/({match_range}=1),ROWS($S$10:S{row})),{ranks},0),"")'
|
||||
for column in range(1, 18):
|
||||
value = f"INDEX('{source.title}'!$A$2:$Q${last},$S{row},{column})"
|
||||
if column == 17:
|
||||
value = f'IF({value}="","",HYPERLINK({value},"Профиль ВШЭ"))'
|
||||
sheet.cell(row, column, f'=IF($S{row}="","",{value})')
|
||||
sheet.column_dimensions["S"].hidden = True
|
||||
widths = [9, 34, 35, 9, 9, 9, 9, 9, 12, 16, 12, 42, 20, 22, 22, 65, 22]
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet.iter_rows(max_col=17):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
cell.font = Font(name="Calibri", size=11, color="263640")
|
||||
for row in (1, 9):
|
||||
for cell in sheet[row][:17]:
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
cell.font = Font(name="Calibri", size=15 if row == 1 else 11, bold=True, color="FFFFFF")
|
||||
for address in ("C3", "C4", "C5", "C6", "K3", "K4", "K5"):
|
||||
sheet[address].fill = PatternFill("solid", fgColor="E7F0EB")
|
||||
for row in range(1, 10):
|
||||
sheet.row_dimensions[row].height = 32
|
||||
sheet.freeze_panes = "D10"
|
||||
sheet.sheet_view.zoomScale = 85
|
||||
workbook.active = 0
|
||||
workbook.calculation.fullCalcOnLoad = True
|
||||
workbook.calculation.forceFullCalc = True
|
||||
workbook.calculation.calcMode = "auto"
|
||||
source.sheet_state = options.sheet_state = "hidden"
|
||||
|
||||
memo_row = 12 + max(1, len(numbers))
|
||||
sheet["A7"] = "Открыть памятку по расчёту и использованию"
|
||||
sheet["A7"].hyperlink = Hyperlink(ref="A7", location=f"'Подбор диссовета'!A{memo_row}")
|
||||
sheet["A7"].font = Font(name="Calibri", size=11, color="0563C1", underline="single")
|
||||
memo = [
|
||||
("Памятка: как пользоваться подбором",
|
||||
"1. Меняйте зелёные поля: специальность ВАК и отрасль, поиск по ФИО, степень, статус, сортировку и направление. Список обновляется автоматически.\n"
|
||||
"2. После смены специальности посмотрите поле «Применяется»: если прежняя отрасль несовместима, используется первая допустимая.\n"
|
||||
"3. Для просмотра всех сотрудников выберите «Все специальности», «Все степени» и «Все статусы», очистите поиск. Чтобы искать потенциальных кандидатов, оставьте «Все статусы»: сведения могут требовать проверки.\n"
|
||||
"4. «Найдено» показывает число строк текущего подбора. «Профиль ВШЭ» открывает внешний сайт и требует подключения к Интернету."),
|
||||
("Какие данные включены",
|
||||
"Файл содержит всех сотрудников с обнаруженной или документально подтверждённой степенью доктора, кандидата наук или PhD на момент выгрузки. Отсутствие публикаций не исключает сотрудника. Из истории степеней выбирается высшая текущая: доктор выше кандидата/PhD. Уволенные сотрудники также могут присутствовать; актуальность работы и возможность участия проверяйте на сайте и по документам."),
|
||||
("Период и виды работ",
|
||||
f"Оценка охватывает пять календарных лет: {result['start'].year}–{result['end'].year}. День и месяц не учитываются. Границы зафиксированы при выгрузке и не сдвигаются при открытии файла в следующем году. Известные другие виды материалов по умолчанию исключаются из счётчиков; для их допуска нужно отдельное проверенное основание. Неизвестный вид может попадать в предварительный счётчик, но для подтверждённого зачёта нужно установить вид работы."),
|
||||
("Q1, Q2, К1, К2 и RSCI",
|
||||
"Каждый счётчик показывает уникальные работы с соответствующим основанием в пятилетнем периоде. Q1/Q2 — применимые сведения о квартиле из источников и каталогов; К1/К2 — сопоставление журнала со справочником ВАК либо проверенная запись. RSCI требует отдельного основания: отметка РИНЦ его не заменяет. Одна работа может одновременно входить в несколько счётчиков; складывать их для получения итога нельзя."),
|
||||
("Как считается «Всего»",
|
||||
"Предварительный итог, как на сайте: уникальные работы Q1 ИЛИ Q2 ИЛИ К1 ИЛИ RSCI; для доктора наук также К2. Для кандидата/PhD работа с одним К2 не входит в итог. Если у неё есть другое допустимое основание, она учитывается по нему. Пересечения учитываются один раз. Само попадание в «Всего» не подтверждает выполнение требований к составу совета."),
|
||||
("«Подтверждено» и подтверждение степени",
|
||||
"«Подтверждено» — работы с допустимым видом, согласованным годом в периоде и проверенным применимым основанием без существенных конфликтов источников. Известная статья с журналом и Q1/Q2 за её год может засчитываться автоматически. Для К1/К2/RSCI требуется отдельная запись проверки работы. Ручная правка журнала или квартиля сама по себе не подтверждает зачёт. «Степень подтверждена» проверяется отдельно; обнаруженная степень в профиле не заменяет документальное подтверждение."),
|
||||
("«Минимум» и «Статус»",
|
||||
"Для доктора минимум: 9 подтверждённых публикаций по технической отрасли, 11 по физико-математической. Для кандидата/PhD — 20. Положительный статус требует достаточного подтверждённого зачёта и подтверждения высшей текущей степени. «Требует проверки» может означать нехватку подтверждений или найденных работ; это не доказательство отсутствия публикаций. Статус оценивает публикационные критерии и не является решением о включении в совет."),
|
||||
("«ЛК сотрудника ВШЭ» и «Все публикации»",
|
||||
"Эти счётчики охватывают все годы и категории. «ЛК сотрудника ВШЭ» — уникальные работы из личного кабинета. «Все публикации» — объединение личного кабинета и выгрузки ВШЭ без дублей. Они не равны пятилетнему «Всего» и не сравниваются с публикационным минимумом."),
|
||||
("Специальности ВАК и ручная связь",
|
||||
f"Подбор ограничен {len(specialties)} специальностями сайта. Сотрудник отбирается, если хотя бы одна подходящая работа за пять лет имеет справочную связь журнала К1 или ручную связь с выбранной специальностью. Числовые счётчики при этом включают все работы сотрудника по своим правилам, а не только работы этой специальности. «Не подтверждены» означает отсутствие кодов по этим правилам, а не отсутствие научной специальности у сотрудника. Ручная связь задаётся на сайте для журнала и применяется ко всем его статьям; она не подтверждает содержание каждой статьи по паспорту специальности."),
|
||||
("Что не проверяется автоматически",
|
||||
"Полнота публикаций, содержание каждой статьи по паспорту, актуальность работы сотрудника и все остальные условия включения в диссовет требуют отдельной проверки. Подробные причины и источники смотрите на сайте. Текущая категория ВАК не доказывает категорию в год статьи. Каталоги имеют ограниченный охват и годы метрик; они не подтверждают текущую индексацию или отсутствие журнала в других базах."),
|
||||
("Почему цифры могут расходиться",
|
||||
"Сравнивайте одинаковые период, фильтры и снимок данных. Неполные профили, совпадающие ФИО, отсутствие DOI, разные названия журналов и конфликты годов могут мешать присвоению или объединению работ. При одинаковых ФИО данные выгрузки ВШЭ не присваиваются автоматически. Необычные символы и пробелы поиска в Excel могут обрабатываться иначе, чем на сайте. «Сотрудники» и «Публикации» содержат полный набор и не фильтруются вместе с первым листом; «Параметры» фиксируют исходные настройки выгрузки."),
|
||||
("Обновление данных и работа в Excel",
|
||||
"XLSX — снимок: обновления сайта и ручные подтверждения попадут в него только при новой выгрузке. Изменения в Excel не отправляются на сайт и не создают подтверждений. Используйте Excel Microsoft 365 / Excel 2021 или новее с автоматическим пересчётом формул; макросы для подбора не нужны. Если список не обновился, включите автоматический расчёт в настройках формул. Меняйте зелёные поля, сохраняйте формулы таблицы и скрытые служебные листы, иначе подбор может перестать работать."),
|
||||
]
|
||||
for title, text in memo:
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
cell = sheet.cell(memo_row, 1, title)
|
||||
cell.font = Font(name="Calibri", size=12, bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
sheet.row_dimensions[memo_row].height = 28
|
||||
memo_row += 1
|
||||
text = "\n".join(fill(paragraph, width=90, break_on_hyphens=False) for paragraph in text.splitlines())
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
cell = sheet.cell(memo_row, 1, text)
|
||||
cell.font = Font(name="Calibri", size=11, color="263640")
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
sheet.row_dimensions[memo_row].height = (text.count("\n") + 1) * 15 + 10
|
||||
memo_row += 3
|
||||
cell = sheet.cell(memo_row, 1, "Вернуться к фильтрам")
|
||||
cell.hyperlink = Hyperlink(ref=cell.coordinate, location="'Подбор диссовета'!A1")
|
||||
cell.font = Font(name="Calibri", size=11, color="0563C1", underline="single")
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
sheet.row_dimensions[memo_row].height = 28
|
||||
+126
-8
@@ -1,4 +1,5 @@
|
||||
.admin {
|
||||
--admin-gutter: 32px;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -27,7 +28,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 32px;
|
||||
padding: 18px var(--admin-gutter);
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d9dee7;
|
||||
flex-wrap: wrap;
|
||||
@@ -46,6 +47,7 @@
|
||||
.admin__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -65,10 +67,14 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:is(.directory__filters, .council__search) :is(input, select):focus {
|
||||
outline: 2px solid #0f766e;
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.admin__main {
|
||||
flex: 1;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 28px auto;
|
||||
margin: 28px var(--admin-gutter);
|
||||
}
|
||||
|
||||
.admin__footer {
|
||||
@@ -281,6 +287,20 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.employee-card__news-toggle {
|
||||
min-height: 44px;
|
||||
align-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.employee-card__news-toggle .employee-section__title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.employee-card__section--news[open] .employee-card__news-toggle {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.employee-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
@@ -544,9 +564,17 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.directory__filter-panel > summary {
|
||||
width: fit-content;
|
||||
min-height: 44px;
|
||||
align-content: center;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr);
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
@@ -586,6 +614,10 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__filter-hint {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -598,6 +630,15 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.directory__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.directory__filter-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
@@ -760,6 +801,12 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.directory__filters {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.directory__filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -779,6 +826,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.admin {
|
||||
--admin-gutter: 16px;
|
||||
}
|
||||
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -789,17 +840,15 @@
|
||||
}
|
||||
.admin__header {
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
padding-block: 16px;
|
||||
}
|
||||
|
||||
.admin__nav {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin__main {
|
||||
width: min(100% - 24px, 1180px);
|
||||
margin: 20px auto;
|
||||
margin-block: 20px;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
@@ -966,3 +1015,72 @@
|
||||
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
|
||||
|
||||
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
|
||||
|
||||
.guide {
|
||||
margin-block: 16px;
|
||||
padding: clamp(12px, 2vw, 20px);
|
||||
border: 1px solid #99d5ce;
|
||||
border-radius: 12px;
|
||||
background: #f0fdfa;
|
||||
color: #164e49;
|
||||
scroll-margin-block: 16px;
|
||||
}
|
||||
.guide__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
}
|
||||
.guide__progress { margin: 0 0 4px; color: #35615b; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.guide__heading { margin: 0; font-size: 18px; }
|
||||
.guide--page .guide__heading { margin-block: 20px 12px; }
|
||||
.guide__heading:focus-visible { outline: 2px solid #0f766e; outline-offset: 4px; border-radius: 2px; }
|
||||
.guide__intro { max-width: 66ch; margin: 8px 0 0; line-height: 1.5; }
|
||||
.guide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.guide__actions .button { min-height: 44px; }
|
||||
.guide__field { display: grid; justify-items: start; min-width: 0; }
|
||||
.guide__tip {
|
||||
position: relative;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 44ch;
|
||||
margin-block: 7px 2px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
background: #e0f5f1;
|
||||
color: #164e49;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
text-align: start;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.guide__tip::before {
|
||||
position: absolute;
|
||||
inset-block-start: -4px;
|
||||
inset-inline-start: 13px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: inherit;
|
||||
content: "";
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.council__candidate-count .guide__tip { padding-inline: 6px; }
|
||||
.council__search:has(.guide__tip) > .directory__field { align-self: start; }
|
||||
@media (min-width: 701px) {
|
||||
.council__candidate-summary:has(.guide__tip) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) 18px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.council__counts:has(.guide__tip) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.directory__checkbox:has(.guide__tip) { flex-wrap: wrap; }
|
||||
.directory__checkbox .guide__tip { flex-basis: 100%; }
|
||||
.guide__fields { margin-block: 16px; }
|
||||
.guide__fields dt { margin-block-start: 14px; font-weight: 600; }
|
||||
.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.guide--page { max-width: 75ch; }
|
||||
.guide--page .guide__step + .guide__step { margin-block-start: 32px; }
|
||||
+10
-2
@@ -1,7 +1,14 @@
|
||||
(() => {
|
||||
const specialty = document.querySelector('#council-specialty');
|
||||
const branch = document.querySelector('#council-branch');
|
||||
specialty?.addEventListener('change', () => {
|
||||
const names = JSON.parse(specialty.selectedOptions[0].dataset.branches);
|
||||
const previous = branch.value;
|
||||
branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name)));
|
||||
if (names.includes(previous)) branch.value = previous;
|
||||
});
|
||||
const dialog = document.querySelector('[data-council-dialog]');
|
||||
if (!dialog) return;
|
||||
const form = dialog.querySelector('[data-edit-form]');
|
||||
const form = dialog?.querySelector('[data-edit-form]');
|
||||
const autoSpecialties = form?.querySelector('[data-specialty-auto]');
|
||||
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
|
||||
autoSpecialties?.addEventListener('change', () => {
|
||||
@@ -141,6 +148,7 @@
|
||||
}, true);
|
||||
window.addEventListener('resize', repositionTooltips);
|
||||
|
||||
if (!dialog) return;
|
||||
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
|
||||
button.addEventListener('click', () => { if (!saving) dialog.close(); });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
(() => {
|
||||
const guide = document.querySelector('[data-guide]');
|
||||
if (!guide) return;
|
||||
|
||||
const directory = '/admin/directory';
|
||||
const council = '/admin/council';
|
||||
const publications = '/admin/council/publications';
|
||||
const councilAccess = guide.dataset.guideCouncil === 'true';
|
||||
const steps = [
|
||||
{id: 'welcome', title: 'Познакомьтесь с Workers',
|
||||
intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. '
|
||||
: 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'},
|
||||
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [
|
||||
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
|
||||
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
|
||||
['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
|
||||
]},
|
||||
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filter-panel', tips: [
|
||||
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
|
||||
['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
|
||||
['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
|
||||
]},
|
||||
{id: 'employee-training', path: directory, title: 'Проверьте повышение квалификации', region: '.directory__filter-panel', tips: [
|
||||
['[name="has_training"]', 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'],
|
||||
['[name="training_since_year"]', 'Например, 2024 включает 2024 год и все последующие.'],
|
||||
]},
|
||||
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [
|
||||
['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'],
|
||||
['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
|
||||
['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
|
||||
]},
|
||||
{id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [
|
||||
['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'],
|
||||
['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'],
|
||||
]},
|
||||
{id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [
|
||||
['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'],
|
||||
['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'],
|
||||
['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'],
|
||||
]},
|
||||
{id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [
|
||||
['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'],
|
||||
['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'],
|
||||
]},
|
||||
{id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading',
|
||||
intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [
|
||||
['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'],
|
||||
['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'],
|
||||
['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'],
|
||||
]},
|
||||
{id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading',
|
||||
intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [
|
||||
['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'],
|
||||
['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'],
|
||||
]},
|
||||
{id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [
|
||||
['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'],
|
||||
['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'],
|
||||
]},
|
||||
{id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [
|
||||
['#publications-employee', 'Укажите фамилию или часть ФИО автора.'],
|
||||
['#publications-article', 'Введите несколько слов из названия статьи.'],
|
||||
['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'],
|
||||
]},
|
||||
{id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles',
|
||||
intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [
|
||||
['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'],
|
||||
['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'],
|
||||
['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'],
|
||||
], editorTips: [
|
||||
['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'],
|
||||
['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
|
||||
['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
|
||||
]},
|
||||
].filter(step => councilAccess || (step.path !== council && step.path !== publications));
|
||||
|
||||
const main = document.querySelector('#main-content');
|
||||
const trigger = document.querySelector('[data-guide-open]');
|
||||
const fallback = document.querySelector('[data-guide-fallback]');
|
||||
const heading = guide.querySelector('#guide-title');
|
||||
const progress = guide.querySelector('[data-guide-progress]');
|
||||
const intro = guide.querySelector('[data-guide-intro]');
|
||||
const reference = guide.querySelector('[data-guide-reference]');
|
||||
const back = guide.querySelector('[data-guide-back]');
|
||||
const next = guide.querySelector('[data-guide-next]');
|
||||
const close = guide.querySelector('[data-guide-close]');
|
||||
const error = guide.querySelector('[data-guide-error]');
|
||||
const unsaved = guide.querySelector('[data-guide-unsaved]');
|
||||
const storageKey = 'workers-guide:' + guide.dataset.guideUser;
|
||||
let index = 0;
|
||||
let paused = false;
|
||||
let saving = false;
|
||||
let removeTips = [];
|
||||
|
||||
function addTip([selector, message, placement], id, allowHidden = false) {
|
||||
const target = main.querySelector(selector);
|
||||
if (!target || (!allowHidden && !target.getClientRects().length)) return;
|
||||
const tip = document.createElement('span');
|
||||
tip.className = 'guide__tip';
|
||||
tip.id = 'guide-tip-' + id;
|
||||
tip.textContent = message;
|
||||
|
||||
const description = target.getAttribute('aria-describedby');
|
||||
target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' '));
|
||||
const label = target.labels?.[0];
|
||||
let group;
|
||||
if (label) {
|
||||
// The description is announced separately from the label text.
|
||||
tip.setAttribute('aria-hidden', 'true');
|
||||
label.append(tip);
|
||||
} else if (placement === 'inside') {
|
||||
target.append(tip);
|
||||
} else if (target.matches('button')) {
|
||||
group = document.createElement('div');
|
||||
group.className = 'guide__field';
|
||||
target.before(group);
|
||||
group.append(target, tip);
|
||||
} else {
|
||||
target.after(tip);
|
||||
}
|
||||
removeTips.push(() => {
|
||||
tip.remove();
|
||||
if (description === null) target.removeAttribute('aria-describedby');
|
||||
else target.setAttribute('aria-describedby', description);
|
||||
if (group) group.replaceWith(target);
|
||||
});
|
||||
}
|
||||
|
||||
function clearTips() {
|
||||
removeTips.forEach(remove => remove());
|
||||
removeTips = [];
|
||||
}
|
||||
|
||||
function showStep(focus = true) {
|
||||
const step = steps[index];
|
||||
paused = Boolean(step.path && step.path !== location.pathname);
|
||||
clearTips();
|
||||
const region = !paused && step.region && main.querySelector(step.region);
|
||||
if (region instanceof HTMLDetailsElement) region.open = true;
|
||||
if (region) region.before(guide);
|
||||
else main.prepend(guide);
|
||||
heading.textContent = paused ? 'Продолжите обучение' : step.title;
|
||||
progress.textContent = `Шаг ${index + 1} из ${steps.length}`;
|
||||
intro.textContent = paused ? step.title + '.' : step.intro || '';
|
||||
intro.hidden = !intro.textContent;
|
||||
reference.hidden = index !== 0;
|
||||
error.hidden = unsaved.hidden = true;
|
||||
back.hidden = paused;
|
||||
back.disabled = index === 0;
|
||||
next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
|
||||
if (!paused) {
|
||||
step.tips?.forEach((tip, number) => addTip(tip, number));
|
||||
step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true));
|
||||
}
|
||||
guide.hidden = false;
|
||||
if (focus) {
|
||||
heading.focus({preventScroll: true});
|
||||
guide.scrollIntoView({block: 'start'});
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(number, path = steps[number].path || '/admin/account') {
|
||||
if (!saveStep(steps[number].id)) {
|
||||
location.assign(trigger.href);
|
||||
return;
|
||||
}
|
||||
if (path !== location.pathname) {
|
||||
location.assign(path);
|
||||
return;
|
||||
}
|
||||
index = number;
|
||||
showStep();
|
||||
}
|
||||
|
||||
function hide(remember = true) {
|
||||
clearTips();
|
||||
guide.hidden = true;
|
||||
saveStep(remember ? 'closed' : null);
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function saveStep(step) {
|
||||
try {
|
||||
if (step === null) sessionStorage.removeItem(storageKey);
|
||||
else sessionStorage.setItem(storageKey, step);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (saving) return;
|
||||
if (guide.dataset.guidePending !== 'true') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
guide.setAttribute('aria-busy', 'true');
|
||||
close.disabled = next.disabled = back.disabled = true;
|
||||
error.hidden = unsaved.hidden = true;
|
||||
try {
|
||||
const response = await fetch('/admin/guide/dismiss', {
|
||||
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
||||
headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
guide.dataset.guidePending = 'false';
|
||||
hide();
|
||||
} catch (_failure) {
|
||||
error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
|
||||
error.hidden = unsaved.hidden = false;
|
||||
unsaved.focus();
|
||||
} finally {
|
||||
saving = false;
|
||||
guide.removeAttribute('aria-busy');
|
||||
close.disabled = next.disabled = false;
|
||||
back.disabled = index === 0;
|
||||
}
|
||||
}
|
||||
|
||||
trigger?.addEventListener('click', event => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
goTo(0, location.pathname);
|
||||
});
|
||||
back.addEventListener('click', () => goTo(index - 1));
|
||||
next.addEventListener('click', () => {
|
||||
if (paused) goTo(index);
|
||||
else if (index === steps.length - 1) finish();
|
||||
else goTo(index + 1);
|
||||
});
|
||||
close.addEventListener('click', finish);
|
||||
unsaved.addEventListener('click', () => hide(false));
|
||||
guide.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
finish();
|
||||
});
|
||||
function restore(allowAutoStart) {
|
||||
let active;
|
||||
try {
|
||||
active = sessionStorage.getItem(storageKey);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const requested = steps.findIndex(step => step.id === active);
|
||||
const autoStart = allowAutoStart && requested < 0 && active !== 'closed' && guide.dataset.guidePending === 'true';
|
||||
if (autoStart && !saveStep(steps[0].id)) return;
|
||||
if (fallback) fallback.hidden = true;
|
||||
if (requested >= 0) {
|
||||
index = requested;
|
||||
showStep(!allowAutoStart || index > 0);
|
||||
} else if (autoStart) {
|
||||
showStep(false);
|
||||
} else {
|
||||
clearTips();
|
||||
guide.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pageshow', event => {
|
||||
if (event.persisted) restore(false);
|
||||
});
|
||||
restore(true);
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Личный кабинет</h2>
|
||||
<p>{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}</p>
|
||||
<p>{{ account_user.full_name or account_user.username }} · {{ user_roles[account_user.role] }}</p>
|
||||
<dl class="users__account-meta"><div><dt>Первый вход</dt><dd>{{ account_user.first_login_at|admin_datetime }}</dd></div><div><dt>Последний вход</dt><dd>{{ account_user.last_login_at|admin_datetime }}</dd></div><div><dt>Пароль изменён</dt><dd>{{ account_user.password_changed_at|admin_datetime }}</dd></div></dl>
|
||||
{% if password_changed %}<p role="status">Пароль изменён. Другие сессии завершены.</p>{% endif %}
|
||||
</section>
|
||||
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
{% set is_admin = current_user and current_user.role == 'admin' %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -17,10 +18,13 @@
|
||||
{% else %}
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
|
||||
{% if council_access %}<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>{% endif %}
|
||||
{% if is_admin %}
|
||||
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
{% if current_user and current_user.role == 'admin' %}<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>{% endif %}
|
||||
<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>
|
||||
{% endif %}
|
||||
<a class="admin__link" href="/admin/account"{% if request.url.path == '/admin/account' %} aria-current="page"{% endif %}>Личный кабинет</a>
|
||||
{% if current_user and not current_user.must_change_password %}<a class="admin__link" href="/admin/guide" data-guide-open>Как пользоваться</a>{% endif %}
|
||||
<form method="post" action="/admin/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
@@ -29,11 +33,17 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin__main" id="main-content">
|
||||
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
|
||||
{% include 'guide_walkthrough.html' %}
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
Backend {{ backend_version }} · Frontend {{ frontend_version }}
|
||||
</footer>
|
||||
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
|
||||
<script src="/static/guide.js?v={{ frontend_version }}" defer></script>
|
||||
{% endif %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
|
||||
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
|
||||
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.</p>
|
||||
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.</p>
|
||||
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
|
||||
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
|
||||
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
|
||||
@@ -34,6 +34,7 @@
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<input type="hidden" name="previous_specialty" value="{{ specialty }}">
|
||||
<label class="directory__field" for="council-branch">Отрасль оценки
|
||||
<select class="directory__input" id="council-branch" name="branch">
|
||||
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
|
||||
@@ -41,9 +42,9 @@
|
||||
</label>
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="">Все специальности</option>
|
||||
<option value="" data-branches='{{ specialties|map(attribute="branches")|sum(start=[])|unique|list|tojson }}'>Все специальности</option>
|
||||
{% for item in specialties %}
|
||||
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
<option value="{{ item.code }}" data-branches='{{ item.branches|tojson }}'{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
@@ -80,12 +81,12 @@
|
||||
<summary class="council__candidate-summary">
|
||||
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}</small></span>
|
||||
<span class="council__counts">
|
||||
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
|
||||
<span class="council__candidate-count" data-guide-count="q1"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
|
||||
<span class="council__candidate-count" data-guide-count="total"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
|
||||
<span class="council__candidate-count" data-guide-count="hse"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
|
||||
<span class="council__candidate-count" data-guide-count="all"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
|
||||
</span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
|
||||
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
|
||||
{% if can_edit %}
|
||||
{% set employee_id = candidate.employee_id %}
|
||||
{% include "council_edit_button.html" %}
|
||||
{% endif %}
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% if can_edit %}
|
||||
<dialog class="columns-modal" data-council-dialog aria-labelledby="article-edit-title">
|
||||
<div class="columns-modal__panel">
|
||||
<div class="columns-modal__header">
|
||||
<h3 class="columns-modal__title" id="article-edit-title">Редактирование статьи</h3>
|
||||
<button class="button button--ghost" type="button" data-edit-close aria-label="Закрыть редактирование">✕</button>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<form class="council-editor" data-edit-form>
|
||||
<label class="directory__field" for="article-edit-name">Название статьи
|
||||
<textarea class="directory__input" id="article-edit-name" name="title" required maxlength="2000" rows="3"></textarea>
|
||||
@@ -37,9 +37,6 @@
|
||||
</div>
|
||||
</form>
|
||||
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
|
||||
{% else %}
|
||||
<p>Для редактирования статей и квартилей войдите в систему.</p>
|
||||
<a class="button" href="/admin/login">Войти в систему</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
@@ -18,16 +18,15 @@
|
||||
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
|
||||
{% else %}
|
||||
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
|
||||
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в систему</a>.</p>{% endif %}
|
||||
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
|
||||
{% for article in articles %}
|
||||
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
|
||||
{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}
|
||||
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
|
||||
{% if can_edit %}{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}{% endif %}
|
||||
<p class="council__hint">{{ article.employee_name }}</p>
|
||||
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span></div>
|
||||
<p class="council__hint">{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</p>
|
||||
<p class="council__hint">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
|
||||
<p class="council__hint">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
|
||||
<p class="council__hint" data-guide-publication="journal">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
|
||||
<p class="council__hint" data-guide-publication="specialties">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
|
||||
</li>
|
||||
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
|
||||
</ol>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a class="metric metric--link" href="/admin/directory"><span class="metric__label">Всего в базе</span><span class="metric__value">{{ counts.total }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=active"><span class="metric__label">Работают</span><span class="metric__value">{{ counts.active }}</span></a>
|
||||
<a class="metric metric--link" href="/admin/directory?status=verification_required"><span class="metric__label">Требуют проверки</span><span class="metric__value">{{ counts.verification_required }}</span></a>
|
||||
<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>
|
||||
{% if is_admin %}<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>{% endif %}
|
||||
<a class="metric metric--link" href="/admin/directory?status=dismissed"><span class="metric__label">Уволены</span><span class="metric__value">{{ counts.dismissed }}</span></a>
|
||||
</section>
|
||||
<section class="stats-strip">
|
||||
@@ -17,6 +17,7 @@
|
||||
<span class="stats-strip__value">Сотрудников пока нет</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_admin %}
|
||||
<a class="stats-strip__item stats-strip__item--link" href="/admin/runs">
|
||||
<span class="stats-strip__label">Запуски</span>
|
||||
<span class="stats-strip__value">{{ counts.runs }}</span>
|
||||
@@ -25,19 +26,21 @@
|
||||
<span class="stats-strip__label">Ошибки</span>
|
||||
<span class="stats-strip__value">{{ counts.errors }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if is_admin %}
|
||||
<section class="panel progress-panel" data-progress-panel>
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>{% endif %}
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/dismissed/refresh">
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
@@ -66,6 +69,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/admin.js"></script>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
|
||||
</div>
|
||||
|
||||
{% set has_filters = filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to or filters.has_training or filters.training_since_year %}
|
||||
<details class="directory__filter-panel">
|
||||
<summary>Фильтры{% if has_filters %} · применены{% endif %}</summary>
|
||||
<form class="directory__filters" method="get" action="/admin/directory">
|
||||
<fieldset class="directory__filter-group directory__filter-group--search">
|
||||
<legend class="directory__filter-legend">Поиск</legend>
|
||||
@@ -35,6 +38,19 @@
|
||||
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select></label>
|
||||
<label class="directory__checkbox"><input type="checkbox" name="pps" value="true" {% if filters.pps %}checked{% endif %}> ППС</label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Повышение квалификации</legend>
|
||||
<label class="directory__field">Курсы в профиле<select class="directory__input" name="has_training">
|
||||
<option value="" {% if not filters.has_training %}selected{% endif %}>Любое</option>
|
||||
<option value="true" {% if filters.has_training == "true" %}selected{% endif %}>Есть курсы</option>
|
||||
<option value="false" {% if filters.has_training == "false" %}selected{% endif %}>Нет курсов</option>
|
||||
</select></label>
|
||||
<label class="directory__field">С какого года включительно
|
||||
<input class="directory__input" name="training_since_year" inputmode="numeric" pattern="[1-9][0-9]{3}" maxlength="4" value="{{ filters.training_since_year }}" placeholder="Например, 2024" aria-describedby="training-year-hint">
|
||||
</label>
|
||||
<small class="directory__filter-hint" id="training-year-hint">Если указан только год, будут показаны сотрудники с курсами.</small>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Период обнаружения</legend>
|
||||
@@ -63,6 +79,7 @@
|
||||
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="directory__help">
|
||||
<summary>Что означают даты и статусы</summary>
|
||||
@@ -112,7 +129,7 @@
|
||||
{% else %}
|
||||
<tr><td class="directory-table__empty" colspan="15">
|
||||
<strong>Сотрудники не найдены</strong>
|
||||
{% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.started_from or filters.started_to %}
|
||||
{% if has_filters %}
|
||||
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
|
||||
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
|
||||
{% else %}
|
||||
|
||||
@@ -106,8 +106,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if employee_view.news_links %}
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">В новостях</h3>
|
||||
<details class="employee-card__section employee-card__section--news">
|
||||
<summary class="employee-card__news-toggle"><h3 class="employee-section__title">В новостях</h3></summary>
|
||||
<ul class="employee-card__list">
|
||||
{% for news in employee_view.news_links %}
|
||||
<li class="employee-card__list-item">
|
||||
@@ -121,14 +121,15 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">Разделы профиля</h3>
|
||||
{% if employee_view.sections %}
|
||||
{% set profile_sections = employee_view.sections | rejectattr('type', 'equalto', 'news') | list %}
|
||||
{% if profile_sections %}
|
||||
<div class="employee-card__sections">
|
||||
{% for section in employee_view.sections %}
|
||||
{% for section in profile_sections %}
|
||||
<article class="employee-section">
|
||||
<div class="employee-section__header">
|
||||
<h4 class="employee-section__title">{{ section.title }}</h4>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<article class="panel guide--page">
|
||||
<h2 class="panel__title">Как пользоваться сайтом</h2>
|
||||
{% include 'guide_content.html' %}
|
||||
<form method="post" action="/admin/guide/dismiss">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Завершить подсказки</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
|
||||
<p>Сайт помогает найти сотрудника и изучить его профиль{% if council_access %}, а также подобрать кандидатов в диссертационный совет{% endif %}.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Сотрудники</dt><dd>Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.</dd>
|
||||
{% if council_access %}<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>{% endif %}
|
||||
<dt>Обзор</dt><dd>Количество сотрудников и их статус.</dd>
|
||||
{% if is_admin %}<dt>Запуски</dt><dd>Состояние загрузки данных и история запусков.</dd>{% endif %}
|
||||
<dt>Личный кабинет</dt><dd>Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.</dd>
|
||||
</dl>
|
||||
{% if is_admin %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% endif %}
|
||||
<p>Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
|
||||
<p>В разделе «Сотрудники» раскройте панель «Фильтры». В поле «ФИО или ссылка» введите фамилию, часть ФИО или ссылку на профиль ВШЭ. Например, Иванов.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Статус, Email, Учёная степень</dt><dd>Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.</dd>
|
||||
<dt>Повышение квалификации</dt><dd>Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ. Например, «Нет курсов» и 2024 покажут сотрудников без записей о курсах за 2024 год и позже. Если указать только год, отобразятся сотрудники с курсами за этот период.</dd>
|
||||
<dt>Период обнаружения: От / До</dt><dd>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</dd>
|
||||
<dt>Сортировка и страница</dt><dd>Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.</dd>
|
||||
<dt>Настроить колонки</dt><dd>Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.</dd>
|
||||
</dl>
|
||||
<p>Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.</p>
|
||||
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.</p>
|
||||
</section>
|
||||
{% if council_access %}
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Подберите кандидатов в диссовет</h3>
|
||||
<dl class="guide__fields">
|
||||
<dt>Специальность ВАК</dt><dd>Выберите код и название либо «Все специальности». Например, 2.3.2 — вычислительные системы и их элементы.</dd>
|
||||
<dt>Отрасль оценки</dt><dd>Выберите отрасль предполагаемого включения в совет. Для 1.1.2 доступны только физико-математические науки, для 2.3.2 — технические, для остальных специальностей сайта — обе отрасли.</dd>
|
||||
<dt>Поиск по ФИО</dt><dd>Введите фамилию или часть полного имени, чтобы сузить список.</dd>
|
||||
<dt>Сортировать по / Направление</dt><dd>Например, выберите «Подтверждено» и «По убыванию», затем нажмите «Применить». Получится список сотрудников в заданном порядке.</dd>
|
||||
</dl>
|
||||
<p>Фильтр специальности ищет сотрудников с подходящей связью журнала. Он не доказывает тематику каждой статьи и не ограничивает «ВСЕГО» только работами по выбранной специальности.</p>
|
||||
<p>Показатели рассчитаны за пять календарных лет, включая текущий год. Для подготовки ходатайства отдельно проверьте требуемый период и документальные основания. Подробности доступны в блоке «Как рассчитываются показатели».</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Прочитайте показатели сотрудника</h3>
|
||||
<p>В строке сотрудника показаны ФИО, обнаруженные степени, отметка об увольнении при наличии, результат оценки и счётчики. Откройте строку, чтобы загрузить публикации и причины дополнительной проверки.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Q1, Q2, К1</dt><dd>Количество работ с соответствующим основанием. Одна работа может попадать в несколько этих счётчиков.</dd>
|
||||
<dt>ВСЕГО (предв.)</dt><dd>Уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Одна работа учитывается один раз. Для кандидата наук или PhD одной категории К2 недостаточно.</dd>
|
||||
<dt>Подтверждено</dt><dd>Работы с проверенными видом материала, периодом и основанием зачёта. Именно это число сравнивается с минимумом: 9 для доктора по технической отрасли, 11 по физико-математической, 20 для кандидата наук или PhD.</dd>
|
||||
<dt>ЛК сотрудника ВШЭ</dt><dd>Публикации из личного кабинета сотрудника за все годы.</dd>
|
||||
<dt>Все публикации</dt><dd>Работы из личного кабинета и выгрузки ВШЭ, объединённые без дублей за все годы и независимо от категории.</dd>
|
||||
</dl>
|
||||
<p>При раскрытии также показаны К2, RSCI, подтверждение степени, причины статуса и ссылка на профиль ВШЭ. «Требует проверки» означает нехватку подтверждений; выполнение публикационного минимума не гарантирует включение в совет.</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Проверьте строку публикации</h3>
|
||||
<dl class="guide__fields">
|
||||
<dt>Название и год</dt><dd>Помогают определить работу и период. Неизвестный или спорный год отмечен подсказкой.</dd>
|
||||
<dt>Источник и тип материала</dt><dd>«ЛК сотрудника ВШЭ» или «Данные ВШЭ» показывают происхождение записи; тип отличает статью от книги и других материалов.</dd>
|
||||
<dt>Журнал и квартиль</dt><dd>Название издания и Q1–Q4 при наличии. Квартиль должен быть применим к году статьи; К1/К2 — отдельные категории ВАК, а не квартили.</dd>
|
||||
<dt>DOI и Публикация на сайте</dt><dd>Ссылки на исходную работу, если они доступны.</dd>
|
||||
<dt>Основания и специальность</dt><dd>В раскрытой карточке сотрудника приведены источники зачёта, годы метрик, данные каталогов, ISSN и ссылки на паспорта при наличии. При выборе специальности показаны сведения о тематическом соответствии.</dd>
|
||||
</dl>
|
||||
<p>Кнопка «Уточнение» рядом с полем объясняет, что требует проверки. Её можно открыть мышью или клавишей Enter. Предупреждение само по себе не означает, что публикации нет.</p>
|
||||
<p>В разделе «Статьи, журналы и квартили» показана сокращённая строка: сотрудник, название, год, тип, журнал, квартиль и специальности журнала. Там можно искать по ФИО, названию статьи и журналу.</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Исправьте сведения и выгрузите результат</h3>
|
||||
<p>Войдя в систему, выберите строку публикации и откройте «Редактировать статью». С клавиатуры: перейдите на строку, нажмите Enter, затем активируйте кнопку редактирования.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Название статьи / Название журнала</dt><dd>Введите проверенные названия. Для журнала можно выбрать предложение из списка.</dd>
|
||||
<dt>Квартиль</dt><dd>Выберите подтверждённый Q1–Q4. Если год неизвестен, поле недоступно. Изменение квартиля затронет все статьи этого журнала за тот же год.</dd>
|
||||
<dt>Специальности журнала</dt><dd>Оставьте связь из справочников или задайте ручную. Ручной выбор заменяет связь для всех статей журнала за все годы; снятие всех специальностей исключает связь.</dd>
|
||||
<dt>Сохранить / Отмена</dt><dd>Сохранение обновит показатели и запишет действие в историю. Ручная правка не заменяет подтверждение источником для зачёта работы.</dd>
|
||||
<dt>Скачать XLSX</dt><dd>В разделе «Диссертационный совет» выгружает параметры оценки, всех найденных сотрудников и их публикации, включая другие страницы списка.</dd>
|
||||
</dl>
|
||||
<p>Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,25 @@
|
||||
<section class="guide" data-guide data-guide-user="{{ current_user.id }}" data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-council="{{ 'true' if council_access else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title" hidden>
|
||||
<div class="guide__header">
|
||||
<div>
|
||||
<p class="guide__progress" data-guide-progress></p>
|
||||
<h2 class="guide__heading" id="guide-title" tabindex="-1"></h2>
|
||||
</div>
|
||||
<div class="guide__actions">
|
||||
<button class="button button--ghost" type="button" data-guide-close aria-label="Закрыть подсказки">Закрыть</button>
|
||||
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
|
||||
<button class="button" type="button" data-guide-next>Далее</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="guide__intro" data-guide-intro hidden></p>
|
||||
<a class="admin__link" href="/admin/guide" target="_blank" rel="noopener" data-guide-reference hidden>Подробная справка ↗</a>
|
||||
<p class="users__error" data-guide-error role="alert" hidden></p>
|
||||
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
|
||||
</section>
|
||||
{% if request.state.guide_pending|default(false) %}
|
||||
<aside class="panel guide--page" aria-label="Подсказки первого входа" data-guide-fallback>
|
||||
<h2 class="panel__title">Как пользоваться сайтом</h2>
|
||||
<p>{{ 'Откройте справку о сотрудниках, диссовете и публикациях.' if council_access else 'Откройте справку о поиске сотрудников и работе со справочником.' }}</p>
|
||||
<a class="button" href="/admin/guide">Открыть подсказки</a>
|
||||
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
|
||||
</aside>
|
||||
{% endif %}
|
||||
@@ -1,10 +1,10 @@
|
||||
{% macro change_value(field, value) %}
|
||||
{% if field == 'specialty_codes' %}{% if value is none or not value.manual %}по справочникам{% else %}{{ value.codes|join(', ') or 'связь исключена' }}{% endif %}
|
||||
{% elif field == 'role' %}{{ {'admin': 'Администратор', 'user': 'Пользователь'}.get(value, 'не задано') }}
|
||||
{% elif field == 'role' %}{{ user_roles.get(value, 'не задано') }}
|
||||
{% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }}
|
||||
{% else %}{{ value if value else 'не указано' }}{% endif %}
|
||||
{% endmacro %}
|
||||
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа'} %}
|
||||
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа', 'guide_start': 'Подсказки первого входа доступны', 'guide_finish': 'Подсказки первого входа закрыты'} %}
|
||||
{% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
|
||||
<div class="users__table-wrap">
|
||||
<table class="table users__history-table">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин<input class="form__input" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" autocomplete="off"></label>
|
||||
<label class="form__label">Имя<input class="form__input" name="full_name" maxlength="255" autocomplete="off"></label>
|
||||
<label class="form__label">Роль<select class="form__input" name="role"><option value="user">Пользователь</option><option value="admin">Администратор</option></select></label>
|
||||
<label class="form__label">Роль<select class="form__input" name="role">{% for role, label in user_roles.items() %}<option value="{{ role }}">{{ label }}</option>{% endfor %}</select></label>
|
||||
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
|
||||
<button class="button" type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
@@ -29,7 +29,7 @@
|
||||
<td class="table__cell">{% if user.id == current_user.id %}Администратор · ваша учётная запись{% else %}
|
||||
<form class="users__controls" method="post" action="/admin/users/{{ user.id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Роль {{ user.username }}<select class="form__input" name="role"><option value="user"{% if user.role == 'user' %} selected{% endif %}>Пользователь</option><option value="admin"{% if user.role == 'admin' %} selected{% endif %}>Администратор</option></select></label>
|
||||
<label>Роль {{ user.username }}<select class="form__input" name="role">{% for role, label in user_roles.items() %}<option value="{{ role }}"{% if user.role == role %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label>Доступ {{ user.username }}<select class="form__input" name="is_active"><option value="true"{% if user.is_active %} selected{% endif %}>Включён</option><option value="false"{% if not user.is_active %} selected{% endif %}>Отключён</option></select></label>
|
||||
<button class="button button--secondary" type="submit">Сохранить доступ</button>
|
||||
</form>{% endif %}
|
||||
|
||||
+31
-5
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -9,7 +9,7 @@ from app.admin import _render
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
from app.security import (SESSION_COOKIE, SESSION_SECONDS, check_csrf, hash_password,
|
||||
from app.security import (SESSION_COOKIE, SESSION_SECONDS, USER_ROLES, check_csrf, hash_password,
|
||||
require_admin, require_user, sign_session, temporary_password, verify_password)
|
||||
|
||||
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
||||
@@ -56,8 +56,8 @@ def create_user(request: Request, username: str = Form(..., max_length=64), full
|
||||
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
username = username.strip().casefold()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in {'admin', 'user'}:
|
||||
return _users_page(request, db, error='Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Выберите одну из двух ролей', status=422)
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in USER_ROLES:
|
||||
return _users_page(request, db, error='Логин должен содержать от 3 до 64 латинских букв, цифр, точек, дефисов или подчёркиваний. Выберите роль из списка', status=422)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
password = temporary_password()
|
||||
@@ -106,7 +106,7 @@ def reset_password(user_id: int, request: Request, db: Session = Depends(get_db)
|
||||
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
if role not in {'admin', 'user'} or is_active not in {'true', 'false'}:
|
||||
if role not in USER_ROLES or is_active not in {'true', 'false'}:
|
||||
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
@@ -157,6 +157,7 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
if new_password != confirmation:
|
||||
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
|
||||
try:
|
||||
first_password_change = user.password_changed_at is None
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = utcnow()
|
||||
@@ -164,6 +165,8 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=user.id, action='password_change'))
|
||||
if first_password_change:
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
@@ -172,3 +175,26 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.get('/guide')
|
||||
def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_user(request, settings, db)
|
||||
return _render(request, 'guide.html', {'guide_page': True})
|
||||
|
||||
|
||||
@router.post('/guide/dismiss')
|
||||
def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, lock=True)
|
||||
try:
|
||||
latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1))
|
||||
if latest == 'guide_start':
|
||||
db.add(UserAction(user_id=user.id, action='guide_finish'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз')
|
||||
if request.headers.get('accept') == 'application/json':
|
||||
return Response(status_code=204)
|
||||
return RedirectResponse('/admin', status_code=303)
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "1.2.1"
|
||||
FRONTEND_VERSION = "1.2.1"
|
||||
BACKEND_VERSION = "1.2.1"
|
||||
APP_VERSION = "2.0.1"
|
||||
FRONTEND_VERSION = "2.0.1"
|
||||
BACKEND_VERSION = "2.0.1"
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Хранение и привязка
|
||||
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. Компактный XLSX содержит только выбранные столбцы; подробные подтверждения и причины проверки в него не включаются. Экспортный «Всего» считает уникальное объединение Q1/Q2/К1 без самостоятельных К2/RSCI.
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. XLSX содержит первый лист интерактивного подбора и компактные подробные листы. Для переключения специальности и отрасли файл включает полный набор сотрудников, а исходные фильтры сайта задают начальный подбор. Для обеих доступных отраслей используются результаты того же сервиса оценки: показаны подтверждённый зачёт, минимум и статус. Подробные записи подтверждений и непубличные ссылки в файл не включаются. Экспортный «Всего» совпадает с сайтом: уникальное объединение Q1/Q2/К1/RSCI, для доктора также К2; одного К2 у кандидата/PhD недостаточно. XLSX — снимок на момент выгрузки; ручное подтверждение на сайте требует новой выгрузки.
|
||||
|
||||
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
|
||||
|
||||
@@ -78,3 +78,8 @@
|
||||
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
|
||||
|
||||
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
|
||||
|
||||
|
||||
Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX.
|
||||
|
||||
Без JavaScript форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
ALTER TABLE public.system_users
|
||||
DROP CONSTRAINT ck_system_users_role,
|
||||
ADD CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1,31 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE system_users_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council')),
|
||||
is_active BOOLEAN NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password BOOLEAN NOT NULL,
|
||||
session_version INTEGER NOT NULL,
|
||||
first_login_at DATETIME,
|
||||
last_login_at DATETIME,
|
||||
password_changed_at DATETIME,
|
||||
password_reset_at DATETIME,
|
||||
failed_login_attempts INTEGER NOT NULL,
|
||||
locked_until DATETIME,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO system_users_new
|
||||
SELECT id, username, full_name, role, is_active, password_hash, must_change_password, session_version,
|
||||
first_login_at, last_login_at, password_changed_at, password_reset_at, failed_login_attempts,
|
||||
locked_until, created_at
|
||||
FROM system_users;
|
||||
DROP TABLE system_users;
|
||||
ALTER TABLE system_users_new RENAME TO system_users;
|
||||
CREATE TEMP TABLE check_user_action_references (violations INTEGER CHECK (violations = 0));
|
||||
INSERT INTO check_user_action_references SELECT count(*) FROM pragma_foreign_key_check('user_actions');
|
||||
DROP TABLE check_user_action_references;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.2.1"
|
||||
version = "2.0.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -170,7 +170,11 @@ if __name__ == "__main__":
|
||||
result = {"schema_version": 1, "source": args.source.name,
|
||||
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
|
||||
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
|
||||
"specialties": [{"code": code, "name": name,
|
||||
"branches": ["физико-математические"] if code == "1.1.2" else
|
||||
["технические"] if code == "2.3.2" else
|
||||
["технические", "физико-математические"]}
|
||||
for code, name in SPECIALTIES], "journals": entries}
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
from collections import Counter
|
||||
print(len(entries), dict(Counter(j['status'] for j in entries)))
|
||||
+25
-1
@@ -75,8 +75,30 @@ def main():
|
||||
page.keyboard.press("Tab")
|
||||
assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'")
|
||||
|
||||
for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320):
|
||||
filters = page.locator(".directory__filter-panel")
|
||||
expect(filters).not_to_have_attribute("open", "")
|
||||
expect(page.get_by_label("ФИО или ссылка")).to_be_hidden()
|
||||
filters.locator("summary").focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_label("ФИО или ссылка")).to_be_visible()
|
||||
assert page.locator('.directory__filters :is(input, select)').evaluate_all("""controls =>
|
||||
controls.every(control => {
|
||||
control.focus();
|
||||
const style = getComputedStyle(control);
|
||||
return style.outlineWidth === '2px' && style.outlineOffset === '0px';
|
||||
})
|
||||
""")
|
||||
for width in (1920, 1440, 1180, 1024, 921, 920, 768, 621, 620, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 844})
|
||||
assert page.locator("main").evaluate("""main => {
|
||||
const header = document.querySelector('.admin__header');
|
||||
const style = getComputedStyle(header);
|
||||
const headerBox = header.getBoundingClientRect();
|
||||
const mainBox = main.getBoundingClientRect();
|
||||
return Math.abs(mainBox.left - headerBox.left - parseFloat(style.paddingLeft)) < 1
|
||||
&& Math.abs(headerBox.right - mainBox.right - parseFloat(style.paddingRight)) < 1
|
||||
&& document.documentElement.scrollWidth <= document.documentElement.clientWidth;
|
||||
}""")
|
||||
assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth")
|
||||
assert page.locator(".directory__input").evaluate_all("""controls => controls.every(control => {
|
||||
const field = control.closest('.directory__field').getBoundingClientRect();
|
||||
@@ -88,6 +110,7 @@ def main():
|
||||
page.get_by_role("button", name="Применить фильтры").click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1")
|
||||
assert "offset=" not in page.url
|
||||
expect(filters).not_to_have_attribute("open", "")
|
||||
trigger = page.get_by_role("button", name="Настроить колонки")
|
||||
trigger.focus()
|
||||
page.keyboard.press("Enter")
|
||||
@@ -116,6 +139,7 @@ def main():
|
||||
assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth")
|
||||
page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True)
|
||||
page.keyboard.press("Escape")
|
||||
filters.locator("summary").click()
|
||||
page.get_by_label("ФИО или ссылка").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить фильтры").click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
|
||||
@@ -92,6 +92,21 @@ def main():
|
||||
assert "Без степени" not in page.content() and "Только звание" not in page.content()
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
branches = page.locator('#council-branch')
|
||||
specialty = page.locator('#council-specialty')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
specialty.select_option('1.1.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
specialty.select_option('2.3.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('технические')
|
||||
specialty.select_option('1.2.2')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
branches.select_option('физико-математические')
|
||||
specialty.select_option('')
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
branches.select_option('технические')
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
@@ -217,6 +232,27 @@ def main():
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
page.screenshot(path=str(output / f"catalog-{width}.png"), full_page=True)
|
||||
assert not errors, errors
|
||||
no_js = browser.new_context(java_script_enabled=False)
|
||||
no_js.route("**/*", serve)
|
||||
fallback = no_js.new_page()
|
||||
for previous, specialty, old_branch, new_branch in [
|
||||
("2.3.2", "1.1.2", "технические", "физико-математические"),
|
||||
("1.1.2", "2.3.2", "физико-математические", "технические"),
|
||||
]:
|
||||
url = f"http://miem.test/admin/council?specialty={previous}&branch={old_branch}&q=Тест&sort=full_name&direction=asc"
|
||||
fallback.goto(url)
|
||||
fallback.get_by_label("Специальность ВАК").select_option(specialty)
|
||||
with fallback.expect_download() as pending_download:
|
||||
fallback.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
book = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
|
||||
fallback.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(fallback.get_by_label("Отрасль оценки")).to_have_value(new_branch)
|
||||
expect(fallback.get_by_label("Специальность ВАК")).to_have_value(specialty)
|
||||
expect(fallback.get_by_label("Поиск по ФИО")).to_have_value("Тест")
|
||||
expect(fallback.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(fallback.get_by_label("Направление")).to_have_value("asc")
|
||||
no_js.close()
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
|
||||
@@ -66,16 +66,11 @@ def main():
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto('http://miem.test/admin/council')
|
||||
page.locator('.council__candidate-summary').click()
|
||||
expect(page.locator('.council__article strong')).to_have_text('Исследование передачи данных')
|
||||
expect(page.locator('[data-council-edit]')).to_have_count(0)
|
||||
expect(page.locator('[data-council-dialog]')).to_have_count(0)
|
||||
article = page.locator('[data-council-article]')
|
||||
pencil = article.locator('[data-council-edit]')
|
||||
expect(pencil).to_be_hidden()
|
||||
article.locator('strong').click()
|
||||
expect(pencil).to_be_visible()
|
||||
pencil.click()
|
||||
expect(page.get_by_role('dialog')).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Войти в систему', exact=True)).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(pencil).to_be_focused()
|
||||
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
|
||||
page.reload()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
|
||||
+155
-2
@@ -71,6 +71,7 @@ def main():
|
||||
creation = page.locator('form[action="/admin/users"]')
|
||||
creation.get_by_label('Логин', exact=True).fill('reader')
|
||||
creation.get_by_label('Имя', exact=True).fill('Тестовый читатель')
|
||||
creation.get_by_role('combobox', name='Роль', exact=True).select_option('council')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
temporary = page.locator('[data-temporary-password]')
|
||||
expect(temporary).to_be_visible()
|
||||
@@ -79,6 +80,10 @@ def main():
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
|
||||
creation.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
creation.get_by_role('combobox', name='Роль', exact=True).select_option('user')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
nojs_password = page.locator('[data-temporary-password]').input_value()
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('reader')
|
||||
@@ -97,9 +102,157 @@ def main():
|
||||
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
expect(page.get_by_role('dialog')).to_have_count(0)
|
||||
expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible()
|
||||
guide = page.locator('[data-guide]')
|
||||
expect(guide).to_be_visible()
|
||||
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
|
||||
restricted = context.new_page()
|
||||
restricted.add_init_script("""Object.defineProperty(window, 'sessionStorage', {
|
||||
get() { throw new DOMException('Storage is disabled', 'SecurityError'); },
|
||||
});""")
|
||||
restricted.goto(base_url + '/admin/account')
|
||||
expect(restricted.get_by_role('link', name='Открыть подсказки', exact=True)).to_be_visible()
|
||||
restricted.get_by_role('link', name='Как пользоваться', exact=True).click()
|
||||
expect(restricted.get_by_role('heading', name='Как пользоваться сайтом')).to_be_visible()
|
||||
restricted.close()
|
||||
for width in (1440, 320):
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
assert guide.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output / f'guide-first-{width}.png'))
|
||||
page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure'))
|
||||
guide.get_by_role('button', name='Закрыть подсказки', exact=True).click()
|
||||
expect(guide.locator('[data-guide-error]')).to_be_visible()
|
||||
guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.unroute('**/admin/guide/dismiss')
|
||||
page.reload()
|
||||
expect(guide).to_be_visible()
|
||||
guide.get_by_role('heading').focus()
|
||||
page.keyboard.press('Escape')
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.reload()
|
||||
expect(guide).not_to_be_visible()
|
||||
page.get_by_role('link', name='Как пользоваться', exact=True).click()
|
||||
expect(guide).to_be_visible()
|
||||
go_next = guide.get_by_role('button', name='Далее', exact=True)
|
||||
go_back = guide.get_by_role('button', name='Назад', exact=True)
|
||||
tips = page.locator('.guide__tip:visible')
|
||||
go_next.click()
|
||||
expect(guide.get_by_role('heading', name='Найдите сотрудника', exact=True)).to_be_focused()
|
||||
assert page.url.endswith('/admin/directory')
|
||||
expect(tips).to_have_count(3)
|
||||
search = page.get_by_role('textbox', name='ФИО или ссылка', exact=True)
|
||||
expect(search).to_have_accessible_description('Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.')
|
||||
search.fill('Сотрудник')
|
||||
page.get_by_role('button', name='Применить фильтры', exact=True).click()
|
||||
expect(tips).to_have_count(3)
|
||||
expect(search).to_have_value('Сотрудник')
|
||||
page.get_by_role('link', name='Сбросить', exact=True).click()
|
||||
expect(search).to_have_value('')
|
||||
expect(tips).to_have_count(3)
|
||||
go_back.click()
|
||||
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
|
||||
for _ in range(3):
|
||||
go_next.click()
|
||||
expect(guide.get_by_role('heading', name='Проверьте повышение квалификации')).to_be_visible()
|
||||
expect(tips).to_have_count(2)
|
||||
expect(page.get_by_role('combobox', name='Курсы в профиле', exact=True)).to_have_accessible_description(
|
||||
'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'
|
||||
)
|
||||
expect(page.get_by_role('textbox', name='С какого года включительно', exact=True)).to_have_accessible_description(
|
||||
'Если указан только год, будут показаны сотрудники с курсами. '
|
||||
'Например, 2024 включает 2024 год и все последующие.'
|
||||
)
|
||||
for width in (1440, 320):
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
assert tips.evaluate_all('tips => tips.every(tip => tip.scrollWidth <= tip.clientWidth)')
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.set_viewport_size({'width': 1440, 'height': 900})
|
||||
for _ in range(3):
|
||||
go_next.click()
|
||||
assert page.url.endswith('/admin/council')
|
||||
expect(tips).to_have_count(3)
|
||||
for width in (1440, 320):
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
assert page.evaluate('''() => {
|
||||
const tips = [...document.querySelectorAll('.guide__tip')];
|
||||
const boxes = tips.map(tip => tip.getBoundingClientRect());
|
||||
const fields = [...document.querySelectorAll('.council__search input:not([type=hidden]), .council__search select')];
|
||||
const overlaps = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
return tips.every(tip => tip.scrollWidth <= tip.clientWidth && tip.scrollHeight <= tip.clientHeight)
|
||||
&& boxes.every((box, i) => boxes.slice(i + 1).every(other => !overlaps(box, other))
|
||||
&& fields.every(field => !overlaps(box, field.getBoundingClientRect())))
|
||||
&& document.documentElement.scrollWidth <= innerWidth;
|
||||
}''')
|
||||
guide.evaluate("node => node.scrollIntoView({block: 'start'})")
|
||||
page.screenshot(path=str(output / f'guide-council-{width}.png'))
|
||||
go_back.click()
|
||||
expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible()
|
||||
profile = page.get_by_role('link', name='Сотрудник для проверки', exact=True)
|
||||
expect(profile).to_have_accessible_description('Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.')
|
||||
profile.click()
|
||||
expect(guide.get_by_role('heading', name='Продолжите обучение')).to_be_visible()
|
||||
guide.get_by_role('button', name='Продолжить', exact=True).click()
|
||||
expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible()
|
||||
go_next.click()
|
||||
go_next.click()
|
||||
go_next.click()
|
||||
expect(tips).to_have_count(3)
|
||||
expect(page.locator('[data-guide-count="total"] .guide__tip')).to_contain_text('Уникальные работы')
|
||||
page.screenshot(path=str(output / 'guide-counts-mobile.png'))
|
||||
go_next.click()
|
||||
expect(tips).to_have_count(2)
|
||||
expect(page.locator('[data-guide-count="hse"] .guide__tip')).to_contain_text('личного кабинета')
|
||||
go_next.click()
|
||||
expect(tips).to_have_count(2)
|
||||
go_next.click()
|
||||
assert page.url.endswith('/admin/council/publications')
|
||||
go_next.click()
|
||||
expect(tips).to_have_count(3)
|
||||
warning = page.locator('[data-warning] button').first
|
||||
warning.focus()
|
||||
expect(warning).to_have_attribute('aria-expanded', 'true')
|
||||
page.keyboard.press('Escape')
|
||||
expect(warning).to_have_attribute('aria-expanded', 'false')
|
||||
expect(guide).to_be_visible()
|
||||
page.locator('[data-council-article]').click()
|
||||
page.get_by_role('button', name='Редактировать статью: Статья для проверки', exact=True).click()
|
||||
guide_editor = page.get_by_role('dialog', name='Редактирование статьи')
|
||||
expect(guide_editor.locator('.guide__tip')).to_have_count(3)
|
||||
expect(guide_editor.get_by_role('combobox', name='Квартиль', exact=True)).to_have_accessible_description(
|
||||
'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'
|
||||
)
|
||||
page.keyboard.press('Escape')
|
||||
expect(guide_editor).not_to_be_visible()
|
||||
expect(guide).to_be_visible()
|
||||
guide.get_by_role('button', name='Завершить подсказки', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(tips).to_have_count(0)
|
||||
page.go_back()
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(tips).to_have_count(0)
|
||||
no_js = browser.new_context(java_script_enabled=False)
|
||||
nojs_page = no_js.new_page()
|
||||
nojs_page.goto(base_url + '/admin/login')
|
||||
nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_role('button', name='Войти', exact=True).click()
|
||||
nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click()
|
||||
expect(nojs_page.locator('[data-guide-step]')).to_have_count(2)
|
||||
expect(nojs_page.get_by_text('Повышение квалификации', exact=True)).to_be_visible()
|
||||
expect(nojs_page.get_by_role('link', name='Диссертационный совет', exact=True)).to_have_count(0)
|
||||
expect(nojs_page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0)
|
||||
nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click()
|
||||
expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0)
|
||||
no_js.close()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0)
|
||||
expect(page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Обзор', exact=True).click()
|
||||
expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Диссертационный совет', exact=True).click()
|
||||
|
||||
@@ -6,7 +6,9 @@ from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
|
||||
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
|
||||
request=SimpleNamespace(url=SimpleNamespace(path="/admin")),
|
||||
current_user=SimpleNamespace(role="admin", must_change_password=True),
|
||||
council_access=True,
|
||||
)
|
||||
|
||||
assert "Обзор" in template
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "1.2.1"
|
||||
assert response.json()["backend_version"] == "2.0.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+86
-20
@@ -13,7 +13,14 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
from app.services.council_export import _append
|
||||
from app.services.council_export import _append, council_xlsx
|
||||
|
||||
|
||||
def selection_results(db, result):
|
||||
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
|
||||
return [result if b == result["branch"] and not result["q"] and not result["specialty"] else
|
||||
council.council_candidates(db, result["end"], sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
|
||||
for b in branches]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -223,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 1.2.1 · Frontend 1.2.1" in response.text
|
||||
assert "Backend 2.0.1 · Frontend 2.0.1" in response.text
|
||||
assert "Вход в систему" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
@@ -264,7 +271,7 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
|
||||
assert book.sheetnames == ["Подбор диссовета", "Сотрудники", "Публикации", "Параметры", "_Данные подбора", "_Справочники подбора"]
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
assert [cell.value for cell in people[1]] == [
|
||||
"№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего", "Всего из ЛК",
|
||||
@@ -287,9 +294,12 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
assert works.auto_filter.ref == works.dimensions
|
||||
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
|
||||
assert "private@example.org" not in text and "Без степени" not in text
|
||||
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
|
||||
focused = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))
|
||||
assert focused["Сотрудники"].max_row == 29
|
||||
assert focused["Подбор диссовета"]["C5"].value == "Петров 27"
|
||||
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
|
||||
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
|
||||
assert empty["Сотрудники"].max_row == 29 and empty["Публикации"].max_row == 29
|
||||
assert empty["Подбор диссовета"]["C5"].value == "missing"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -307,10 +317,12 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Сотрудники"]["B2"].value == person.full_name
|
||||
assert book["Сотрудники"]["B2"].data_type == "s"
|
||||
assert book["_Данные подбора"]["B2"].value == person.full_name
|
||||
assert book["_Данные подбора"]["B2"].data_type == "s"
|
||||
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
|
||||
assert titles == {"=1+1": "s", "#N/A": "s"}
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
|
||||
assert all(b"<f>" not in archive.read(f"xl/worksheets/sheet{n}.xml") for n in (2, 3, 4))
|
||||
person.full_name = "Long " + "x" * 32768
|
||||
db_session.commit()
|
||||
failure = client.get("/admin/council/export.xlsx")
|
||||
@@ -320,6 +332,60 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_selection_contains_full_data_and_preserves_initial_filters(db_session, export):
|
||||
matching = employee(db_session)
|
||||
publication(db_session, matching, kind="ARTICLE", raw_data={"journal": "Бизнес-информатика"})
|
||||
other = employee(db_session, "Другой сотрудник", key="other")
|
||||
publication(db_session, other, title="Other", kind="ARTICLE")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get('/admin/council/export.xlsx?specialty=1.2.2&q=Иванов&sort=confirmed_count&direction=asc')
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
dashboard, source, options = book.worksheets[0], book['_Данные подбора'], book['_Справочники подбора']
|
||||
assert dashboard.title == 'Подбор диссовета' and book.active == dashboard
|
||||
assert dashboard['C3'].value.startswith('1.2.2 —')
|
||||
assert dashboard['C4'].value == 'технические' and dashboard['C5'].value == 'Иванов'
|
||||
assert dashboard['K3'].value == 'Подтверждено' and dashboard['K4'].value == 'По возрастанию'
|
||||
assert source.max_row == 5 and source.sheet_state == options.sheet_state == 'hidden'
|
||||
assert set(source.cell(row, 2).value for row in range(2, 6)) == {matching.full_name, other.full_name}
|
||||
assert {source.cell(row, 18).value for row in range(2, 6)} == {'технические', 'физико-математические'}
|
||||
assert {source.cell(row, 11).value for row in range(2, 6)} == {9, 11}
|
||||
assert book['Сотрудники'].max_row == book['Публикации'].max_row == 3
|
||||
assert len(dashboard.data_validations.dataValidation) == 6
|
||||
for row in range(3, options.max_row + 1):
|
||||
code = options.cell(row, 1).value.split(' ')[0]
|
||||
expected = ['физико-математические'] if code == '1.1.2' else ['технические'] if code == '2.3.2' else ['технические', 'физико-математические']
|
||||
assert [options.cell(row, column).value for column in (4, 5) if options.cell(row, column).value] == expected
|
||||
assert options.cell(row, 2).value in book.defined_names
|
||||
assert '_xlfn.AGGREGATE' in dashboard['S10'].value
|
||||
assert '$C$3' in dashboard['F4'].value and 'IFERROR' in dashboard['F4'].value
|
||||
memo_address = dashboard['A7'].hyperlink.location.split('!', 1)[1]
|
||||
assert dashboard[memo_address].value == 'Памятка: как пользоваться подбором'
|
||||
assert dashboard[memo_address].row > 11
|
||||
memo_text = ' '.join(str(dashboard.cell(row, 1).value or '').replace('\n', ' ')
|
||||
for row in range(dashboard[memo_address].row, dashboard.max_row + 1))
|
||||
for explanation in ('Для кандидата/PhD', 'Подтверждено', '9 подтверждённых',
|
||||
'11 по физико-математической', '20', 'Не подтверждены', 'все работы сотрудника',
|
||||
'Уволенные', 'автоматический расчёт', 'не отправляются на сайт'):
|
||||
assert explanation in memo_text
|
||||
assert any(cell.hyperlink and cell.hyperlink.location == "'Подбор диссовета'!A1"
|
||||
for row in dashboard for cell in row)
|
||||
assert book.calculation.calcMode == 'auto' and book.calculation.forceFullCalc
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert not any('vbaProject' in name or 'externalLinks' in name for name in archive.namelist())
|
||||
db_session.query(EmployeePublication).delete()
|
||||
db_session.query(Employee).delete()
|
||||
db_session.commit()
|
||||
empty = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
|
||||
assert empty['Сотрудники'].max_row == 1 and empty['Публикации'].max_row == 1
|
||||
assert empty['_Данные подбора'].max_row == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
def fail_on_scan(sheet):
|
||||
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
|
||||
@@ -392,8 +458,7 @@ def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_sess
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
|
||||
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 8)] == [1, 1]
|
||||
|
||||
|
||||
@@ -453,8 +518,7 @@ def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
|
||||
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
|
||||
assert [candidate["name"] for candidate in result["candidates"]] == expected
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
assert headers[3:8] == ["Q1", "Q2", "К1", "К2", "Всего"]
|
||||
assert "CSV по критериям" not in headers
|
||||
@@ -501,7 +565,9 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
|
||||
assert response.status_code == 200
|
||||
assert '<option value="1.2.2" selected>' in response.text
|
||||
from bs4 import BeautifulSoup
|
||||
selected = BeautifulSoup(response.text, "html.parser").select_one('#council-specialty option[selected]')
|
||||
assert selected["value"] == "1.2.2"
|
||||
assert response.text.count('<option value="2.3.5"') == 1
|
||||
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
|
||||
assert "Не подходит" not in response.text
|
||||
@@ -513,10 +579,11 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
|
||||
assert "Показаны 1–1 из 1" in filtered.text
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
|
||||
assert book["Сотрудники"].max_row == 29
|
||||
assert book["Сотрудники"].max_row == 30
|
||||
assert book["Подбор диссовета"]["C3"].value.startswith("1.2.2 —")
|
||||
params = dict(book["Параметры"].values)
|
||||
assert params["Специальность ВАК"].startswith("1.2.2.")
|
||||
assert "По специальности 1.2.2" in book["Публикации"]["F2"].value
|
||||
assert "Перечень ВАК" in book["Публикации"]["F2"].value or "справочник ВАК" in book["Публикации"]["F2"].value
|
||||
empty = client.get("/admin/council?specialty=2.2.14")
|
||||
assert "Сотрудники не найдены" in empty.text
|
||||
finally:
|
||||
@@ -556,10 +623,9 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
|
||||
other = employee(db_session, name='Без соответствия', key='other')
|
||||
publication(db_session, other, title='Unknown', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
from app.services.council_export import council_xlsx
|
||||
for specialty in ('', '1.2.2'):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
staff = list(book['Сотрудники'].values)
|
||||
works = list(book['Публикации'].values)
|
||||
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
|
||||
@@ -601,16 +667,16 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8))
|
||||
candidate = result['candidates'][0]
|
||||
other = next(a for a in candidate['articles'] if a['title'] == 'Other publication')
|
||||
# Isolate the export rule: other qualifying council bases do not add to its total.
|
||||
# Export the same total as the site, including doctor-only K2 and RSCI.
|
||||
other.update(k2=True, rsci=True, qualifies=True)
|
||||
candidate.update(k2_count=1, rsci_count=1, total_count=2)
|
||||
shared = next(a for a in candidate['articles'] if a['title'] == 'Shared work')
|
||||
shared['criteria'].extend(['В пределах пяти лет', 'В пределах пяти лет по году; точную дату нужно проверить'])
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
staff = dict(zip([c.value for c in book['Сотрудники'][1]], [c.value for c in book['Сотрудники'][2]]))
|
||||
assert staff['Научная степень'] == 'Доктор технических наук'
|
||||
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == staff['Всего'] == 1
|
||||
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == 1
|
||||
assert staff['Всего'] == candidate['total_count'] == 2
|
||||
assert staff['Всего из ЛК'] == staff['Общее количество всех публикаций без дублей'] == 2
|
||||
rows = {values[2]: dict(zip(next(book['Публикации'].values), values)) for values in list(book['Публикации'].values)[1:]}
|
||||
assert rows['Other publication']['Тип материала'] == 'не указан'
|
||||
@@ -618,7 +684,7 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
|
||||
assert rows['Shared work']['Критерии'].count('В пределах пяти лет') == 1
|
||||
assert 'точную дату' not in rows['Shared work']['Критерии']
|
||||
params = dict(book['Параметры'].values)
|
||||
assert 'Q1 ИЛИ Q2 ИЛИ К1' in params['Всего']
|
||||
assert 'Q1/Q2/К1/RSCI' in params['Всего'] and 'для доктора наук также К2' in params['Всего']
|
||||
assert 'источники указаны на листе' not in ' '.join(str(v) for v in params.values())
|
||||
assert not any('Работ / По выбранной специальности' == key for key in params)
|
||||
|
||||
|
||||
@@ -48,9 +48,14 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
|
||||
"bases": [{**SOURCE, "basis": basis, "applicable_year": year}], **proof_fields}
|
||||
|
||||
|
||||
def result(db, branch="технические", **kwargs):
|
||||
def result(db, branch="технические", assessment_branch=None, **kwargs):
|
||||
db.commit()
|
||||
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
value = council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
if assessment_branch:
|
||||
return assessment.assess_candidate(value["degree"].split("; "), value["articles"],
|
||||
assessment.evidence()["employees"]["test"], assessment_branch, "",
|
||||
date(2022, 1, 1), date(2026, 12, 31))
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
|
||||
@@ -61,7 +66,7 @@ def test_each_doctor_threshold_and_monograph_requirement(db_session, proofs, bra
|
||||
employee = person(db_session, [degree])
|
||||
records = [work(db_session, employee, n) for n in range(minimum + delta)]
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
value = result(db_session, branch)
|
||||
value = result(db_session, assessment_branch=branch)
|
||||
assert value["minimum"] == minimum and value["monograph_minimum"] == mono
|
||||
assert value["confirmed_count"] == minimum + delta
|
||||
assert value["assessment_status"].startswith("Соответствует") == (delta >= 0 and not mono)
|
||||
@@ -79,6 +84,24 @@ def test_candidate_phd_20_and_k2_exclusion(db_session, proofs, degree, count, ba
|
||||
assert value["assessment_status"].startswith("Соответствует") == (basis != "К2" and count >= 20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("degree,total", [("Доктор технических наук", 3), ("Кандидат технических наук", 2), ("Ph.D.", 2)])
|
||||
def test_xlsx_total_matches_site_for_k2_rsci_and_overlapping_bases(db_session, proofs, degree, total):
|
||||
employee = person(db_session, [degree])
|
||||
records = [work(db_session, employee, number, basis=basis) for number, basis in enumerate(("К2", "RSCI", "К1"))]
|
||||
records[-1]["bases"].append({**SOURCE, "basis": "К2", "applicable_year": 2024})
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
db_session.commit()
|
||||
selections = [council.council_candidates(db_session, date(2026, 10, 8), branch=branch, paginate=False)
|
||||
for branch in ("технические", "физико-математические")]
|
||||
candidate = selections[0]["candidates"][0]
|
||||
assert candidate["k2_count"] == 2 and candidate["rsci_count"] == 1
|
||||
assert candidate["total_count"] == total
|
||||
book = load_workbook(BytesIO(council_xlsx(selections[0], selections)))
|
||||
assert book["Сотрудники"]["H2"].value == total
|
||||
assert book["_Данные подбора"]["I2"].value == total
|
||||
assert book["_Данные подбора"]["I3"].value == total
|
||||
|
||||
|
||||
@pytest.mark.parametrize("degrees", list(itertools.permutations(["Кандидат технических наук", "PhD", "Доктор технических наук"])))
|
||||
def test_history_always_chooses_doctor_without_fallback(db_session, proofs, degrees):
|
||||
person(db_session, list(degrees) + ["Профессор", "Неизвестная квалификация"])
|
||||
@@ -102,10 +125,10 @@ def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branc
|
||||
records = [work(db_session, employee, n) for n in range(8)]
|
||||
records.append(work(db_session, employee, 8, kind="BOOK", kind_evidence=SOURCE))
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
assert result(db_session, branch)["monograph_count"] == 0
|
||||
assert result(db_session, assessment_branch=branch)["monograph_count"] == 0
|
||||
records[-1]["kind"] = "monograph"
|
||||
proofs([{**SOURCE, "degree": degree}], records, {"monograph_period": {**SOURCE, "value": "five_years"}})
|
||||
value = result(db_session, branch)
|
||||
value = result(db_session, assessment_branch=branch)
|
||||
assert value["confirmed_count"] == 8 and value["monograph_count"] == 1
|
||||
assert value["assessment_status"].startswith("Соответствует")
|
||||
|
||||
@@ -175,8 +198,34 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
|
||||
assert "council-date" not in page.text and "Монографии" not in page.text
|
||||
assert "Дата ходатайства" not in page.text
|
||||
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
|
||||
legacy_book = load_workbook(BytesIO(legacy.content))
|
||||
assert dict(legacy_book["Параметры"].values)["Отрасль оценки"] == "экономические"
|
||||
assert legacy.status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("populated", [False, True])
|
||||
def test_empty_branch_is_rejected_before_export_and_omitted_branch_keeps_default(db_session, proofs, populated):
|
||||
if populated:
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
work(db_session, employee)
|
||||
db_session.commit()
|
||||
with pytest.raises(council.CouncilInputError, match="Неизвестная отрасль науки"):
|
||||
council.council_candidates(db_session, branch="")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for query in ("branch=", "branch=&specialty=1.1.2", "branch=&specialty=1.1.2&previous_specialty="):
|
||||
for path in ("/admin/council", "/admin/council/export.xlsx", "/admin/council/employees/1/articles"):
|
||||
response = client.get(path + "?" + query)
|
||||
assert response.status_code == 422
|
||||
assert "Неизвестная отрасль науки" in response.text
|
||||
assert "content-disposition" not in response.headers
|
||||
assert client.get("/admin/council").status_code == 200
|
||||
response = client.get("/admin/council/export.xlsx")
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Подбор диссовета"]["C4"].value == "технические"
|
||||
assert book["Сотрудники"].max_row == (2 if populated else 1)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -319,10 +368,10 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
|
||||
# The policy permits a type; it does not itself confirm a quartile.
|
||||
assert candidate["confirmed_count"] == (int(admitted) if basis not in {"Q1", "Q2"} else 0)
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
exported = dict(zip(headers, [cell.value for cell in book["Сотрудники"][2]]))
|
||||
assert exported["Всего"] == (int(admitted) if basis in {"Q1", "Q2", "К1"} else 0)
|
||||
assert exported["Всего"] == candidate["total_count"] == int(admitted)
|
||||
publication = dict(zip([cell.value for cell in book["Публикации"][1]], [cell.value for cell in book["Публикации"][2]]))
|
||||
assert publication[basis] == ("Да" if admitted else "Нет")
|
||||
if basis != "RSCI":
|
||||
@@ -366,7 +415,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
|
||||
assert candidate["confirmed_count"] == 1
|
||||
assert full_result["catalog_issues"] and not article["catalog_issues"]
|
||||
assert {r["catalog"] for r in article["catalog_records"]} == {valid_path.parent.name}
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
assert book["Сотрудники"].cell(2, 5 if flag == "q2" else 4).value == 1
|
||||
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
@@ -396,7 +445,8 @@ def test_automatic_source_quartile_calendar_years(db_session, proofs, year, kind
|
||||
if expected:
|
||||
assert article['confirmed_bases'][0]['basis'] == 'Q2'
|
||||
assert not any('Вид материала не подтверждён' in m for m in article['messages'])
|
||||
book = load_workbook(BytesIO(council_xlsx(council.council_candidates(db_session, today=date(2026, 10, 8)))))
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
headers = [cell.value for cell in book['Сотрудники'][1]]
|
||||
exported = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
|
||||
assert exported['Q2'] == candidate['q2_count']
|
||||
@@ -492,3 +542,65 @@ def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_se
|
||||
assert article['display_type'] == 'Статья' and article['types'] == []
|
||||
assert article['warnings']['type'] and 'не указан тип материала' in article['warnings']['type'][0]
|
||||
assert not article['confirmed'] and candidate['confirmed_count'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("specialty,branches", [
|
||||
("", ["технические", "физико-математические"]),
|
||||
("1.1.2", ["физико-математические"]),
|
||||
("2.3.2", ["технические"]),
|
||||
*[(code, ["технические", "физико-математические"]) for code in
|
||||
["1.2.2", "1.3.8", "2.2.2", "2.2.13", "2.2.14", "2.2.15", "2.3.1", "2.3.5", "2.3.6", "2.3.7"]],
|
||||
])
|
||||
def test_specialty_limits_branch_options_and_rejects_invalid_requests(db_session, proofs, specialty, branches):
|
||||
from bs4 import BeautifulSoup
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for branch in assessment.BRANCHES:
|
||||
params = {"specialty": specialty, "branch": branch}
|
||||
page = client.get("/admin/council", params=params)
|
||||
if branch in branches:
|
||||
assert page.status_code == 200
|
||||
options = BeautifulSoup(page.text, "html.parser").select("#council-branch option")
|
||||
assert [option["value"] for option in options] == branches
|
||||
assert next(option["value"] for option in options if "selected" in option.attrs) == branch
|
||||
else:
|
||||
assert page.status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx", params=params).status_code == 422
|
||||
assert client.get(f"/admin/council/employees/{employee.id}/articles", params=params).status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous,specialty,old_branch,new_branch", [
|
||||
("2.3.2", "1.1.2", "технические", "физико-математические"),
|
||||
("1.1.2", "2.3.2", "физико-математические", "технические"),
|
||||
("", "1.1.2", "технические", "физико-математические"),
|
||||
])
|
||||
def test_specialty_change_without_js_uses_compatible_branch(db_session, proofs, previous, specialty, old_branch, new_branch):
|
||||
from bs4 import BeautifulSoup
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
params = {"previous_specialty": previous, "specialty": specialty, "branch": old_branch,
|
||||
"q": "Тест", "sort": "full_name", "direction": "asc"}
|
||||
page = client.get("/admin/council", params=params)
|
||||
assert page.status_code == 200
|
||||
parsed = BeautifulSoup(page.text, "html.parser")
|
||||
assert parsed.select_one('#council-branch option[selected]')["value"] == new_branch
|
||||
assert parsed.select_one('[name=previous_specialty]')["value"] == specialty
|
||||
assert parsed.select_one('[name=q]')["value"] == "Тест"
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx", params=params).content))
|
||||
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
|
||||
for invalid_previous in [None, specialty, "oops"]:
|
||||
invalid = {**params, "previous_specialty": invalid_previous}
|
||||
if invalid_previous is None:
|
||||
del invalid["previous_specialty"]
|
||||
assert client.get("/admin/council", params=invalid).status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx", params=invalid).status_code == 422
|
||||
invalid = {**params, "branch": "экономические"}
|
||||
assert client.get("/admin/council", params=invalid).status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -82,8 +82,15 @@ def test_editor_permissions_csrf_validation_and_missing_article(editor, db_sessi
|
||||
assert db_session.get(CouncilCorrections, 1) is None
|
||||
client.cookies.clear()
|
||||
assert save(client, payload).status_code == 401
|
||||
original_title = db_session.scalar(select(EmployeePublication.title).where(
|
||||
EmployeePublication.employee_id == payload['employee_id']))
|
||||
response = client.get('/admin/council/publications')
|
||||
assert response.status_code == 200 and 'Для редактирования' in response.text
|
||||
assert response.status_code == 200
|
||||
assert original_title in response.text
|
||||
assert 'data-council-edit' not in response.text and 'data-council-dialog' not in response.text
|
||||
fragment = client.get(f"/admin/council/employees/{payload['employee_id']}/articles")
|
||||
assert fragment.status_code == 200 and original_title in fragment.text
|
||||
assert 'data-council-article' not in fragment.text and 'data-council-edit' not in fragment.text
|
||||
|
||||
|
||||
def test_editor_journal_reassignment_year_scope_and_unknown_year(editor, db_session):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.dialects.sqlite import dialect
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
from app.db import _ensure_runtime_schema
|
||||
from app.models import SystemUser
|
||||
|
||||
|
||||
def test_runtime_schema_adds_skipped_count_to_existing_crawl_runs_table(monkeypatch):
|
||||
@@ -142,3 +148,24 @@ def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
|
||||
columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
|
||||
assert "employee_profile_urls" in inspector.get_table_names()
|
||||
|
||||
|
||||
def test_sqlite_council_role_migration_preserves_users_and_history():
|
||||
original = str(CreateTable(SystemUser.__table__).compile(dialect=dialect())).replace(", 'council'", "")
|
||||
with sqlite3.connect(":memory:") as connection:
|
||||
connection.execute(original)
|
||||
connection.execute("CREATE TABLE user_actions (user_id INTEGER REFERENCES system_users(id))")
|
||||
connection.execute("""
|
||||
INSERT INTO system_users
|
||||
(id, username, full_name, role, is_active, password_hash, must_change_password,
|
||||
session_version, failed_login_attempts, created_at)
|
||||
VALUES (17, 'reader', '', 'user', 1, 'fixture', 0, 4, 0, '2026-11-12')
|
||||
""")
|
||||
connection.execute("INSERT INTO user_actions VALUES (17)")
|
||||
before = connection.execute("SELECT * FROM system_users").fetchall()
|
||||
migration = Path("migrations/sqlite/014_system_user_council_role.sql").read_text(encoding="utf-8")
|
||||
connection.executescript(migration)
|
||||
assert connection.execute("SELECT * FROM system_users").fetchall() == before
|
||||
assert connection.execute("SELECT * FROM user_actions").fetchall() == [(17,)]
|
||||
connection.execute("UPDATE system_users SET role = 'council' WHERE id = 17")
|
||||
assert connection.execute("SELECT role FROM system_users").fetchone() == ("council",)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,113 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee
|
||||
from app.parser.profile import extract_sections
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services.admin_data import list_employees_page
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("html", "has_course", "has_recent_course"),
|
||||
[
|
||||
('<p>2024 (11 декабря), НИУ ВШЭ. Программа повышения квалификации «ИИ».</p>', True, True),
|
||||
('<ul><li>Программа повышения квалификации «ДПО», октябрь 2024 года.</li></ul>', True, True),
|
||||
('<p>Курс повышения квалификации «ДПО», 7 сентября 2024 год.</p>', True, True),
|
||||
('<p>Курсы повышения квалификации: «Технологии 2025 г.», 2023г.</p>', True, False),
|
||||
('<p>Программа повышения квалификации «Технологии 2025 г.»</p>', True, False),
|
||||
('<p>2025. Стажировка «Технологии».</p>', False, False),
|
||||
('<p>2025. Стажировка «Повышение квалификации педагогов».</p>', False, False),
|
||||
('<p>2025. Стажировка в Институте повышения квалификации.</p>', False, False),
|
||||
],
|
||||
)
|
||||
def test_training_filter_reads_courses_from_profile_paragraphs_and_lists(
|
||||
db_session, html, has_course, has_recent_course,
|
||||
):
|
||||
url = "https://example.test/employee"
|
||||
soup = BeautifulSoup(
|
||||
f"<h2>Дополнительное образование / Повышение квалификации / Стажировки</h2><div>{html}</div>",
|
||||
"html.parser",
|
||||
)
|
||||
db_session.add(Employee(
|
||||
profile_key="training:text", canonical_url=url,
|
||||
current_data={"sections": extract_sections(soup, url)},
|
||||
))
|
||||
db_session.commit()
|
||||
assert list_employees_page(db_session, has_training=True)["total"] == int(has_course)
|
||||
assert list_employees_page(db_session, training_since_year=2024)["total"] == int(has_recent_course)
|
||||
assert list_employees_page(db_session, training_since_year=2024, has_training=False)["total"] == int(
|
||||
not has_recent_course
|
||||
)
|
||||
|
||||
|
||||
def test_training_filter_uses_course_year_and_kind_before_pagination(db_session):
|
||||
title = "Дополнительное образование / Повышение квалификации / Стажировки"
|
||||
profiles = [
|
||||
(title, "2023", "«Курс 2024», повышение квалификации"),
|
||||
(title, "2024", "«Курс», повышение квалификации, НИУ ВШЭ"),
|
||||
(title, "2025", "«Курс», Повышение квалификации"),
|
||||
(title, "2025", "«Стажировка», стажировка"),
|
||||
("Конференции", "2025", "«Доклад», повышение квалификации"),
|
||||
(title, "", "«Курс без года», повышение квалификации"),
|
||||
]
|
||||
for index, (section_title, year, text) in enumerate(profiles):
|
||||
soup = BeautifulSoup(
|
||||
f'<h2>{section_title}</h2><div><div class="with-indent">'
|
||||
f'<div class="person-list-hangover">{year}</div><span>{text}</span></div></div>',
|
||||
"html.parser",
|
||||
)
|
||||
url = f"https://example.test/{index}"
|
||||
db_session.add(Employee(
|
||||
profile_key=f"training:{index}", canonical_url=url, full_name=f"Person {index}",
|
||||
current_data={
|
||||
"sections": extract_sections(soup, url),
|
||||
"positions": ["Доцент: кафедра"] if index == 1 else ["Инженер"],
|
||||
},
|
||||
))
|
||||
db_session.add(Employee(profile_key="training:6", canonical_url="https://example.test/6", full_name="Person 6"))
|
||||
db_session.commit()
|
||||
|
||||
def names(**filters):
|
||||
return [item["full_name"] for item in list_employees_page(db_session, **filters)["employees"]]
|
||||
|
||||
assert names(has_training=True) == ["Person 0", "Person 1", "Person 2", "Person 5"]
|
||||
assert names(has_training=False) == ["Person 3", "Person 4", "Person 6"]
|
||||
assert names(training_since_year=2024) == ["Person 1", "Person 2"]
|
||||
assert names(training_since_year=2024, has_training=False) == [
|
||||
"Person 0", "Person 3", "Person 4", "Person 5", "Person 6",
|
||||
]
|
||||
assert names(training_since_year=2024, pps=True) == ["Person 1"]
|
||||
assert names(training_since_year=2024, offset=1) == ["Person 2"]
|
||||
assert names(training_since_year=2024, offset=25) == ["Person 1", "Person 2"]
|
||||
|
||||
settings = Settings(_env_file=None, admin_username="admin", admin_password="password", session_secret="test-training")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
response = client.get("/api/employees?has_training=false&training_since_year=2024")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total"] == 5
|
||||
|
||||
response = client.get("/admin/directory?has_training=true&training_since_year=2024&offset=1")
|
||||
assert response.status_code == 200
|
||||
page = BeautifulSoup(response.text, "html.parser")
|
||||
panel = page.select_one(".directory__filter-panel")
|
||||
assert not panel.has_attr("open")
|
||||
assert panel.summary.get_text(strip=True) == "Фильтры · применены"
|
||||
assert panel.select_one('select[name="has_training"] option[selected]')["value"] == "true"
|
||||
assert panel.select_one('input[name="training_since_year"]')["value"] == "2024"
|
||||
assert "Person 1" not in response.text
|
||||
assert "Person 2" in response.text
|
||||
previous = page.select_one(".directory__pagination a")["href"]
|
||||
assert "has_training=true" in previous
|
||||
assert "training_since_year=2024" in previous
|
||||
assert client.get("/admin/directory?has_training=&training_since_year=").status_code == 200
|
||||
assert client.get("/admin/directory?training_since_year=bad").status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
+109
-2
@@ -108,6 +108,7 @@ def test_create_user_first_login_forces_password_change(accounts):
|
||||
assert not user.must_change_password and user.password_changed_at and user.session_version == 1
|
||||
assert user.first_login_at == first_login
|
||||
assert 'data-required-password' not in client.get('/admin/account').text
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory').status_code == 200
|
||||
assert client.get('/admin/users').status_code == 403
|
||||
client.cookies.clear()
|
||||
@@ -125,13 +126,19 @@ def test_regular_user_cannot_start_scraping(accounts, path):
|
||||
assert client.get('/api/stats').status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
|
||||
def test_council_user_edit_audit_and_personal_history_isolation(accounts):
|
||||
client, db, settings, admin, user, employee = accounts
|
||||
act_as(client, user, settings)
|
||||
candidate = council.council_candidates(db)['candidates'][0]
|
||||
article = candidate['articles'][0]
|
||||
data = {'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0,
|
||||
'title': 'Изменённая статья', 'journal': 'Новый журнал', 'quartile': 'Q2', 'specialty_codes': ['1.2.2']}
|
||||
act_as(client, user, settings)
|
||||
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 403
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'council', 'is_active': 'true'},
|
||||
headers=act_as(client, admin, settings), follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.role == 'council' and user.session_version == 1
|
||||
act_as(client, user, settings)
|
||||
response = client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'})
|
||||
assert response.status_code == 200
|
||||
action = db.scalar(select(UserAction).where(UserAction.action == 'card_edit'))
|
||||
@@ -149,6 +156,29 @@ def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
|
||||
def test_navigation_and_run_access_match_role(accounts, role):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
user.role = role
|
||||
db.commit()
|
||||
act_as(client, user, settings)
|
||||
dashboard = BeautifulSoup(client.get('/admin').text, 'html.parser')
|
||||
links = {link['href'] for link in dashboard.select('nav a[href]')}
|
||||
assert {'/admin', '/admin/directory', '/admin/account', '/admin/guide'} <= links
|
||||
assert ('/admin/council' in links) == (role != 'user')
|
||||
assert ('/admin/runs' in links) == (role == 'admin')
|
||||
assert ('/admin/users' in links) == (role == 'admin')
|
||||
assert bool(dashboard.select('[data-progress-panel]')) == (role == 'admin')
|
||||
assert ('latest_run' in client.get('/api/stats').json()) == (role == 'admin')
|
||||
for path in ('/admin/runs', '/api/crawl-runs', '/api/crawl-runs/latest', '/api/crawl-runs/1'):
|
||||
assert client.get(path).status_code == (200 if role == 'admin' else 403)
|
||||
assert client.get('/admin/runs/1', follow_redirects=False).status_code == (303 if role == 'admin' else 403)
|
||||
client.cookies.clear()
|
||||
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/login'
|
||||
assert client.get('/admin/council').status_code == 200
|
||||
assert client.get('/docs').status_code == 404
|
||||
|
||||
|
||||
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
user.password_changed_at = utcnow()
|
||||
@@ -247,6 +277,8 @@ def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
|
||||
|
||||
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
|
||||
client, db, settings, _, user, employee = accounts
|
||||
user.role = 'council'
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
|
||||
@@ -286,6 +318,81 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
|
||||
db.refresh(user)
|
||||
assert user.password_hash == before and user.session_version == 0
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
|
||||
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
user.role = role
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
data = {'current_password': 'Initial user password', 'new_password': 'New onboarding passphrase',
|
||||
'confirmation': 'New onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
page = client.get('/admin/account')
|
||||
assert 'data-guide-pending="true"' in page.text
|
||||
assert 'data-required-password' not in page.text
|
||||
assert page.headers['Cache-Control'] == 'no-store'
|
||||
guide = client.get('/admin/guide')
|
||||
parsed = BeautifulSoup(guide.text, 'html.parser')
|
||||
assert len(parsed.select('[data-guide-step]')) == (2 if role == 'user' else 6)
|
||||
assert not parsed.select('[data-guide]')
|
||||
assert ('Прочитайте показатели сотрудника' in guide.text) == (role != 'user')
|
||||
assert ('Проверьте строку публикации' in guide.text) == (role != 'user')
|
||||
assert ('управлять доступом' in guide.text) == (role == 'admin')
|
||||
assert ('не ограничивает «ВСЕГО»' in guide.text) == (role != 'user')
|
||||
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide]')['data-guide-csrf']
|
||||
assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/directory').text
|
||||
for _ in range(2):
|
||||
response = client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': csrf, 'Accept': 'application/json'}, follow_redirects=False)
|
||||
assert response.status_code == 204
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_finish')).all()) == 1
|
||||
client.cookies.clear()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin').text
|
||||
data = {'current_password': data['new_password'], 'new_password': 'Second onboarding passphrase',
|
||||
'confirmation': 'Second onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
assert client.get('/admin/guide').status_code == 200
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_start')).all()) == 1
|
||||
|
||||
|
||||
def test_guide_access_and_no_javascript_completion(accounts):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
assert not BeautifulSoup(client.get('/admin/council').text, 'html.parser').select('[data-guide]')
|
||||
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login'
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False).headers['location'] == '/admin/account'
|
||||
user.must_change_password = False
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
page = BeautifulSoup(client.get('/admin').text, 'html.parser')
|
||||
assert page.select_one('[data-guide-fallback] a[href="/admin/guide"]')
|
||||
response = client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers['location'] == '/admin'
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
|
||||
|
||||
def test_guide_dismissal_failure_does_not_mark_it_finished(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
def unavailable():
|
||||
raise OperationalError('INSERT', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/guide/dismiss', headers={**headers, 'Accept': 'application/json'})
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_finish')) is None
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/account').text
|
||||
|
||||
|
||||
def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts):
|
||||
|
||||
Reference in new issue
Block a user