Compare commits
122
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 | ||
|
|
96987e90c9 | ||
|
|
a82f63d6cb | ||
|
|
03ac2f5ee8 | ||
|
|
a7de80de3b | ||
|
|
e08c30848f | ||
|
|
2554cbf39a | ||
|
|
c58bb8d626 | ||
|
|
f4fb966b78 | ||
|
|
9910fc77d6 | ||
|
|
f45c66e069 | ||
|
|
2e0c71367d | ||
|
|
194ed30dd4 | ||
|
|
7a51521abf | ||
|
|
5cc7a2dc52 | ||
|
|
b777bb9e16 | ||
|
|
2484bad0fa | ||
|
|
33f5a61865 | ||
|
|
c548998522 | ||
|
|
2e48292a32 | ||
|
|
ec9a03fcf2 | ||
|
|
a3f4a5b321 | ||
|
|
fba963e110 | ||
|
|
24d07706e1 | ||
|
|
44fadd7289 | ||
|
|
f851c62b8a | ||
|
|
86f0ee1137 | ||
|
|
00c818e681 | ||
|
|
76bcb0db35 | ||
|
|
74f32a3e67 | ||
|
|
dffe1afb14 | ||
|
|
9f5e508b47 | ||
|
|
881aba6b35 | ||
|
|
675a3b5c6d | ||
|
|
7eaed9aa8a | ||
|
|
d92338d252 | ||
|
|
64618354be | ||
|
|
ff11da2a56 | ||
|
|
6bcef05d27 | ||
|
|
2bfed87f98 | ||
|
|
8ab36a7f52 | ||
|
|
784dd68a3b | ||
|
|
a56fb723dd | ||
|
|
5bda837ee2 | ||
|
|
9324639c58 | ||
|
|
2a99ff4df2 | ||
|
|
30ea1c9257 | ||
|
|
0af278247a | ||
|
|
2dc63098e4 | ||
|
|
339dd71f63 | ||
|
|
4aeb26abe9 | ||
|
|
6bd1f31f3a | ||
|
|
32e26ccdd2 | ||
|
|
fac0431a03 | ||
|
|
ed9d0b48c9 | ||
|
|
c6c6866235 | ||
|
|
66c8e9e0c3 | ||
|
|
f39b5dd512 | ||
|
|
b8a27758b6 | ||
|
|
c975e7418b | ||
|
|
4c609cc7ea | ||
|
|
8adaa4d6ea | ||
|
|
e0f9ab7411 | ||
|
|
25eb6481f7 | ||
|
|
72afb31049 | ||
|
|
05a62d18e6 | ||
|
|
111ee15289 | ||
|
|
cb15d11df4 | ||
|
|
07bd42927b | ||
|
|
e49bcf6ba6 | ||
|
|
4cf6cba77c | ||
|
|
9fc7de113a | ||
|
|
69c1dd52cf | ||
|
|
b933f95302 | ||
|
|
384cd550d5 | ||
|
|
5c3883beca | ||
|
|
144e62a35e | ||
|
|
8102556862 | ||
|
|
711c6ae7b0 | ||
|
|
580df29849 | ||
|
|
186f2d0804 | ||
|
|
e4e64b92c1 | ||
|
|
aa43da2857 | ||
|
|
bee3dc9848 | ||
|
|
d95133e257 | ||
|
|
2bf6ff570c | ||
|
|
e58156fbeb | ||
|
|
ddb3ad1a72 |
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
app/data/council_articles.csv text eol=lf
|
||||
app/data/council_articles.json text eol=lf
|
||||
app/data/k1_journals.json text eol=lf
|
||||
app/data/k1_specialties.json text eol=lf
|
||||
+42
-6
@@ -6,6 +6,11 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diagnostic_only:
|
||||
description: 'Print key fingerprint; production deployment is temporarily disabled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -23,9 +28,16 @@ jobs:
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pytest -q
|
||||
- run: node --check app/static/admin.js
|
||||
- run: node --check app/static/council.js
|
||||
- run: node --check app/static/users.js
|
||||
- run: node --check app/static/guide.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
deploy:
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
||||
# Production deployment is disabled until separate Workers deployment access is installed.
|
||||
name: Fingerprint diagnostics (production deployment temporarily disabled)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.diagnostic_only == 'true'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
@@ -37,9 +49,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare release
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
set +x
|
||||
set -euo pipefail
|
||||
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
|
||||
case "$backend_version" in
|
||||
''|*[!0-9.]*) exit 1 ;;
|
||||
esac
|
||||
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
|
||||
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
|
||||
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
|
||||
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
|
||||
@@ -47,7 +66,15 @@ jobs:
|
||||
env:
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }}
|
||||
- name: Deployment key fingerprint
|
||||
shell: bash
|
||||
run: |
|
||||
set +x
|
||||
set -euo pipefail
|
||||
trap 'rm -f "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"' EXIT
|
||||
ssh-keygen -y -P '' -f "$RUNNER_TEMP/deploy_key" | ssh-keygen -lf - -E sha256 | awk '{print $2}'
|
||||
- name: Upload release
|
||||
if: ${{ false }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -60,6 +87,7 @@ jobs:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
- name: Deploy and verify
|
||||
if: ${{ false }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -67,22 +95,30 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$PROD_USER@$PROD_HOST" \
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' bash -s" <<'REMOTE'
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
cd "$PROD_PATH"
|
||||
release="$HOME/$RELEASE_NAME"
|
||||
mkdir -p "$HOME/backups/miem_workers"
|
||||
backup="$HOME/backups/miem_workers/miem_workers-$(date +%Y%m%d-%H%M%S).sql"
|
||||
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > "$backup"
|
||||
test -f "$release"
|
||||
archive_version="$(tar -xOf "$release" ./app/version.py | sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p')"
|
||||
test "$archive_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
docker run --rm \
|
||||
-v "$release:/src/release.tar:ro" \
|
||||
-v "$PROD_PATH":/dst \
|
||||
alpine:3.20 sh -c 'tar -xf /src/release.tar -C /dst'
|
||||
sh /srv/miem_workers/infra/install-postgres-backup-cron.sh
|
||||
docker compose up -d --build
|
||||
curl --fail --silent --show-error http://127.0.0.1:8000/api/health
|
||||
runtime_version="$(docker compose exec -T api python -c 'from app.version import BACKEND_VERSION; print(BACKEND_VERSION)')"
|
||||
test "$runtime_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
health="$(curl --fail --silent --show-error http://127.0.0.1:8000/api/health)"
|
||||
case "$health" in
|
||||
*"\"status\":\"ok\""*"\"backend_version\":\"$EXPECTED_BACKEND_VERSION\""*) ;;
|
||||
*) echo "Unexpected health response: $health" >&2; exit 1 ;;
|
||||
esac
|
||||
docker compose ps
|
||||
rm -f "$release"
|
||||
REMOTE
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
|
||||
@@ -9,3 +9,5 @@ pytest-cache-files-*/
|
||||
htmlcov/
|
||||
postgres_data/
|
||||
MCP_DESCRIPTION.md
|
||||
|
||||
.impeccable/review/
|
||||
+148
@@ -1,5 +1,153 @@
|
||||
# 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 не превращается в предварительное совпадение.
|
||||
- Квартильные основания привязаны к собственному DOI и журналу. После изменения публикации парсером старые основания сохраняются, но исключаются из счётчиков при конфликте; обновление общего результата поиска не перепривязывает старые записи.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Добавлено поле `vak_categories` с результатом проверки журнала по предоставленным спискам К1/К2, источником и способом сопоставления. Совпадение по названию отмечается отдельно от подтверждённого ISSN; текущий снимок не объявляется исторической категорией.
|
||||
- Инструмент обогащения сохраняет все доступные квартильные категории всех систем, включая дополнительные сведения для публикаций с уже известным квартилем.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
- Добавлено отдельное поле публикации `quartiles` с квартилями Q1–Q4, системой, годом метрики и источником. Данные ВШЭ переносятся без удаления исходных значений; новые сведения сопоставляются по DOI и ISSN.
|
||||
- Добавлена возобновляемая проверка DOI и обогащение по квартильным каталогам: при отсутствии исторической метрики можно явно разрешить последний доступный год. Недоступные сведения и конфликты фиксируются отдельно; текущий квартиль не подменяет исторический зачёт в совете.
|
||||
- Состав полей публикации, миграция и порядок обогащения описаны в документации.
|
||||
|
||||
## 0.16.5
|
||||
|
||||
- Закреплён обязательный перенос недостающих данных перед будущим production-выпуском: резервная копия, предварительная сверка, сохранение заполненных значений, отдельная обработка конфликтов и проверка полноты до обновления приложения.
|
||||
|
||||
- Источники публикаций названы «Данные ВШЭ» и «ЛК сотрудника ВШЭ» на странице совета и в XLSX. Счётчик личного кабинета не включает записи, полученные только из выгрузки ВШЭ.
|
||||
- Из выгрузки ВШЭ удалены 797 строк, однозначно найденных у всех указанных авторов в локальной БД workers; метаданные сохранены в 1226 существующих записях, резервные копии созданы. Неоднозначные совпадения и неполные связи авторов оставлены.
|
||||
|
||||
## 0.16.4
|
||||
|
||||
- Вступление страницы совета и пояснения занимают всю доступную ширину своих блоков; убрана flex-разметка заголовка и ограничения ширины текста.
|
||||
|
||||
## 0.16.3
|
||||
|
||||
- Уточнены описание страницы совета и пояснения к источникам данных, предварительным счётчикам, подтверждённым работам и подбору по специальности.
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Страница совета и XLSX настроены на технические науки и пятилетний период относительно текущей даты. Убраны дата ходатайства, сведения о монографиях и связанные причины проверки.
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Ручные основания больше не возвращают книги и другие недопущенные виды в предварительные счётчики совета. Дополнительные виды учитываются только при подтверждённой разрешающей политике и согласованном виде материала.
|
||||
- Повреждение одного квартильного каталога не блокирует метрики исправного второго. Общее предупреждение о снимке отделено от проблем конкретной публикации; страница и XLSX сохраняют доступные проверенные сведения.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
- Проверка публикационных критериев совета расширена на кандидатов наук и PhD. Выбирается высшая степень из истории; сотрудники без публикаций остаются видимыми. Добавлены дата ходатайства, отрасль оценки, пороги пунктов 5–6 и отдельный минимум монографий.
|
||||
- Предварительные числа отделены от подтверждённых работ и итогового заключения. Недостаток доказательств, пограничные даты и конфликты требуют проверки. Документальные подтверждения хранятся отдельно от результатов парсинга; без подтверждения степени и обязательного контекста положительное заключение не формируется.
|
||||
- Добавлены справочник К2, отдельное основание RSCI, два тематических каталога Q1/Q2 и проверки целостности паспортов. К2 самостоятельно допустим только для доктора. Снимки метрик не распространяются на другие годы; аналитическая связь журнала и рассмотрение статьи показываются отдельно.
|
||||
- Страница и XLSX используют единый расчёт, сохраняют контекст при поиске и переходах и показывают пороги, подтверждения, источники и причины проверки. Подбор специальности по К1 обозначен как вспомогательный; версии backend/frontend повышены до 0.16.0.
|
||||
|
||||
## 0.15.4
|
||||
|
||||
- Исправлена аналогичная ошибка SHA-256 в справочнике специальностей К1: хеш исходного JSON рассчитывается с LF, оба генератора справочников записывают LF, формат закреплён в Git. Добавлена проверка одинаковой генерации из LF/CRLF; журналы и связи со специальностями не изменены.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Исправлена воспроизводимость справочника публикаций на Windows и Linux: при подготовке CSV окончания CRLF нормализуются в LF, JSON записывается с LF, а SHA-256 совпадает с CSV из Git. Исходный CSV не перезаписывается; проверка воспроизводимости сохранена и дополнена тестом обоих форматов строк.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Портал использует подготовленный читаемый JSON из 1797 публикаций и индекс 825 ФИО. CSV сохранён для проверки и импорта; преобразование выполняется отдельным атомарным скриптом, а готовые данные кэшируются на процесс. Номера строк, источники и правила сопоставления сохранены.
|
||||
- При подготовке кандидатов в диссертационный совет докторская степень проверяется до разбора публикаций сотрудника, исключая повторную обработку данных неподходящих сотрудников.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- В XLSX диссертационного совета добавлены отдельные колонки с кодами и названиями подтверждённых специальностей ВАК для сотрудников и публикаций, включая выгрузку без фильтра. При выборе специальности показаны число подходящих работ сотрудника и отметка соответствия каждой публикации.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- На странице «Диссертационный совет» добавлен подбор докторов наук по 12 специальностям ВАК: учитывается хотя бы одна публикация за последние пять лет в релевантном журнале К1. Показаны основания соответствия и количество подходящих работ; поиск, пагинация и XLSX сохраняют выбранную специальность.
|
||||
- Добавлен справочник связей всех 773 журналов К1 со специальностями из Перечня ВАК на 15.09.2026, с ISSN, страницами, строками источника и датами назначений. Исключены истёкшие назначения; одинаковое название «Нефтегазовое дело» для двух ISSN помечено как неоднозначное и не используется в автоматическом подборе.
|
||||
- Добавлен воспроизводимый скрипт извлечения и проверенные варианты названий. Сервис читает готовый JSON без обработки PDF при запросах.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Из XLSX диссертационного совета удалён столбец «Статус». Колонка «В БД» переименована в «Страница ВШЭ» на листах «Сотрудники» и «Публикации»; расчёт значений сохранён.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Исправлено сопоставление К1 для переименованного журнала: действующее и явно указанное в исходном PDF прежнее название распознаются как один журнал. Административное примечание хранится отдельно от названия; конфликтующие алиасы не присваиваются автоматически.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- В списке кандидатов в диссертационный совет и XLSX заменён «CSV по критериям» на Q1, Q2, К1 и ВСЕГО за последние пять лет; ВСЕГО учитывает объединение категорий без дублей.
|
||||
- Добавлен извлечённый справочник 773 журналов К1 из предоставленного PDF; PDF не читается при запросах. Названия журналов берутся из данных ВШЭ или необязательного поля CSV.
|
||||
- Сортировка поддерживает новые счётчики. При расхождении годов источников критерии проверяются по году каждой записи, независимо от их порядка; расхождение отмечено в перечне публикаций.
|
||||
|
||||
## 0.8.15
|
||||
|
||||
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
|
||||
|
||||
## 0.8.14
|
||||
|
||||
- Production CD больше не создаёт backup при каждом deploy; еженедельная задача
|
||||
сохраняет один полный PostgreSQL backup с атомарной заменой; ошибки и
|
||||
пропущенные из-за lock запуски пишутся в system log.
|
||||
|
||||
## 0.8.12
|
||||
|
||||
- Передача версии в production deploy ограничена безопасным форматом.
|
||||
|
||||
## 0.8.11
|
||||
|
||||
- CSS-ассеты админки получают версию в URL и обновляются после релиза без ручной очистки кэша браузера.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
- Production deploy проверяет совпадение версии release-архива и запущенного API.
|
||||
|
||||
## 0.8.9
|
||||
|
||||
- Контейнеры API, worker и PostgreSQL автоматически перезапускаются после остановки или перезагрузки хоста.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Исправлено вертикальное выравнивание кнопки и ссылки в действиях фильтров справочника.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
- Production CD оставляет только последний PostgreSQL backup после успешного деплоя.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
- Исправлена загрузка release-архива production CD в домашний каталог deploy-пользователя.
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
# MIEM Employees Server
|
||||
|
||||
# MIEM Employees Server
|
||||
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
|
||||
|
||||
## Архитектура
|
||||
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и поменяйте секреты:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Админка: `http://localhost:8000/admin`.
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
@@ -52,77 +52,139 @@ uvicorn app.main:app --reload
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
|
||||
## Пользователи и личный кабинет
|
||||
|
||||
`/admin/users` доступен только администратору. Здесь можно создавать учётные записи, назначать роль, включать и отключать доступ, сбрасывать пароль и смотреть историю входов.
|
||||
|
||||
- **Пользователь** — обзор, сотрудники, личный кабинет и подсказки по справочнику.
|
||||
- **Пользователь диссовета** — те же разделы плюс «Диссертационный совет», подсказки по нему и редактирование публикаций.
|
||||
- **Администратор** — все разделы, включая «Запуски» и управление пользователями. Список, подробности и текущий прогресс запусков ограничены этой ролью в HTML и API. Остальные роли получают в `/api/stats` сводку по сотрудникам.
|
||||
|
||||
«Диссертационный совет», просмотр его публикаций и XLSX доступны публично. Открытая интерактивная документация API отключена. Для мониторинга доступен `/api/health`.
|
||||
|
||||
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
|
||||
|
||||
При первом успешном входе и после сброса требуется задать свой пароль (15–128 символов, можно фразу), указав текущий пароль. Попап нельзя закрыть Escape или нажатием на фон; можно сменить пароль либо выйти. Без JavaScript доступна обычная форма, а серверные операции до смены пароля заблокированы. Начальный администратор также меняет пароль при первом входе после обновления.
|
||||
|
||||
`/admin/account` показывает первый/последний вход, смену пароля и историю действий. Каждый видит только свои действия и события, относящиеся к его учётной записи; администратор на странице пользователей видит общую историю или фильтр по учётной записи. Журнал фиксирует успешные входы, смену/сброс пароля, изменения ролей/доступа и реальные правки карточек: сотрудник, статья, год, прежние/новые значения названия, журнала, квартиля и тематической связи. Сохранение правки и запись истории — одна транзакция; отклонённые и неизменённые сохранения не создают запись о редактировании. История разбита на страницы по 30 записей.
|
||||
|
||||
Сессия ограничена 12 часами. Смена/сброс пароля, изменение роли/доступа и выход отзывают прежние сессии. Собственную роль/доступ и пароль через административный сброс менять нельзя: свой пароль меняется в личном кабинете. Изменения доступа сериализуются, чтобы параллельные администраторы не лишили систему всех администраторов. После пяти неверных входов учётная запись блокирует новые входы на 15 минут; административный сброс снимает это ограничение.
|
||||
|
||||
Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.
|
||||
|
||||
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. При переходе с 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 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`.
|
||||
|
||||
`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`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Парсинг
|
||||
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие 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`.
|
||||
|
||||
`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. Явно пустая отрасль (`branch=`) также отклоняется для списка, карточки и XLSX; отсутствие параметра сохраняет значение по умолчанию «технические».
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
Начальный список диссовета содержит только сотрудников и счётчики. Публикации загружаются при раскрытии карточки через `/admin/council/employees/{id}/articles` с сохранением отрасли и специальности; повторное раскрытие использует уже загруженные данные текущей страницы. При ошибке доступна кнопка повтора, без JavaScript — ссылка на публикации. Экспорт XLSX продолжает включать все найденные записи.
|
||||
|
||||
До загрузки публикаций применяется поиск по ФИО и выбор конкретного сотрудника. Проверка одинаковых ФИО остаётся глобальной, а общий счётчик сотрудников не зависит от поиска. PostgreSQL возвращает только разделы профиля об образовании/степенях и необходимые поля публикаций; аннотации, авторы и остальной профиль не загружаются. Plain-text описания журналов не проходят через HTML-парсер; HTML и сущности обрабатываются как прежде.
|
||||
|
||||
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
|
||||
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Пользователь диссовета или администратор может выбрать карточку статьи мышью либо клавишами Enter или пробел и открыть форму редактирования названия, журнала и квартиля. Та же форма доступна в карточках основного списка.
|
||||
|
||||
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
|
||||
|
||||
Расчёт использует пять календарных лет, включая текущий год Москвы: в 2026 году — 2022–2026. Точные день и месяц для периода не нужны; конфликт годов остаётся причиной проверки. В форме фильтров выбирается отрасль оценки, которая задаёт порог для доктора (9 для технических, 11 для физико-математических и т. д.); для кандидата/PhD минимум 20. Выбранная отрасль сохраняется при переходах страниц и выгрузке XLSX, а все пороги вынесены в пояснения над списком.
|
||||
|
||||
Статья с известным видом материала, журналом и квартилем Q1/Q2 из источника, применимым к году публикации, автоматически входит в «Подтверждено», если год входит в период и нет конфликтов идентичности, вида и годов. Неизвестный вид, отсутствующий журнал, квартиль другого года или ручная замена квартиля без источника не дают автоматического зачёта. Подтверждение степени и рассмотрение содержания статьи по специальности оцениваются отдельно.
|
||||
|
||||
Редактор позволяет заменить связь журнала с одной или несколькими из доступных специальностей. Ручная связь применяется ко всем статьям журнала независимо от года; в фильтр попадают допустимые публикации за календарный период. Пустой ручной список исключает связь, переключатель «Использовать связь из справочников» восстанавливает справочные данные. Связь тематики журнала не заменяет рассмотрение содержания статьи. Ручные специальности сохраняются в той же таблице, в разделе `specialties`; дополнительная миграция не нужна.
|
||||
|
||||
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
|
||||
|
||||
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
|
||||
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
|
||||
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX. Первый лист «Подбор диссовета» работает в Excel Microsoft 365 / Excel 2021 и новее без макросов: выбор специальности ВАК, допустимой отрасли, поиск по ФИО, фильтры по степени и статусу, сортировка по ФИО и счётчикам. Начальные фильтры взяты с сайта. На первом листе ссылка «Открыть памятку по расчёту и использованию» ведёт к инструкции под списком: определения счётчиков и статусов, правило К2, ограничения специальностей и источников, различия снимка XLSX и сайта, работа с формулами. Из памятки можно вернуться к фильтрам. При несовместимой отрасли после смены специальности применяется первая допустимая; она показана рядом. Список показывает Q1/Q2/К1/К2/RSCI, «Всего», подтверждённый зачёт, минимум и статус; ссылки ведут на публичные профили ВШЭ. Для переключения фильтров в файл включены все сотрудники с учёной степенью, даже если страница сайта была отфильтрована. Компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов) также содержат полный набор данных, а правила и исходные фильтры вынесены в «Параметры». Служебные данные и справочники находятся на скрытых листах. Внешние ссылки проверяются; исходные строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся вспомогательным подбором, а не условием соответствия пунктам 5–6. XLSX хранит снимок на момент выгрузки: новые данные и ручные подтверждения требуют повторной выгрузки; изменения фильтров в Excel не отправляются на сайт.
|
||||
|
||||
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» совпадает с предварительным счётчиком сайта: уникальное объединение Q1/Q2/К1/RSCI, для доктора наук также К2. Для кандидата/PhD самостоятельный К2 исключается; работа с другим подходящим основанием учитывается один раз. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
|
||||
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
|
||||
- новые сотрудники добавляются в `employees`;
|
||||
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
|
||||
- старые URL сохраняются в истории `employee_profile_urls`;
|
||||
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
|
||||
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
|
||||
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
|
||||
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
|
||||
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
|
||||
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
|
||||
- новые сотрудники добавляются в `employees`;
|
||||
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
|
||||
- старые URL сохраняются в истории `employee_profile_urls`;
|
||||
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
|
||||
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
|
||||
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
|
||||
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
|
||||
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
|
||||
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
|
||||
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
|
||||
- кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в `active` без обновления содержимого профиля;
|
||||
- каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`;
|
||||
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
|
||||
|
||||
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
|
||||
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. Перед обновлением workflow сохраняет PostgreSQL backup в домашний каталог deploy-пользователя, пересобирает `api` и `worker` и проверяет `/api/health`. Одновременно выполняется только один production deploy.
|
||||
Развёртывание на новом 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. Этот job относится к диагностике ключа. Выбор и публикация production-релиза выполняются в infra-platform.
|
||||
|
||||
## Проверки
|
||||
|
||||
@@ -135,4 +197,103 @@ node --check app/static/admin.js
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.8.6`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.18.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
|
||||
|
||||
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
|
||||
|
||||
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
|
||||
|
||||
### Подбор по специальностям ВАК
|
||||
|
||||
На странице `/admin/council` доступны 12 специальностей. При выборе специальности
|
||||
показаны доктора наук с хотя бы одной учитываемой за последние пять лет публикацией
|
||||
в подходящем журнале К1. Публикации без года или названия журнала и материалы с
|
||||
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
|
||||
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
|
||||
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
|
||||
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
|
||||
подтверждённых специальностей, включая выгрузку без фильтра. Число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации в компактный XLSX не включаются.
|
||||
|
||||
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
|
||||
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
|
||||
назначений и SHA-256 источников. Специальности взяты из «ВАК Перечень.pdf» на
|
||||
15.09.2026; категория К1 — из отдельного предоставленного справочника. Это связь по
|
||||
журналу, без оценки содержания статьи или исторической специальности на дату её
|
||||
публикации. Одинаковое название «Нефтегазовое дело» относится к двум ISSN: связь
|
||||
помечена как неоднозначная и не используется для автоматического подбора.
|
||||
Неполные даты начала из исходного PDF сохранены без восстановления даты; текущее
|
||||
назначение определяется по отсутствию истёкшего срока окончания в снимке перечня.
|
||||
|
||||
Для воспроизводимого обновления нужен `pdfplumber` только в среде разработки:
|
||||
|
||||
```sh
|
||||
python scripts/extract_k1_specialties.py "app/data/ВАК Перечень.pdf"
|
||||
```
|
||||
|
||||
Скрипт проверяет последовательность всех 3231 строк. Проверенные различия названий
|
||||
сохранены в `scripts/k1_specialty_matches.json`; каждое сопоставление проверяется по
|
||||
номеру строки и точному исходному названию. Нечёткий поиск не применяется.
|
||||
Сервис читает готовый JSON и не требует PDF или `pdfplumber` при запуске. Хеш
|
||||
`k1_source_sha256` рассчитывается для исходного JSON с окончаниями строк LF; оба
|
||||
JSON-справочника записываются с LF для воспроизводимости на Windows и Linux.
|
||||
|
||||
### Квартили публикаций и схема БД
|
||||
|
||||
Полный состав полей `employee_publications`, структура отдельного поля `quartiles`, ограничения сопоставления и команды обогащения описаны в [docs/publication-database.md](docs/publication-database.md). Для существующей БД добавлены nullable JSONB-поля миграциями `010_publication_quartiles.sql` и `011_publication_vak_categories.sql`; данные переносятся отдельным инструментом, а не при старте приложения.
|
||||
|
||||
Квартили Q1–Q4 из данных ВШЭ сохраняются с исходной системой; неизвестный год метрики остаётся неизвестным. Для остальных публикаций журнал устанавливается по DOI, рейтинг — по ISSN. Исторические SJR доступны через открытые данные университета Эстремадуры за 2009–2025; CiteScore/JCR читаются из проверенных каталогов. При отсутствии метрики за год публикации допускается явно разрешённый последний доступный год, с сохранением его года и источника. Отсутствие DOI, неоднозначность и недоступная метрика фиксируются без присвоения вымышленного квартиля. Парсер сохраняет новое поле при обновлении записи.
|
||||
|
||||
### Подготовка публикаций для портала
|
||||
|
||||
Портал читает `app/data/council_articles.json`: публикации представлены отдельными
|
||||
объектами с названием, годом, DOI, массивами авторов, квартилей и журналов.
|
||||
Дополнительные поля исходного экспорта сохранены в `metadata`; `source_row` связывает
|
||||
запись со строкой исходного CSV. `by_author` — готовый индекс нормализованных ФИО
|
||||
со ссылками на публикации, без копирования одной работы для каждого соавтора.
|
||||
|
||||
Исходный `app/data/council_articles.csv` сохранён для проверки и отдельного импорта
|
||||
в БД. Данные файла и индекс кэшируются на процесс, CSV не преобразуется при
|
||||
HTTP-запросах. В интерфейсе и XLSX используются названия «Данные ВШЭ» (выгрузка из базы публикаций ВШЭ) и «ЛК сотрудника ВШЭ» (публикации из личного кабинета). Номера строк выгрузки сохраняются для проверки источника. Показываются сотрудники с обнаруженной или подтверждённой степенью доктора, кандидата наук или PhD.
|
||||
|
||||
При сверке 08.10.2026 с локальной БД workers из исходной выгрузки удалены 797 строк, однозначно найденных у всех указанных авторов. Их метаданные перенесены в `raw_data.csv_enrichment` 1226 существующих публикаций; парсер сохраняет это обогащение при обновлении записи. Выгрузка теперь содержит 1000 строк и 776 ФИО. Резервные копии исходной выгрузки, JSON и записей БД сохранены в `.impeccable/review/hse-source-backup/` и `.impeccable/review/workers-enrichment-backup.json` (не публикуются в Git). Строки с конфликтами или неполными связями авторов сохранены. Для другого экземпляра БД перенос метаданных из исходной выгрузки необходимо выполнить до использования сокращённого файла: сам Git-коммит данные БД не переносит.
|
||||
|
||||
### Обязательный перенос данных перед выпуском в production
|
||||
|
||||
Production пока остаётся на текущей стабильной версии; локальная БД может продолжать обогащаться. Перед будущим обновлением production до версии из этого PR **обязательно перенести недостающие данные из локальной БД, актуальной на момент выпуска**. Наличие самой публикации в production не заменяет перенос её метаданных, включая квартильные сведения в `raw_data.csv_enrichment`. Обновление Git эти данные не переносит. Полная замена production-БД локальной копией недопустима.
|
||||
|
||||
Порядок выпуска:
|
||||
|
||||
1. Зафиксировать актуальный снимок локальных данных и создать проверенную резервную копию production с возможностью восстановления. Сверку и перенос проводить с учётом параллельных изменений: перед записью повторно проверить значения назначения в транзакции.
|
||||
2. Выполнить предварительное сравнение без записи. Сотрудников сопоставлять по устойчивому идентификатору профиля; публикации — внутри соответствующего сотрудника по идентификатору источника или нормализованному DOI. Сопоставление по названию и году разрешено только при однозначном совпадении без конфликтующих идентификаторов. Одного совпадения ФИО недостаточно. Сформировать перечни новых записей, недостающих значений и конфликтов.
|
||||
3. Добавлять только отсутствующие записи и значения полей. Заполненные значения production не перезаписывать и не удалять. В JSON-объектах добавлять недостающие ключи, включая вложенные ключи `raw_data.csv_enrichment`, сохраняя остальные данные. Не заменять целиком заполненные JSON-объекты или массивы локальными значениями.
|
||||
4. Считать отсутствующим значением SQL `NULL`, отсутствующий JSON-ключ или JSON `null`; для текстовых полей также пустую строку либо строку только из пробельных символов. Числа `0`, логическое `false`, пустые массивы `[]` и объекты `{}` не считать отсутствующими значениями. В существующем объекте `{}` можно добавлять недостающие ключи без замены самого объекта. Некорректное заполненное значение — конфликт для отдельной проверки, а не разрешение на автоматическую перезапись.
|
||||
5. Неоднозначные совпадения и различия заполненных значений фиксировать в отдельном отчёте и рассматривать вручную. Повторный перенос того же снимка должен быть безопасным: не создавать дубли и не изменять уже перенесённые значения. Проверять уникальность связей публикации с сотрудником и повторять сопоставление перед добавлением записи.
|
||||
6. После переноса сверить запланированные и фактически перенесённые записи и значения, проверить метаданные и показатели страницы/XLSX. Убедиться, что удалённые из справочника публикации доступны в production со всеми необходимыми данными и что счётчики не снизились из-за пропущенного переноса. Конфликты, мешающие подтвердить полноту переноса, разрешить до выпуска. **Обновлять приложение и использовать сокращённый справочник только после успешной проверки миграции.**
|
||||
|
||||
Миграция относится к будущему production-выпуску; в рамках исправления этого PR она не выполняется.
|
||||
|
||||
### Пересоздание справочника публикаций
|
||||
|
||||
После обновления выгрузки ВШЭ пересоздайте JSON до сборки/развёртывания сервиса:
|
||||
|
||||
```sh
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
```
|
||||
|
||||
Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс,
|
||||
сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON
|
||||
атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV
|
||||
не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
|
||||
### Подсказки первого входа
|
||||
|
||||
После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
|
||||
|
||||
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+244
-34
@@ -1,14 +1,19 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from copy import deepcopy
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
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
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction
|
||||
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, 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,
|
||||
format_admin_datetime,
|
||||
@@ -17,18 +22,25 @@ from app.services.admin_data import (
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.services.council import CouncilInputError, council_candidates, _journal_key
|
||||
from app.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.filters["admin_datetime"] = format_admin_datetime
|
||||
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_admin(request, settings)
|
||||
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()
|
||||
@@ -38,25 +50,48 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
response = _render(request, "login.html", {"error": None, "csrf_token": csrf})
|
||||
response.set_cookie(LOGIN_CSRF_COOKIE, csrf, httponly=True, samesite="strict", max_age=600, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
username: str = Form(..., max_length=64),
|
||||
password: str = Form(..., max_length=1024),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
try:
|
||||
user = authenticate(db, username, password, settings)
|
||||
if user is None:
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль, либо вход временно недоступен",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=401)
|
||||
db.add(UserAction(user_id=user.id, action="login"))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "login.html", {"error": "Вход временно недоступен. Попробуйте ещё раз",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=503)
|
||||
redirect = RedirectResponse("/admin/account" if user.must_change_password else "/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite="lax", max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
redirect.delete_cookie(LOGIN_CSRF_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
def logout(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = optional_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if user is not None:
|
||||
try:
|
||||
user.session_version += 1
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось завершить сессии. Попробуйте ещё раз")
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
@@ -67,14 +102,15 @@ def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -82,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,
|
||||
@@ -89,7 +128,7 @@ def directory(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
@@ -103,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,
|
||||
@@ -123,23 +165,184 @@ 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"],
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
branch=branch, 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": can_use_council(authenticated), **result})
|
||||
|
||||
|
||||
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
||||
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
|
||||
if not result["candidates"]:
|
||||
raise HTTPException(404, "Сотрудник не найден или не соответствует фильтру. Обновите список")
|
||||
except CouncilInputError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
|
||||
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
|
||||
"can_edit": can_use_council(authenticated), **result})
|
||||
|
||||
|
||||
class CouncilArticleEdit(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True)
|
||||
employee_id: int = Field(gt=0)
|
||||
edit_key: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
revision: int = Field(ge=0)
|
||||
title: str = Field(min_length=1, max_length=2000)
|
||||
journal: str = Field(min_length=1, max_length=500)
|
||||
quartile: str = Field(pattern=r"^(Q[1-4])?$")
|
||||
specialty_codes: list[str] | None = Field(default=None, max_length=12)
|
||||
|
||||
|
||||
@router.get("/council/publications", response_class=HTMLResponse)
|
||||
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
|
||||
"can_edit": 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"]}
|
||||
for candidate in result["candidates"] for article in candidate["articles"]
|
||||
if employee.casefold().strip() in candidate["name"].casefold()
|
||||
and title.casefold().strip() in article["title"].casefold()
|
||||
and journal.casefold().strip() in " ".join(article["journals"]).casefold()]
|
||||
total, limit = len(articles), 50
|
||||
offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0
|
||||
return _render(request, "council_publications.html", {**context, "error": False,
|
||||
"articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit,
|
||||
"revision": result["revision"], "journal_names": result["journal_names"], "specialties": result["specialties"]})
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council_publications.html", {**context, "error": True}, status_code=503)
|
||||
|
||||
|
||||
@router.post("/council/publications")
|
||||
def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_user(request, settings, db, lock=True)
|
||||
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":
|
||||
raise HTTPException(403, "Недопустимый запрос на изменение")
|
||||
if not _journal_key(change.journal):
|
||||
raise HTTPException(422, "Укажите название журнала")
|
||||
try:
|
||||
result = council_candidates(db, employee_id=change.employee_id, paginate=False)
|
||||
article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id
|
||||
for a in c["articles"] if a["edit_key"] == change.edit_key), None)
|
||||
if not article:
|
||||
raise HTTPException(404, "Статья не найдена. Обновите страницу")
|
||||
if article["year"] is None and change.quartile:
|
||||
raise HTTPException(422, "Для квартиля необходим однозначный год публикации")
|
||||
if change.specialty_codes is not None and not set(change.specialty_codes) <= {item["code"] for item in result["specialties"]}:
|
||||
raise HTTPException(422, "Неизвестная специальность ВАК")
|
||||
record = db.get(CouncilCorrections, 1)
|
||||
revision = record.revision if record else 0
|
||||
if revision != change.revision:
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
data = deepcopy(record.data) if record else {"articles": {}, "journals": {}}
|
||||
if change.title != article["title"] or change.journal != article["edit_journal"]:
|
||||
data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal}
|
||||
specialty_data = data.setdefault("specialties", {})
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
if change.specialty_codes is None:
|
||||
specialty_data.pop(_journal_key(change.journal), None)
|
||||
else:
|
||||
specialty_data[_journal_key(change.journal)] = sorted(set(change.specialty_codes))
|
||||
if article["year"] is not None and (change.quartile != article["edit_quartile"] or change.journal != article["edit_journal"]):
|
||||
data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile
|
||||
if record:
|
||||
count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1,
|
||||
CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1,
|
||||
updated_by=username)).rowcount
|
||||
if not count:
|
||||
db.rollback()
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
else:
|
||||
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
|
||||
changes = {}
|
||||
for field, before, after in (("title", article["title"], change.title),
|
||||
("journal", " / ".join(article["journals"]), change.journal),
|
||||
("quartile", article["edit_quartile"], change.quartile)):
|
||||
if before != after:
|
||||
changes[field] = {"before": before, "after": after}
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
before = {"manual": article["manual_specialties"], "codes": article["edit_specialty_codes"] if article["manual_specialties"] else None}
|
||||
after = {"manual": change.specialty_codes is not None,
|
||||
"codes": sorted(set(change.specialty_codes or [])) if change.specialty_codes is not None else None}
|
||||
if before != after:
|
||||
changes["specialty_codes"] = {"before": before, "after": after}
|
||||
if changes:
|
||||
candidate = next(c for c in result["candidates"] if c["employee_id"] == change.employee_id)
|
||||
db.add(UserAction(user_id=actor.id, action="card_edit", employee_id=change.employee_id,
|
||||
employee_name=candidate["name"], publication_year=article["year"], publication_key=change.edit_key, article_title=change.title, changes=changes))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз")
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
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):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
|
||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
@@ -170,7 +373,7 @@ def refresh_employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
@@ -181,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_admin(request, settings)
|
||||
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()
|
||||
@@ -195,7 +398,7 @@ def run_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
@@ -209,7 +412,7 @@ def trigger_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
@@ -228,7 +431,7 @@ def crawl_now(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -247,7 +450,7 @@ def refresh_dismissed(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -260,13 +463,20 @@ 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": user,
|
||||
"council_access": can_use_council(user),
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
response = templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
if getattr(request.state, "user", None) is not None or template == "login.html":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
|
||||
+17
-11
@@ -1,18 +1,18 @@
|
||||
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
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlRun, Employee
|
||||
from app.security import require_admin
|
||||
from app.security import require_admin, require_user, check_csrf
|
||||
from app.services.admin_data import employee_display_payload, list_employees_page, run_detail_payload, run_payload, stats_payload
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -29,6 +29,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,
|
||||
@@ -36,7 +39,7 @@ def list_employees(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
@@ -45,6 +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,
|
||||
@@ -59,7 +65,7 @@ def get_employee(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return {"error": "not_found"}
|
||||
@@ -73,7 +79,7 @@ def list_crawl_runs(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
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_admin(request, settings)
|
||||
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_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return {"error": "not_found"}
|
||||
@@ -111,7 +117,7 @@ def trigger_crawl(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
if running:
|
||||
return {"status": "already_running", "run": run_payload(running)}
|
||||
@@ -130,8 +136,8 @@ def stats(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Инструкция по выбору критериев для кандидата в диссертационный совет
|
||||
|
||||
Источник: [npa8.pdf](npa8.pdf), приложение № 1, пункты 5–6, страницы 3–4. Рекомендация от 07.07.2023 № 19/11-разн на странице 5 устанавливает дату введения критериев — 01.01.2024.
|
||||
|
||||
## 1. Установить учёную степень
|
||||
|
||||
Определить документально подтверждённую степень сотрудника: доктор наук, кандидат наук или PhD. Учёное звание профессора или доцента не заменяет степень. Если степень отсутствует или не подтверждена, по этим критериям заключение не формировать.
|
||||
|
||||
## 2. Определить период оценки
|
||||
|
||||
Проверять публикации за последние пять лет, предшествующих дате подачи ходатайства. Использовать дату ходатайства, а не автоматически текущую дату. Если известен только год публикации, для пограничных лет потребуется уточнение даты.
|
||||
|
||||
## 3. Выбрать критерии по степени
|
||||
|
||||
| Учёная степень | Применяемый пункт | Требование к публикациям | Допустимые издания |
|
||||
|---|---|---|---|
|
||||
| Доктор наук | Пункт 5 | Порог зависит от отрасли науки — см. таблицу ниже | К1 или К2 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
|
||||
| Кандидат наук | Пункт 6 | Не менее 20 | К1 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
|
||||
| PhD | Пункт 6 | Не менее 20 | К1 Перечня ВАК, либо RSCI, либо Q1/Q2 в предусмотренных международных базах |
|
||||
|
||||
Привязка докторов наук к пункту 5 следует из структуры документа: пункт 6 отдельно устанавливает критерии для кандидатов наук и PhD; заголовок пункта 5 формулирует общие критерии для кандидатов в члены советов.
|
||||
|
||||
Для доктора наук выбрать порог по отрасли, по которой предполагается включение в совет:
|
||||
|
||||
| Отрасль науки | Минимум публикаций | Требование к монографиям |
|
||||
|---|---:|---|
|
||||
| Биологические, географические, геолого-минералогические, медицинские, фармацевтические, физико-математические, химические | 11 | Справочно |
|
||||
| Архитектура, ветеринарные, сельскохозяйственные, технические | 9 | Справочно |
|
||||
| Исторические, педагогические, политические, психологические, социологические, филологические, философские, экономические, теология | 8 | Не менее 1 |
|
||||
| Юридические | 8 | Справочно |
|
||||
| Искусствоведение, культурология | 7 | Справочно |
|
||||
|
||||
«Справочно» означает отсутствие обязательного минимального количества монографий в таблице. В пункте 6 отдельное требование к монографиям не приведено. Единого порога «10 публикаций» документ не устанавливает.
|
||||
|
||||
## 4. Проверить основания зачёта публикаций
|
||||
|
||||
Проверить период публикации и подтверждение хотя бы одного допустимого основания по выбранному пункту. Для кандидата наук и PhD принадлежность только к К2 недостаточна.
|
||||
|
||||
Международные базы, перечисленные в документе на странице 1: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef.
|
||||
|
||||
При подготовке расчёта одну публикацию учитывать один раз, даже если она соответствует нескольким основаниям. Это правило предотвращения дублей в расчёте; отдельной формулировки о дедупликации в документе нет.
|
||||
|
||||
## 5. Зафиксировать результат
|
||||
|
||||
Указать:
|
||||
|
||||
- подтверждённую учёную степень;
|
||||
- отрасль науки;
|
||||
- дату подачи ходатайства и период оценки;
|
||||
- применённый пункт документа;
|
||||
- требуемое и подтверждённое количество публикаций;
|
||||
- сведения о монографиях, включая обязательный минимум там, где он установлен;
|
||||
- неподтверждённые сведения и причины необходимости дополнительной проверки.
|
||||
|
||||
При недостатке подтверждений указать «требует проверки». При выполнении требований итог формулировать как «соответствует публикационным критериям пункта 5/6».
|
||||
|
||||
## Пределы инструкции
|
||||
|
||||
Инструкция составлена по предоставленному файлу `npa8.pdf`. Последующие изменения документа и остальные условия членства в диссертационном совете не проверены. Соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Работа с каталогами журналов Q1 и Q2
|
||||
|
||||
Используйте каталоги для подбора журналов по выбранным научным специальностям и проверки сохранённых квартильных данных. Тематическое соответствие в файлах относится к профилю журнала. При оценке публикации отдельно сопоставляйте содержание и научный результат конкретной статьи с паспортом специальности.
|
||||
|
||||
**Каталоги являются тематическими подборками. Отсутствие журнала в файле не означает, что он не имеет Q1/Q2 или не подходит для выбранной специальности.** Журналы, не включённые в подборку, в этих файлах не оценены.
|
||||
|
||||
Структура и значения полей проверены 08.10.2026.
|
||||
|
||||
## Файлы и период данных
|
||||
|
||||
Пути в таблице указаны относительно папки `app/data`.
|
||||
|
||||
| Файл | Версия схемы | Период метрик | Критерий включения |
|
||||
|---|---|---|---|
|
||||
| [q1-journals/catalog.json](q1-journals/catalog.json) | `1.0.0` | 2025 | Хотя бы одна категория Q1 в Scopus и хотя бы одна категория Q1 в JCR/JIF |
|
||||
| [q2-journals/catalog.json](q2-journals/catalog.json) | `1.1.0` | 2025 | Хотя бы одна категория Q2 в Scopus и хотя бы одна категория Q2 в JCR/JIF |
|
||||
|
||||
Scopus представлен метрикой **CiteScore**, Web of Science — **JIF-квартилями Journal Citation Reports**. Значение CiteScore и квартиль являются разными показателями. SJR в качестве критерия отбора этих подборок не использован.
|
||||
|
||||
Для CiteScore Q1 соответствует перцентилю от 75 включительно; Q2 — от 50 включительно до 75 исключительно. Квартиль JCR берите из сохранённого поля `quartile` соответствующей категории.
|
||||
|
||||
Предметные категории двух систем могут различаться. Принадлежность к подборке не означает, что журнал имеет указанный квартиль во всех своих категориях или именно в категории, наиболее близкой к рассматриваемой статье.
|
||||
|
||||
## Разделы catalog.json
|
||||
|
||||
| Раздел | Содержание и применение |
|
||||
|---|---|
|
||||
| `metadata` | Область охвата, правила отбора, годы, версия схемы и ограничения |
|
||||
| `journals` | Названия, идентификаторы, тематика, квартильные данные и ссылки на источники |
|
||||
| `specialty_links` | Подробные связи между журналами и специальностями: уровень, обоснования, условия, пункты паспортов |
|
||||
| `assessments` | Оценки каждой пары «включённый журнал — выбранная специальность», включая отсутствие установленного соответствия |
|
||||
| `specialties` | Названия специальностей, версии паспортов и направления исследований с номерами пунктов |
|
||||
| `sources` | Источники квартильных таблиц, годы, даты проверки и контрольные суммы |
|
||||
|
||||
Связывайте записи по идентификаторам:
|
||||
|
||||
- `specialty_links[].journal_id` и `assessments[].journal_id` ссылаются на `journals[].id`;
|
||||
- `specialty_code` ссылается на `specialties[].code`;
|
||||
- `passport_references[].passport_id` ссылается на `specialties[].passports[].id`;
|
||||
- `point_numbers` ссылаются на `directions[].number` соответствующего паспорта;
|
||||
- `scopus.source_id` и `wos.source_id` ссылаются на `sources[].id`.
|
||||
|
||||
Ключ журнала имеет вид `scopus:<source ID>`. Название используйте для отображения и поиска; связи храните по `id`. Поле `journals[].name` содержит исходное название, преимущественно английское. Русскими являются описания тематики и тематических связей. Перевод названия для отображения должен сохранять связь с исходным названием и идентификаторами.
|
||||
|
||||
`journals[].scopus_source_id` — числовой идентификатор самого журнала в Scopus. Вложенные `scopus.source_id` и `wos.source_id` обозначают источники подтверждения квартилей из раздела `sources`.
|
||||
|
||||
Относительные пути `specialties[].passports[].local_pdf` разрешайте от папки соответствующего `catalog.json`. Поэтому `../scientific-specialty-passports/pdf/...` из папки Q1 или Q2 ведёт к сохранённым паспортам в `app/data/scientific-specialty-passports`.
|
||||
|
||||
## Уровни тематического соответствия
|
||||
|
||||
| Значение | Значение для читателя | Как действовать |
|
||||
|---|---|---|
|
||||
| `direct` | Устойчивое профильное соответствие журнала специальности | Рассматривать журнал как профильного кандидата; проверять конкретную статью по указанным пунктам паспорта |
|
||||
| `conditional` | Соответствие установлено при определённом содержании или результате статьи | Прочитать `conditions` и проверить, выполняются ли относящиеся к статье условия |
|
||||
| `not_established` | По использованным источникам самостоятельное соответствие не установлено | Не считать пару подтверждённой; при необходимости провести отдельное тематическое рассмотрение |
|
||||
|
||||
`direct` и `conditional` представлены в `specialty_links[].match_level`. Все три значения встречаются в `assessments[].assessment`.
|
||||
|
||||
Для каждой найденной связи читайте:
|
||||
|
||||
- `rationales` — тематическое обоснование;
|
||||
- `conditions` — текстовые условия и оговорки;
|
||||
- `passport_references` — конкретные версии паспортов и номера пунктов;
|
||||
- `scope_source_url` — источник описания темы журнала;
|
||||
- `assessment_kind` — характер оценки; в текущих файлах используется `analytical`.
|
||||
|
||||
Условия могут присутствовать и у связи с уровнем `direct`. Пустой массив условий также не подтверждает соответствие любой статьи. Массивы `rationales` и `conditions` содержат текстовые пояснения, а не формальные логические выражения: машинная логика AND/OR для них схемой не задана.
|
||||
|
||||
`journals[].specialty_codes` объединяет специальности с прямыми **и** условными связями. Это поле удобно для предварительного поиска, но уровня соответствия в нём нет. Для решения по конкретной специальности обращайтесь к `specialty_links`.
|
||||
|
||||
Если журнал вообще отсутствует в `journals`, для него нельзя вывести `not_established`: такой вывод описывает оценку пары в каталоге, а не любой журнал за его пределами.
|
||||
|
||||
## Проверка конкретной публикации
|
||||
|
||||
1. Найдите журнал по ISSN/eISSN и исходному названию. При расхождении названий проверьте, относятся ли они к одному изданию. Переводная версия, переименование и совпадение отдельных слов требуют проверки идентификаторов.
|
||||
2. Выберите научную специальность и найдите связь по паре `journal_id` + `specialty_code`. При отсутствии связи посмотрите соответствующую запись `assessments`.
|
||||
3. Прочитайте уровень, обоснования и условия. Сопоставьте цель, методы и научный результат статьи с указанными направлениями исследований. Одного совпадения ключевых слов или тематики журнала недостаточно.
|
||||
4. Откройте нужную версию паспорта через `specialties[].passports` и проверьте номера пунктов. Для 1.3.8 технический и физико-математический паспорта имеют раздельную нумерацию. Учитывайте `metadata.passport_note`, включая примечание к 1.2.2 о диссертационном исследовании.
|
||||
5. Проверьте квартиль в каждой системе, категорию и год. Для другого года потребуется соответствующее подтверждение; данные за 2025 год сами по себе не устанавливают квартиль за иной период.
|
||||
6. Если требуется актуальный статус индексации или подбор площадки подачи, отдельно проверьте текущую индексацию, описание тематики, принимаемые типы статей и редакционные правила.
|
||||
7. Зафиксируйте результат проверки статьи отдельно от исходной связи журнала: выбранную специальность, пункты паспорта, выполненные условия, квартильные категории и годы, подтверждающие источники и дату рассмотрения.
|
||||
|
||||
Тематическая оценка каталога не является официальным присвоением журналу специальности и не заменяет решение по конкретной публикации.
|
||||
|
||||
## Квартильные данные и источники
|
||||
|
||||
| Поля | Как читать |
|
||||
|---|---|
|
||||
| `scopus.metric`, `scopus.year`, `scopus.value` | Название метрики CiteScore, её год и числовое значение |
|
||||
| `scopus.categories[]` | Квартиль, перцентиль, ранг и размер списка для каждой категории Scopus |
|
||||
| `scopus.categories[].source_row` | Номер строки опубликованной таблицы, на которую опирается запись |
|
||||
| `wos.metric_year`, `wos.release_year` | Год метрики JCR/JIF и год выпуска отчёта; в этих данных — 2025 и 2026 соответственно |
|
||||
| `wos.categories[]` | Квартиль, предметная категория и редакция индекса (`edition`) |
|
||||
| `wos.categories[].source_pdf_page` | Номер страницы опубликованной таблицы JCR |
|
||||
| `scopus.q1_verified`, `wos.q1_verified` | Подтверждение критерия Q1 по сохранённым таблицам; поля используются в каталоге Q1 |
|
||||
| `scopus.q2_verified`, `wos.q2_verified` | Подтверждение критерия Q2 по сохранённым таблицам; поля используются в каталоге Q2 |
|
||||
| `live_indexing_status_verified` | Проверялся ли отдельно текущий статус индексации; `false` означает отсутствие такой проверки, а не отсутствие индексации |
|
||||
|
||||
В каталогах сохранены все доступные категории включённых журналов, в том числе категории с другими квартилями. Для подтверждения основания выбирайте конкретную категорию и соответствующий ей квартиль.
|
||||
|
||||
`metadata.generated_on` — дата формирования подборки, а не год метрики. Даты `checked_on` относятся к проверке сохранённого подтверждения и не подменяют актуальную проверку индексации.
|
||||
|
||||
Квартильные данные основаны на открыто опубликованных копиях таблиц Scopus и JCR. Учитывайте `metadata.quartile_evidence`, сведения в `sources`, а также ссылки на первичные карточки Scopus. Подтверждение по такой таблице не следует представлять как результат текущего авторизованного сеанса Scopus/JCR.
|
||||
|
||||
Для тематики читайте `journals[].scope.summary_ru`, `source_url`, `evidence_type` и `verification_note`. Архивное или воспроизведённое описание может обосновывать профиль журнала, но не подтверждает текущие ограничения подачи. При выборе журнала также учитывайте `article_type_note`.
|
||||
|
||||
## Пересечения Q1 и Q2
|
||||
|
||||
Подборки могут пересекаться: один журнал может иметь категории Q1 и Q2 одновременно. В Q2 отбор выполнен по наличию категории Q2 в обеих системах, а не по требованию, чтобы лучший квартиль был Q2.
|
||||
|
||||
В каталоге Q2 предусмотрены дополнительные поля:
|
||||
|
||||
| Поле | Значение |
|
||||
|---|---|
|
||||
| `scopus.best_quartile`, `wos.best_quartile` | Лучший из сохранённых квартилей соответствующей системы |
|
||||
| `also_meets_q1_rule` | Журнал имеет хотя бы одну категорию Q1 в каждой из двух систем |
|
||||
| `in_local_q1_catalog` | Журнал фактически присутствует в ранее сохранённой тематической подборке Q1 |
|
||||
|
||||
Эти два признака пересечения различаются: журнал может выполнять квартильный критерий Q1, но отсутствовать в локальной подборке Q1 из-за её ограниченного охвата.
|
||||
|
||||
При объединении файлов учитывайте журнал один раз по `journals[].id`, сохраняя оба основания отбора. Связи со специальностями сопоставляйте по паре `journal_id` + `specialty_code`; сохраняйте относящиеся к ним обоснования, условия и источники. Совпадение журнала в двух каталогах не образует две разные публикации и не удваивает число связанных специальностей.
|
||||
|
||||
## Различия схем и чтение полей
|
||||
|
||||
В текущем Q1 отсутствуют поля `metadata.target_quartile` и `metadata.selection_rule`; правило находится в `metadata.q1_rule`. В Q2 используются `target_quartile`, `q2_rule` и `selection_rule`.
|
||||
|
||||
Сначала прочитайте `metadata.schema_version`. Правило отбора берите из `selection_rule`, если оно есть, иначе из `q1_rule` или `q2_rule`. Дополнительные поля Q2 не следует требовать от файла Q1. Отсутствующее поле и значение `false` имеют разный смысл.
|
||||
|
||||
При обработке данных проверяйте, что идентификаторы журналов и пар «журнал — специальность» уникальны, все ссылки разрешаются и номера пунктов существуют в соответствующих паспортах. Повреждённую или неполную запись возвращайте на проверку, сохраняя причину; не превращайте её автоматически в положительный или отрицательный вывод о соответствии.
|
||||
|
||||
## Сопоставление с К1 и обновление
|
||||
|
||||
Категория К1 ВАК и квартили Q1/Q2 являются самостоятельными основаниями. Принадлежность к одному списку не устанавливает принадлежность к другому. Для сопоставления с [k1_journals.json](k1_journals.json) используйте ISSN/eISSN и проверенные варианты названий; дополнительные идентификаторы доступны в [k1_specialties.json](k1_specialties.json). Перед использованием такого сопоставления проверяйте его связь с актуальными исходными файлами.
|
||||
|
||||
При обновлении каталогов заново сверяйте квартильные категории за выбранный год, идентификаторы, источники тематики и связи с паспортами. Изменение квартиля, описания журнала или паспорта может потребовать пересмотра условий и уровня соответствия. Сохраняйте год, дату проверки, источник и версию схемы; исключение журнала из обновлённой подборки само по себе не является отрицательной оценкой его тематики.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"policy": {},
|
||||
"employees": {}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,9 @@ def init_db() -> None:
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_runtime_schema()
|
||||
from app.security import ensure_bootstrap_admin
|
||||
with SessionLocal() as db:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
|
||||
|
||||
def _ensure_runtime_schema() -> None:
|
||||
@@ -37,6 +40,14 @@ def _ensure_runtime_schema() -> None:
|
||||
models.EmployeePublication.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employee_publications" in table_names:
|
||||
publication_columns = {column["name"] for column in inspector.get_columns("employee_publications")}
|
||||
for column in ("quartiles", "vak_categories"):
|
||||
if column not in publication_columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("ALTER TABLE employee_publications ADD COLUMN " +
|
||||
("IF NOT EXISTS " if engine.dialect.name == "postgresql" else "") +
|
||||
column + " " + ("JSONB" if engine.dialect.name == "postgresql" else "JSON")))
|
||||
if "employees" in table_names and "employee_news_links" not in table_names:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
|
||||
+3
-1
@@ -3,12 +3,14 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
from app.users import router as users_router
|
||||
from app.db import init_db
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app = 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)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
|
||||
+53
-1
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy import CheckConstraint, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -108,6 +108,8 @@ class EmployeePublication(Base):
|
||||
other_url: Mapped[str | None] = mapped_column(Text)
|
||||
document_url: Mapped[str | None] = mapped_column(Text)
|
||||
citation_text: Mapped[str | None] = mapped_column(Text)
|
||||
quartiles: Mapped[dict | None] = mapped_column(json_type)
|
||||
vak_categories: Mapped[dict | None] = mapped_column(json_type)
|
||||
annotation: Mapped[dict | None] = mapped_column(json_type)
|
||||
description: Mapped[dict | None] = mapped_column(json_type)
|
||||
authors: Mapped[list | None] = mapped_column(json_type)
|
||||
@@ -241,3 +243,53 @@ class ParseResourceCache(Base):
|
||||
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class CouncilCorrections(Base):
|
||||
"""Manual council data, outside crawler-owned publications."""
|
||||
__tablename__ = "council_corrections"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
data: Mapped[dict] = mapped_column(json_type, nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
class SystemUser(Base):
|
||||
__tablename__ = "system_users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user', '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)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
session_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
first_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class UserAction(Base):
|
||||
__tablename__ = "user_actions"
|
||||
__table_args__ = (Index("ix_user_actions_user_created", "user_id", "created_at"),
|
||||
Index("ix_user_actions_target_created", "target_user_id", "created_at"))
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("system_users.id"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("system_users.id"))
|
||||
employee_id: Mapped[int | None] = mapped_column(Integer)
|
||||
employee_name: Mapped[str | None] = mapped_column(Text)
|
||||
publication_year: Mapped[int | None] = mapped_column(Integer)
|
||||
publication_key: Mapped[str | None] = mapped_column(String(64))
|
||||
article_title: Mapped[str | None] = mapped_column(Text)
|
||||
changes: Mapped[dict] = mapped_column(json_type, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
+192
-25
@@ -2,45 +2,212 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
SESSION_SECONDS = 12 * 60 * 60
|
||||
PASSWORD_ITERATIONS = 600_000
|
||||
|
||||
|
||||
def verify_admin(username: str, password: str, settings: Settings) -> bool:
|
||||
return hmac.compare_digest(username, settings.admin_username) and hmac.compare_digest(
|
||||
password, settings.admin_password
|
||||
)
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": username, "iat": int(time.time())}, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
signature = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}.{signature}"
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt, expected = encoded.split('$')
|
||||
if scheme != 'pbkdf2_sha256' or int(iterations) != PASSWORD_ITERATIONS:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), PASSWORD_ITERATIONS)
|
||||
return hmac.compare_digest(actual, bytes.fromhex(expected))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY_HASH = hash_password('dummy password for unknown account timing')
|
||||
|
||||
|
||||
def temporary_password() -> str:
|
||||
groups = ('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnpqrstuvwxyz', '23456789', '!@#$%+-_')
|
||||
characters = [secrets.choice(group) for group in groups]
|
||||
characters.extend(secrets.choice(''.join(groups)) for _ in range(4))
|
||||
secrets.SystemRandom().shuffle(characters)
|
||||
return ''.join(characters)
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(db: Session, settings: Settings) -> SystemUser:
|
||||
username = settings.admin_username.strip().casefold()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
if user is not None:
|
||||
return user
|
||||
user = SystemUser(username=username, role='admin', password_hash=hash_password(settings.admin_password),
|
||||
must_change_password=False)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
return user
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings, version: int = 0) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({'sub': username, 'iat': int(time.time()),
|
||||
'v': version, 'csrf': secrets.token_urlsafe(24)}, separators=(',', ':')).encode()).decode().rstrip('=')
|
||||
signature = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return f'{payload}.{signature}'
|
||||
|
||||
|
||||
def _session_data(token: str | None, settings: Settings) -> dict | None:
|
||||
if not token or len(token) > 2048 or '.' not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit('.', 1)
|
||||
expected = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode((payload + '=' * (-len(payload) % 4)).encode()))
|
||||
now = int(time.time())
|
||||
if (not isinstance(data, dict) or not isinstance(data.get('sub'), str)
|
||||
or type(data.get('iat')) is not int or not now - SESSION_SECONDS <= data['iat'] <= now + 60
|
||||
or type(data.get('v')) is not int or not isinstance(data.get('csrf'), str)):
|
||||
return None
|
||||
return data
|
||||
except (ValueError, TypeError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_session(token: str | None, settings: Settings) -> str | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit(".", 1)
|
||||
expected = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
data = _session_data(token, settings)
|
||||
return data['sub'] if data else None
|
||||
|
||||
|
||||
def session_csrf(request: Request, settings: Settings) -> str:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
return data['csrf'] if data else ''
|
||||
|
||||
|
||||
async def check_csrf(request: Request, settings: Settings = Depends(get_settings)):
|
||||
if request.method != 'POST':
|
||||
return
|
||||
origin = request.headers.get('origin')
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
raise HTTPException(403, 'Недопустимый источник запроса')
|
||||
if parsed.scheme not in {'http', 'https'} or parsed.netloc.casefold() != request.url.netloc.casefold():
|
||||
raise HTTPException(403, 'Запрос должен исходить со страницы этой системы')
|
||||
# JSON with a custom header cannot be submitted by a cross-site HTML form; CORS stays disabled.
|
||||
if request.headers.get('X-Council-Edit') == '1' and request.headers.get('content-type', '').startswith('application/json'):
|
||||
return
|
||||
expected = (request.cookies.get(LOGIN_CSRF_COOKIE, '') if request.url.path == '/admin/login'
|
||||
else session_csrf(request, settings))
|
||||
if request.url.path == '/admin/logout' and not expected:
|
||||
return
|
||||
supplied = request.headers.get('X-CSRF-Token', '')
|
||||
if not supplied:
|
||||
form = await request.form()
|
||||
supplied = form.get('csrf_token', '')
|
||||
if not expected or not isinstance(supplied, str) or not hmac.compare_digest(supplied.encode(), expected.encode()):
|
||||
raise HTTPException(403, 'Обновите страницу и повторите действие')
|
||||
|
||||
|
||||
def optional_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser | None:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not data:
|
||||
return None
|
||||
query = select(SystemUser).where(SystemUser.username == data['sub'].casefold())
|
||||
if lock:
|
||||
query = query.with_for_update().execution_options(populate_existing=True)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
except Exception:
|
||||
user = db.scalar(query)
|
||||
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
|
||||
user = ensure_bootstrap_admin(db, settings)
|
||||
if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password
|
||||
and request.method == 'GET' and request.url.path.startswith('/admin')):
|
||||
request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start'
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
if user is None or not user.is_active or user.session_version != data['v']:
|
||||
return None
|
||||
return data.get("sub")
|
||||
request.state.user = user
|
||||
request.state.csrf_token = data['csrf']
|
||||
if user.must_change_password and not allow_password_change:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(403, 'Сначала смените временный пароль в личном кабинете')
|
||||
raise HTTPException(303, headers={'Location': '/admin/account'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings) -> str:
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
|
||||
return username
|
||||
def require_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser:
|
||||
user = optional_user(request, settings, db, allow_password_change=allow_password_change, lock=lock)
|
||||
if user is None:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(401, 'Для этого действия войдите в систему')
|
||||
raise HTTPException(303, headers={'Location': '/admin/login'})
|
||||
return user
|
||||
|
||||
|
||||
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':
|
||||
raise HTTPException(403, 'Это действие доступно только администратору')
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str, settings: Settings) -> SystemUser | None:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username.strip().casefold()).with_for_update())
|
||||
valid = verify_password(password, user.password_hash if user else _DUMMY_HASH)
|
||||
if user is None:
|
||||
return None
|
||||
now = utcnow()
|
||||
locked_until = user.locked_until
|
||||
if locked_until and locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if not user.is_active or (locked_until and locked_until > now):
|
||||
return None
|
||||
if not valid:
|
||||
if locked_until:
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts = 0
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= 5:
|
||||
user.locked_until = now + timedelta(minutes=15)
|
||||
db.commit()
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
if user.first_login_at is None:
|
||||
user.first_login_at = now
|
||||
user.must_change_password = True
|
||||
user.last_login_at = now
|
||||
return user
|
||||
+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:
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, func, case, cast
|
||||
from sqlalchemy.dialects.postgresql import JSONPATH
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Employee, EmployeePublication, CouncilCorrections, json_type
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.council_source import _doi, _journal_names, _name, load_reference, _description_journals
|
||||
from app.services.council_catalog import catalog, publication_catalog
|
||||
from app.services.publication_quartiles import qualifying_entries, identity_issues, hse_entries, merge as merge_quartiles
|
||||
from app.services.council_assessment import (
|
||||
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
|
||||
publication_type_allowed, sourced,
|
||||
)
|
||||
|
||||
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.json"
|
||||
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
|
||||
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
|
||||
K2_JOURNALS = EXPORT.with_name("k2_journals.json")
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
|
||||
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
|
||||
|
||||
|
||||
class CouncilInputError(ValueError):
|
||||
"""Invalid public assessment context, as opposed to damaged source data."""
|
||||
|
||||
|
||||
def _safe_url(value: str | None) -> str | None:
|
||||
try:
|
||||
url = urlsplit(value or "")
|
||||
if url.scheme in {"http", "https"} and url.hostname and not url.username and not url.password:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def period(today: date) -> tuple[date, date]:
|
||||
return date(today.year - 4, 1, 1), date(today.year, 12, 31)
|
||||
|
||||
|
||||
def _journal_key(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_journals() -> dict[str, str]:
|
||||
data = json.loads(K1_JOURNALS.read_text(encoding="utf-8"))
|
||||
journals = {}
|
||||
for journal in data["journals"]:
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
key = _journal_key(name)
|
||||
if key in journals and journals[key] != journal["name"]:
|
||||
raise ValueError("Ambiguous K1 journal alias")
|
||||
journals[key] = journal["name"]
|
||||
return journals
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k1_specialties() -> dict:
|
||||
data = json.loads(K1_SPECIALTIES.read_text(encoding="utf-8"))
|
||||
if hashlib.sha256(K1_JOURNALS.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != data["k1_source_sha256"]:
|
||||
raise ValueError("K1 specialty source checksum mismatch")
|
||||
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]},
|
||||
"journal_issns": {j["name"]: [entry["issn"] for entry in j.get("vak_entries", []) if entry.get("issn")]
|
||||
for j in data["journals"] if j.get("status") == "matched"}}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _k2_journals() -> dict:
|
||||
data = json.loads(K2_JOURNALS.read_text(encoding="utf-8"))
|
||||
if data["source_sha256"] != json.loads(K1_JOURNALS.read_text(encoding="utf-8"))["source_sha256"]:
|
||||
raise ValueError("K2 source differs from K1 source")
|
||||
result = {}
|
||||
for journal in data["journals"]:
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
key = _journal_key(name)
|
||||
if key in result and result[key] != journal["name"]:
|
||||
raise ValueError("Ambiguous K2 journal alias")
|
||||
result[key] = journal["name"]
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
return load_reference(EXPORT)
|
||||
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
imported = raw.get("csv_enrichment")
|
||||
from_site = not imported or bool(publication.publication_id or publication.url or getattr(publication, "raw_has_metadata", any(key != "csv_enrichment" for key in raw)))
|
||||
origins = ["ЛК сотрудника ВШЭ"] if from_site else []
|
||||
if imported:
|
||||
origins.append("Данные ВШЭ")
|
||||
kind = publication.publication_type
|
||||
description = publication.description if isinstance(publication.description, dict) else raw.get("description")
|
||||
journals = _journal_names(raw)
|
||||
if isinstance(description, dict):
|
||||
journals.extend(_description_journals(description))
|
||||
doi = _doi(publication.doi_url or "")
|
||||
identifiers = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
|
||||
current_journals = list(journals)
|
||||
imported_quartiles = merge_quartiles(None, hse_entries(raw, publication.year))
|
||||
issues = identity_issues(publication.quartiles, doi, current_journals, identifiers)
|
||||
imported_issues = identity_issues(imported_quartiles, doi, current_journals, identifiers)
|
||||
imported_doi = _doi(str(imported.get("DOI") or "")) if isinstance(imported, dict) else ""
|
||||
if isinstance(imported, dict) and not imported_issues and (not imported_doi or imported_doi == doi):
|
||||
journals.extend(_journal_names(imported))
|
||||
issues = list(dict.fromkeys([*issues, *imported_issues]))
|
||||
quartile_entries = [*qualifying_entries(publication.quartiles, publication.year, doi, current_journals, identifiers),
|
||||
*qualifying_entries(imported_quartiles, publication.year, doi, current_journals, identifiers)]
|
||||
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
|
||||
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
|
||||
"quartiles": list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in quartile_entries)),
|
||||
"quartile_entries": quartile_entries,
|
||||
"quartile_issues": issues,
|
||||
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)],
|
||||
"source_hashes": [publication.source_hash],
|
||||
"origins": origins, "rows": [], "in_csv": bool(imported), "in_db": from_site,
|
||||
"url": _safe_url(publication.url)}
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date, corrections=None, employee_id=None) -> list[dict]:
|
||||
k1_journals = _k1_journals()
|
||||
specialties = _k1_specialties()["by_journal"]
|
||||
title_years = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["year"] is not None:
|
||||
title_years[_name(article["title"])].add(article["year"])
|
||||
def title_key(article):
|
||||
title = _name(article["title"])
|
||||
years = title_years[title]
|
||||
year = article["year"]
|
||||
return title, next(iter(years)) if year is None and len(years) == 1 else year
|
||||
|
||||
# A title/year fallback is safe only when it identifies at most one DOI.
|
||||
title_dois = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["doi"]:
|
||||
title_dois[title_key(article)].add(article["doi"])
|
||||
unique = {}
|
||||
for article in articles:
|
||||
fallback = title_key(article)
|
||||
identifiers = title_dois[fallback]
|
||||
identifier = article["doi"] or (next(iter(identifiers)) if len(identifiers) == 1 else "")
|
||||
key = identifier or fallback
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "doi": identifier,
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
|
||||
"evidence": [], "issns": [], "source_hashes": [], "quartile_issues": []}
|
||||
merged = unique[key]
|
||||
merged["evidence"].append(article)
|
||||
merged["quartile_issues"] = list(dict.fromkeys([*merged["quartile_issues"], *article.get("quartile_issues", [])]))
|
||||
for field in ("issns", "source_hashes"):
|
||||
merged[field] = list(dict.fromkeys([*merged[field], *article.get(field, [])]))
|
||||
for field in ("types", "quartiles", "journals", "origins", "rows"):
|
||||
merged[field] = list(dict.fromkeys([*merged[field], *article[field]]))
|
||||
merged["in_csv"] |= article["in_csv"]
|
||||
merged["in_db"] |= article["in_db"]
|
||||
merged["url"] = merged["url"] or article["url"]
|
||||
for identity, article in unique.items():
|
||||
evidence = article.pop("evidence")
|
||||
article["edit_key"] = hashlib.sha256(json.dumps(
|
||||
identity,
|
||||
ensure_ascii=False).encode()).hexdigest()
|
||||
correction = (corrections or {}).get("articles", {}).get(f"{employee_id}:{article['edit_key']}")
|
||||
article["manually_corrected"] = bool(correction)
|
||||
article["journal_corrected"] = bool(correction and article["journals"] != [correction["journal"]])
|
||||
if correction:
|
||||
journal_changed = article["journal_corrected"]
|
||||
article["title"] = correction["title"]
|
||||
article["journals"] = [correction["journal"]]
|
||||
if journal_changed:
|
||||
article["issns"] = []
|
||||
article["quartiles"] = []
|
||||
article["quartile_issues"] = []
|
||||
evidence = [{**item, "journals": article["journals"],
|
||||
"quartiles": [] if journal_changed else item["quartiles"]} for item in evidence]
|
||||
years = sorted({item["year"] for item in evidence if item["year"] is not None})
|
||||
# Never pick a conflicting source year merely because that row came first.
|
||||
year = article["year"] = years[0] if len(years) == 1 else None
|
||||
article["source_years"] = years
|
||||
article["automatic_quartile_bases"] = []
|
||||
if len(article["journals"]) == 1 and not article["quartile_issues"] and not article["journal_corrected"]:
|
||||
for item in evidence:
|
||||
if item["year"] != year:
|
||||
continue
|
||||
details = item.get("quartile_entries")
|
||||
if details is None:
|
||||
# Prepared CSV evidence has no category or metric metadata.
|
||||
details = [{"quartile": label[-2:], "system": label[:-3],
|
||||
"source": " / ".join(item["origins"]), "source_url": item.get("url")}
|
||||
for label in item["quartiles"] if label.endswith((" Q1", " Q2"))]
|
||||
article["automatic_quartile_bases"].extend(
|
||||
{**entry, "basis": entry["quartile"], "applicable_year": year} for entry in details)
|
||||
article["criteria"] = [*article["quartiles"], *article["quartile_issues"]]
|
||||
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals))
|
||||
article["criteria"].extend(f"К1 · {name} (справочник ВАК)" for name in matches)
|
||||
eligible_type = not article["types"] or article["types"] == ["Статья"]
|
||||
article["q1"] = article["q2"] = article["k1"] = False
|
||||
article["specialty_codes"] = []
|
||||
for item in evidence:
|
||||
source_year = item["year"] if item["year"] is not None else year
|
||||
if not eligible_type or source_year is None or not start.year <= source_year <= end.year:
|
||||
continue
|
||||
article["q1"] |= any(q.endswith(" Q1") for q in item["quartiles"])
|
||||
article["q2"] |= any(q.endswith(" Q2") for q in item["quartiles"])
|
||||
item_journals = {k1_journals[key] for name in item["journals"]
|
||||
if (key := _journal_key(name)) in k1_journals}
|
||||
article["k1"] |= bool(item_journals)
|
||||
article["specialty_codes"] = sorted(set(article["specialty_codes"]).union(
|
||||
*(specialties.get(name, []) for name in item_journals)))
|
||||
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
|
||||
article["k2"] = any(start.year <= (item["year"] or year or 0) <= end.year and eligible_type and
|
||||
any(_journal_key(name) in _k2_journals() for name in item["journals"]) for item in evidence)
|
||||
article["rsci"] = False
|
||||
article["catalog_records"] = []
|
||||
article["catalog_issues"] = []
|
||||
if article["k2"]:
|
||||
article["criteria"].append("К2 · текущий справочник ВАК; историческая категория требует проверки")
|
||||
if len(years) > 1:
|
||||
article["criteria"].append("Годы источников расходятся: " + ", ".join(map(str, years))
|
||||
+ "; критерии учитываются по году каждой исходной записи")
|
||||
elif year is None:
|
||||
article["criteria"].append("Год не указан — период не подтверждён")
|
||||
elif start.year <= year <= end.year:
|
||||
article["criteria"].append(f"В пределах пятилетнего периода {start.year}–{end.year}")
|
||||
else:
|
||||
article["criteria"].append(f"Вне пятилетнего периода {start.year}–{end.year}")
|
||||
if not article["quartiles"] and not matches:
|
||||
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
|
||||
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
|
||||
article["type_label"] = " / ".join(article["types"]) or "Тип не указан — статья не подтверждена"
|
||||
if article["types"] and article["types"] != ["Статья"]:
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчики Q1/Q2/К1")
|
||||
return sorted(unique.values(), key=lambda item: (not item["qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
|
||||
|
||||
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,
|
||||
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":
|
||||
degree_sections = func.jsonb_path_query_array(Employee.current_data,
|
||||
cast('$.sections[*] ? (@.title like_regex "образован|степен|academic degree|education" flag "i")', JSONPATH), type_=json_type)
|
||||
employees = db.execute(select(Employee.id, Employee.profile_key, Employee.full_name, Employee.status,
|
||||
Employee.canonical_url, degree_sections.label("degree_sections"))).all()
|
||||
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
|
||||
by_author = _articles_by_author()
|
||||
verified = evidence()
|
||||
corrections_record = db.get(CouncilCorrections, 1)
|
||||
corrections = corrections_record.data if corrections_record else {}
|
||||
revision = corrections_record.revision if corrections_record else 0
|
||||
eligible = []
|
||||
degrees_by_employee = {}
|
||||
for employee in employees:
|
||||
try:
|
||||
degrees = academic_degrees({"sections": employee.degree_sections})
|
||||
except (AttributeError, TypeError):
|
||||
degrees = []
|
||||
if any(degree_level(degree) for degree in degrees) or verified["employees"].get(employee.profile_key, {}).get("degrees"):
|
||||
eligible.append(employee)
|
||||
degrees_by_employee[employee.id] = degrees
|
||||
total = len(eligible)
|
||||
query = _name(q)
|
||||
eligible = [employee for employee in eligible if query in _name(employee.full_name or "ФИО не указано")
|
||||
and (employee_id is None or employee.id == employee_id)]
|
||||
publication_fields = [EmployeePublication.employee_id, EmployeePublication.title, EmployeePublication.year,
|
||||
EmployeePublication.publication_type, EmployeePublication.publication_id, EmployeePublication.url,
|
||||
EmployeePublication.doi_url, EmployeePublication.source_hash, EmployeePublication.quartiles]
|
||||
raw_data, description = EmployeePublication.raw_data, EmployeePublication.description
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
# Only council metadata crosses the DB boundary; abstracts/authors stay in PostgreSQL.
|
||||
description = case((func.jsonb_typeof(description) == "object", func.jsonb_build_object(
|
||||
"short", description["short"], "shortLeft", description["shortLeft"], type_=json_type)),
|
||||
else_=description)
|
||||
raw = EmployeePublication.raw_data
|
||||
raw_has_metadata = case((func.jsonb_typeof(raw) == "object",
|
||||
raw != func.jsonb_build_object("csv_enrichment", raw["csv_enrichment"])), else_=False)
|
||||
raw_keys = ("csv_enrichment", "journal", "Журнал", "Название журнала", "issn", "eissn", "ISSN", "EISSN")
|
||||
raw_data = case((func.jsonb_typeof(raw) == "object", func.jsonb_build_object(
|
||||
*[value for key in raw_keys for value in (key, raw[key])],
|
||||
"description", case((func.jsonb_typeof(EmployeePublication.description) == "object", None),
|
||||
else_=raw["description"]), type_=json_type)), else_=None)
|
||||
publication_fields.append(raw_has_metadata.label("raw_has_metadata"))
|
||||
publications_by_employee = defaultdict(list)
|
||||
if eligible:
|
||||
publications = db.execute(select(*publication_fields, raw_data.label("raw_data"), description.label("description"))
|
||||
.where(EmployeePublication.employee_id.in_([e.id for e in eligible]))).all()
|
||||
for publication in publications:
|
||||
publications_by_employee[publication.employee_id].append(publication)
|
||||
candidates = []
|
||||
for employee in eligible:
|
||||
author = _name(employee.full_name or "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
degrees = degrees_by_employee[employee.id]
|
||||
employee_evidence = verified["employees"].get(employee.profile_key, {})
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in publications_by_employee[employee.id])], start, end, corrections, employee.id)
|
||||
for article in articles:
|
||||
article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and
|
||||
f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {}))
|
||||
proofs = [] if article["manual_review"] else publication_proofs(employee_evidence, article)
|
||||
kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
|
||||
kind = next(iter(kinds)) if len(kinds) == 1 else None
|
||||
preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed(
|
||||
article["types"], kind, verified.get("policy", {}), preliminary=True)
|
||||
if not preliminary_type_allowed:
|
||||
for flag in ("q1", "q2", "k1", "k2", "rsci"):
|
||||
article[flag] = False
|
||||
identifiers = [] if article["journal_corrected"] else [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
|
||||
for name in article["journals"]:
|
||||
canonical = _k1_journals().get(_journal_key(name))
|
||||
identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, []))
|
||||
article["catalog_records"], article["catalog_issues"] = publication_catalog(identifiers, article["journals"], specialty)
|
||||
for record in article["catalog_records"]:
|
||||
for metric in record["metrics"]:
|
||||
if article["year"] == metric["year"] and start.year <= article["year"] <= end.year and metric["quartile"] in {"Q1", "Q2"} and not article["catalog_issues"] and preliminary_type_allowed:
|
||||
article[metric["quartile"].lower()] = True
|
||||
for proof in proofs:
|
||||
if not preliminary_type_allowed:
|
||||
continue
|
||||
for basis in proof.get("bases", []):
|
||||
if not isinstance(basis, dict) or basis.get("applicable_year") != article["year"] or article["year"] is None or not start.year <= article["year"] <= end.year:
|
||||
continue
|
||||
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
|
||||
if key:
|
||||
article[key] = True
|
||||
if not article["catalog_issues"] and not article["journal_corrected"]:
|
||||
article["automatic_quartile_bases"].extend({"basis": metric["quartile"], "system": metric["system"],
|
||||
"metric": metric["metric"], "category": metric["category"], "metric_year": metric["year"],
|
||||
"applicable_year": article["year"], "source": metric["source"], "source_url": metric["source_url"]}
|
||||
for record in article["catalog_records"] for metric in record["metrics"]
|
||||
if article["year"] == metric["year"] and metric["quartile"] in {"Q1", "Q2"})
|
||||
journal_key = _journal_key(article["journals"][0]) if len(article["journals"]) == 1 else ""
|
||||
manual_quartile = corrections.get("journals", {}).get(f"{journal_key}:{article['year']}")
|
||||
if manual_quartile is not None:
|
||||
article["quartiles"] = [f"Ручная корректировка {manual_quartile}"] if manual_quartile else []
|
||||
article["q1"] = bool(manual_quartile == "Q1" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
|
||||
article["q2"] = bool(manual_quartile == "Q2" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
|
||||
article["criteria"] = [c for c in article["criteria"] if not c.endswith((" Q1", " Q2", " Q3", " Q4")) and c != "Q1/Q2 и К1 не подтверждены доступными данными"]
|
||||
article["criteria"].append(f"Квартиль журнала за {article['year']}: {manual_quartile or 'не указан'} · ручная корректировка, требует подтверждения")
|
||||
if article["manual_review"]:
|
||||
article["automatic_quartile_bases"] = []
|
||||
manual_specialties = corrections.get("specialties", {}).get(journal_key) if journal_key else None
|
||||
article["manual_specialties"] = manual_specialties is not None
|
||||
default_specialties = sorted(set().union(
|
||||
*(_k1_specialties()["by_journal"].get(_k1_journals().get(_journal_key(name)), []) for name in article["journals"]),
|
||||
*(record["specialty_codes"] for record in article["catalog_records"])))
|
||||
article["edit_specialty_codes"] = manual_specialties if manual_specialties is not None else default_specialties
|
||||
if manual_specialties is not None:
|
||||
eligible = preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year
|
||||
article["specialty_codes"] = manual_specialties if eligible else []
|
||||
for record in article["catalog_records"]:
|
||||
record["thematic"] = (["Связь журнала со специальностью: ручная корректировка"] if specialty in manual_specialties
|
||||
else ["Связь журнала с выбранной специальностью исключена вручную"] if specialty else [])
|
||||
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
|
||||
labels = {q[-2:] for q in article["quartiles"] if q.endswith((" Q1", " Q2", " Q3", " Q4"))}
|
||||
if manual_quartile is None:
|
||||
labels.update(metric["quartile"] for record in article["catalog_records"] for metric in record["metrics"]
|
||||
if metric["year"] == article["year"] and metric["quartile"] in {"Q1", "Q2", "Q3", "Q4"})
|
||||
labels.update(basis["basis"] for proof in proofs for basis in proof.get("bases", [])
|
||||
if isinstance(basis, dict) and basis.get("applicable_year") == article["year"] and basis.get("basis") in {"Q1", "Q2", "Q3", "Q4"})
|
||||
article["edit_quartile"] = manual_quartile if manual_quartile is not None else next(iter(labels)) if len(labels) == 1 else ""
|
||||
article["edit_journal"] = article["journals"][0] if len(article["journals"]) == 1 else ""
|
||||
changed = [a for a in articles if a["manual_review"]]
|
||||
adjusted_evidence = {**employee_evidence, "publications": [p for p in employee_evidence.get("publications", [])
|
||||
if not any((a["doi"] and p.get("doi") == a["doi"]) or p.get("source_hash") in a["source_hashes"] for a in changed)]}
|
||||
assessment = assess_candidate(degrees, articles, adjusted_evidence, branch, specialty, start, end)
|
||||
for article in changed:
|
||||
message = "Журнал или квартиль исправлен вручную. Для зачёта требуется источник, подтверждающий эти сведения за год статьи"
|
||||
article["confirmation_reasons"].append(message)
|
||||
article["warnings"]["quartile"].append(message)
|
||||
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
|
||||
for article in articles:
|
||||
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
|
||||
if article["matches_specialty"]:
|
||||
article["criteria"].append(f"По специальности {specialty}: {specialty_name} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})")
|
||||
for article in articles:
|
||||
if specialty:
|
||||
article["warnings"]["specialty"].extend(text for record in article["catalog_records"]
|
||||
for text in record["thematic"] if not text.startswith(("Паспорт ", "Аналитическая связь журнала:")))
|
||||
article["messages"] = list(dict.fromkeys([*article["criteria"], *article["confirmation_reasons"]]))
|
||||
candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id,
|
||||
"count": len(articles),
|
||||
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")},
|
||||
"total_count": sum(a["qualifies"] for a in articles),
|
||||
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
|
||||
"profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous,
|
||||
"dismissed": employee.status == "dismissed"})
|
||||
sort = sort if sort in {"full_name", "q1_count", "q2_count", "k1_count", "k2_count", "rsci_count", "confirmed_count", "total_count", "db_count", "count"} else "total_count"
|
||||
direction = direction if direction in {"asc", "desc"} else "desc"
|
||||
candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
|
||||
if sort != "full_name":
|
||||
candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
|
||||
candidates = [item for item in candidates if not specialty or item["specialty_count"] > 0]
|
||||
matched = len(candidates)
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
criteria_groups = {}
|
||||
for name in branches:
|
||||
minimum, monographs = BRANCHES[name]
|
||||
criteria_groups.setdefault((minimum, monographs), []).append(name)
|
||||
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
|
||||
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
|
||||
"branch": branch, "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)
|
||||
for data in [json.loads(path.read_text(encoding="utf-8"))]],
|
||||
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Explicit, sourced council evidence kept outside crawler-owned records."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.council_catalog import catalog, safe_url
|
||||
from app.services.council_source import _doi, _name
|
||||
|
||||
|
||||
EVIDENCE = Path(__file__).resolve().parents[1] / "data/council_evidence.json"
|
||||
BRANCHES = {}
|
||||
for names, minimum, monographs in [
|
||||
("биологические|географические|геолого-минералогические|медицинские|фармацевтические|физико-математические|химические", 11, 0),
|
||||
("архитектура|ветеринарные|сельскохозяйственные|технические", 9, 0),
|
||||
("исторические|педагогические|политические|психологические|социологические|филологические|философские|экономические|теология", 8, 1),
|
||||
("юридические", 8, 0), ("искусствоведение|культурология", 7, 0),
|
||||
]:
|
||||
for name in names.split("|"):
|
||||
BRANCHES[name] = (minimum, monographs)
|
||||
SYSTEMS = {"Web of Science", "Scopus", "PubMed", "MathSciNet", "zbMATH", "Chemical Abstracts", "Springer", "GeoRef"}
|
||||
TYPE_LABELS = {"article": "Статья", "monograph": "Книга", "book": "Книга", "chapter": "Глава книги",
|
||||
"report": "Доклад", "patent": "Патент", "preprint": "Препринт", "conference_paper": "Материал конференции"}
|
||||
|
||||
|
||||
def iso_date(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
raise ValueError("Дата должна иметь формат ГГГГ-ММ-ДД")
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def sourced(record):
|
||||
if not isinstance(record, dict) or not isinstance(record.get("source"), str) or not record["source"].strip():
|
||||
return False
|
||||
try:
|
||||
iso_date(record.get("checked_on"))
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def publication_type_allowed(types, kind, policy, *, preliminary=False):
|
||||
if kind is None:
|
||||
return preliminary and (not types or types == ["Статья"])
|
||||
if types and types != [TYPE_LABELS.get(kind)]:
|
||||
return False
|
||||
if kind == "article":
|
||||
return True
|
||||
admitted = policy.get("publication_types", {})
|
||||
return sourced(admitted) and kind in admitted.get("values", [])
|
||||
|
||||
|
||||
def degree_level(degree):
|
||||
if re.fullmatch(r"доктор\b.+\bнаук", degree, re.I):
|
||||
return 2
|
||||
if re.fullmatch(r"кандидат\b.+\bнаук|ph\.?\s*d\.?", degree, re.I):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def evidence():
|
||||
data = json.loads(EVIDENCE.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data["schema_version"] != 1 or not isinstance(data.get("employees"), dict):
|
||||
raise ValueError("Invalid council evidence schema")
|
||||
if not isinstance(data.get("policy", {}), dict):
|
||||
raise ValueError("Invalid council policy")
|
||||
admitted = data.get("policy", {}).get("publication_types", {})
|
||||
if not isinstance(admitted, dict) or not isinstance(admitted.get("values", []), list) or not all(
|
||||
isinstance(v, str) for v in admitted.get("values", [])
|
||||
):
|
||||
raise ValueError("Invalid admitted publication types")
|
||||
for employee in data["employees"].values():
|
||||
if not isinstance(employee, dict):
|
||||
raise ValueError("Invalid employee evidence")
|
||||
for field in ("degrees", "publications"):
|
||||
if not isinstance(employee.get(field, []), list) or not all(isinstance(r, dict) for r in employee.get(field, [])):
|
||||
raise ValueError("Invalid council evidence records")
|
||||
for record in employee.get("publications", []):
|
||||
for field in ("doi", "source_hash"):
|
||||
if record.get(field) is not None and not isinstance(record[field], str):
|
||||
raise ValueError("Invalid publication evidence identifier")
|
||||
if not (isinstance(record.get("doi"), str) and record["doi"]) and not (
|
||||
isinstance(record.get("source_hash"), str) and record["source_hash"]
|
||||
):
|
||||
raise ValueError("Publication evidence requires a stable identifier")
|
||||
if record.get("doi") and record["doi"] != _doi(record["doi"]):
|
||||
raise ValueError("Publication evidence DOI must be normalized")
|
||||
if record.get("kind") is not None and not isinstance(record["kind"], str):
|
||||
raise ValueError("Invalid publication kind")
|
||||
if not isinstance(record.get("bases", []), list) or not all(isinstance(b, dict) for b in record.get("bases", [])):
|
||||
raise ValueError("Invalid publication bases")
|
||||
for basis in record.get("bases", []):
|
||||
for field in ("basis", "system", "category", "metric"):
|
||||
if basis.get(field) is not None and not isinstance(basis[field], str):
|
||||
raise ValueError("Invalid publication basis field")
|
||||
if not isinstance(record.get("issns", []), list) or not all(isinstance(v, str) for v in record.get("issns", [])):
|
||||
raise ValueError("Invalid publication ISSN")
|
||||
review = record.get("thematic_review")
|
||||
if review is not None and (not isinstance(review, dict) or not isinstance(review.get("point_numbers", []), list)
|
||||
or not all(type(v) is int for v in review.get("point_numbers", []))):
|
||||
raise ValueError("Invalid thematic review")
|
||||
if review is not None:
|
||||
for field in ("specialty_code", "branch", "passport_id", "passport_sha256", "result"):
|
||||
if review.get(field) is not None and not isinstance(review[field], str):
|
||||
raise ValueError("Invalid thematic review field")
|
||||
return data
|
||||
|
||||
|
||||
def publication_proofs(employee_evidence, article):
|
||||
matches = []
|
||||
for record in employee_evidence.get("publications", []):
|
||||
doi_matches = bool(article["doi"] and record.get("doi") == article["doi"])
|
||||
hash_matches = bool(record.get("source_hash") and record["source_hash"] in article.get("source_hashes", []))
|
||||
if record.get("doi") and record.get("source_hash"):
|
||||
if doi_matches != hash_matches:
|
||||
article["proof_identity_conflict"] = True
|
||||
matched = doi_matches and hash_matches
|
||||
else:
|
||||
matched = doi_matches or hash_matches
|
||||
if matched:
|
||||
matches.append(record)
|
||||
return matches
|
||||
|
||||
|
||||
def assess_article(article, proofs, start, end, level, branch, specialty, policy):
|
||||
reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])]
|
||||
warnings = {"journal": list(dict.fromkeys(reasons)), "year": [], "type": [], "quartile": [], "specialty": []}
|
||||
def report(field, message):
|
||||
reasons.append(message)
|
||||
warnings[field].append(message)
|
||||
years = article["source_years"]
|
||||
dates = set()
|
||||
for proof in proofs:
|
||||
if proof.get("publication_date") and sourced(proof.get("date_evidence")):
|
||||
try:
|
||||
dates.add(iso_date(proof["publication_date"]))
|
||||
except ValueError:
|
||||
pass # Exact dates are optional; the calendar-year window uses the publication year.
|
||||
date_years = {value.year for value in dates}
|
||||
date_conflict = len(years) > 1 or len(date_years) > 1 or bool(date_years and years and not date_years <= set(years))
|
||||
if date_conflict:
|
||||
report("year", "Источники указывают разные годы публикации. Уточните год статьи")
|
||||
exact = next(iter(dates)) if len(dates) == 1 else None
|
||||
publication_year = article["year"] if article["year"] is not None else next(iter(date_years)) if len(date_years) == 1 else None
|
||||
in_period = publication_year is not None and start.year <= publication_year <= end.year
|
||||
if not in_period:
|
||||
report("year", f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None
|
||||
else "Год не указан — период не подтверждён")
|
||||
types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
|
||||
automatic_bases = [b for b in article.get("automatic_quartile_bases", [])
|
||||
if b["applicable_year"] == (exact.year if exact else article["year"])
|
||||
and not article.get("catalog_issues") and not article.get("quartile_issues")]
|
||||
if not types and not (article["types"] == ["Статья"] and automatic_bases):
|
||||
if len(article["types"]) > 1:
|
||||
report("type", "Источники называют разные виды работы: " + ", ".join(article["types"]) + ". Уточните тип материала")
|
||||
elif not article["types"]:
|
||||
report("type", "В источниках не указан тип материала. В карточке показано «Статья», но для зачёта нужно уточнить вид работы")
|
||||
elif article["types"] == ["Статья"]:
|
||||
report("quartile", "Тип «Статья» указан в источнике, но для зачёта по К1/К2/RSCI отдельная запись проверки работы не сохранена. Проверьте основание зачёта")
|
||||
else:
|
||||
report("type", f"Источник указывает вид «{article['types'][0]}». Для включения такого материала в минимум нужно отдельное подтверждение")
|
||||
kind = next(iter(types)) if len(types) == 1 else "article" if not types and article["types"] == ["Статья"] and automatic_bases else None
|
||||
type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)])
|
||||
if type_conflict:
|
||||
report("type", "Источники называют разные виды работы. Уточните, является ли публикация статьёй")
|
||||
eligible_kind = publication_type_allowed(article["types"], kind, policy)
|
||||
if kind and kind != "article" and not eligible_kind:
|
||||
report("type", "Это другой вид работы, а не статья. Его включение в публикационный минимум требует отдельного основания")
|
||||
bases = []
|
||||
basis_year = exact.year if exact else article["year"]
|
||||
for proof in proofs:
|
||||
for basis in proof.get("bases", []):
|
||||
if not isinstance(basis, dict):
|
||||
report("quartile", "Запись об основании зачёта повреждена. Проверьте источник категории журнала")
|
||||
continue
|
||||
category = basis.get("basis")
|
||||
valid = sourced(basis) and type(basis.get("applicable_year")) is int and basis["applicable_year"] == basis_year
|
||||
if category in {"Q1", "Q2"}:
|
||||
valid = valid and basis.get("system") in SYSTEMS and bool(basis.get("category")) and bool(basis.get("metric")) and type(basis.get("metric_year")) is int
|
||||
elif category not in {"К1", "К2", "RSCI"}:
|
||||
valid = False
|
||||
if valid:
|
||||
bases.append(basis)
|
||||
else:
|
||||
report("quartile", "Для категории журнала не подтверждён источник или год применимости. Нельзя переносить категорию автоматически на год статьи")
|
||||
if kind == "article":
|
||||
bases.extend(automatic_bases)
|
||||
# Different categories may legitimately have different quartiles; conflicting
|
||||
# claims for the same system/category/year must not be resolved by ordering.
|
||||
claims = {}
|
||||
basis_conflict = False
|
||||
for basis in bases:
|
||||
key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else (
|
||||
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric"), basis.get("metric_year"), basis_year)
|
||||
if key in claims and claims[key] != basis["basis"]:
|
||||
report("quartile", "Подтверждённые основания источников расходятся: для одной системы, категории, метрики и года указаны разные значения")
|
||||
basis_conflict = True
|
||||
claims[key] = basis["basis"]
|
||||
accepted = [b for b in bases if level == 2 or b["basis"] != "К2"]
|
||||
if not accepted:
|
||||
report("quartile", f"Нет подтверждённого основания за {basis_year or 'год публикации'}: Q1/Q2, К1{'/К2' if level == 2 else ''} или RSCI. Проверьте категорию журнала, год и источник")
|
||||
# Missing optional evidence is not a veto on another sufficient independent
|
||||
# basis. Identity/date/type conflicts, however, invalidate the whole work.
|
||||
identity_conflict = bool(article.get("quartile_issues")) or any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
|
||||
for reason in article.get("catalog_issues", []))
|
||||
if article.get("proof_identity_conflict"):
|
||||
identity_conflict = True
|
||||
report("journal", "DOI и идентификатор в ручном подтверждении указывают на разные работы. Проверьте привязку источника к этой статье")
|
||||
conflicts = date_conflict or type_conflict or basis_conflict or identity_conflict
|
||||
article["confirmed"] = bool(in_period and eligible_kind and accepted and not conflicts)
|
||||
mono_policy = policy.get("monograph_period", {})
|
||||
article["monograph_confirmed"] = bool(kind == "monograph" and not conflicts and sourced(mono_policy)
|
||||
and (mono_policy.get("value") == "all_years" or
|
||||
(mono_policy.get("value") == "five_years" and in_period)))
|
||||
article["confirmation_reasons"] = list(dict.fromkeys(reasons))
|
||||
article["warnings"] = warnings
|
||||
article["display_type"] = article["type_label"] if len(article["types"]) == 1 else "Статья"
|
||||
article["publication_date"] = exact
|
||||
article["confirmed_bases"] = [{**b, "source_url": safe_url(b.get("source_url"))} for b in accepted]
|
||||
article["thematic_status"] = "Рассмотрение статьи по паспорту не подтверждено" if specialty else "Специальность для рассмотрения статьи не выбрана"
|
||||
reviews = [p.get("thematic_review") for p in proofs if p.get("thematic_review")]
|
||||
valid_reviews = []
|
||||
for review in reviews:
|
||||
if not isinstance(review, dict):
|
||||
continue
|
||||
passport = catalog()["passports"].get(review.get("passport_id"), {})
|
||||
passport_branch = passport.get("branch", "см. паспорт").removesuffix(" науки")
|
||||
if (sourced(review) and review.get("specialty_code") == specialty and review.get("branch") == branch and
|
||||
(passport_branch == "см. паспорт" or passport_branch == branch) and
|
||||
passport.get("specialty_code") == specialty and review.get("passport_sha256") == passport.get("sha256") and
|
||||
review.get("point_numbers") and set(review["point_numbers"]) <= {p["number"] for p in passport.get("directions", [])} and
|
||||
review.get("materials") and isinstance(review.get("checked_conditions"), list) and review.get("result") in {"confirmed", "requires_check"}):
|
||||
valid_reviews.append(review)
|
||||
article["thematic_status"] = ("Статья рассмотрена: " + {"confirmed": "соответствие подтверждено", "requires_check": "требует проверки"}[review["result"]] + f"; паспорт {review['passport_id']}; "
|
||||
f"пункты {review['point_numbers']}; {review['checked_on']}")
|
||||
if len({r["result"] for r in valid_reviews}) > 1:
|
||||
article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка"
|
||||
if specialty and (not valid_reviews or any(r["result"] != "confirmed" for r in valid_reviews)):
|
||||
warnings["specialty"].append("Связь журнала со специальностью не доказывает соответствие содержания каждой статьи. Рассмотрение этой работы по паспорту специальности не подтверждено")
|
||||
|
||||
|
||||
def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end):
|
||||
proofs = [r for r in employee_evidence.get("degrees", []) if isinstance(r.get("degree"), str) and sourced(r)]
|
||||
history = list(dict.fromkeys([*degrees, *(r["degree"] for r in proofs)]))
|
||||
level = max((degree_level(d) for d in history), default=0)
|
||||
selected = sorted((d for d in history if degree_level(d) == level and level), key=_name)
|
||||
degree_confirmed = bool(selected and any(degree_level(r["degree"]) == level for r in proofs))
|
||||
minimum, monographs = BRANCHES.get(branch, (None, 0)) if level == 2 else (20, 0) if level == 1 else (None, 0)
|
||||
policy = evidence().get("policy", {})
|
||||
for article in articles:
|
||||
assess_article(article, publication_proofs(employee_evidence, article), start, end, level, branch, specialty, policy)
|
||||
if article["confirmed"]:
|
||||
for basis in article["confirmed_bases"]:
|
||||
article[{"К1": "k1", "К2": "k2", "RSCI": "rsci", "Q1": "q1", "Q2": "q2"}[basis["basis"]]] = True
|
||||
article["qualifies"] = article["q1"] or article["q2"] or article["k1"] or article["rsci"] or (level == 2 and article["k2"])
|
||||
confirmed = sum(a["confirmed"] for a in articles)
|
||||
mono_count = sum(a["monograph_confirmed"] for a in articles)
|
||||
reasons = []
|
||||
if not level:
|
||||
reasons.append("Учёная степень для пунктов 5–6 не установлена")
|
||||
elif not degree_confirmed:
|
||||
reasons.append("Высшая текущая степень документально не подтверждена")
|
||||
if not branch:
|
||||
reasons.append("Отрасль предполагаемого включения не задана")
|
||||
if not articles:
|
||||
reasons.append("Доступных публикаций нет; полнота сведений не подтверждена")
|
||||
if minimum is not None and confirmed < minimum:
|
||||
reasons.append(f"Подтверждено {confirmed} из требуемых {minimum}; полнота перечня требует проверки")
|
||||
if mono_count < monographs:
|
||||
reasons.append("Обязательная монография не подтверждена")
|
||||
clause = 5 if level == 2 else 6 if level == 1 else None
|
||||
status = f"Соответствует публикационным критериям пункта {clause}" if clause and not reasons else "Требует проверки"
|
||||
return {"degree": "; ".join(history) or "Учёная степень не указана", "selected_degree": "; ".join(selected),
|
||||
"degree_confirmed": degree_confirmed, "degree_level": level, "clause": clause, "minimum": minimum,
|
||||
"monograph_minimum": monographs, "monograph_count": mono_count, "confirmed_count": confirmed,
|
||||
"assessment_status": status, "assessment_reasons": reasons}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Read the supplied thematic snapshots; never infer article approval from them."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
CATALOGS = (DATA / "q1-journals/catalog.json", DATA / "q2-journals/catalog.json")
|
||||
|
||||
|
||||
def safe_url(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme in {"http", "https"} and parsed.hostname and not parsed.username and not parsed.password:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def issn(value):
|
||||
return re.sub(r"[^0-9X]", "", str(value or "").upper())
|
||||
|
||||
|
||||
def citescore_quartile(percentile):
|
||||
if type(percentile) not in {int, float} or not 0 <= percentile <= 100:
|
||||
raise ValueError("Invalid CiteScore percentile")
|
||||
return "Q1" if percentile >= 75 else "Q2" if percentile >= 50 else "Q3" if percentile >= 25 else "Q4"
|
||||
|
||||
|
||||
def _unique(records, key):
|
||||
result = {}
|
||||
for record in records:
|
||||
identifier = key(record)
|
||||
if identifier in result:
|
||||
raise ValueError("Duplicate catalog identifier")
|
||||
result[identifier] = record
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def catalog():
|
||||
journals = defaultdict(list)
|
||||
passports = {}
|
||||
metadata = []
|
||||
issues = []
|
||||
for path in CATALOGS:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
meta = data["metadata"]
|
||||
if meta["schema_version"] not in {"1.0.0", "1.1.0"}:
|
||||
raise ValueError("Unsupported catalog schema")
|
||||
rule = meta.get("selection_rule") or meta.get("q1_rule") or meta["q2_rule"]
|
||||
sources = _unique(data["sources"], lambda r: r["id"])
|
||||
specialties = _unique(data["specialties"], lambda r: r["code"])
|
||||
local_passports = {}
|
||||
for specialty in specialties.values():
|
||||
for passport in specialty["passports"]:
|
||||
pdf = (path.parent / passport["local_pdf"]).resolve()
|
||||
if not pdf.is_relative_to(DATA.resolve()):
|
||||
raise ValueError("Passport path outside data directory")
|
||||
if hashlib.sha256(pdf.read_bytes()).hexdigest() != passport["sha256"]:
|
||||
raise ValueError("Passport checksum mismatch")
|
||||
entry = {**passport, "specialty_code": specialty["code"]}
|
||||
if passport["id"] in local_passports:
|
||||
raise ValueError("Duplicate passport")
|
||||
if passport["id"] in passports and passports[passport["id"]] != entry:
|
||||
raise ValueError("Conflicting passport versions")
|
||||
local_passports[passport["id"]] = entry
|
||||
local_journals = _unique(data["journals"], lambda r: r["id"])
|
||||
links = _unique(data["specialty_links"], lambda r: (r["journal_id"], r["specialty_code"]))
|
||||
assessments = _unique(data["assessments"], lambda r: (r["journal_id"], r["specialty_code"]))
|
||||
for (journal_id, code), assessment in assessments.items():
|
||||
if journal_id not in local_journals or code not in specialties:
|
||||
raise ValueError("Unresolved assessment")
|
||||
if assessment["assessment"] not in {"direct", "conditional", "not_established"}:
|
||||
raise ValueError("Invalid assessment level")
|
||||
for (journal_id, code), link in links.items():
|
||||
if journal_id not in local_journals or code not in specialties:
|
||||
raise ValueError("Unresolved specialty link")
|
||||
if link["match_level"] not in {"direct", "conditional"}:
|
||||
raise ValueError("Invalid specialty level")
|
||||
for field in ("rationales", "conditions"):
|
||||
if not isinstance(link[field], list) or not all(isinstance(v, str) for v in link[field]):
|
||||
raise ValueError("Invalid specialty explanations")
|
||||
if assessments.get((journal_id, code), {}).get("assessment") != link["match_level"]:
|
||||
raise ValueError("Conflicting assessment")
|
||||
for reference in link["passport_references"]:
|
||||
passport = local_passports[reference["passport_id"]]
|
||||
if passport["specialty_code"] != code or not set(reference["point_numbers"]) <= {
|
||||
point["number"] for point in passport["directions"]
|
||||
}:
|
||||
raise ValueError("Invalid passport points")
|
||||
prepared = []
|
||||
for journal in local_journals.values():
|
||||
if journal["id"] != f"scopus:{journal['scopus_source_id']}":
|
||||
raise ValueError("Conflicting Scopus journal identifier")
|
||||
metrics = []
|
||||
for system, field in (("Scopus", "scopus"), ("Web of Science", "wos")):
|
||||
metric = journal[field]
|
||||
source = sources[metric["source_id"]]
|
||||
year = metric["year"] if field == "scopus" else metric["metric_year"]
|
||||
if source["metric_year"] != year:
|
||||
raise ValueError("Conflicting metric year")
|
||||
for category in metric["categories"]:
|
||||
quartile = citescore_quartile(category["percentile"]) if field == "scopus" else category["quartile"]
|
||||
if quartile not in {"Q1", "Q2", "Q3", "Q4"} or category.get("quartile", quartile) != quartile:
|
||||
raise ValueError("Conflicting quartile")
|
||||
metrics.append({"system": system, "metric": metric["metric"], "year": year,
|
||||
"release_year": metric.get("release_year"), "category": category["category"],
|
||||
"edition": category.get("edition"), "quartile": quartile,
|
||||
"source": source["title"], "source_url": safe_url(source.get("url")),
|
||||
"source_row": category.get("source_row"), "source_pdf_page": category.get("source_pdf_page")})
|
||||
prepared.append({**journal, "metrics": metrics, "catalog": path.parent.name,
|
||||
"links": {code: link for (jid, code), link in links.items() if jid == journal["id"]},
|
||||
"assessments": {code: value["assessment"] for (jid, code), value in assessments.items() if jid == journal["id"]}})
|
||||
# Commit only a fully validated snapshot. Broken data cannot supply evidence.
|
||||
passports.update(local_passports)
|
||||
for journal in prepared:
|
||||
journals[journal["id"]].append(journal)
|
||||
metadata.append({"catalog": path.parent.name, "schema_version": meta["schema_version"],
|
||||
"rule": rule, "metric_years": meta["metric_years"], "generated_on": meta["generated_on"]})
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError, IndexError):
|
||||
issues.append(f"{path.parent.name}: целостность каталога не подтверждена")
|
||||
by_issn = defaultdict(set)
|
||||
for identifier, records in journals.items():
|
||||
for record in records:
|
||||
for value in (record.get("issn"), record.get("eissn")):
|
||||
if value:
|
||||
by_issn[issn(value)].add(identifier)
|
||||
return {"journals": dict(journals), "by_issn": dict(by_issn), "passports": passports,
|
||||
"metadata": metadata, "issues": issues}
|
||||
|
||||
|
||||
def publication_catalog(identifiers, names, specialty=""):
|
||||
data = catalog()
|
||||
matches = set().union(*(data["by_issn"].get(issn(value), set()) for value in identifiers))
|
||||
# Snapshot failures are reported once in the assessment context. Only
|
||||
# problems with this journal may prevent use of its validated metrics.
|
||||
issues = []
|
||||
if len(matches) > 1:
|
||||
return [], [*issues, "ISSN указывают на разные журналы — требуется проверка"]
|
||||
records = data["journals"].get(next(iter(matches)), []) if matches else []
|
||||
if records and names and not any(str(name).casefold().strip() == r["name"].casefold() for name in names for r in records):
|
||||
issues.append("Название журнала в публикации и запись по ISSN расходятся. Проверьте, относятся ли они к одному журналу")
|
||||
output = []
|
||||
for record in records:
|
||||
link = record["links"].get(specialty)
|
||||
thematic = []
|
||||
labels = {"direct": "прямая", "conditional": "условная", "not_established": "не установлена"}
|
||||
if link:
|
||||
thematic.extend([f"Аналитическая связь журнала: {labels[link['match_level']]}", *link["rationales"], *link["conditions"]])
|
||||
for ref in link["passport_references"]:
|
||||
passport = data["passports"][ref["passport_id"]]
|
||||
thematic.append(f"Паспорт {ref['passport_id']} ({passport['branch']}), SHA-256 {passport['sha256']}; пункты: "
|
||||
+ ", ".join(map(str, ref["point_numbers"])))
|
||||
elif specialty:
|
||||
thematic.append("Связь журнала: " + labels.get(record["assessments"].get(specialty), "не оценена"))
|
||||
output.append({"id": record["id"], "name": record["name"], "issn": record.get("issn"), "eissn": record.get("eissn"),
|
||||
"catalog": record["catalog"], "metrics": record["metrics"], "thematic": thematic,
|
||||
"match_level": link["match_level"] if link else None,
|
||||
"specialty_codes": list(record["links"]),
|
||||
"passport_urls": [safe_url(data["passports"][r["passport_id"]]["source_url"]) for r in link["passport_references"]] if link else []})
|
||||
# A conflict is preserved; it never resolves by load order.
|
||||
if len(records) > 1:
|
||||
for field in ("name", "issn", "eissn", "metrics", "links", "assessments"):
|
||||
if any(record[field] != records[0][field] for record in records[1:]):
|
||||
issues.append("Сведения каталогов расходятся: " + field)
|
||||
return output, issues
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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):
|
||||
values = [ILLEGAL_CHARACTERS_RE.sub("", value) if isinstance(value, str) else value for value in 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, selection_results: list[dict]) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
specialty_labels = {item["code"]: f"{item['code']}. — {item['name']}" for item in result["specialties"]}
|
||||
def specialties_text(codes):
|
||||
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
|
||||
|
||||
_append(employees, ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего",
|
||||
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
|
||||
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
|
||||
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
|
||||
all_candidates = next(data["candidates"] for data in selection_results if data["branch"] == result["branch"])
|
||||
for number, candidate in enumerate(all_candidates, 1):
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
total = candidate["total_count"]
|
||||
_append(employees, [number, candidate["name"], candidate["selected_degree"],
|
||||
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
|
||||
candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
|
||||
for article in candidate["articles"]:
|
||||
criteria = list(dict.fromkeys("В пределах пяти лет" if value.startswith(
|
||||
("В пределах пяти лет", "В пределах пятилетнего периода")) else value for value in article["criteria"]))
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"],
|
||||
article["type_label"] if article["types"] else "не указан", "; ".join(criteria),
|
||||
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "k2", "rsci")],
|
||||
specialties_text(article["specialty_codes"]), article["doi_url"], article["url"]])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Отрасль оценки", result["branch"]],
|
||||
["Период оценки", "Пять календарных лет, включая текущий год Москвы; день и месяц не учитываются"],
|
||||
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI показывают наличие соответствующего основания у уникальной работы в календарном периоде. Это не заключение о включении сотрудника в совет; подтверждённый зачёт и причины проверки доступны на сайте."],
|
||||
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
|
||||
["Границы и виды", "Год публикации должен быть согласован между источниками; допустимость иных видов требует подтверждения. Без него соответствующие работы не подтверждают минимум."],
|
||||
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
|
||||
["Подбор по специальности", "Хотя бы одна публикация за пять календарных лет в журнале К1 по Перечню ВАК на 15.09.2026 либо с ручной тематической связью; содержание статьи не проверяется."],
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
|
||||
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", len(all_candidates)], ["Найдено с исходными фильтрами", result["matched"]],
|
||||
["Всего сотрудников в списке", result["total"]],
|
||||
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Всего", "Как на сайте: уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Для кандидата/PhD одного К2 недостаточно. Пересечения учитываются один раз. Это предварительный счётчик; подтверждённый зачёт показан отдельно."],
|
||||
["Интерактивный подбор", "Первый лист содержит всех сотрудников с учёной степенью на момент выгрузки. Специальность, отрасль, поиск, степень, статус и сортировка меняются в Excel без макросов. Начальные фильтры взяты с сайта; подробные листы содержат полный набор сотрудников и публикаций."],
|
||||
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
|
||||
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
|
||||
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
|
||||
["Автоматический зачёт на сайте", "Известная статья с журналом и Q1/Q2 из источника за применимый год автоматически засчитывается при согласованном годе в периоде и отсутствии конфликтов. Подтверждение степени и тематическое рассмотрение проверяются отдельно."],
|
||||
["Ручные корректировки", "Учитываются правки статьи, журнала, квартиля и специальностей из редактора. Квартиль относится к году публикации; тематическая связь журнала действует независимо от года. Ручной квартиль без источника требует проверки."],
|
||||
["Совпадение ФИО", "При одинаковом ФИО нескольких сотрудников данные выгрузки ВШЭ не присваиваются автоматически; используются записи личного кабинета каждого сотрудника."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for group in result["criteria_groups"]:
|
||||
_append(parameters, ["Доктор · " + group["names"], f"{group['minimum']} публикаций; К1/К2/RSCI/Q1/Q2; обязательных монографий: {group['monographs']}"])
|
||||
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
|
||||
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
|
||||
for meta in result["catalog_metadata"]:
|
||||
_append(parameters, [meta["catalog"], f"Схема {meta['schema_version']}; сформирован {meta['generated_on']}; {meta['metric_years']}; {meta['rule']}"])
|
||||
for issue in result["catalog_issues"]:
|
||||
_append(parameters, ["Проверка каталога", issue])
|
||||
for meta in result["category_metadata"]:
|
||||
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 10, 12, 18, 28, 70, 45]),
|
||||
(publications, [7, 34, 65, 12, 24, 80, 10, 10, 10, 10, 10, 70, 45, 45]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
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
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Prepared council publication data and shared source normalization."""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from io import StringIO
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
def _journal_names(data: dict) -> list[str]:
|
||||
names = []
|
||||
for field in ("Название журнала", "Журнал", "journal"):
|
||||
value = data.get(field)
|
||||
if isinstance(value, dict):
|
||||
value = value.get("name")
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
return names
|
||||
|
||||
|
||||
def _description_journals(description: dict) -> list[str]:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
names = []
|
||||
for field in ("short", "shortLeft"):
|
||||
value = description.get(field)
|
||||
values = value.values() if isinstance(value, dict) else [value]
|
||||
for text in values:
|
||||
if isinstance(text, str):
|
||||
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) if "<" in text or "&" in text else text.strip()
|
||||
# HSE short descriptions start with the journal, followed by the year.
|
||||
names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;"))
|
||||
return names
|
||||
|
||||
|
||||
def prepare_reference(source: Path) -> dict:
|
||||
publications = []
|
||||
by_author = defaultdict(list)
|
||||
# Git stores this text export with LF; canonicalize Windows CRLF before
|
||||
# parsing and hashing so the prepared artifact is identical on both hosts.
|
||||
source_bytes = source.read_bytes().replace(b"\r\n", b"\n")
|
||||
with StringIO(source_bytes.decode("utf-8-sig"), newline="") as stream:
|
||||
reader = csv.DictReader(stream, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
||||
raise ValueError("Duplicate council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
authors = list(dict.fromkeys(value.strip() for value in row["Авторы НИУ ВШЭ"].split(";") if value.strip()))
|
||||
record = {"source_row": row_number, "title": row["Название публикации"].strip(),
|
||||
"year": int(row["Год"]) if row["Год"].strip().isdigit() else None,
|
||||
"doi": _doi(row["DOI"]), "authors": authors, "quartiles": _quartiles(row),
|
||||
"journals": _journal_names(row),
|
||||
"metadata": {key: value for key, value in row.items()
|
||||
if key not in {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}}}
|
||||
index = len(publications)
|
||||
publications.append(record)
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in authors} - {""}):
|
||||
by_author[author].append(index)
|
||||
return {"schema_version": 1, "source_file": source.name,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(), "source_hash_eol": "LF",
|
||||
"source_rows": len(publications), "publications": publications, "by_author": dict(by_author)}
|
||||
|
||||
|
||||
def load_reference(path: Path) -> dict[str, list[dict]]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or type(data.get("schema_version")) is not int or data.get("schema_version") != 1:
|
||||
raise ValueError("Unsupported council reference schema")
|
||||
publications = data.get("publications")
|
||||
by_author = data.get("by_author")
|
||||
if not isinstance(publications, list) or not isinstance(by_author, dict) or data.get("source_rows") != len(publications):
|
||||
raise ValueError("Invalid council reference records")
|
||||
articles = []
|
||||
expected = defaultdict(list)
|
||||
for index, record in enumerate(publications):
|
||||
if not isinstance(record, dict) or not isinstance(record.get("title"), str) or not isinstance(record.get("doi"), str):
|
||||
raise ValueError("Invalid council publication text")
|
||||
if record["doi"] != _doi(record["doi"]) or (record.get("year") is not None and type(record["year"]) is not int):
|
||||
raise ValueError("Invalid council publication DOI/year")
|
||||
if record.get("source_row") != index + 2:
|
||||
raise ValueError("Invalid council source row")
|
||||
for field in ("authors", "quartiles", "journals"):
|
||||
if not isinstance(record.get(field), list) or not all(isinstance(value, str) for value in record[field]):
|
||||
raise ValueError("Invalid council publication list")
|
||||
if not set(record["quartiles"]) <= {"Scopus Q1", "Scopus Q2", "Web of Science Q1", "Web of Science Q2"}:
|
||||
raise ValueError("Invalid council quartiles")
|
||||
if record["title"]:
|
||||
for author in sorted({_name(value) for value in record["authors"]} - {""}):
|
||||
expected[author].append(index)
|
||||
articles.append({"title": record["title"], "year": record["year"], "doi": record["doi"],
|
||||
"types": [], "quartiles": record["quartiles"], "journals": record["journals"],
|
||||
"origins": ["Данные ВШЭ"], "rows": [record["source_row"]],
|
||||
"in_csv": True, "in_db": False, "url": None})
|
||||
if by_author != dict(expected) or any(type(i) is not int for indexes in by_author.values() for i in indexes):
|
||||
raise ValueError("Council author index disagrees with publication authors")
|
||||
return {author: [articles[index] for index in indexes] for author, indexes in by_author.items()}
|
||||
+60
-10
@@ -2,7 +2,8 @@ import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
@@ -398,8 +399,11 @@ def _employee_publications_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
imported = [item for item in db.scalars(select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id
|
||||
)).all() if _csv_enrichment(item) is not None]
|
||||
seen_hashes = set()
|
||||
for publication in publications:
|
||||
source_hash = _publication_hash(publication)
|
||||
@@ -420,8 +424,10 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
if not existing:
|
||||
existing = _match_imported_publication(imported, publication)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
db.add(existing)
|
||||
_apply_publication(existing, publication, source_hash)
|
||||
|
||||
@@ -432,8 +438,39 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
for item in stale:
|
||||
if _csv_enrichment(item) is None:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _csv_enrichment(publication: EmployeePublication) -> dict | None:
|
||||
raw = publication.raw_data
|
||||
metadata = raw.get("csv_enrichment") if isinstance(raw, dict) else None
|
||||
return metadata if isinstance(metadata, dict) else None
|
||||
|
||||
|
||||
def _match_imported_publication(imported: list[EmployeePublication], publication: dict) -> EmployeePublication | None:
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
def title(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
identifier = doi(publication.get("doi_url"))
|
||||
year = _int_or_none(publication.get("year"))
|
||||
name = title(publication.get("title") or publication.get("text"))
|
||||
matches = [item for item in imported if
|
||||
(identifier and doi(item.doi_url) == identifier) or
|
||||
(name and title(item.title) == name and item.year in (None, year))]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("Ambiguous imported publication; synchronization rolled back")
|
||||
if not matches:
|
||||
return None
|
||||
match = matches[0]
|
||||
if ((identifier and doi(match.doi_url) and identifier != doi(match.doi_url)) or
|
||||
(year and match.year and year != match.year)):
|
||||
raise ValueError("Conflicting imported publication; synchronization rolled back")
|
||||
return match
|
||||
|
||||
|
||||
def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
@@ -447,8 +484,16 @@ def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
return publications
|
||||
|
||||
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
enrichment = _csv_enrichment(target)
|
||||
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
if enrichment is not None and target.publication_id and publication_id and target.publication_id != publication_id:
|
||||
raise ValueError(
|
||||
f"Ambiguous HSE IDs for CSV publication {target.id}: "
|
||||
f"{target.publication_id} != {publication_id}; publication synchronization rolled back"
|
||||
)
|
||||
preserved = {field: getattr(target, field) for field in ("title", "year", "doi_url")}
|
||||
target.publication_id = (publication_id or target.publication_id) if enrichment is not None else publication_id
|
||||
target.title = _publication_title(publication)
|
||||
target.year = _int_or_none(publication.get("year"))
|
||||
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
|
||||
@@ -462,7 +507,12 @@ def _apply_publication(target: EmployeePublication, publication: dict, source_ha
|
||||
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
|
||||
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
|
||||
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
if enrichment is not None:
|
||||
target.raw_data = {**target.raw_data, "csv_enrichment": enrichment}
|
||||
for field in ("title", "year", "doi_url"):
|
||||
if not getattr(target, field) or (field == "title" and not (publication.get("title") or publication.get("text"))):
|
||||
setattr(target, field, preserved[field])
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Publication quartiles with source, system and year kept separately."""
|
||||
|
||||
from datetime import date
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import publication_catalog, safe_url, issn
|
||||
from app.services.council_source import _name, _doi
|
||||
|
||||
|
||||
SYSTEM_FIELDS = {"Scopus": "Квартиль scopus", "Web of Science": "Квартиль wos"}
|
||||
QUARTILES = {"Q1", "Q2", "Q3", "Q4"}
|
||||
|
||||
|
||||
def entries(payload):
|
||||
if payload is None:
|
||||
return []
|
||||
if (not isinstance(payload, dict) or type(payload.get("schema_version")) is not int or payload["schema_version"] != 1
|
||||
or not isinstance(payload.get("entries"), list)
|
||||
or (payload.get("lookup") is not None and not isinstance(payload["lookup"], dict))
|
||||
or (isinstance(payload.get("lookup"), dict) and payload["lookup"].get("doi") is not None
|
||||
and not isinstance(payload["lookup"]["doi"], str))
|
||||
or not all(isinstance(item, dict) and item.get("quartile") in QUARTILES
|
||||
and item.get("system") in {*SYSTEM_FIELDS, "SCImago"}
|
||||
and (item.get("doi") is None or isinstance(item["doi"], str))
|
||||
and (item.get("journal") is None or isinstance(item["journal"], str))
|
||||
and isinstance(item.get("issns", []), list)
|
||||
and all(isinstance(value, str) for value in item.get("issns", []))
|
||||
for item in payload["entries"])):
|
||||
raise ValueError("Invalid publication quartiles")
|
||||
return payload["entries"]
|
||||
|
||||
|
||||
def hse_entries(raw, publication_year):
|
||||
metadata = raw.get("csv_enrichment", {}) if isinstance(raw, dict) else {}
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
source_year = str(metadata.get("Год") or "").strip()
|
||||
applicable_year = int(source_year) if source_year.isdigit() else publication_year
|
||||
output = []
|
||||
for system, field in SYSTEM_FIELDS.items():
|
||||
quartile = str(metadata.get(field) or "").strip().upper()
|
||||
if quartile in QUARTILES:
|
||||
output.append({"system": system, "quartile": quartile, "metric": None,
|
||||
"doi": _doi(str(metadata.get("DOI") or "")) or None,
|
||||
"metric_year": None, "publication_year": applicable_year,
|
||||
"category": None, "journal": metadata.get("Название журнала") or None,
|
||||
"issns": [], "source": "Данные ВШЭ", "source_url": None,
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output
|
||||
|
||||
|
||||
def catalog_entries(doi_metadata, publication_year, allow_latest=False):
|
||||
"""Only a journal identified by DOI/ISSN and the exact requested metric year."""
|
||||
if doi_metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None:
|
||||
return [], "missing_publication_year"
|
||||
if publication_year not in doi_metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
if not doi_metadata.get("issns"):
|
||||
return [], "missing_journal_issn"
|
||||
records, issues = publication_catalog(doi_metadata["issns"], [])
|
||||
if issues:
|
||||
return [], "journal_identity_conflict"
|
||||
metrics = [(record, metric) for record in records for metric in record["metrics"]]
|
||||
selected_years = {}
|
||||
for system in SYSTEM_FIELDS:
|
||||
years = {metric["year"] for _, metric in metrics if metric["system"] == system}
|
||||
selected_years[system] = publication_year if publication_year in years else max(years, default=None) if allow_latest else None
|
||||
output = []
|
||||
for record in records:
|
||||
for metric in record["metrics"]:
|
||||
if metric["year"] == selected_years.get(metric["system"]) and metric["system"] in SYSTEM_FIELDS:
|
||||
output.append({"system": metric["system"], "quartile": metric["quartile"],
|
||||
"doi": doi_metadata.get("doi"),
|
||||
"metric": metric["metric"], "metric_year": metric["year"],
|
||||
"publication_year": publication_year, "category": metric["category"],
|
||||
"journal": record["name"], "issns": doi_metadata["issns"],
|
||||
"source": metric["source"], "source_url": safe_url(metric["source_url"]),
|
||||
"checked_on": date.today().isoformat()})
|
||||
return output, ("found_latest" if any(item["metric_year"] != publication_year for item in output) else "found") if output else "historical_quartile_unavailable"
|
||||
|
||||
|
||||
def merge(existing, additions, lookup=None):
|
||||
current = entries(existing)
|
||||
previous_doi = _doi(((existing or {}).get("lookup") or {}).get("doi") or "") or None
|
||||
# Freeze the previous lookup's identity on legacy evidence before replacing it.
|
||||
output = [{**item, "doi": previous_doi} if "doi" not in item else dict(item) for item in current]
|
||||
# Repeating a lookup must not duplicate evidence merely because its date changed.
|
||||
def key(item):
|
||||
return tuple(item.get(field) for field in ("system", "quartile", "metric", "metric_year",
|
||||
"publication_year", "category", "journal", "source", "source_url", "doi"))
|
||||
seen = {key(item) for item in output}
|
||||
for item in additions:
|
||||
if key(item) not in seen:
|
||||
output.append(item)
|
||||
seen.add(key(item))
|
||||
result = {**(existing or {}), "schema_version": 1, "entries": output}
|
||||
if lookup is not None:
|
||||
result["lookup"] = lookup
|
||||
return result
|
||||
|
||||
|
||||
def binding_issue(item, payload, doi="", journals=(), identifiers=()):
|
||||
value = item.get("doi") if "doi" in item else ((payload or {}).get("lookup") or {}).get("doi")
|
||||
source_doi = _doi(value or "")
|
||||
if value and not source_doi:
|
||||
return "DOI сохранённого квартиля некорректен — требуется перепроверка"
|
||||
current_doi = _doi(doi or "")
|
||||
if source_doi and source_doi != current_doi:
|
||||
return "Сохранённый квартиль относится к другому DOI — требуется перепроверка"
|
||||
source_ids = {issn(value) for value in item.get("issns", [])} - {""}
|
||||
current_ids = {issn(value) for value in identifiers} - {""}
|
||||
ids_match = bool(source_ids & current_ids)
|
||||
if source_ids and current_ids and not ids_match:
|
||||
return "ISSN сохранённого квартиля и публикации расходятся — требуется перепроверка"
|
||||
source_name = _name(item.get("journal") or "")
|
||||
names_match = bool(source_name and source_name in {_name(name) for name in journals if name})
|
||||
if source_name and journals and not names_match and not ids_match:
|
||||
return f"Квартиль сохранён для журнала «{item.get('journal')}», а статья отнесена к «{' / '.join(journals)}». Совпадение по ISSN не подтверждено. Уточните журнал статьи и источник квартиля"
|
||||
if not (source_doi and source_doi == current_doi) and not ids_match and not names_match:
|
||||
return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка"
|
||||
return None
|
||||
|
||||
|
||||
def identity_issues(payload, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(issue for item in entries(payload)
|
||||
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
|
||||
|
||||
|
||||
def qualifying_entries(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return [item for item in entries(payload)
|
||||
if item["quartile"] in {"Q1", "Q2"}
|
||||
and item["system"] in SYSTEM_FIELDS
|
||||
and item.get("publication_year") == publication_year
|
||||
and item.get("metric_year") in {None, publication_year}
|
||||
and not binding_issue(item, payload, doi, journals, identifiers)]
|
||||
|
||||
|
||||
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
|
||||
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in
|
||||
qualifying_entries(payload, publication_year, doi, journals, identifiers)))
|
||||
|
||||
|
||||
def index_sjr(rankings):
|
||||
from app.services.council_catalog import issn
|
||||
indexed = {}
|
||||
for year, records in rankings.items():
|
||||
by_issn = defaultdict(list)
|
||||
for record in records:
|
||||
for identifier in set(map(issn, record["issns"])) - {""}:
|
||||
by_issn[identifier].append(record)
|
||||
indexed[int(year)] = dict(by_issn)
|
||||
return indexed
|
||||
|
||||
|
||||
def with_title_history(metadata, by_issn, by_title, source_url):
|
||||
"""Use explicit Scopus title succession, never a fuzzy journal-name match."""
|
||||
from app.services.council_catalog import issn
|
||||
if not safe_url(source_url):
|
||||
return metadata
|
||||
extra = set()
|
||||
names = {_name(name) for name in metadata.get("journals", [])}
|
||||
for identifier in metadata.get("issns", []):
|
||||
for old in by_issn.get(issn(identifier), []):
|
||||
if old["history"] not in {"Continued as", "Continues"}:
|
||||
continue
|
||||
for related in old["related"]:
|
||||
candidates = by_title.get(_name(related), [])
|
||||
if _name(related) in names and len(candidates) == 1:
|
||||
extra.update(candidates[0]["issns"])
|
||||
if not extra:
|
||||
return metadata
|
||||
return {**metadata, "ranking_issns": sorted(set(metadata["issns"]) | extra),
|
||||
"journal_identity_source_url": source_url}
|
||||
|
||||
|
||||
def sjr_entries(metadata, publication_year, rankings, allow_latest=False):
|
||||
from app.services.council_catalog import issn
|
||||
if metadata.get("type") != "journal-article":
|
||||
return [], "not_journal_article"
|
||||
if publication_year is None or publication_year not in metadata.get("years", []):
|
||||
return [], "publication_year_conflict"
|
||||
matches = {}
|
||||
identifiers = set(map(issn, metadata.get("ranking_issns", metadata.get("issns", [])))) - {""}
|
||||
for year, by_issn in rankings.items():
|
||||
journals = {record["source_url"]: record for identifier in identifiers for record in by_issn.get(identifier, [])}
|
||||
if journals:
|
||||
matches[int(year)] = journals
|
||||
year = publication_year if publication_year in matches else max(matches, default=None) if allow_latest else None
|
||||
if year is None:
|
||||
return [], "historical_quartile_unavailable"
|
||||
if len(matches[year]) != 1:
|
||||
return [], "journal_identity_conflict"
|
||||
record = next(iter(matches[year].values()))
|
||||
if record.get("conflict"):
|
||||
return [], "ranking_category_conflict"
|
||||
categories = []
|
||||
for category in record.get("categories", "").split(";"):
|
||||
if not category.strip():
|
||||
continue
|
||||
match = re.fullmatch(r"\s*(.+?)\s*\((Q[1-4])\)\s*", category)
|
||||
if match:
|
||||
categories.append((match[1].strip(), match[2]))
|
||||
else:
|
||||
return [], "ranking_category_conflict"
|
||||
if categories and min(quartile for _, quartile in categories) != record["quartile"]:
|
||||
return [], "ranking_category_conflict"
|
||||
if not categories and record.get("quartile") in QUARTILES:
|
||||
categories = [(None, record["quartile"])]
|
||||
result = [{"system": "SCImago", "quartile": quartile, "metric": "SJR" if category else "SJR best quartile",
|
||||
"doi": metadata.get("doi"),
|
||||
"metric_year": year, "publication_year": publication_year, "category": category,
|
||||
"journal": record["journal"], "issns": record["issns"],
|
||||
"source": "SCImago Journal & Country Rank · Universidad de Extremadura",
|
||||
"source_url": record["source_url"], "checked_on": date.today().isoformat()}
|
||||
for category, quartile in categories]
|
||||
if metadata.get("journal_identity_source_url"):
|
||||
for item in result:
|
||||
item["journal_identity_source_url"] = metadata["journal_identity_source_url"]
|
||||
return result, "found" if year == publication_year else "found_latest"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Match a publication's journal to the supplied K1/K2 category snapshots."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from app.services.council_catalog import issn
|
||||
from app.services.council_source import _name
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
return " ".join(re.findall(r"\w+", _name(value)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def category_index():
|
||||
files = {category: DATA / filename for category, filename in
|
||||
(("К1", "k1_journals.json"), ("К2", "k2_journals.json"))}
|
||||
documents = {category: json.loads(path.read_text(encoding="utf8")) for category, path in files.items()}
|
||||
if documents["К1"]["source_sha256"] != documents["К2"]["source_sha256"]:
|
||||
raise ValueError("VAK category source mismatch")
|
||||
identities = json.loads((DATA / "k1_specialties.json").read_text(encoding="utf8"))
|
||||
if identities["k1_source_sha256"] != hashlib.sha256(files["К1"].read_bytes().replace(b"\r\n", b"\n")).hexdigest():
|
||||
raise ValueError("VAK journal identity source mismatch")
|
||||
known = {item["name"]: sorted({issn(entry["issn"]) for entry in item["vak_entries"]})
|
||||
for item in identities["journals"] if item.get("status") == "matched"}
|
||||
by_name, by_issn = defaultdict(list), defaultdict(list)
|
||||
for category, document in documents.items():
|
||||
for journal in document["journals"]:
|
||||
record = {"category": category, "journal": journal["name"], "source": document["source"],
|
||||
"source_sha256": document["source_sha256"], "source_row": journal["source_row"],
|
||||
"categorized_on": journal["categorized_on"], "issns": known.get(journal["name"], []) if category == "К1" else []}
|
||||
if record["issns"]:
|
||||
record.update(identity_source=identities["source"], identity_source_sha256=identities["source_sha256"],
|
||||
identity_as_of=identities["as_of"])
|
||||
for name in [journal["name"], *journal.get("aliases", [])]:
|
||||
by_name[journal_key(name)].append(record)
|
||||
for identifier in record["issns"]:
|
||||
by_issn[identifier].append(record)
|
||||
return dict(by_name), dict(by_issn)
|
||||
|
||||
|
||||
def assess_vak(names, identifiers):
|
||||
by_name, by_issn = category_index()
|
||||
matches = {}
|
||||
normalized = {issn(value) for value in identifiers} - {""}
|
||||
for identifier in normalized:
|
||||
for record in by_issn.get(identifier, []):
|
||||
matches[(record["category"], record["source_row"])] = {**record, "match_method": "issn", "identity_confirmed": True}
|
||||
for name in names:
|
||||
for record in by_name.get(journal_key(name), []):
|
||||
if record["issns"] and normalized and not normalized.intersection(record["issns"]):
|
||||
return {"schema_version": 1, "entries": [], "status": "journal_identity_conflict",
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False, "checked_on": date.today().isoformat()}
|
||||
key = record["category"], record["source_row"]
|
||||
matches.setdefault(key, {**record, "match_method": "normalized_name", "identity_confirmed": False})
|
||||
if len(matches) > 1:
|
||||
status, output = "ambiguous_journal", []
|
||||
elif matches:
|
||||
status, output = "found", list(matches.values())
|
||||
else:
|
||||
status, output = ("not_found_in_k1_k2" if names or identifiers else "unknown_journal"), []
|
||||
return {"schema_version": 1, "entries": output, "status": status,
|
||||
"scope": "supplied_category_snapshot", "historical_category_confirmed": False,
|
||||
"checked_on": date.today().isoformat()}
|
||||
+278
-9
@@ -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 {
|
||||
@@ -183,6 +189,7 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
@@ -280,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));
|
||||
@@ -543,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;
|
||||
@@ -585,6 +614,10 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__filter-hint {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -597,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;
|
||||
@@ -759,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;
|
||||
@@ -777,7 +825,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 620px) {
|
||||
.admin {
|
||||
--admin-gutter: 16px;
|
||||
}
|
||||
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -788,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 {
|
||||
@@ -814,4 +864,223 @@
|
||||
.directory__field {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.council {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
color: #25313a;
|
||||
}
|
||||
|
||||
.council__hero {
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
color: #f9f6f0;
|
||||
background: #263640;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
|
||||
.council__intro { margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
|
||||
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
|
||||
.council__criteria {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding-block: 6px 22px;
|
||||
border-bottom: 1px solid #cdd4d5;
|
||||
}
|
||||
|
||||
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
|
||||
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.council__criteria span { color: #4c5b64; font-size: 13px; }
|
||||
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
|
||||
.council__notice strong { color: #593b20; }
|
||||
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
|
||||
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
|
||||
.council__list { display: grid; gap: 10px; }
|
||||
|
||||
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__candidate[open] { border-color: #8fa8a5; }
|
||||
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
|
||||
.council__candidate-summary::-webkit-details-marker { display: none; }
|
||||
.council__candidate-summary:hover { background: #f3f7f5; }
|
||||
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
|
||||
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
|
||||
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
|
||||
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
|
||||
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
|
||||
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
|
||||
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
|
||||
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
|
||||
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
|
||||
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
|
||||
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
|
||||
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
|
||||
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
|
||||
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
|
||||
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
|
||||
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
|
||||
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
|
||||
.council__candidate-summary .council__candidate-identity { grid-column: 1; }
|
||||
.council__candidate-cue { grid-column: 2; grid-row: 1; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) 14px; }
|
||||
.council__counts { grid-column: 1 / -1; justify-content: space-between; }
|
||||
.council__candidate-count { text-align: start; }
|
||||
.council__candidate-body { padding: 14px; }
|
||||
}
|
||||
|
||||
.council__search { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; }
|
||||
.council__search .directory__field { flex: 1 1 240px; max-width: 520px; }
|
||||
.council__notice summary { cursor: pointer; padding-block: 6px; }
|
||||
.council__notice p { margin-bottom: 0; }
|
||||
.council__counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: clamp(8px, 1.5vw, 20px); }
|
||||
@media (max-width: 700px) {
|
||||
.council__counts { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 14px; }
|
||||
}
|
||||
.council__candidate-count small { max-width: 105px; line-height: 1.4; }
|
||||
.council__hint { margin: 0; color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.council__candidate-body .council__hint { margin-top: 12px; }
|
||||
.council__evidence { display: flex; flex-wrap: wrap; gap: 4px 20px; padding-inline-start: 18px; margin: 10px 0 0; color: #4c5b64; font-size: 13px; line-height: 1.5; }
|
||||
.council__article-heading span { flex-shrink: 0; }
|
||||
.council ::selection { background: #ccfbf1; color: #25313a; }
|
||||
.council input { caret-color: #0f766e; }
|
||||
.council a { text-underline-offset: 3px; }
|
||||
.council__article { overflow-wrap: anywhere; }
|
||||
@media (max-width: 400px) {
|
||||
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
|
||||
.council__article-heading { flex-direction: column; gap: 4px; }
|
||||
}
|
||||
|
||||
.council__article[data-council-article] { position: relative; padding-inline-end: 60px; cursor: pointer; }
|
||||
.council__article[data-council-article]:hover { background: #f3f7f5; }
|
||||
.council__article--active { background: #e7f0eb; }
|
||||
.council__article[data-council-article]:focus-visible { outline: 3px solid #0f766e; outline-offset: 2px; }
|
||||
.council__edit { position: absolute; inset-inline-end: 4px; top: 12px; min-width: 44px; min-height: 44px; visibility: hidden; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.council__article--active > .council__edit,
|
||||
.council__article:focus-within > .council__edit { visibility: visible; }
|
||||
.council__articles--editor { padding: 20px 20px 20px 42px; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__articles--editor .council__hint { margin-block: 8px; }
|
||||
.council-editor { display: grid; gap: 16px; margin-top: 20px; }
|
||||
.council-editor .directory__field { min-width: 0; }
|
||||
.council-editor textarea { resize: vertical; min-height: 90px; font: inherit; }
|
||||
.council-editor__actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.council-editor__error { margin: 0; color: #991b1b; line-height: 1.5; }
|
||||
@media (max-width: 600px) {
|
||||
.council__articles--editor { padding-inline: 28px 12px; }
|
||||
.council-editor .directory__input { font-size: 16px; }
|
||||
}
|
||||
|
||||
.council-editor__specialties { min-width: 0; }
|
||||
.council-editor__specialties summary { cursor: pointer; min-height: 44px; align-content: center; }
|
||||
.council-editor__specialties fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid #d6dddc; border-radius: 8px; }
|
||||
.council-editor__specialties .columns-modal__grid { grid-template-columns: 1fr; margin-block: 12px; }
|
||||
.council__criteria-list { line-height: 1.55; }
|
||||
|
||||
.council__warning { display: inline-block; vertical-align: middle; }
|
||||
.council__warning-button { display: inline-flex; justify-content: center; align-items: center; min-width: 40px; min-height: 40px; padding: 0; border: 0; border-radius: 4px; color: #991b1b; background: transparent; cursor: pointer; vertical-align: middle; }
|
||||
.council__warning-button:hover { background: #fee2e2; }
|
||||
.council__tooltip { position: fixed; z-index: 30; width: min(360px, calc(100vw - 24px)); padding: 12px; box-sizing: border-box; border: 1px solid #d6dddc; border-radius: 8px; color: #1f2937; background: #fff; box-shadow: 0 6px 20px rgba(15, 23, 42, .18); font: 14px/1.5 system-ui, sans-serif; max-height: min(360px, calc(100vh - 24px)); overflow: auto; text-align: start; overflow-wrap: anywhere; }
|
||||
.council__tooltip[hidden] { display: none; }
|
||||
.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } }
|
||||
|
||||
.users__table-wrap { overflow-x: auto; margin-block: 20px; }
|
||||
.users__table-wrap .table { min-width: 720px; }
|
||||
.users__table-wrap .table__cell { vertical-align: top; overflow-wrap: anywhere; max-width: 420px; }
|
||||
.users__controls { display: grid; gap: 8px; min-width: 160px; }
|
||||
.users__controls label { display: grid; gap: 4px; }
|
||||
.users__create { margin-block: 24px; }
|
||||
.users__create summary { cursor: pointer; padding-block: 10px; font-weight: 600; }
|
||||
.users__create-form, .users__password-form { max-width: 520px; }
|
||||
.users__hint { color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.users__error { color: #991b1b; line-height: 1.5; }
|
||||
.users__temporary { padding: 16px; background: #e7f0eb; border: 1px solid #8fa8a5; border-radius: 8px; }
|
||||
.users__temporary .form__input { max-width: 240px; font-family: monospace; font-size: 20px; }
|
||||
.users__account-meta { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.users__account-meta dt { color: #4c5b64; font-size: 14px; }
|
||||
.users__account-meta dd { margin: 4px 0 0; }
|
||||
.users__change { margin-top: 6px; }
|
||||
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
|
||||
|
||||
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
|
||||
|
||||
.guide {
|
||||
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; }
|
||||
@@ -0,0 +1,201 @@
|
||||
(() => {
|
||||
const specialty = document.querySelector('#council-specialty');
|
||||
const branch = document.querySelector('#council-branch');
|
||||
specialty?.addEventListener('change', () => {
|
||||
const names = JSON.parse(specialty.selectedOptions[0].dataset.branches);
|
||||
const previous = branch.value;
|
||||
branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name)));
|
||||
if (names.includes(previous)) branch.value = previous;
|
||||
});
|
||||
const dialog = document.querySelector('[data-council-dialog]');
|
||||
const form = dialog?.querySelector('[data-edit-form]');
|
||||
const autoSpecialties = form?.querySelector('[data-specialty-auto]');
|
||||
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
|
||||
autoSpecialties?.addEventListener('change', () => {
|
||||
specialtyCheckboxes.forEach(input => { input.disabled = autoSpecialties.checked; });
|
||||
});
|
||||
let selected;
|
||||
let trigger;
|
||||
let saving = false;
|
||||
const openKey = 'miem.council.openCandidates';
|
||||
|
||||
try {
|
||||
const opened = JSON.parse(sessionStorage.getItem(openKey) || '[]');
|
||||
document.querySelectorAll('[data-employee-id]').forEach(node => {
|
||||
if (opened.includes(node.dataset.employeeId)) node.open = true;
|
||||
});
|
||||
sessionStorage.removeItem(openKey);
|
||||
} catch (_error) { /* Storage is optional. */ }
|
||||
|
||||
function activate(article) {
|
||||
document.querySelectorAll('.council__article--active').forEach(node => {
|
||||
node.classList.remove('council__article--active');
|
||||
});
|
||||
article.classList.add('council__article--active');
|
||||
}
|
||||
|
||||
function positionTooltip(button, tip) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
tip.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - tip.offsetWidth - 12))}px`;
|
||||
tip.style.top = `${Math.max(12, rect.bottom + tip.offsetHeight + 8 < innerHeight ? rect.bottom + 8 : rect.top - tip.offsetHeight - 8)}px`;
|
||||
}
|
||||
function repositionTooltips() {
|
||||
document.querySelectorAll('[data-warning]').forEach(warning => {
|
||||
const tip = warning.querySelector('[role="tooltip"]');
|
||||
if (!tip.hidden) positionTooltip(warning.querySelector('button'), tip);
|
||||
});
|
||||
}
|
||||
function setupArticles(root) {
|
||||
root.querySelectorAll('[data-council-article]').forEach(article => {
|
||||
article.addEventListener('click', event => {
|
||||
if (!event.target.closest('a, button')) activate(article);
|
||||
});
|
||||
article.addEventListener('keydown', event => {
|
||||
if (event.target === article && ['Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
activate(article);
|
||||
article.querySelector('[data-council-edit]').focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-council-edit]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
trigger = button;
|
||||
selected = JSON.parse(button.dataset.article);
|
||||
if (form) {
|
||||
form.elements.title.value = selected.title;
|
||||
form.elements.journal.value = selected.journal;
|
||||
form.elements.quartile.value = selected.quartile;
|
||||
autoSpecialties.checked = !selected.manual_specialties;
|
||||
form.querySelector("[data-edit-specialties]").open = selected.manual_specialties;
|
||||
specialtyCheckboxes.forEach(input => {
|
||||
input.checked = selected.specialty_codes.includes(input.value);
|
||||
input.disabled = autoSpecialties.checked;
|
||||
});
|
||||
form.elements.quartile.disabled = selected.year === null;
|
||||
form.querySelector('[data-edit-error]').hidden = true;
|
||||
form.querySelector('[data-edit-note]').textContent = selected.year === null
|
||||
? 'Год публикации не указан или неоднозначен. Можно изменить название статьи и журнал; квартиль недоступен.'
|
||||
: `Изменение квартиля применяется ко всем статьям выбранного журнала за ${selected.year} год. Проверьте квартиль при смене журнала.`;
|
||||
}
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('[data-warning]').forEach(warning => {
|
||||
const button = warning.querySelector('button');
|
||||
const tip = warning.querySelector('[role="tooltip"]');
|
||||
let pinned = false;
|
||||
function show(visible) {
|
||||
tip.hidden = !visible;
|
||||
button.setAttribute('aria-expanded', String(visible));
|
||||
if (!visible) return;
|
||||
positionTooltip(button, tip);
|
||||
}
|
||||
warning.addEventListener('mouseenter', () => show(true));
|
||||
warning.addEventListener('mouseleave', () => { if (!pinned && document.activeElement !== button) show(false); });
|
||||
button.addEventListener('focus', () => show(true));
|
||||
button.addEventListener('blur', () => { pinned = false; show(false); });
|
||||
button.addEventListener('click', () => { pinned = !pinned; show(pinned); });
|
||||
warning.addEventListener('council-tooltip-close', () => { pinned = false; show(false); });
|
||||
});
|
||||
}
|
||||
setupArticles(document);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape') return;
|
||||
document.querySelectorAll('[data-warning] button[aria-expanded="true"]').forEach(button => {
|
||||
button.closest('[data-warning]').dispatchEvent(new Event('council-tooltip-close'));
|
||||
});
|
||||
});
|
||||
|
||||
async function loadArticles(details) {
|
||||
if (details.dataset.loaded || details.dataset.loading) return;
|
||||
const panel = details.querySelector('[data-council-content]');
|
||||
const status = panel.querySelector('[data-articles-status]');
|
||||
const content = panel.querySelector('[data-articles-content]');
|
||||
details.dataset.loading = '1';
|
||||
panel.setAttribute('aria-busy', 'true');
|
||||
status.textContent = 'Загружаем публикации…';
|
||||
try {
|
||||
const response = await fetch(panel.dataset.articlesUrl, {credentials: 'same-origin', signal: AbortSignal.timeout(15000)});
|
||||
if (!response.ok) throw new Error('Не удалось загрузить публикации. Попробуйте ещё раз.');
|
||||
content.innerHTML = await response.text();
|
||||
setupArticles(content);
|
||||
details.dataset.loaded = '1';
|
||||
status.textContent = 'Публикации загружены';
|
||||
status.classList.add('council__load-status--done');
|
||||
} catch (_error) {
|
||||
status.textContent = 'Не удалось загрузить публикации. Проверьте подключение и повторите загрузку.';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'button button--secondary';
|
||||
retry.textContent = 'Повторить загрузку публикаций';
|
||||
retry.addEventListener('click', () => loadArticles(details));
|
||||
content.replaceChildren(retry);
|
||||
} finally {
|
||||
delete details.dataset.loading;
|
||||
panel.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('[data-employee-id]').forEach(details => {
|
||||
details.addEventListener('toggle', () => { if (details.open) loadArticles(details); });
|
||||
if (details.open) loadArticles(details);
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target instanceof Element && event.target.closest('.council__tooltip')) return;
|
||||
repositionTooltips();
|
||||
}, true);
|
||||
window.addEventListener('resize', repositionTooltips);
|
||||
|
||||
if (!dialog) return;
|
||||
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
|
||||
button.addEventListener('click', () => { if (!saving) dialog.close(); });
|
||||
});
|
||||
dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); });
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog && !saving) dialog.close(); });
|
||||
dialog.addEventListener('close', () => trigger?.focus());
|
||||
|
||||
form?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
const error = form.querySelector('[data-edit-error]');
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
const payload = {
|
||||
employee_id: selected.employee_id, edit_key: selected.edit_key, revision: selected.revision,
|
||||
title: form.elements.title.value.trim(), journal: form.elements.journal.value.trim(),
|
||||
quartile: selected.year === null ? '' : form.elements.quartile.value,
|
||||
specialty_codes: autoSpecialties.checked ? null : specialtyCheckboxes.filter(input => input.checked).map(input => input.value),
|
||||
};
|
||||
saving = true;
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Сохраняем…';
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
error.hidden = true;
|
||||
try {
|
||||
const response = await fetch('/admin/council/publications', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-Council-Edit': '1'},
|
||||
body: JSON.stringify(payload), signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(typeof data.detail === 'string' ? data.detail : 'Проверьте название статьи, журнал и квартиль.');
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(openKey, JSON.stringify(Array.from(document.querySelectorAll('[data-employee-id][open]')).map(node => node.dataset.employeeId)));
|
||||
} catch (_error) { /* Storage is optional. */ }
|
||||
window.location.reload();
|
||||
} catch (failure) {
|
||||
error.textContent = failure.name === 'TimeoutError'
|
||||
? 'Не получен ответ сервера. Обновите страницу, чтобы проверить сохранение.'
|
||||
: failure.message === 'Failed to fetch' ? 'Нет связи с сервером. Проверьте подключение и попробуйте ещё раз.' : failure.message;
|
||||
error.hidden = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
submit.disabled = false;
|
||||
submit.textContent = 'Сохранить';
|
||||
form.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,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);
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const required = document.querySelector('[data-required-password]');
|
||||
if (required) {
|
||||
const form = document.querySelector('[data-password-form]');
|
||||
required.querySelector('[data-password-dialog-body]').append(form);
|
||||
required.addEventListener('cancel', event => event.preventDefault());
|
||||
required.showModal();
|
||||
(form.querySelector('[aria-invalid="true"]') || form.querySelector('input[type="password"]')).focus();
|
||||
}
|
||||
document.querySelectorAll('[data-password-reset]').forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!window.confirm('Сбросить пароль? Текущие сессии пользователя завершатся. При следующем входе потребуется сменить временный пароль.')) event.preventDefault();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Личный кабинет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Личный кабинет</h2>
|
||||
<p>{{ account_user.full_name or account_user.username }} · {{ 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>
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Сменить пароль</h2>
|
||||
<p class="users__hint">Смена пароля или выход из системы завершает все ваши сессии.</p>
|
||||
<form class="form users__password-form" method="post" action="/admin/account/password" data-password-form>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if error %}<p class="users__error" id="password-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<label class="form__label">Текущий пароль<input class="form__input" name="current_password" type="password" required maxlength="1024" autocomplete="current-password"{% if error_field == 'current_password' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<label class="form__label">Новый пароль<input class="form__input" name="new_password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" aria-describedby="password-hint{% if error_field == 'new_password' %} password-error{% endif %}"{% if error_field == 'new_password' %} aria-invalid="true"{% endif %}></label>
|
||||
<label class="form__label">Повторите новый пароль<input class="form__input" name="confirmation" type="password" required minlength="15" maxlength="128" autocomplete="new-password"{% if error_field == 'confirmation' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<p class="users__hint" id="password-hint">От 15 до 128 символов. Можно использовать фразу. Новый пароль должен отличаться от текущего.</p>
|
||||
<button class="button" type="submit">Сохранить новый пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">Последние действия</h2>{% include 'user_activity.html' %}</section>
|
||||
{% if account_user.must_change_password %}
|
||||
<dialog class="columns-modal" data-required-password aria-labelledby="required-password-title" aria-describedby="required-password-note">
|
||||
<div class="columns-modal__panel">
|
||||
<h2 class="columns-modal__title" id="required-password-title">Смените временный пароль</h2>
|
||||
<p id="required-password-note">Прежде чем пользоваться учётной записью, задайте свой пароль. Закрыть это окно без смены пароля нельзя.</p>
|
||||
<div data-password-dialog-body></div>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Выйти из системы</button></form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+22
-3
@@ -1,30 +1,49 @@
|
||||
{% set is_admin = current_user and current_user.role == 'admin' %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MIEM Employees{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1>
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/admin' }}">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
{% if public_page and not current_user %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в систему</a>
|
||||
{% else %}
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
{% 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>
|
||||
<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>
|
||||
{% endif %}
|
||||
<a class="admin__link" href="/admin/account"{% if request.url.path == '/admin/account' %} aria-current="page"{% endif %}>Личный кабинет</a>
|
||||
{% if current_user and not current_user.must_change_password %}<a class="admin__link" href="/admin/guide" data-guide-open>Как пользоваться</a>{% endif %}
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</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>
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Диссертационный совет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council" aria-labelledby="council-title">
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a class="button button--secondary" href="/admin/council/publications" target="_blank" rel="noopener noreferrer">Статьи, журналы и квартили ↗</a>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>{% if input_error %}Проверьте параметры оценки{% elif export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
|
||||
{% if input_error %}<p>{{ input_error }}</p>{% endif %}
|
||||
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
|
||||
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if catalog_issues %}<div class="council__message" role="alert"><p>{{ catalog_issues|join('; ') }}. Повреждённые сведения не используются для зачёта.</p></div>{% endif %}
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как рассчитываются показатели</strong></summary>
|
||||
<p>Период оценки: {{ start.year }}–{{ end.year }}, пять календарных лет, включая текущий год Москвы; день и месяц публикации не влияют на зачёт. Для доктора наук минимум зависит от отрасли науки по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
|
||||
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
|
||||
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
|
||||
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.</p>
|
||||
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
|
||||
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
|
||||
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
|
||||
<p>«ЛК сотрудника ВШЭ» — количество публикаций из личного кабинета сотрудника за все годы. «Данные ВШЭ» — выгрузка из базы публикаций ВШЭ, которая дополняет сведения из личного кабинета. «Все публикации» объединяет оба источника без дублей за все годы и независимо от категории. При совпадении ФИО нескольких сотрудников данные ВШЭ не присваиваются автоматически. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<input type="hidden" name="previous_specialty" value="{{ specialty }}">
|
||||
<label class="directory__field" for="council-branch">Отрасль оценки
|
||||
<select class="directory__input" id="council-branch" name="branch">
|
||||
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="" data-branches='{{ specialties|map(attribute="branches")|sum(start=[])|unique|list|tojson }}'>Все специальности</option>
|
||||
{% for item in specialties %}
|
||||
<option value="{{ item.code }}" data-branches='{{ item.branches|tojson }}'{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field" for="council-search">Поиск по ФИО
|
||||
<input class="directory__input" id="council-search" name="q" type="search" value="{{ q }}" placeholder="Фамилия или полное имя">
|
||||
</label>
|
||||
<label class="directory__field">Сортировать по
|
||||
<select class="directory__input" name="sort">
|
||||
{% for value, label in [('full_name', 'ФИО'), ('confirmed_count', 'Подтверждено'), ('q1_count', 'Q1'), ('q2_count', 'Q2'), ('k1_count', 'К1'), ('k2_count', 'К2'), ('rsci_count', 'RSCI'), ('total_count', 'ВСЕГО'), ('db_count', 'ЛК сотрудника ВШЭ'), ('count', 'Все публикации')] %}
|
||||
<option value="{{ value }}"{% if sort == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field">Направление
|
||||
<select class="directory__input" name="direction">
|
||||
<option value="asc"{% if direction == 'asc' %} selected{% endif %}>По возрастанию</option>
|
||||
<option value="desc"{% if direction == 'desc' %} selected{% endif %}>По убыванию</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q or specialty %} · Всего в списке: {{ total }}{% endif %}</p>
|
||||
</div>
|
||||
{% if specialty %}<p class="council__hint">{{ specialty }}. — {{ specialty_name }}. Основания отмечены в публикациях каждого сотрудника.</p>{% endif %}
|
||||
{% if candidates %}
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
<div class="council__list">
|
||||
{% for candidate in candidates %}
|
||||
<details class="council__candidate" data-employee-id="{{ candidate.employee_id }}">
|
||||
<summary class="council__candidate-summary">
|
||||
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}</small></span>
|
||||
<span class="council__counts">
|
||||
<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" 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>
|
||||
<div class="council__candidate-body" data-council-content
|
||||
data-articles-url="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&branch={{ branch|urlencode }}">
|
||||
<p class="council__hint" role="status" data-articles-status>Публикации загрузятся при раскрытии карточки.</p>
|
||||
<div data-articles-content><noscript><a class="admin__link" href="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&branch={{ branch|urlencode }}">Открыть публикации сотрудника</a></noscript></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not error %}{% include "council_edit_dialog.html" %}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
|
||||
<p>Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
|
||||
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}{% if not reason.startswith("Подтверждено ") %}<li>{{ reason }}</li>{% endif %}{% endfor %}</ul>{% endif %}
|
||||
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
|
||||
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
|
||||
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
|
||||
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article"{% 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>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
|
||||
<span>{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
</div>
|
||||
<p class="council__hint">Журнал: {{ article.journals|join(" / ") or "не указан" }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %}</p>
|
||||
<p class="council__hint">Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
|
||||
{% if specialty %}<p class="council__hint">Специальность: {{ specialty }} — {{ specialty_name }}{% set field = 'specialty' %}{% set label = 'специальность статьи' %}{% include 'council_warning.html' %}</p>{% endif %}
|
||||
<ul class="council__evidence" aria-label="Источники сведений">
|
||||
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for record in article.catalog_records %}
|
||||
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
|
||||
<ul>{% for metric in record.metrics %}<li>{{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
|
||||
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button class="button button--ghost council__edit" type="button" aria-label="Редактировать статью: {{ article.title }}" aria-haspopup="dialog" data-council-edit data-article='{{ {"employee_id": employee_id, "edit_key": article.edit_key, "revision": revision, "title": article.title, "journal": article.edit_journal, "quartile": article.edit_quartile, "year": article.year, "specialty_codes": article.edit_specialty_codes, "manual_specialties": article.manual_specialties}|tojson|forceescape }}'>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-12 12-6 1 1-6Z"/><path d="m14 5 5 5"/></svg>
|
||||
</button>
|
||||
@@ -0,0 +1,42 @@
|
||||
{% 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>
|
||||
<form class="council-editor" data-edit-form>
|
||||
<label class="directory__field" for="article-edit-name">Название статьи
|
||||
<textarea class="directory__input" id="article-edit-name" name="title" required maxlength="2000" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="directory__field" for="article-edit-journal">Название журнала
|
||||
<input class="directory__input" id="article-edit-journal" name="journal" required maxlength="500" list="council-journals">
|
||||
</label>
|
||||
<label class="directory__field" for="article-edit-quartile">Квартиль
|
||||
<select class="directory__input" id="article-edit-quartile" name="quartile">
|
||||
<option value="">Не указан</option>{% for q in ['Q1', 'Q2', 'Q3', 'Q4'] %}<option value="{{ q }}">{{ q }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<details class="council-editor__specialties" data-edit-specialties>
|
||||
<summary>Специальности журнала</summary>
|
||||
<fieldset>
|
||||
<legend>Специальности журнала</legend>
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_auto" data-specialty-auto> Использовать связь из справочников</label>
|
||||
<div class="columns-modal__grid">{% for item in specialties %}
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_codes" value="{{ item.code }}"> {{ item.code }} — {{ item.name }}</label>
|
||||
{% endfor %}</div>
|
||||
<p class="council__hint">Ручная связь заменяет справочную для всех статей выбранного журнала, независимо от года. Без отмеченных специальностей связь исключается.</p>
|
||||
</fieldset>
|
||||
</details>
|
||||
<p class="council__hint" data-edit-note></p>
|
||||
<p class="council__hint">Ручная корректировка требует повторного подтверждения источниками для зачёта работы.</p>
|
||||
<p class="council-editor__error" data-edit-error role="alert" hidden></p>
|
||||
<div class="council-editor__actions">
|
||||
<button class="button" type="submit">Сохранить</button>
|
||||
<button class="button button--secondary" type="button" data-edit-close>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Статьи, журналы и квартили · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council" aria-labelledby="publications-title">
|
||||
<header class="council__hero"><div class="council__hero-copy">
|
||||
<h2 class="council__title" id="publications-title">Статьи, журналы и квартили</h2>
|
||||
<p class="council__intro">Публикации сотрудников из списка диссовета. Квартиль журнала относится к году публикации.</p>
|
||||
</div></header>
|
||||
<a class="admin__link" href="/admin/council">Вернуться к диссовету</a>
|
||||
<form class="council__search" method="get" role="search">
|
||||
<label class="directory__field" for="publications-employee">ФИО сотрудника<input class="directory__input" type="search" id="publications-employee" name="employee" value="{{ employee }}"></label>
|
||||
<label class="directory__field" for="publications-article">Название статьи<input class="directory__input" type="search" id="publications-article" name="title" value="{{ title }}"></label>
|
||||
<label class="directory__field" for="publications-journal">Название журнала<input class="directory__input" type="search" id="publications-journal" name="journal" value="{{ journal }}"></label>
|
||||
<button class="button" type="submit">Найти</button>
|
||||
{% if employee or title or journal %}<a class="admin__link" href="/admin/council/publications">Сбросить фильтры</a>{% endif %}
|
||||
</form>
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
|
||||
{% else %}
|
||||
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
|
||||
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
|
||||
{% for article in articles %}
|
||||
<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" 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>
|
||||
{% if matched > limit %}<nav class="directory__pagination" aria-label="Страницы статей">
|
||||
{% set filters = 'employee=' ~ (employee|urlencode) ~ '&title=' ~ (title|urlencode) ~ '&journal=' ~ (journal|urlencode) %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?{{ filters }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span>{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?{{ filters }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
{% include "council_edit_dialog.html" %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% if article.warnings[field] %}
|
||||
<span class="council__warning" data-warning>
|
||||
{% set warning_id = 'warning-' ~ employee_id ~ '-' ~ article.edit_key ~ '-' ~ field %}
|
||||
<button class="council__warning-button" type="button" aria-label="Уточнение: {{ label }}" aria-expanded="false" aria-describedby="{{ warning_id }}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 6v8"/><circle cx="12" cy="18" r=".5" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<span class="council__tooltip" role="tooltip" id="{{ warning_id }}" hidden>{{ article.warnings[field]|unique|join('. ') }}</span>
|
||||
<noscript><span>{{ article.warnings[field]|unique|join('. ') }}</span></noscript>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -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,17 +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">
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
@@ -64,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 %}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||
</div>
|
||||
<div class="employee-card__actions">
|
||||
<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--compact" type="submit">Обновить данные</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,8 +106,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if employee_view.news_links %}
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">В новостях</h3>
|
||||
<details class="employee-card__section employee-card__section--news">
|
||||
<summary class="employee-card__news-toggle"><h3 class="employee-section__title">В новостях</h3></summary>
|
||||
<ul class="employee-card__list">
|
||||
{% for news in employee_view.news_links %}
|
||||
<li class="employee-card__list-item">
|
||||
@@ -120,14 +121,15 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">Разделы профиля</h3>
|
||||
{% if employee_view.sections %}
|
||||
{% set profile_sections = employee_view.sections | rejectattr('type', 'equalto', 'news') | list %}
|
||||
{% if profile_sections %}
|
||||
<div class="employee-card__sections">
|
||||
{% for section in employee_view.sections %}
|
||||
{% for section in profile_sections %}
|
||||
<article class="employee-section">
|
||||
<div class="employee-section__header">
|
||||
<h4 class="employee-section__title">{{ section.title }}</h4>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<article class="panel guide--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 %}
|
||||
@@ -4,18 +4,20 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход · MIEM Employees</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<main class="admin__main">
|
||||
<section class="panel">
|
||||
<h1 class="panel__title">Вход в админку</h1>
|
||||
<h1 class="panel__title">Вход в систему</h1>
|
||||
{% if error %}<p>{{ error }}</p>{% endif %}
|
||||
<form class="form" method="post" action="/admin/login">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" required maxlength="64" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" required maxlength="1024" autocomplete="current-password"></label>
|
||||
<button class="button" type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Если забыли пароль, обратитесь к администратору системы.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<section class="panel">
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Запуски парсинга</h2>
|
||||
<form method="post" action="/admin/runs"><button class="button" type="submit">Запустить парсинг</button></form>
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/runs">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button" type="submit">Запустить парсинг</button></form>{% endif %}
|
||||
</div>
|
||||
{% set run = runs[0] if runs else none %}
|
||||
{% if run %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% macro change_value(field, value) %}
|
||||
{% if field == 'specialty_codes' %}{% if value is none or not value.manual %}по справочникам{% else %}{{ value.codes|join(', ') or 'связь исключена' }}{% endif %}
|
||||
{% elif field == 'role' %}{{ 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': 'Изменение роли или доступа', 'guide_start': 'Подсказки первого входа доступны', 'guide_finish': 'Подсказки первого входа закрыты'} %}
|
||||
{% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
|
||||
<div class="users__table-wrap">
|
||||
<table class="table users__history-table">
|
||||
<thead><tr><th class="table__head" scope="col">Когда</th><th class="table__head" scope="col">Кто</th><th class="table__head" scope="col">Действие</th><th class="table__head" scope="col">Что изменено</th></tr></thead>
|
||||
<tbody>{% for action in actions %}<tr class="table__row">
|
||||
<td class="table__cell">{{ action.created_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{{ user_names.get(action.user_id, '—') }}</td>
|
||||
<td class="table__cell">{{ action_labels.get(action.action, action.action) }}{% if action.target_user_id %}<br>{{ user_names.get(action.target_user_id, '') }}{% endif %}</td>
|
||||
<td class="table__cell">{% if action.employee_name %}<a class="admin__link" href="/admin/council?q={{ action.employee_name|urlencode }}">{{ action.employee_name }}</a><br>{{ action.article_title }}{% if action.publication_year %} · {{ action.publication_year }}{% endif %}{% endif %}
|
||||
{% for field, change in action.changes.items() %}<div class="users__change"><strong>{{ field_labels.get(field, field) }}:</strong> {{ change_value(field, change.before) }} → {{ change_value(field, change.after) }}</div>{% endfor %}
|
||||
{% if not action.changes and not action.employee_name %}—{% endif %}
|
||||
</td>
|
||||
</tr>{% else %}<tr><td class="table__cell" colspan="4">Действий пока нет. Здесь появятся входы, смена пароля и редактирование карточек.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if history_total > history_limit %}<nav class="directory__pagination" aria-label="История действий">
|
||||
{% if history_offset > 0 %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset - history_limit }}">Назад</a>{% endif %}
|
||||
<span>{{ history_offset // history_limit + 1 }} / {{ (history_total + history_limit - 1) // history_limit }}</span>
|
||||
{% if history_offset + history_limit < history_total %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset + history_limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Управление пользователями</h2>
|
||||
<p>Пользователь может просматривать данные и редактировать карточки. Запуск парсинга и управление пользователями доступны администратору.</p>
|
||||
{% if error %}<p class="users__error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if temporary %}<div class="users__temporary" role="status">
|
||||
<p>Временный пароль для <strong>{{ temporary.username }}</strong>. Показывается только сейчас — передайте его пользователю. При входе потребуется смена пароля.</p>
|
||||
<label class="form__label">Временный пароль<input class="form__input" type="text" value="{{ temporary.password }}" readonly autocomplete="off" data-temporary-password></label>
|
||||
</div>{% endif %}
|
||||
<details class="users__create" open>
|
||||
<summary>Создать пользователя</summary>
|
||||
<form class="form users__create-form" method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин<input class="form__input" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" autocomplete="off"></label>
|
||||
<label class="form__label">Имя<input class="form__input" name="full_name" maxlength="255" autocomplete="off"></label>
|
||||
<label class="form__label">Роль<select class="form__input" name="role">{% 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>
|
||||
</details>
|
||||
<div class="users__table-wrap"><table class="table">
|
||||
<thead><tr>{% for title in ['Пользователь', 'Первый вход', 'Последний вход', 'Пароль изменён', 'Временный пароль выдан', 'Роль и доступ', 'Действия'] %}<th class="table__head" scope="col">{{ title }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>{% for user in users %}<tr class="table__row">
|
||||
<td class="table__cell"><a class="admin__link" href="?user_id={{ user.id }}">{{ user.username }}</a>{% if user.full_name %}<br>{{ user.full_name }}{% endif %}{% if user.must_change_password %}<br>Требуется смена пароля{% endif %}{% if user.locked_until %}<br>Ограничение входа до {{ user.locked_until|admin_datetime }}{% endif %}</td>
|
||||
<td class="table__cell">{{ user.first_login_at|admin_datetime if user.first_login_at else 'Ещё не входил' }}</td><td class="table__cell">{{ user.last_login_at|admin_datetime if user.last_login_at else 'Ещё не входил' }}</td>
|
||||
<td class="table__cell">{{ user.password_changed_at|admin_datetime }}</td><td class="table__cell">{{ user.password_reset_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{% if user.id == current_user.id %}Администратор · ваша учётная запись{% else %}
|
||||
<form class="users__controls" method="post" action="/admin/users/{{ user.id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Роль {{ user.username }}<select class="form__input" name="role">{% 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 %}
|
||||
</td>
|
||||
<td class="table__cell">{% if user.id != current_user.id %}<form method="post" action="/admin/users/{{ user.id }}/reset-password" data-password-reset>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--secondary" type="submit">Сбросить пароль {{ user.username }}</button>
|
||||
</form>{% else %}<a class="admin__link" href="/admin/account">Сменить свой пароль</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">{% if selected_user %}Действия {{ selected_user.username }}{% else %}Последние действия пользователей{% endif %}</h2>
|
||||
{% if selected_user %}<a class="admin__link" href="/admin/users">Показать всех пользователей</a>{% endif %}
|
||||
{% include 'user_activity.html' %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin import _render
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
from app.security import (SESSION_COOKIE, SESSION_SECONDS, USER_ROLES, check_csrf, hash_password,
|
||||
require_admin, require_user, sign_session, temporary_password, verify_password)
|
||||
|
||||
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
def _history(db, user_id=None, offset=0):
|
||||
query = select(UserAction)
|
||||
if user_id is not None:
|
||||
query = query.where(or_(UserAction.user_id == user_id, UserAction.target_user_id == user_id))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
offset = min(max(0, offset), ((total - 1) // 30) * 30) if total else 0
|
||||
items = db.scalars(query.order_by(desc(UserAction.created_at), desc(UserAction.id)).offset(offset).limit(30)).all()
|
||||
return {'actions': items, 'history_total': total, 'history_offset': offset, 'history_limit': 30}
|
||||
|
||||
|
||||
def _users_page(request, db, *, user_id=None, offset=0, error=None, temporary=None, status=200):
|
||||
users = db.scalars(select(SystemUser).order_by(SystemUser.username)).all()
|
||||
selected = next((u for u in users if u.id == user_id), None)
|
||||
if user_id is not None and selected is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
response = _render(request, 'users.html', {'users': users, 'selected_user': selected,
|
||||
'user_names': {u.id: u.username for u in users}, 'error': error, 'temporary': temporary,
|
||||
**_history(db, user_id, offset)}, status_code=status)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
def _lock_admin(request, db, settings):
|
||||
# Serialize role/availability changes so concurrent admins cannot disable each other.
|
||||
db.scalars(select(SystemUser).order_by(SystemUser.id).with_for_update()
|
||||
.execution_options(populate_existing=True)).all()
|
||||
return require_admin(request, settings, db)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def users(request: Request, user_id: int | None = None, offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings, db)
|
||||
return _users_page(request, db, user_id=user_id, offset=offset)
|
||||
|
||||
|
||||
@router.post('/users')
|
||||
def create_user(request: Request, username: str = Form(..., max_length=64), full_name: str = Form('', max_length=255),
|
||||
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
username = username.strip().casefold()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in USER_ROLES:
|
||||
return _users_page(request, db, error='Логин должен содержать от 3 до 64 латинских букв, цифр, точек, дефисов или подчёркиваний. Выберите роль из списка', status=422)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
password = temporary_password()
|
||||
user = SystemUser(username=username, full_name=full_name.strip(), role=role,
|
||||
password_hash=hash_password(password), password_reset_at=utcnow())
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(UserAction(user_id=actor.id, action='user_create', target_user_id=user.id,
|
||||
changes={'role': {'before': None, 'after': role}}))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Этот логин уже занят. Выберите другой', status=409)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось создать пользователя. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}/reset-password')
|
||||
def reset_password(user_id: int, request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Свой пароль измените в личном кабинете')
|
||||
password = temporary_password()
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = True
|
||||
user.password_reset_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=actor.id, action='password_reset', target_user_id=user.id))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось сбросить пароль. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}')
|
||||
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
if role not in USER_ROLES or is_active not in {'true', 'false'}:
|
||||
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Собственную роль и доступ изменить нельзя')
|
||||
changes = {}
|
||||
for field, value in (('role', role), ('is_active', is_active == 'true')):
|
||||
old = getattr(user, field)
|
||||
if old != value:
|
||||
changes[field] = {'before': old, 'after': value}
|
||||
setattr(user, field, value)
|
||||
if changes:
|
||||
user.session_version += 1
|
||||
db.add(UserAction(user_id=actor.id, action='user_update', target_user_id=user.id, changes=changes))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось изменить доступ. Попробуйте ещё раз', status=503)
|
||||
return RedirectResponse('/admin/users', status_code=303)
|
||||
|
||||
|
||||
@router.get('/account')
|
||||
def account(request: Request, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True)
|
||||
return _account_page(request, db, user, offset=offset)
|
||||
|
||||
|
||||
def _account_page(request, db, user, *, offset=0, error=None, error_field=None, status=200):
|
||||
history = _history(db, user.id, offset)
|
||||
ids = {a.user_id for a in history['actions']} | {a.target_user_id for a in history['actions'] if a.target_user_id is not None}
|
||||
names = dict(db.execute(select(SystemUser.id, SystemUser.username).where(SystemUser.id.in_(ids))).all()) if ids else {}
|
||||
return _render(request, 'account.html', {'account_user': user, 'user_names': names, 'error': error,
|
||||
'error_field': error_field, 'password_changed': request.query_params.get('password_changed') == '1', **history}, status_code=status)
|
||||
|
||||
|
||||
@router.post('/account/password')
|
||||
def change_password(request: Request, current_password: str = Form(..., max_length=1024),
|
||||
new_password: str = Form(..., max_length=128), confirmation: str = Form(..., max_length=128),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return _account_page(request, db, user, error='Текущий пароль неверен. Введите пароль, с которым вошли', error_field='current_password', status=422)
|
||||
if len(new_password) < 15 or new_password == current_password:
|
||||
return _account_page(request, db, user, error='Выберите новый пароль от 15 до 128 символов, отличающийся от текущего. Можно использовать фразу', error_field='new_password', status=422)
|
||||
if new_password != confirmation:
|
||||
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
|
||||
try:
|
||||
first_password_change = user.password_changed_at is None
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=user.id, action='password_change'))
|
||||
if first_password_change:
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _account_page(request, db, user, error='Не удалось сохранить пароль. Попробуйте ещё раз', status=503)
|
||||
response = RedirectResponse('/admin/account?password_changed=1', status_code=303)
|
||||
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.get('/guide')
|
||||
def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_user(request, settings, db)
|
||||
return _render(request, 'guide.html', {'guide_page': True})
|
||||
|
||||
|
||||
@router.post('/guide/dismiss')
|
||||
def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, lock=True)
|
||||
try:
|
||||
latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1))
|
||||
if latest == 'guide_start':
|
||||
db.add(UserAction(user_id=user.id, action='guide_finish'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз')
|
||||
if request.headers.get('accept') == 'application/json':
|
||||
return Response(status_code=204)
|
||||
return RedirectResponse('/admin', status_code=303)
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.8.6"
|
||||
FRONTEND_VERSION = "0.8.6"
|
||||
BACKEND_VERSION = "0.8.6"
|
||||
APP_VERSION = "2.0.1"
|
||||
FRONTEND_VERSION = "2.0.1"
|
||||
BACKEND_VERSION = "2.0.1"
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-miem_workers}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-miem}
|
||||
@@ -15,6 +16,7 @@ services:
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: always
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -27,6 +29,7 @@ services:
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: always
|
||||
command: python -m app.worker
|
||||
env_file: .env
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Подтверждения публикационных критериев совета
|
||||
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX используют выбранную **отрасль оценки**, по умолчанию технические науки: для доктора порог зависит от отрасли, для кандидата/PhD — 20. Период — пять календарных лет, включая текущий год Москвы (2022–2026 в 2026 году); день и месяц не учитываются. Пороги и требования по отраслям показаны в шапке. Статья с известным видом, журналом и применимым к её году квартилем Q1/Q2 засчитывается автоматически без отдельной ручной записи, если нет конфликтов. Подтверждение степени и тематическое рассмотрение статьи остаются отдельными проверками.
|
||||
|
||||
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, данные ВШЭ и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
|
||||
|
||||
## Хранение и привязка
|
||||
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. XLSX содержит первый лист интерактивного подбора и компактные подробные листы. Для переключения специальности и отрасли файл включает полный набор сотрудников, а исходные фильтры сайта задают начальный подбор. Для обеих доступных отраслей используются результаты того же сервиса оценки: показаны подтверждённый зачёт, минимум и статус. Подробные записи подтверждений и непубличные ссылки в файл не включаются. Экспортный «Всего» совпадает с сайтом: уникальное объединение Q1/Q2/К1/RSCI, для доктора также К2; одного К2 у кандидата/PhD недостаточно. XLSX — снимок на момент выгрузки; ручное подтверждение на сайте требует новой выгрузки.
|
||||
|
||||
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
|
||||
|
||||
Минимальный пример структуры (иллюстративные значения, не подтверждение реального сотрудника):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"policy": {},
|
||||
"employees": {
|
||||
"staff:123": {
|
||||
"degrees": [
|
||||
{"degree": "Доктор технических наук", "source": "Запись проверки диплома", "checked_on": "2026-10-08"}
|
||||
],
|
||||
"publications": [
|
||||
{
|
||||
"doi": "10.1234/example",
|
||||
"issns": ["0006-3835"],
|
||||
"kind": "article",
|
||||
"kind_evidence": {"source": "Карточка издателя", "checked_on": "2026-10-08"},
|
||||
"publication_date": "2024-06-15",
|
||||
"date_evidence": {"source": "Выходные данные статьи", "checked_on": "2026-10-08"},
|
||||
"bases": [
|
||||
{
|
||||
"basis": "Q2",
|
||||
"system": "Scopus",
|
||||
"metric": "CiteScore",
|
||||
"metric_year": 2024,
|
||||
"category": "Applied Mathematics",
|
||||
"applicable_year": 2024,
|
||||
"source": "Проверенная таблица и идентичность журнала",
|
||||
"source_url": "https://example.org/evidence",
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точная дата для периода не требуется: любой согласованный год из пяти календарных лет, включая текущий, подтверждает период. Разные дни одного года не создают конфликт; разные годы источников требуют проверки. Некорректная необязательная точная дата не отменяет известный год. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
|
||||
|
||||
`bases[].basis` — `К1`, `К2`, `RSCI`, `Q1` или `Q2`. `applicable_year` должен совпадать с подтверждаемым годом публикации. Для Q1/Q2 обязательны система, метрика, категория и год метрики. Допустимые системы: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef. Заполнение названия системы не устанавливает наличие квартильной методики в ней: администратор должен проверить применимость конкретной метрики и основания по инструкции. Простая индексация, РИНЦ, `hse_list` и числовой CiteScore не подтверждают основание. Если год метрики отличается от года публикации, `source` должен обосновывать применимость: сервис не распространяет снимок автоматически.
|
||||
|
||||
Для степени выбирается максимальный уровень по всему профилю и документально проверенным записям: доктор выше кандидата/PhD; последние имеют равный уровень. История остаётся видимой. Подтверждение кандидата не разрешает заменить неподтверждённого доктора пунктом 6. Подтверждения добавляют только после проверки документов выбранного уровня.
|
||||
|
||||
## Нормативные уточнения
|
||||
|
||||
Период сайта задан как пять календарных лет по продуктовому правилу. Иные виды работ и период зачёта монографии требуют подтверждения источником. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
|
||||
|
||||
- `boundary_dates`: устаревшее поле; календарный период не использует точные граничные даты, запись не влияет на подсчёт.
|
||||
- `monograph_period`: `value: "five_years"` либо `"all_years"` определяет период отдельного зачёта подтверждённых монографий. Без него монографический минимум не подтверждается.
|
||||
- `publication_types`: `values: ["monograph", "chapter", ...]` разрешает явно проверенные дополнительные виды в общий публикационный минимум. Статьи учитываются при подтверждённом виде и основании. Не считайте исключение других видов нормативным запретом: допустимость пока не установлена. Монография отдельно и в общем числе может учитываться одновременно только после такого уточнения.
|
||||
|
||||
## Тематическое рассмотрение
|
||||
|
||||
Прямая и условная связь журнала — аналитическая подсказка, не подтверждение любой статьи. Текст условий не вычисляется по ключевым словам и не трактуется как AND/OR. Отсутствие журнала в подборке означает «не оценён», а `not_established` относится к конкретной оценённой паре и не запрещает междисциплинарную статью.
|
||||
|
||||
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (для публичной страницы — `технические`), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
|
||||
|
||||
## Каталоги и обновление
|
||||
|
||||
Читаются только JSON Q1/Q2 со схемами 1.0.0/1.1.0; журналы объединяются по ID, все категории и источники обеих подборок сохраняются. Сопоставление — по ISSN/eISSN; название служит проверкой идентичности, несовпадение требует рассмотрения. Перцентиль CiteScore: Q1 от 75, Q2 от 50 до 75; в JCR используется квартиль категории. Год метрики 2025 и год выпуска 2026 различаются. Снимок не распространяется на другие годы и не является белым списком: отдельное внешнее подтверждение допускается и вне каталога. Значение «текущая индексация не проверена» не означает отсутствия индексации.
|
||||
|
||||
Проверяются уникальность ID и пар, источники, паспорта, пункты, SHA-256 локальных PDF и безопасность относительных путей. Повреждённый снимок не даёт оснований и показывается как требующий проверки; второй исправный снимок сохраняется. Связь К1 со справочником специальностей проверяется по SHA-256. К1 и К2 остаются предварительными снимками категории; ручное основание должно иметь свой применимый год и источник. Подбор специальности по К1 остаётся вспомогательным и может скрывать сотрудников с иными основаниями; он не является условием пунктов 5–6.
|
||||
|
||||
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
|
||||
|
||||
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
|
||||
|
||||
|
||||
Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX.
|
||||
|
||||
Без JavaScript форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Проверка импорта на изолированном PostgreSQL
|
||||
|
||||
Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker
|
||||
Compose. Не использует production Compose, `.env`, SSH или данные сотрудников.
|
||||
PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных
|
||||
портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы:
|
||||
`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials
|
||||
синтетические; тестовые пароли непригодны для реальных систем.
|
||||
|
||||
Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore
|
||||
исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные
|
||||
`app/data` и остальные файлы workspace. Runtime не получает переменные хоста.
|
||||
CLI subprocess дополнительно получает только явно заданные тестовые libpq
|
||||
параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test`
|
||||
или имена тестовых БД для подключения к существующему серверу.
|
||||
|
||||
Синтетическая роль `import_test` создаётся только init-скриптом одноразового
|
||||
тестового кластера. Fixture выдаёт ей документированные column grants и USAGE
|
||||
sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE
|
||||
публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД.
|
||||
Таблицы создаются моделью приложения и очищаются только в тестовых БД.
|
||||
|
||||
HTTP заблокирован test fixture; входные профили полного обхода подставляются
|
||||
локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON,
|
||||
но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных
|
||||
тестов не используются.
|
||||
|
||||
## Что проверяется
|
||||
|
||||
1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после.
|
||||
2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника.
|
||||
3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences.
|
||||
4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE
|
||||
и успешного INSERT; все строки возвращаются в исходное состояние. Sequence
|
||||
может продвинуться при rollback — это ожидаемое поведение PostgreSQL.
|
||||
5. Обновление только года без raw_data сохраняет существующий JSONB.
|
||||
6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется
|
||||
при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен.
|
||||
7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ;
|
||||
появившаяся статья получает HSE ID без второй записи и потери метаданных.
|
||||
8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись
|
||||
откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at;
|
||||
публикация второго сохраняется. Проверяются обе очередности ID и уже
|
||||
установленная привязка; неоднозначность отражается в CrawlError.
|
||||
|
||||
Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном
|
||||
`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1`
|
||||
разрешён только с точным тестовым DATABASE_URL Compose-проекта.
|
||||
|
||||
## Команды владельцу
|
||||
|
||||
Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория
|
||||
после независимого ревью. Команды явно выбирают локальный Unix socket и очищают
|
||||
наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с
|
||||
доступным локальным `/var/run/docker.sock`. Для отдельного стенда на VPS используйте
|
||||
[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный
|
||||
Compose не предназначен для запуска на сервере с работающими сервисами.
|
||||
Первый шаг:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version
|
||||
```
|
||||
|
||||
Ожидается exit code 0 и секции `Client` и `Server`. `command not found`,
|
||||
`permission denied` или только Client с ошибкой подключения означает, что
|
||||
до запуска этого варианта нужен локальный Docker. При его отсутствии сначала
|
||||
выполните только read-only диагностику из инструкции VPS.
|
||||
|
||||
Дальнейшие точные команды сохранены здесь для воспроизводимого запуска:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Первые две команды проверяют Compose и собирают runner с уже используемыми
|
||||
зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая
|
||||
runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей
|
||||
команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа,
|
||||
column grants и PostgreSQL можно считать подтверждённой **только после такого
|
||||
выполнения**, не по unit-тестам или статическому чтению файлов.
|
||||
|
||||
После сохранения вывода тестов удалите именно этот тестовый проект:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down
|
||||
```
|
||||
|
||||
Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не
|
||||
используйте `docker system prune`, production Compose или удаление рабочих
|
||||
volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий
|
||||
кластер начинался с чистого init.sql; не запускайте два прогона одновременно.
|
||||
|
||||
## Статус подготовки
|
||||
|
||||
В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались.
|
||||
Локально доступны только unit-тесты, проверка Python и статические проверки.
|
||||
Production/VPS и доступ к рабочей БД не изменялись.
|
||||
@@ -0,0 +1,153 @@
|
||||
# PostgreSQL-проверка на отдельном стенде VPS
|
||||
|
||||
Команды выполняет владелец под root. Подготовка не подключается к VPS и не
|
||||
запускает стенд. Не используйте production Compose/`.env`, Docker context,
|
||||
volumes, сети, mounts или production credentials. Никаких production-ролей БД,
|
||||
SSH-ключей и правил firewall эти команды не создают и не изменяют.
|
||||
|
||||
## Сначала только read-only проверка
|
||||
|
||||
Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные.
|
||||
Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация
|
||||
CLI указывает на отдельный каталог, который здесь не создаётся.
|
||||
|
||||
```sh
|
||||
free -h
|
||||
nproc
|
||||
uptime
|
||||
df -h / /var/lib/docker
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
```
|
||||
|
||||
Как читать результат:
|
||||
|
||||
- `free`: нужна колонка **available**, а не только free. Предварительный запас —
|
||||
не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся
|
||||
системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков.
|
||||
- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне
|
||||
числа CPU или выше — основание отложить тест, а не останавливать сервисы.
|
||||
- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы
|
||||
Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните
|
||||
`df -h` для показанного каталога. Не используйте prune для освобождения места.
|
||||
- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые
|
||||
требования. Docker warnings о недоступных memory/swap/cgroup limits нужно
|
||||
устранить до запуска; без действующих лимитов этот вариант не запускать.
|
||||
- `image inspect`: существующие образы показывают tags, ID и revision.
|
||||
`No such image` означает отсутствие образа, **не** разрешение на pull/build.
|
||||
Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision
|
||||
проверенного commit PR #55. Base-образы нужны только для сборки, не для самого
|
||||
запуска уже подготовленного стенда.
|
||||
|
||||
Пришлите только этот диагностический вывод. Не присылайте `docker inspect`
|
||||
production-контейнеров целиком, env, конфигурации подключения или секреты.
|
||||
До оценки ресурсов и завершения независимого review остальные действия ниже
|
||||
не выполнять.
|
||||
|
||||
## Изоляция и лимиты подготовленного стенда
|
||||
|
||||
Используется только `tests/postgres/compose.vps.yml`, не override production или
|
||||
локального Compose. Project name фиксирован:
|
||||
`miem-publication-integration-vps`.
|
||||
|
||||
| Ресурс | Лимит / область |
|
||||
|---|---|
|
||||
| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов |
|
||||
| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 |
|
||||
| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities |
|
||||
| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB |
|
||||
| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера |
|
||||
| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет |
|
||||
| Логи | json-file, до 5 MiB на контейнер, один файл |
|
||||
|
||||
Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел
|
||||
размера синтетических данных. Данные не сохраняются после остановки/размонтирования.
|
||||
Образы содержат только код и синтетический init.sql. Host bind mounts,
|
||||
Docker socket, production networks и env_file отсутствуют. Docker socket
|
||||
доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся.
|
||||
Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при
|
||||
отсутствии заранее подготовленного образа.
|
||||
|
||||
Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM
|
||||
limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами
|
||||
не выполняйте незапланированную сборку. Если готовых образов нет, отдельно
|
||||
согласуйте место сборки и окно загрузки после read-only диагностики.
|
||||
|
||||
## Подготовка образов после review (не запуск стенда)
|
||||
|
||||
На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55,
|
||||
укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет).
|
||||
Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать
|
||||
с TEST_COMMIT. Это не команды для немедленного выполнения на VPS.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
git rev-parse HEAD
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile .
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55
|
||||
sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml
|
||||
```
|
||||
|
||||
Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные
|
||||
файлы workspace из обоих контекстов сборки. Передайте владельцу только архив
|
||||
этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace
|
||||
и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production
|
||||
credentials. Base-образы могут скачиваться только на согласованной машине сборки.
|
||||
|
||||
На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются
|
||||
в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`,
|
||||
архив — в `images.tar`. Проверка checksum должна совпасть с источником.
|
||||
|
||||
```sh
|
||||
install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config
|
||||
sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet
|
||||
```
|
||||
|
||||
Проверьте отсутствие уже существующих ресурсов с этим project name. Если они
|
||||
есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие
|
||||
ресурсы. Следующие команды только читают labels Docker Compose:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
```
|
||||
|
||||
## Запуск и адресная очистка (пока не выполнять)
|
||||
|
||||
Только после согласования владельцем ресурсов и проверенного состава стенда:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log
|
||||
```
|
||||
|
||||
**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно
|
||||
этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните
|
||||
отчёт. Она не затрагивает ресурсы других Compose-проектов:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes
|
||||
```
|
||||
|
||||
Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune
|
||||
или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть
|
||||
общими для других проверок. Архив и журнал также не удаляются этой командой.
|
||||
Для подтверждения очистки повторите три read-only команды с project label:
|
||||
в выводе должны остаться только заголовки.
|
||||
|
||||
## Статус
|
||||
|
||||
VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде
|
||||
подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Запись публикации в БД
|
||||
|
||||
Объект `EmployeePublication`, таблица `employee_publications`. Одна запись относится к одному сотруднику; одна работа у нескольких соавторов может иметь несколько записей с одним DOI. Типы ниже указаны для PostgreSQL; в SQLite JSONB представлен JSON.
|
||||
|
||||
| Поле | Тип | Обязательное | Назначение |
|
||||
|---|---|:---:|---|
|
||||
| `id` | integer | Да | Первичный ключ |
|
||||
| `employee_id` | integer | Да | Внешний ключ `employees.id`; удаление сотрудника удаляет связанные записи |
|
||||
| `publication_id` | varchar(64) | Нет | Идентификатор публикации в источнике ВШЭ |
|
||||
| `title` | text | Да | Название |
|
||||
| `year` | integer | Нет | Год публикации |
|
||||
| `publication_type` | varchar(64) | Нет | Тип материала: ARTICLE, CHAPTER, BOOK, PREPRINT и другие |
|
||||
| `language` | varchar(16) | Нет | Язык |
|
||||
| `status` | integer | Нет | Код статуса из источника |
|
||||
| `url` | text | Нет | Основная ссылка |
|
||||
| `doi_url` | text | Нет | DOI или ссылка DOI |
|
||||
| `other_url` | text | Нет | Дополнительная ссылка |
|
||||
| `document_url` | text | Нет | Ссылка на документ |
|
||||
| `citation_text` | text | Нет | Библиографическое описание |
|
||||
| `quartiles` | jsonb | Нет | Квартили Q1–Q4 с системой, годом метрики, источником и результатом поиска |
|
||||
| `vak_categories` | jsonb | Нет | Результат проверки журнала по предоставленным спискам К1/К2, способ идентификации и источник |
|
||||
| `annotation` | jsonb | Нет | Аннотация |
|
||||
| `description` | jsonb | Нет | Структурированное описание |
|
||||
| `authors` | jsonb | Нет | Список авторов |
|
||||
| `raw_data` | jsonb | Нет | Исходные данные, включая обогащение из данных ВШЭ |
|
||||
| `source_hash` | varchar(64) | Да | Хеш исходной записи |
|
||||
| `created_at` | timestamptz | Да | Создание записи |
|
||||
| `updated_at` | timestamptz | Да | Обновление записи |
|
||||
|
||||
Уникальны пары `(employee_id, publication_id)` и `(employee_id, source_hash)`. Индексы: `employee_id`, `publication_id`, `doi_url`, `year`, `publication_type`. Атрибут ORM `employee` ссылается на объект сотрудника и отдельным столбцом не является.
|
||||
|
||||
## Поле квартилей
|
||||
|
||||
`NULL` означает, что обогащение ещё не выполнялось. После проверки поле содержит объект со схемой версии 1. Пустой `entries` означает отсутствие найденного квартиля; причина сохраняется в `lookup`. Пустой результат не равнозначен Q4 и не доказывает отсутствие индексации.
|
||||
|
||||
Пример структуры (иллюстративные данные):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"system": "Scopus",
|
||||
"quartile": "Q2",
|
||||
"doi": "10.1234/example",
|
||||
"metric": "CiteScore",
|
||||
"metric_year": 2024,
|
||||
"publication_year": 2024,
|
||||
"category": "Applied Mathematics",
|
||||
"journal": "Название журнала",
|
||||
"issns": ["0006-3835"],
|
||||
"source": "Название проверенного источника",
|
||||
"source_url": "https://example.org/metrics",
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
],
|
||||
"lookup": {
|
||||
"doi": "10.1234/example",
|
||||
"status": "found",
|
||||
"requested_year": 2024,
|
||||
"checked_on": "2026-10-08"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `entries` сохраняет все найденные категории и системы отдельно, без выбора одного «лучшего» квартиля вместо остальных. Возможные системы: Scopus, Web of Science, SCImago. Метрики CiteScore, JCR и SJR не подменяют друг друга. При наличии только лучшего квартиля SJR используется метрика `SJR best quartile`, категория остаётся `null`.
|
||||
- Каждое основание хранит собственный `doi`. При дополнении старого payload основания без этого ключа получают DOI из прежнего `lookup` до его обновления; при отсутствии прежнего DOI сохраняется `null`. Новый общий `lookup` не перепривязывает прежние записи. DOI данных ВШЭ берётся из их собственных исходных метаданных.
|
||||
- Перед использованием в счётчиках основание сверяется с текущими DOI, журналом и ISSN публикации. Конфликт исключает это основание и выводится как причина перепроверки; доказательства сохраняются. Совпадающий ISSN позволяет подтвердить переименование журнала, но не снимает конфликт DOI. Основание без проверяемой привязки не засчитывается по одному совпадению года. Это также относится к обратному чтению `raw_data.csv_enrichment`.
|
||||
- Значения `Квартиль scopus` и `Квартиль wos` из `raw_data.csv_enrichment` переносятся в отдельные записи Q1–Q4. Их точная метрика и год метрики неизвестны: `metric` и `metric_year` остаются `null`. Год публикации не объявляется годом метрики. Исходное обогащение не удаляется.
|
||||
- При поиске новых значений журнал определяется по DOI через [Crossref](https://www.crossref.org/documentation/retrieve-metadata/rest-api/), а при отсутствии записи — через публичный [DataCite API](https://support.datacite.org/docs/rest-api). ISSN связывает журнал с рейтингом. Проверяются идентичность DOI, тип материала и соответствие года публикации данным DOI. Книге или главе не присваивается журнальный квартиль по одному похожему названию. Препринт DataCite не получает квартиль журнала связанной статьи автоматически.
|
||||
- Для старого ISSN допускается подтверждённая преемственность названия по официальному [списку Scopus за август 2026](https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx). Нужны явная связь Continued as/Continues и единственное связанное название, совпадающее с журналом в ответе DOI. Похожесть названий не используется. Исходные ISSN Crossref сохраняются; дополнительные ISSN для рейтинга и ссылка на историю записываются отдельно в `lookup`, ссылка `journal_identity_source_url` также сохраняется в найденных квартилях.
|
||||
- Для каждой системы сначала используется метрика за год публикации. При явно разрешённом `--allow-latest` и отсутствии исторической метрики допускается последний доступный год: он остаётся в `metric_year`, а результат имеет статус `found_latest`. Все категории CiteScore/JCR и SJR сохраняются вместе; разные системы могут иметь разные годы метрики. Это последний год в доступных проверенных источниках, а не обещание глобально самого нового рейтинга.
|
||||
- Источники: проверяемые каталоги проекта (CiteScore/JCR) и исторические SJR-данные [Universidad de Extremadura](https://opendata.unex.es/dataset/revistas-cientificas-2025) за 2009–2025. Портал указывает SCImago как автора и поддерживающую организацию и публикует данные по CC BY 3.0; атрибуция и ссылка сохраняются в каждой записи. [SJR](https://www.scimagolab.com/products/sjr-scimago-journal-country-rank/) хранится с системой SCImago, даже несмотря на происхождение исходных данных из Scopus.
|
||||
|
||||
`lookup` также может содержать полученные по DOI тип материала, названия журнала, ISSN, годы и ссылку на регистратора DOI. Состояния поиска:
|
||||
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| `found` | Найдена метрика за год публикации |
|
||||
| `found_latest` | Использован другой, последний доступный год |
|
||||
| `missing_doi` / `invalid_doi` | DOI отсутствует или некорректен |
|
||||
| `doi_not_found` / `doi_lookup_failed` | DOI не найден в Crossref и DataCite / источник не ответил успешно; DOI может относиться к другому регистратору |
|
||||
| `doi_identity_conflict` | Ответ источника относится к другому DOI |
|
||||
| `not_journal_article` | DOI относится к материалу, не определённому как журнальная статья |
|
||||
| `missing_publication_year` / `publication_year_conflict` | Год неизвестен / отличается от годов источника DOI |
|
||||
| `missing_journal_issn` | Журнал по ISSN не установлен |
|
||||
| `journal_identity_conflict` | Идентификаторы связываются с несколькими журналами |
|
||||
| `ranking_category_conflict` | Сведения о категориях SJR противоречат друг другу |
|
||||
| `historical_quartile_unavailable` | Проверенные источники не дали допустимой метрики |
|
||||
|
||||
## Миграция и обогащение
|
||||
|
||||
Миграции `migrations/010_publication_quartiles.sql` и `migrations/011_publication_vak_categories.sql` добавляют nullable JSONB-поля без удаления или перезаписи данных. Инициализация приложения также добавляет поля на существующей схеме PostgreSQL/SQLite. Обогащение не запускается при старте API или обработке HTTP-запроса.
|
||||
|
||||
Перед изменением заполненной БД создать и проверить резервную копию. Подключение инструмента — только через `DATABASE_URL` из окружения; `.env` и файлы с credentials инструмент не читает. Не передавать секреты в аргументах и не коммитить отчёты, кэши и резервные копии.
|
||||
|
||||
Сначала выполнить предварительное сравнение без записи, затем перенос известных значений:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-plan.json
|
||||
python scripts/enrich_publication_quartiles.py --apply --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/hse-applied.json
|
||||
```
|
||||
|
||||
Затем проверить DOI у записей без квартиля. Для предварительного сравнения убрать `--apply`:
|
||||
|
||||
```sh
|
||||
python scripts/enrich_publication_quartiles.py --apply --lookup-doi --all-systems --check-vak --sjr-history --allow-latest --workers 4 --cache-dir .impeccable/quartiles/cache --report .impeccable/quartiles/doi-applied.json
|
||||
```
|
||||
|
||||
Для проверки смены ISSN добавить `--scopus-title-history /путь/ext_list_Aug_2026.xlsx`. Используется конкретный официальный снимок: SHA-256 `11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb`; несоответствующий файл отклоняется. Это список названий и их истории, не источник квартилей.
|
||||
|
||||
`--all-systems` проверяет также публикации с уже известным квартилем: добавляются остальные доступные системы и предметные категории. Без этого флага проверяются записи без квартиля, ранее найденные последние метрики и заполненные основания с изменившейся или неподтверждённой привязкой. Новые доказательства дополняют список, не удаляя прежние. Несколько категорий с одинаковым Q1 сохраняются отдельно; один квартиль не заменяет остальные.
|
||||
|
||||
## Поле категорий ВАК
|
||||
|
||||
`vak_categories` имеет `schema_version: 1`, массив `entries`, `status`, `scope: "supplied_category_snapshot"`, `historical_category_confirmed: false` и дату `checked_on`. `NULL` — проверка ещё не проводилась. В каждом совпадении сохраняются `category` (К1/К2), каноническое название журнала, название и SHA-256 источника, строка источника, дата категоризации, ISSN при наличии, `match_method` и `identity_confirmed`.
|
||||
|
||||
Проверяются `k1_journals.json` и `k2_journals.json`, извлечённые из предоставленного документа «ВАК категории.pdf». Для К1 доступна дополнительная идентификация по ISSN из `k1_specialties.json`; её источник, SHA-256 и дата списка сохраняются отдельно. В проверку передаются ISSN/eISSN исходного `raw_data`, данных ВШЭ и актуального ответа DOI; исходные идентификаторы используются и без сетевого поиска или DOI. Ответ, сохранённый для другого DOI, не считается актуальным источником идентификаторов. Поиск К2 в текущих данных выполняется по нормализованному названию и явным административным переименованиям. Транслитерация, похожесть названий и предположение о соответствии русской и переводной версии не используются.
|
||||
|
||||
- `match_method: "issn"`, `identity_confirmed: true` — журнал идентифицирован по ISSN.
|
||||
- `match_method: "normalized_name"`, `identity_confirmed: false` — название совпало со списком; идентичность по ISSN не подтверждена. Это предварительное совпадение.
|
||||
- `status: "found"` — одно совпадение; `ambiguous_journal` или `journal_identity_conflict` — неоднозначность/конфликт, категория автоматически не выбирается.
|
||||
- `not_found_in_k1_k2` — совпадений в этих двух снимках нет; это не доказывает отсутствие журнала в ВАК или наличие К3. `unknown_journal` — данных для идентификации журнала нет.
|
||||
|
||||
Дата категоризации в документе не объявляется датой всего снимка, а категория по имеющемуся списку не подтверждает её наличие в год публикации. Книги и главы не становятся журнальными статьями из-за совпадения названия. Проверка К1/К2 не является подтверждением зачёта конкретной работы по критериям совета.
|
||||
|
||||
`--check-vak` сохраняет этот результат в отдельном поле. Уже заполненные отличающиеся значения не перезаписываются; число таких случаев отражается в `vak_existing_value_kept`. Их нужно отдельно сравнить с актуальными источниками. Проверить результаты и причины неоднозначности до использования данных в заключении.
|
||||
|
||||
Запросы DOI кэшируются по нормализованному DOI и переиспользуются для соавторов. Ошибки сети допускают повторную попытку; для полного обновления ранее найденных ответов использовать новый каталог кэша. Исторические рейтинги запрашиваются только для ISSN найденных журналов; ошибки источника отражаются в `ranking_failures` отчёта. Существующие квартильные записи не перезаписываются; повторный перенос не дублирует доказательства только из-за новой даты проверки. Перед записью инструмент проверяет, что публикация не изменилась параллельно; при изменении пропускает её и фиксирует `concurrent_change_skipped`. Коммит выполняется по публикациям: прерванную обработку можно возобновить. Проверить итоговый отчёт и полноту переноса после завершения.
|
||||
|
||||
Парсер сохраняет `quartiles`, обновляя остальные исходные поля публикации. Страница совета читает отдельное поле с обратной совместимостью для исходных данных ВШЭ. В предварительные Q1/Q2 совета попадают Scopus/Web of Science с применимым годом; последние метрики другого года и SJR автоматически не превращаются в исторический зачёт или документальное подтверждение критериев.
|
||||
|
||||
Миграция и обогащение локальной БД не обновляют production. Порядок переноса только недостающих данных в production и сохранения его заполненных значений описан в README.
|
||||
@@ -0,0 +1,312 @@
|
||||
# Разовый импорт публикаций
|
||||
|
||||
Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает
|
||||
`.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение
|
||||
берётся только из libpq environment/service, настроенных владельцем. Production в
|
||||
рамках подготовки не проверялся и не изменялся.
|
||||
|
||||
Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты,
|
||||
неоднозначности, конфликты DOI/года, сохранение заполненных значений и
|
||||
транзакционный путь на тестовом соединении (read-only / rollback при второй
|
||||
ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка
|
||||
grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.
|
||||
|
||||
## Вход и сопоставление
|
||||
|
||||
Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей,
|
||||
SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`.
|
||||
Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`.
|
||||
Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.
|
||||
|
||||
Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е.
|
||||
Учитываются все существующие статусы; новые сотрудники не создаются. Ноль
|
||||
совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы,
|
||||
переводы, перестановки слов и приблизительные совпадения не используются.
|
||||
|
||||
Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI
|
||||
или полному названию и году. Отсутствующий год БД допускает заполнение при
|
||||
совпадении названия. Если эти способы указывают на разные записи, импорт
|
||||
пропускается (`ambiguous_publication`). Разные заполненные DOI или годы —
|
||||
`doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не
|
||||
удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские
|
||||
связи, а не дубли внутри карточки.
|
||||
|
||||
Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет
|
||||
ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе
|
||||
ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение
|
||||
запланированной вставки — `merged_csv_duplicate`. При конфликте непустых
|
||||
метаданных первое значение сохраняется; остальные пустые поля всё равно
|
||||
дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список
|
||||
`conflict_fields` (только названия полей, без значений) и сводный счётчик
|
||||
`metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает
|
||||
всю связь. Если строка без DOI была запланирована первой, следующая строка с
|
||||
DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают
|
||||
`already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо
|
||||
названию/году. `source_hash` существующих записей не меняется.
|
||||
|
||||
Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год
|
||||
(допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая
|
||||
структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД
|
||||
пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`).
|
||||
|
||||
## Таблицы, столбцы и права
|
||||
|
||||
| Объект | Операция / столбцы |
|
||||
|---|---|
|
||||
| `public.employees` | Только SELECT: `id`, `full_name` |
|
||||
| `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` |
|
||||
| Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` |
|
||||
| Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` |
|
||||
| Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` |
|
||||
|
||||
Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние
|
||||
ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми
|
||||
значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`,
|
||||
`publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются.
|
||||
CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом
|
||||
авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные
|
||||
непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации.
|
||||
Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные
|
||||
таблицы не означают обновления всех представлений интерфейса.
|
||||
|
||||
Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public,
|
||||
перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence.
|
||||
Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим
|
||||
таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях.
|
||||
Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их;
|
||||
этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при
|
||||
уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.
|
||||
|
||||
## Применение и ограничения
|
||||
|
||||
Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT,
|
||||
UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией
|
||||
SERIALIZABLE. Advisory lock предотвращает одновременное применение этим
|
||||
импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд.
|
||||
Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических
|
||||
повторов нет. Sequence может получить пропуски при откате — это нормальное
|
||||
поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.
|
||||
|
||||
JSON-отчёт содержит количества insert/update/skip, причины и пары
|
||||
`номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики
|
||||
относятся к авторским связям, а не уникальным статьям. Консоль выводит только
|
||||
сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается.
|
||||
Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход
|
||||
транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после
|
||||
commit также возможна. Безопасный повторный dry-run позволяет проверить состояние.
|
||||
При необходимости владелец перемещает `.pending` в архив перед повтором.
|
||||
|
||||
**До применения нужен свежий backup БД.** Во время импорта владелец должен
|
||||
исключить конкурентные записи публикаций на время транзакции, согласовав окно
|
||||
работы; эта подготовка не отключает crawler. Не выполняйте production apply до
|
||||
независимого ревью и развёртывания исправления crawler из этого PR.
|
||||
|
||||
До исправления `_upsert_employee` запускал синхронизацию публикаций даже для
|
||||
неизменённой карточки. При пустом списке публикаций или отсутствии таблицы
|
||||
синхронизация пропускалась. При непустом списке `_sync_employee_publications`
|
||||
удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа
|
||||
ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью
|
||||
получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления.
|
||||
|
||||
Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке
|
||||
устаревших публикаций. При обновлении сохраняется этот ключ и заполненные
|
||||
title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ
|
||||
продолжают обновлять канонические поля, а исходные CSV-значения остаются в
|
||||
метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с
|
||||
импортированной записью по DOI или названию/году до новой вставки, сохраняет id
|
||||
и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint
|
||||
синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает
|
||||
обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь
|
||||
не приведены команды рестарта или отключения сервисов.
|
||||
|
||||
Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID,
|
||||
даже когда DOI/название/год совпадают. Это проверяется перед изменением полей
|
||||
при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи
|
||||
не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается
|
||||
независимо от порядка статей. Отсутствующий ID во входе не очищает существующую
|
||||
CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с
|
||||
идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.
|
||||
|
||||
## Команды владельцу (не выполнялись)
|
||||
|
||||
Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере.
|
||||
Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны
|
||||
быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать
|
||||
в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту;
|
||||
владелец проверяет их локально перед выполнением.
|
||||
|
||||
Сначала резервная копия в приватный каталог владельца:
|
||||
|
||||
```sh
|
||||
install -d -m 700 /var/backups/miem-publication-import
|
||||
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
|
||||
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
|
||||
chmod 600 /var/backups/miem-publication-import/before.dump
|
||||
```
|
||||
|
||||
При повторной операции используйте новый путь backup; не перезаписывайте нужную
|
||||
предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write.
|
||||
|
||||
Откройте psql владельцем БД:
|
||||
|
||||
```sh
|
||||
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
|
||||
```
|
||||
|
||||
Выполните SQL (без пароля в SQL/истории):
|
||||
|
||||
```sql
|
||||
\set ON_ERROR_STOP on
|
||||
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
|
||||
\password miem_publication_import
|
||||
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
|
||||
GRANT USAGE ON SCHEMA public TO miem_publication_import;
|
||||
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
|
||||
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
|
||||
-- Сначала выдаются только read-права для dry-run.
|
||||
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
|
||||
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
|
||||
has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
|
||||
has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;
|
||||
```
|
||||
|
||||
Три проверки должны вернуть false. Не выдавайте write-права при неожиданном
|
||||
доступе. Если sequence не найдена, выясните фактический механизм id перед импортом.
|
||||
Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и
|
||||
завершение открытых сессий.
|
||||
|
||||
После принятого dry-run, непосредственно перед разрешённым apply:
|
||||
|
||||
```sql
|
||||
GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
```
|
||||
|
||||
Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и
|
||||
`pgpass` из окружения/секрет-хранилища владельца: service
|
||||
`miem_publication_import`, БД `miem_workers`, пользователь
|
||||
`miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose,
|
||||
порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`,
|
||||
доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг
|
||||
зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.
|
||||
|
||||
Установите файлы (команды root; каталог контейнера не должен быть volume с
|
||||
непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
|
||||
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
|
||||
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
|
||||
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
|
||||
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
|
||||
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
|
||||
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
|
||||
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
|
||||
useradd --create-home --shell /bin/sh miem-pub-import
|
||||
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR.
|
||||
Секретные файлы должны существовать перед командами chown/chmod. Root-владелец
|
||||
каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать
|
||||
только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу
|
||||
docker или общий sudo. Каталоги и executables выше должны быть root-owned и
|
||||
недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker
|
||||
на сервере — `/usr/bin/docker`.
|
||||
|
||||
Создайте `/etc/sudoers.d/miem-publication-import` с mode 440:
|
||||
|
||||
```sudoers
|
||||
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
|
||||
```
|
||||
|
||||
```sh
|
||||
visudo -cf /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ
|
||||
владельца с ограничениями (замените `<PUBLIC_KEY>` самим публичным ключом):
|
||||
|
||||
```text
|
||||
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
|
||||
```
|
||||
|
||||
```sh
|
||||
chown root:root /home/miem-pub-import/.ssh/authorized_keys
|
||||
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
|
||||
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
|
||||
chmod 755 /home/miem-pub-import
|
||||
chmod 755 /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Root-владение home/.ssh предотвращает замену ограниченного authorized_keys.
|
||||
Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль,
|
||||
keyboard-interactive, пользовательские environment/rc или общий shell.
|
||||
При необходимости задайте в sshd `Match User miem-pub-import` с
|
||||
`AuthenticationMethods publickey`, `PasswordAuthentication no`,
|
||||
`KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`,
|
||||
`PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от
|
||||
конфигурации сервера; перед reload сохраните действующую сессию владельца.
|
||||
|
||||
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
После проверки сводки, выдачи write-grants и отдельного решения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
Повторный dry-run должен показать ноль insert/update. При concurrent changes
|
||||
сводки могут различаться; без production-проверки фактические количества неизвестны.
|
||||
Владелец забирает отчёты без предоставления SCP/general shell временному ключу:
|
||||
|
||||
```sh
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json
|
||||
```
|
||||
|
||||
## Отзыв доступа
|
||||
|
||||
Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой
|
||||
закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.
|
||||
|
||||
```sh
|
||||
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
|
||||
rm /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В psql владельцем:
|
||||
|
||||
```sql
|
||||
ALTER ROLE miem_publication_import NOLOGIN;
|
||||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
|
||||
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
|
||||
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
|
||||
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
|
||||
INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
|
||||
UPDATE (title, year, doi_url, raw_data, updated_at)
|
||||
ON public.employee_publications FROM miem_publication_import;
|
||||
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
DROP ROLE miem_publication_import;
|
||||
```
|
||||
|
||||
После сохранения отчётов и backup удалите только временные секреты подключения:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
|
||||
```
|
||||
|
||||
Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не
|
||||
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
|
||||
оставшиеся grants; не применяйте CASCADE/удаление данных.
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
temporary=$(mktemp)
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
|
||||
|
||||
crontab -l 2>/dev/null | grep -v 'postgres-backup.sh' > "$temporary" || true
|
||||
printf '%s\n' "30 3 * * 0 flock -w 3600 /tmp/miem-postgres-backup.lock $app_dir/infra/postgres-backup.sh || logger -t miem-postgres-backup -- 'backup skipped or failed'" >> "$temporary"
|
||||
crontab "$temporary"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
backup_dir="$HOME/backups/postgres"
|
||||
backup="$backup_dir/postgres-latest.sql"
|
||||
temporary="$backup.tmp.$$"
|
||||
|
||||
log() { logger -t miem-postgres-backup -- "$*" 2>/dev/null || true; }
|
||||
|
||||
mkdir -p "$backup_dir"
|
||||
trap 'status=$?; rm -f -- "$temporary"; [ "$status" -eq 0 ] || log "backup failed"; exit "$status"' EXIT HUP INT TERM
|
||||
cd "$app_dir"
|
||||
|
||||
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > "$temporary"
|
||||
test -s "$temporary"
|
||||
mv "$temporary" "$backup"
|
||||
find "$HOME/backups/miem_workers" -maxdepth 1 -type f -name 'miem_workers-*.sql' -delete 2>/dev/null || true
|
||||
log "created $backup"
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Source/year-aware quartiles; NULL means enrichment has not run yet.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS quartiles JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Matches to the supplied category snapshot, with provenance and identity state.
|
||||
ALTER TABLE employee_publications ADD COLUMN IF NOT EXISTS vak_categories JSONB;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive storage: original employee publications remain unchanged.
|
||||
CREATE TABLE IF NOT EXISTS council_corrections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_by VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS system_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user' CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
first_login_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
password_reset_at TIMESTAMPTZ,
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES system_users(id),
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id INTEGER REFERENCES system_users(id),
|
||||
employee_id INTEGER,
|
||||
employee_name TEXT,
|
||||
publication_year INTEGER,
|
||||
publication_key VARCHAR(64),
|
||||
article_title TEXT,
|
||||
changes JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_user_created ON user_actions(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_target_created ON user_actions(target_user_id, created_at);
|
||||
@@ -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;
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.8.6"
|
||||
version = "2.0.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"openpyxl>=3.1.5,<4",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
|
||||
@@ -6,6 +6,7 @@ jinja2>=3.1.4
|
||||
lxml>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.4.0
|
||||
openpyxl>=3.1.5,<4
|
||||
python-multipart>=0.0.9
|
||||
requests>=2.32.0
|
||||
sqlalchemy>=2.0.32
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Backfill HSE quartiles and resolve remaining DOI journals. No .env or DDL."""
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import date, datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from sqlalchemy import MetaData, Table, create_engine, select, update
|
||||
from openpyxl import load_workbook
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import _doi, _name, _journal_names, _description_journals
|
||||
from app.services.council_catalog import issn
|
||||
from app.services.publication_quartiles import catalog_entries, entries, hse_entries, merge, sjr_entries, index_sjr, with_title_history, identity_issues
|
||||
from app.services.publication_vak import assess_vak
|
||||
|
||||
SOURCE_TITLES_URL = "https://downloads.ctfassets.net/o78em1y1w4i4/7xtaTxNiNcWRTeZkV86eNy/69cf2d506c905dc299531fdc93049dbb/ext_list_Aug_2026.xlsx"
|
||||
SOURCE_TITLES_SHA256 = "11e81f686401c89fbef28de31d1880388bb7122f35c89e09db6e1848237e9afb"
|
||||
|
||||
|
||||
def load_title_history(path):
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != SOURCE_TITLES_SHA256:
|
||||
raise ValueError("Scopus title-list snapshot checksum mismatch")
|
||||
by_issn, by_title = defaultdict(list), defaultdict(list)
|
||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
rows = workbook.worksheets[0].iter_rows(values_only=True)
|
||||
columns = next(rows)
|
||||
required = {"Source Title", "ISSN", "EISSN", "Source Type", "Title History Indication", "Related Title 1"}
|
||||
if not required <= set(columns):
|
||||
raise ValueError("Invalid Scopus title list")
|
||||
for row in rows:
|
||||
data = dict(zip(columns, row))
|
||||
if data["Source Type"] != "Journal":
|
||||
continue
|
||||
identifiers = {issn(data.get(key)) for key in ("ISSN", "EISSN")} - {""}
|
||||
identifiers = {identifier[:4] + "-" + identifier[4:] for identifier in identifiers if len(identifier) == 8}
|
||||
record = {"title": str(data["Source Title"] or ""), "issns": identifiers,
|
||||
"history": data["Title History Indication"],
|
||||
"related": [str(value) for key, value in data.items() if key and "Related Title" in key and value]}
|
||||
by_title[_name(record["title"])].append(record)
|
||||
for identifier in identifiers:
|
||||
by_issn[issn(identifier)].append(record)
|
||||
finally:
|
||||
workbook.close()
|
||||
return dict(by_issn), dict(by_title)
|
||||
|
||||
|
||||
def load_sjr(identifiers, years, cache):
|
||||
"""Read the university's licensed SCImago dataset for the requested ISSNs."""
|
||||
rankings = {}
|
||||
failures = []
|
||||
for year in sorted(set(years) & set(range(2009, 2026))):
|
||||
records = {}
|
||||
for offset in range(0, len(identifiers), 200):
|
||||
batch = identifiers[offset:offset + 200]
|
||||
digest = hashlib.sha256(json.dumps([year, batch]).encode()).hexdigest()
|
||||
path = cache / f"sjr-{digest}.json"
|
||||
if path.exists():
|
||||
bindings = json.loads(path.read_text(encoding="utf8"))
|
||||
else:
|
||||
values = " ".join(json.dumps(value) for value in batch)
|
||||
query = f'''PREFIX bibo: <http://purl.org/ontology/bibo/>
|
||||
PREFIX dct: <http://purl.org/dc/terms/>
|
||||
PREFIX ou: <https://opendata.unex.es/def/ontouniversidad#>
|
||||
SELECT DISTINCT ?uri ?title ?issn ?eissn ?quartile ?categories WHERE {{
|
||||
VALUES ?wanted {{ {values} }}
|
||||
{{ ?uri bibo:issn ?wanted }} UNION {{ ?uri bibo:eissn ?wanted }}
|
||||
?uri a bibo:Journal; dct:title ?title; dct:identifier ?id; ou:sjrMejorCuartil ?quartile .
|
||||
FILTER(STRSTARTS(STR(?id), "{year}-"))
|
||||
OPTIONAL {{ ?uri bibo:issn ?issn }} OPTIONAL {{ ?uri bibo:eissn ?eissn }}
|
||||
OPTIONAL {{ ?uri ou:categoriaSJR ?categories }}
|
||||
}} LIMIT 10001'''
|
||||
try:
|
||||
response = requests.get("https://opendata.unex.es/sparql/", timeout=45,
|
||||
params={"query": query, "format": "application/sparql-results+json"},
|
||||
headers={"Accept": "application/sparql-results+json"})
|
||||
response.raise_for_status()
|
||||
bindings = response.json()["results"]["bindings"]
|
||||
if len(bindings) >= 10000:
|
||||
raise ValueError("Truncated ranking query")
|
||||
path.write_text(json.dumps(bindings, ensure_ascii=False), encoding="utf8")
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError):
|
||||
failures.append({"year": year, "batch": offset, "status": "ranking_source_unavailable"})
|
||||
continue
|
||||
for item in bindings:
|
||||
value = lambda field: item.get(field, {}).get("value", "")
|
||||
url = value("uri")
|
||||
if not url.startswith(f"https://opendata.unex.es/recurso/ciencia-tecnologia/investigacion/revistas/Revista/{year}-"):
|
||||
raise ValueError("Ranking year/identity mismatch")
|
||||
record = {"journal": value("title"), "issns": sorted({value("issn"), value("eissn")} - {""}),
|
||||
"quartile": value("quartile"), "categories": value("categories"), "source_url": url}
|
||||
if record["quartile"] not in {"Q1", "Q2", "Q3", "Q4"}:
|
||||
continue
|
||||
previous = records.get(url)
|
||||
if previous:
|
||||
record["journal"] = min(previous["journal"], record["journal"])
|
||||
record["issns"] = sorted(set(previous["issns"]) | set(record["issns"]))
|
||||
record["categories"] = "; ".join(sorted({category.strip() for value in
|
||||
(previous["categories"], record["categories"]) for category in value.split(";") if category.strip()}))
|
||||
record["conflict"] = previous.get("conflict", False) or previous["quartile"] != record["quartile"]
|
||||
records[url] = record
|
||||
rankings[year] = list(records.values())
|
||||
print(f"SJR {year}: {len(records)} matching journals", flush=True)
|
||||
return index_sjr(rankings), failures
|
||||
|
||||
|
||||
def resolve_datacite(doi, timeout):
|
||||
url = "https://api.datacite.org/dois/" + quote(doi, safe="")
|
||||
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url, "datacite_checked": True}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, allow_redirects=False,
|
||||
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles", "Accept": "application/json"})
|
||||
if response.status_code == 404:
|
||||
return {**result, "status": "doi_not_found"}
|
||||
if response.status_code == 429 or response.status_code >= 500:
|
||||
time.sleep(min(10, 2 ** attempt))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
metadata = response.json()["data"]["attributes"]
|
||||
if _doi(metadata.get("doi", "")) != doi:
|
||||
return {**result, "status": "doi_identity_conflict"}
|
||||
types = metadata.get("types", {})
|
||||
general = types.get("resourceTypeGeneral")
|
||||
journal_article = general == "JournalArticle" or (general in {None, "Text"} and types.get("citeproc") == "article-journal")
|
||||
container = metadata.get("container") or {}
|
||||
identifiers = []
|
||||
if container.get("identifierType") == "ISSN":
|
||||
identifiers.append(container.get("identifier", ""))
|
||||
for related in metadata.get("relatedIdentifiers", []):
|
||||
if related.get("relatedIdentifierType") == "ISSN" and related.get("relationType") in {"IsPartOf", "IsPublishedIn"}:
|
||||
identifiers.append(related.get("relatedIdentifier", ""))
|
||||
identifiers = sorted({value[:4] + "-" + value[-4:] for value in identifiers if isinstance(value, str)
|
||||
and re.fullmatch(r"\d{4}-?\d{3}[\dX]", value, re.I)})
|
||||
year = metadata.get("publicationYear")
|
||||
years = [int(year)] if str(year).isdigit() else []
|
||||
return {**result, "status": "resolved", "type": "journal-article" if journal_article else "non-journal-material",
|
||||
"issns": identifiers, "journals": [container["title"]] if isinstance(container.get("title"), str) else [], "years": years}
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
|
||||
if attempt < 2:
|
||||
time.sleep(2 ** attempt)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_doi(doi, cache, timeout=20):
|
||||
path = cache / (hashlib.sha256(doi.encode()).hexdigest() + ".json")
|
||||
if path.exists():
|
||||
result = json.loads(path.read_text(encoding="utf8"))
|
||||
if result.get("doi") == doi and (result.get("status") == "resolved"
|
||||
or (result.get("status") == "doi_not_found" and result.get("datacite_checked"))):
|
||||
return result
|
||||
url = "https://api.crossref.org/works/" + quote(doi, safe="")
|
||||
result = {"doi": doi, "status": "doi_lookup_failed", "source_url": url}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, allow_redirects=False,
|
||||
headers={"User-Agent": "MIEM-workers/0.18 publication-quartiles",
|
||||
"Accept": "application/json"})
|
||||
if response.status_code == 404:
|
||||
result = resolve_datacite(doi, timeout)
|
||||
break
|
||||
if response.status_code == 429 or response.status_code >= 500:
|
||||
time.sleep(min(10, 2 ** attempt))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
message = response.json()["message"]
|
||||
if _doi(message.get("DOI", "")) != doi:
|
||||
result["status"] = "doi_identity_conflict"
|
||||
break
|
||||
years = set()
|
||||
for field in ("published", "published-print", "published-online", "issued"):
|
||||
for parts in message.get(field, {}).get("date-parts", []):
|
||||
if parts and type(parts[0]) is int:
|
||||
years.add(parts[0])
|
||||
result.update(status="resolved", type=message.get("type"),
|
||||
journals=[v for v in message.get("container-title", []) if isinstance(v, str)],
|
||||
issns=[v for v in message.get("ISSN", []) if isinstance(v, str)
|
||||
and re.fullmatch(r"\d{4}-\d{3}[\dX]", v, re.I)], years=sorted(years))
|
||||
break
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError):
|
||||
if attempt < 2:
|
||||
time.sleep(2 ** attempt)
|
||||
if result["status"] in {"resolved", "doi_not_found"}:
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(result, ensure_ascii=False), encoding="utf8")
|
||||
temporary.replace(path)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="Commit changes; default is read-only")
|
||||
parser.add_argument("--lookup-doi", action="store_true")
|
||||
parser.add_argument("--all-systems", action="store_true", help="Collect additional systems/categories even for publications with known quartiles")
|
||||
parser.add_argument("--check-vak", action="store_true", help="Check the supplied K1/K2 snapshots")
|
||||
parser.add_argument("--allow-latest", action="store_true", help="Use latest available metric if the publication year is unavailable")
|
||||
parser.add_argument("--sjr-history", action="store_true", help="Check UNEx/SCImago historical quartiles by DOI ISSNs")
|
||||
parser.add_argument("--scopus-title-history", type=Path, help="Official Scopus August 2026 source-title XLSX for explicit ISSN succession")
|
||||
parser.add_argument("--cache-dir", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--workers", type=int, choices=range(1, 9), default=4)
|
||||
args = parser.parse_args()
|
||||
pending = args.report.with_suffix(args.report.suffix + ".pending")
|
||||
if args.scopus_title_history and args.scopus_title_history.resolve() in {args.report.resolve(), pending.resolve()}:
|
||||
raise ValueError("Report must not overwrite the source title list")
|
||||
args.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Verify report destination before changing any data.
|
||||
pending.write_text("", encoding="utf8")
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
table = Table("employee_publications", MetaData(), autoload_with=engine)
|
||||
if "quartiles" not in table.c:
|
||||
raise ValueError("Apply migration 010 before enrichment")
|
||||
fields = ["id", "year", "doi_url", "raw_data", "quartiles", "description"]
|
||||
if args.check_vak:
|
||||
if "vak_categories" not in table.c:
|
||||
raise ValueError("Apply migration 011 before VAK enrichment")
|
||||
fields.extend(["publication_type", "vak_categories"])
|
||||
columns = [table.c[field] for field in fields]
|
||||
with engine.connect() as connection:
|
||||
rows = [dict(row) for row in connection.execute(select(*columns)).mappings()]
|
||||
def needs_lookup(row):
|
||||
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
|
||||
names = _journal_names(raw)
|
||||
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
|
||||
if isinstance(description, dict):
|
||||
names.extend(_description_journals(description))
|
||||
ids = [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)]
|
||||
payload = merge(row["quartiles"], hse_entries(row["raw_data"], row["year"]))
|
||||
return (args.all_systems or not entries(payload) or (payload.get("lookup") or {}).get("status") == "found_latest"
|
||||
or bool(identity_issues(payload, row["doi_url"], names, ids)))
|
||||
identifiers = sorted({_doi(row["doi_url"] or "") for row in rows
|
||||
if needs_lookup(row)} - {""})
|
||||
resolved = {}
|
||||
if args.lookup_doi:
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
tasks = {pool.submit(resolve_doi, doi, args.cache_dir): doi for doi in identifiers}
|
||||
for task in as_completed(tasks):
|
||||
resolved[tasks[task]] = task.result()
|
||||
if len(resolved) % 100 == 0 or len(resolved) == len(identifiers):
|
||||
print(f"DOI checked: {len(resolved)}/{len(identifiers)}", flush=True)
|
||||
rankings, ranking_failures = {}, []
|
||||
if args.scopus_title_history:
|
||||
by_issn, by_title = load_title_history(args.scopus_title_history)
|
||||
resolved = {doi: with_title_history(metadata, by_issn, by_title, SOURCE_TITLES_URL)
|
||||
for doi, metadata in resolved.items()}
|
||||
if args.lookup_doi and args.sjr_history:
|
||||
issns = sorted({value for metadata in resolved.values() if metadata.get("type") == "journal-article"
|
||||
for value in metadata.get("ranking_issns", metadata.get("issns", []))})
|
||||
years = {row["year"] for row in rows if needs_lookup(row) and row["year"] is not None}
|
||||
if args.allow_latest:
|
||||
years.add(2025)
|
||||
rankings, ranking_failures = load_sjr(issns, years, args.cache_dir)
|
||||
counts = Counter()
|
||||
report = []
|
||||
for row in rows:
|
||||
doi = _doi(row["doi_url"] or "")
|
||||
previous_lookup = (row["quartiles"].get("lookup") or {}) if row["quartiles"] else {}
|
||||
metadata = resolved.get(doi, previous_lookup if doi and _doi(previous_lookup.get("doi") or "") == doi else {})
|
||||
additions = hse_entries(row["raw_data"], row["year"])
|
||||
payload = merge(row["quartiles"], additions)
|
||||
if additions:
|
||||
counts["hse_quartiles"] += 1
|
||||
if args.lookup_doi and needs_lookup(row):
|
||||
doi = _doi(row["doi_url"] or "")
|
||||
metadata = resolved.get(doi, {"status": "invalid_doi" if row["doi_url"] else "missing_doi"})
|
||||
status = metadata["status"]
|
||||
if status == "resolved":
|
||||
found, status = catalog_entries(metadata, row["year"], args.allow_latest)
|
||||
if rankings:
|
||||
sjr, sjr_status = sjr_entries(metadata, row["year"], rankings, args.allow_latest)
|
||||
found.extend(sjr)
|
||||
if not found and status == "historical_quartile_unavailable":
|
||||
status = sjr_status
|
||||
if found:
|
||||
status = "found_latest" if any(item["metric_year"] != row["year"] for item in found) else "found"
|
||||
payload = merge(payload, found)
|
||||
payload["lookup"] = {**metadata, "status": status, "requested_year": row["year"],
|
||||
"checked_on": date.today().isoformat()}
|
||||
counts[status] += 1
|
||||
counts["with_quartile" if entries(payload) else "without_quartile"] += 1
|
||||
vak_payload = row.get("vak_categories")
|
||||
if args.check_vak:
|
||||
raw = row["raw_data"] if isinstance(row["raw_data"], dict) else {}
|
||||
imported = raw.get("csv_enrichment", {})
|
||||
names = [*_journal_names(raw), *(_journal_names(imported) if isinstance(imported, dict) else []),
|
||||
*(metadata.get("journals", []) if metadata.get("type") in {"journal-article", "journal"} else [])]
|
||||
if row["publication_type"] == "ARTICLE" and metadata.get("type") in {None, "journal-article", "journal"}:
|
||||
description = row["description"] if isinstance(row["description"], dict) else raw.get("description", {})
|
||||
if isinstance(description, dict):
|
||||
names.extend(_description_journals(description))
|
||||
identifiers = list(metadata.get("issns", []))
|
||||
for source in (raw, imported):
|
||||
if isinstance(source, dict):
|
||||
identifiers.extend(source[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(source.get(key), str))
|
||||
vak_payload = assess_vak(list(dict.fromkeys(names)), list(dict.fromkeys(identifiers)))
|
||||
if row.get("vak_categories") is not None and row["vak_categories"] != vak_payload:
|
||||
counts["vak_existing_value_kept"] += 1
|
||||
vak_payload = row["vak_categories"]
|
||||
counts["vak_" + vak_payload["status"]] += 1
|
||||
if payload == row["quartiles"] and vak_payload == row.get("vak_categories"):
|
||||
continue
|
||||
if args.apply:
|
||||
# Only this field is changed; parser-owned data and existing evidence survive.
|
||||
with engine.begin() as connection:
|
||||
current = dict(connection.execute(select(*columns).where(table.c.id == row["id"])
|
||||
.with_for_update()).mappings().one())
|
||||
if any(current[field] != row[field] for field in fields if field != "id"):
|
||||
counts["concurrent_change_skipped"] += 1
|
||||
continue
|
||||
values = {"quartiles": payload, "updated_at": datetime.now(timezone.utc)}
|
||||
if args.check_vak:
|
||||
values["vak_categories"] = vak_payload
|
||||
connection.execute(update(table).where(table.c.id == row["id"]).values(**values))
|
||||
counts["changed"] += 1
|
||||
report.append({"id": row["id"], "quartiles": payload, **({"vak_categories": vak_payload} if args.check_vak else {})})
|
||||
summary = {"mode": "apply" if args.apply else "dry-run", "publications": len(rows),
|
||||
"unique_doi_checked": len(resolved), "counts": dict(counts), "ranking_failures": ranking_failures}
|
||||
pending.write_text(json.dumps({"summary": summary, "records": report}, ensure_ascii=False, indent=2), encoding="utf8")
|
||||
pending.replace(args.report)
|
||||
print(json.dumps(summary, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
# Connection errors can contain credentials; do not print their text.
|
||||
print(f"Enrichment failed ({type(error).__name__}); completed records are retained; retry is safe.", flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""One-time extraction: python scripts/extract_k1_journals.py <categories.pdf>.
|
||||
|
||||
Requires pdfplumber for this development operation only; the service reads JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pdfplumber
|
||||
|
||||
|
||||
def extract(source: Path, category: str = "К1") -> dict:
|
||||
if category not in {"К1", "К2"}:
|
||||
raise ValueError("Unsupported category")
|
||||
rows = []
|
||||
with pdfplumber.open(source) as document:
|
||||
for page in document.pages:
|
||||
for table in page.extract_tables():
|
||||
for row in table:
|
||||
if row[0] and row[0].isdigit():
|
||||
if len(row) != 4 or not all(row) or not set(row[2].split()) <= {"К1", "К2", "К3"}:
|
||||
raise ValueError(f"Invalid category row: {row}")
|
||||
# Some entries include previous categories; use the final category.
|
||||
row[2] = row[2].split()[-1]
|
||||
row[3] = row[3].splitlines()[-1]
|
||||
datetime.strptime(row[3], "%d.%m.%Y")
|
||||
rows.append(row)
|
||||
if not rows or [int(row[0]) for row in rows] != list(range(1, len(rows) + 1)):
|
||||
raise ValueError("Journal numbering is incomplete or duplicated")
|
||||
journals = []
|
||||
for row in rows:
|
||||
if row[2] != category:
|
||||
continue
|
||||
name = " ".join(row[1].split())
|
||||
journal = {"name": name, "categorized_on": row[3], "source_row": int(row[0])}
|
||||
# Only an explicit administrative rename note supplies an alias.
|
||||
# Parentheses in journal names (e.g. bilingual titles) stay unchanged.
|
||||
rename = re.fullmatch(r"(.+?) \((до \d{2}\.\d{2}\.\d{4} г\. наименование в Перечне «(.+)»)\)", name)
|
||||
if rename:
|
||||
journal.update(name=rename[1], note=rename[2], aliases=[rename[3]])
|
||||
journals.append(journal)
|
||||
if not journals:
|
||||
raise ValueError(f"No {category} journals found")
|
||||
return {"source": source.name, "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"source_rows": len(rows), "category_counts": dict(Counter(row[2] for row in rows)),
|
||||
"journals": journals}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
parser.add_argument("--category", choices=["К1", "К2"], default="К1")
|
||||
args = parser.parse_args()
|
||||
result = extract(args.source, args.category)
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
print(f"Saved {len(result['journals'])} {args.category} journals; verified {result['source_rows']} source rows")
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Extract K1 journal specialties from the supplied VAK list (development only).
|
||||
|
||||
python scripts/extract_k1_specialties.py <list.pdf> [--rows-cache <json>]
|
||||
Requires pdfplumber only for regeneration; requests read the generated JSON.
|
||||
"""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
SPECIALTIES = [
|
||||
("1.2.2", "Математическое моделирование, численные методы и комплексы программ"),
|
||||
("2.2.2", "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"),
|
||||
("2.3.6", "Методы и системы защиты информации, информационная безопасность"),
|
||||
("2.3.1", "Системный анализ, управление и обработка информации, статистика"),
|
||||
("2.3.7", "Компьютерное моделирование и автоматизация проектирования"),
|
||||
("1.3.8", "Физика конденсированного состояния"),
|
||||
("2.2.15", "Системы, сети и устройства телекоммуникаций"),
|
||||
("1.1.2", "Дифференциальные уравнения и математическая физика"),
|
||||
("2.2.13", "Радиотехника, в том числе системы и устройства телевидения"),
|
||||
("2.3.2", "Вычислительные системы и их элементы"),
|
||||
("2.2.14", "Антенны, СВЧ-устройства и их технологии"),
|
||||
("2.3.5", "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"),
|
||||
]
|
||||
CODE = re.compile(r"(?<![\d.])([1-6]\.\d{1,2}\.\d{1,2})\.")
|
||||
|
||||
|
||||
def journal_key(value):
|
||||
# Administrative notes are separate from the title; do not remove ordinary parentheses.
|
||||
value = re.split(r"\s*\(\s*(?:до\s+\d|перевод)", value, flags=re.I)[0]
|
||||
return " ".join(re.findall(r"\w+", unicodedata.normalize("NFKC", value).casefold().replace("ё", "е")))
|
||||
|
||||
|
||||
def extract_rows(source):
|
||||
import pdfplumber
|
||||
rows = []
|
||||
with pdfplumber.open(source) as document:
|
||||
for number, page in enumerate(document.pages, 1):
|
||||
for table in page.find_tables():
|
||||
# Ignore tiny tables detected inside the journal-name cell.
|
||||
if table.bbox[2] - table.bbox[0] < 500:
|
||||
continue
|
||||
for row, values in zip(table.rows, table.extract()):
|
||||
columns = [""] * 5
|
||||
for cell, value in zip(row.cells, values):
|
||||
if cell and value:
|
||||
# This source uses five fixed columns; some pages contain
|
||||
# additional cell borders within those columns.
|
||||
column = sum(cell[0] >= edge - 1 for edge in (71.424, 234.387, 295.39, 498.48))
|
||||
columns[column] += ("\n" if columns[column] else "") + value
|
||||
rows.append({"page": number, "cells": columns})
|
||||
page.close()
|
||||
return rows
|
||||
|
||||
|
||||
def parse_rows(rows):
|
||||
journals = []
|
||||
for row in rows:
|
||||
cells = row["cells"]
|
||||
if len(cells) != 5:
|
||||
raise ValueError("Expected five VAK columns")
|
||||
if re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journals.append({"row": int(cells[0].strip(". ")), "name": cells[1], "issn": cells[2],
|
||||
"pages": [], "assignments": []})
|
||||
elif not journals:
|
||||
continue # The first-page header.
|
||||
elif cells[0].strip():
|
||||
raise ValueError(f"Unexpected journal number on page {row['page']}")
|
||||
elif cells[1]:
|
||||
journals[-1]["name"] += " " + cells[1]
|
||||
journal = journals[-1]
|
||||
if row["page"] not in journal["pages"]:
|
||||
journal["pages"].append(row["page"])
|
||||
if cells[2] and not re.fullmatch(r"\d+\.", cells[0].strip()):
|
||||
journal["issn"] += " " + cells[2]
|
||||
if cells[3]:
|
||||
if cells[4]:
|
||||
dates = re.sub(r"\s+", "", cells[4]).lower().replace("c", "с")
|
||||
dates = dates.removeprefix("с")
|
||||
bounds = dates.split("по")
|
||||
if len(bounds) > 2:
|
||||
raise ValueError(f"Unrecognized VAK dates: {cells[4]}")
|
||||
def parse_date(value):
|
||||
try:
|
||||
return datetime.strptime(value, "%d.%m.%Y").date().isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
start = parse_date(bounds[0])
|
||||
end = parse_date(bounds[1]) if len(bounds) == 2 else None
|
||||
if len(bounds) == 2 and end is None:
|
||||
raise ValueError(f"Unrecognized end date: {cells[4]}")
|
||||
assignment = {"codes": [], "valid_from": start, "valid_until": end,
|
||||
"source_dates": cells[4], "pages": []}
|
||||
if start is None:
|
||||
# Preserve source typos, without inventing a historical start date.
|
||||
assignment["date_note"] = "Дата начала неполная в источнике; текущее назначение определяется по отсутствию истёкшего срока окончания в Перечне."
|
||||
journal["assignments"].append(assignment)
|
||||
elif not journal["assignments"]:
|
||||
raise ValueError(f"Missing initial dates in VAK row {journal['row']}")
|
||||
assignment = journal["assignments"][-1]
|
||||
assignment["codes"] = sorted(set(assignment["codes"]) | set(CODE.findall(cells[3])))
|
||||
if row["page"] not in assignment["pages"]:
|
||||
assignment["pages"].append(row["page"])
|
||||
elif cells[4]:
|
||||
raise ValueError("Dates without specialty text")
|
||||
if not journals or [j["row"] for j in journals] != list(range(1, len(journals) + 1)):
|
||||
raise ValueError("VAK numbering is incomplete or duplicated")
|
||||
for journal in journals:
|
||||
journal["name"] = " ".join(journal["name"].split())
|
||||
journal["issn"] = " ".join(journal["issn"].split())
|
||||
return journals
|
||||
|
||||
|
||||
def active_codes(journal, as_of):
|
||||
return sorted({code for a in journal["assignments"]
|
||||
if (a["valid_from"] is None or a["valid_from"] <= as_of)
|
||||
and (a["valid_until"] is None or as_of <= a["valid_until"])
|
||||
for code in a["codes"]})
|
||||
|
||||
|
||||
def build_reference(journals, k1, overrides, as_of):
|
||||
by_name = defaultdict(list)
|
||||
by_row = {j["row"]: j for j in journals}
|
||||
for journal in journals:
|
||||
by_name[journal_key(journal["name"])].append(journal)
|
||||
reviewed = {o["k1_name"]: o for o in overrides}
|
||||
if len(reviewed) != len(overrides) or not set(reviewed) <= {j["name"] for j in k1["journals"]}:
|
||||
raise ValueError("Invalid or stale reviewed K1 matches")
|
||||
result = []
|
||||
for journal in k1["journals"]:
|
||||
entry = {"name": journal["name"], "specialty_codes": [], "status": "not_found", "vak_entries": []}
|
||||
matches = by_name[journal_key(journal["name"])]
|
||||
if journal["name"] in reviewed:
|
||||
override = reviewed[journal["name"]]
|
||||
candidate = by_row[override["vak_row"]]
|
||||
if candidate["name"] != override["vak_name"]:
|
||||
raise ValueError("Reviewed source journal title changed")
|
||||
matches = [candidate]
|
||||
entry["match_note"] = override["reason"]
|
||||
active = [j for j in matches if active_codes(j, as_of)]
|
||||
if len(active) == 1:
|
||||
entry.update(specialty_codes=active_codes(active[0], as_of), status="matched")
|
||||
elif len(active) > 1:
|
||||
entry["status"] = "ambiguous" # Do not union different journals with identical titles.
|
||||
elif matches:
|
||||
entry["status"] = "no_current_specialties"
|
||||
entry["vak_entries"] = matches
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--k1", type=Path, default=Path("app/data/k1_journals.json"))
|
||||
parser.add_argument("--matches", type=Path, default=Path("scripts/k1_specialty_matches.json"))
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/k1_specialties.json"))
|
||||
parser.add_argument("--rows-cache", type=Path)
|
||||
parser.add_argument("--as-of", default="2026-09-15")
|
||||
args = parser.parse_args()
|
||||
date.fromisoformat(args.as_of)
|
||||
rows = json.loads(args.rows_cache.read_text(encoding="utf-8")) if args.rows_cache else extract_rows(args.source)
|
||||
journals = parse_rows(rows)
|
||||
k1 = json.loads(args.k1.read_text(encoding="utf-8"))
|
||||
entries = build_reference(journals, k1, json.loads(args.matches.read_text(encoding="utf-8")), args.as_of)
|
||||
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,
|
||||
"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)))
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Install root-owned; caller cannot choose container, executable, input or env.
|
||||
set -eu
|
||||
case "${1:-}" in
|
||||
dry-run) mode=dry-run ;;
|
||||
apply) mode=apply ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
[ "$#" -eq 1 ] || exit 1
|
||||
if [ "$mode" = apply ]; then
|
||||
exec /usr/bin/docker exec --user 65534:65534 \
|
||||
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
|
||||
-e PGSERVICE=miem_publication_import \
|
||||
-e PGPASSFILE=/opt/miem-import/pgpass \
|
||||
-e PYTHONNOUSERSITE=1 \
|
||||
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
|
||||
/opt/miem-import/articles.csv --apply --report /tmp/miem-import/apply.json
|
||||
fi
|
||||
exec /usr/bin/docker exec --user 65534:65534 \
|
||||
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
|
||||
-e PGSERVICE=miem_publication_import \
|
||||
-e PGPASSFILE=/opt/miem-import/pgpass \
|
||||
-e PYTHONNOUSERSITE=1 \
|
||||
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
|
||||
/opt/miem-import/articles.csv --report /tmp/miem-import/dry-run.json
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Forced SSH command: no arguments, paths, forwarding or shell from the client.
|
||||
set -eu
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
'import-publications dry-run') exec sudo -n /usr/local/sbin/miem-publication-import dry-run ;;
|
||||
'import-publications apply') exec sudo -n /usr/local/sbin/miem-publication-import apply ;;
|
||||
*) printf '%s\n' 'Allowed: import-publications dry-run | import-publications apply' >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Owner-run CSV enrichment; no app startup, .env loading, DDL or deletes."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def normalized(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
|
||||
def read_rows(path):
|
||||
with path.open(encoding="utf-8-sig", newline="") as source:
|
||||
reader = csv.DictReader(source, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing required CSV headers")
|
||||
rows = []
|
||||
for number, row in enumerate(reader, 2):
|
||||
if None in row or any(v is None for v in row.values()):
|
||||
raise ValueError(f"Malformed CSV record {number}")
|
||||
rows.append((number, {k.strip(): v.strip() for k, v in row.items()}))
|
||||
return rows
|
||||
|
||||
|
||||
def plan(rows, employees, publications):
|
||||
names = defaultdict(list)
|
||||
by_employee = defaultdict(list)
|
||||
for employee in employees:
|
||||
if normalized(employee["full_name"]):
|
||||
names[normalized(employee["full_name"])].append(employee["id"])
|
||||
for publication in publications:
|
||||
by_employee[publication["employee_id"]].append(dict(publication))
|
||||
actions, audit, seen = [], [], set()
|
||||
for number, row in rows:
|
||||
title, identifier = row["Название публикации"], doi(row["DOI"])
|
||||
author_names = sorted(set(normalized(a) for a in row["Авторы НИУ ВШЭ"].split(";")) - {""})
|
||||
year = int(row["Год"]) if row["Год"].isdigit() else None
|
||||
if not title or not year or not 1000 <= year <= 9999 or not author_names or (identifier and not re.fullmatch(r"10\.\d{4,9}/\S+", identifier)):
|
||||
audit.append({"row": number, "employee_id": None, "result": "skip", "reason": "invalid_fields"})
|
||||
continue
|
||||
for author in author_names:
|
||||
ids = names.get(author, [])
|
||||
entry = {"row": number, "employee_id": ids[0] if len(ids) == 1 else None}
|
||||
def skip(reason):
|
||||
audit.append({**entry, "result": "skip", "reason": reason})
|
||||
if len(ids) != 1:
|
||||
skip("employee_not_found" if not ids else "ambiguous_employee")
|
||||
continue
|
||||
employee_id = ids[0]
|
||||
candidates = by_employee[employee_id]
|
||||
exact = [p for p in candidates if identifier and doi(p.get("doi_url")) == identifier]
|
||||
title_matches = [p for p in candidates if normalized(p["title"]) == normalized(title) and p.get("year") in (None, year)]
|
||||
matches = {p["id"]: p for p in exact + title_matches}
|
||||
if len(matches) > 1:
|
||||
skip("ambiguous_publication")
|
||||
continue
|
||||
existing = next(iter(matches.values()), None)
|
||||
if existing and identifier and doi(existing.get("doi_url")) and doi(existing["doi_url"]) != identifier:
|
||||
skip("doi_conflict")
|
||||
continue
|
||||
if existing and existing.get("year") and existing["year"] != year:
|
||||
skip("year_conflict")
|
||||
continue
|
||||
key = (employee_id, identifier or (normalized(title), year))
|
||||
duplicate = key in seen
|
||||
seen.add(key)
|
||||
metadata = {k: v for k, v in row.items() if v}
|
||||
raw = existing.get("raw_data") if existing else None
|
||||
if raw is not None and not isinstance(raw, dict):
|
||||
skip("unsupported_raw_data")
|
||||
continue
|
||||
raw = dict(raw or {})
|
||||
old_metadata = raw.get("csv_enrichment", {})
|
||||
if not isinstance(old_metadata, dict):
|
||||
skip("unsupported_csv_metadata")
|
||||
continue
|
||||
conflicts = sorted(k for k, v in metadata.items() if old_metadata.get(k) and old_metadata[k] != v)
|
||||
if conflicts:
|
||||
entry["conflict_fields"] = conflicts
|
||||
merged = dict(old_metadata)
|
||||
for k, v in metadata.items():
|
||||
if not merged.get(k):
|
||||
merged[k] = v
|
||||
raw["csv_enrichment"] = merged
|
||||
values = {"raw_data": raw}
|
||||
if identifier and (not existing or not existing.get("doi_url")):
|
||||
values["doi_url"] = "https://doi.org/" + identifier
|
||||
if not existing or not existing.get("year"):
|
||||
values["year"] = year
|
||||
if not existing or not existing.get("title"):
|
||||
values["title"] = title
|
||||
if existing:
|
||||
values = {k: v for k, v in values.items() if existing.get(k) != v}
|
||||
if not values:
|
||||
skip("metadata_conflict_first_value_kept" if conflicts else "csv_duplicate" if duplicate else "already_present")
|
||||
continue
|
||||
if isinstance(existing["id"], str):
|
||||
actions[int(existing["id"].split("-")[1])]["values"].update(values)
|
||||
existing.update(values)
|
||||
skip("metadata_conflict_first_value_kept" if conflicts else "merged_csv_duplicate")
|
||||
continue
|
||||
action = {"result": "update", "id": existing["id"], "values": values}
|
||||
existing.update(values)
|
||||
else:
|
||||
source_hash = hashlib.sha256(json.dumps(key, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||
values.update(employee_id=employee_id, source_hash=source_hash)
|
||||
action = {"result": "insert", "values": values}
|
||||
by_employee[employee_id].append({**values, "id": f"planned-{len(actions)}"})
|
||||
actions.append(action)
|
||||
audit.append({**entry, "result": action["result"], "reason": "metadata_conflict_first_value_kept" if conflicts else ""})
|
||||
return actions, audit
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("file", type=Path)
|
||||
parser.add_argument("--apply", action="store_true", help="Commit; default is read-only dry-run")
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
pending_report = args.report.with_suffix(args.report.suffix + ".pending")
|
||||
if args.file.resolve() in (args.report.resolve(), pending_report.resolve()):
|
||||
raise ValueError("Report must not overwrite input")
|
||||
source_digest = hashlib.sha256(args.file.read_bytes()).hexdigest()
|
||||
rows = read_rows(args.file)
|
||||
# libpq environment/service only; never load application settings or .env.
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg import sql
|
||||
|
||||
# Check report access before any possible commit; pending file is not a success report.
|
||||
with pending_report.open("x", encoding="utf-8"):
|
||||
pass
|
||||
with psycopg.connect("", row_factory=dict_row) as connection:
|
||||
connection.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" + ("" if args.apply else " READ ONLY"))
|
||||
connection.execute("SET LOCAL search_path = pg_catalog, public")
|
||||
connection.execute("SET LOCAL statement_timeout = '60s'")
|
||||
connection.execute("SET LOCAL lock_timeout = '5s'")
|
||||
if args.apply:
|
||||
locked = connection.execute("SELECT pg_try_advisory_xact_lock(716203841)").fetchone()
|
||||
if not next(iter(locked.values())):
|
||||
raise RuntimeError("Another import is running")
|
||||
employees = connection.execute("SELECT id, full_name FROM public.employees").fetchall()
|
||||
publications = connection.execute("SELECT id, employee_id, title, year, doi_url, raw_data FROM public.employee_publications").fetchall()
|
||||
actions, audit = plan(rows, employees, publications)
|
||||
if args.apply:
|
||||
for action in actions:
|
||||
values = dict(action["values"])
|
||||
if "raw_data" in values:
|
||||
values["raw_data"] = Jsonb(values["raw_data"])
|
||||
columns = list(values)
|
||||
if action["result"] == "insert":
|
||||
query = sql.SQL("INSERT INTO public.employee_publications ({}, created_at, updated_at) VALUES ({}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").format(
|
||||
sql.SQL(", ").join(map(sql.Identifier, columns)), sql.SQL(", ").join(sql.Placeholder() for _ in columns))
|
||||
connection.execute(query, list(values.values()))
|
||||
else:
|
||||
query = sql.SQL("UPDATE public.employee_publications SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = %s").format(
|
||||
sql.SQL(", ").join(sql.SQL("{} = %s").format(sql.Identifier(k)) for k in columns))
|
||||
result = connection.execute(query, [*values.values(), action["id"]])
|
||||
if result.rowcount != 1:
|
||||
raise RuntimeError("Publication changed concurrently")
|
||||
if hashlib.sha256(args.file.read_bytes()).hexdigest() != source_digest:
|
||||
raise RuntimeError("Input changed during import")
|
||||
summary = {"mode": "apply" if args.apply else "dry-run", "csv_rows": len(rows),
|
||||
"sha256": source_digest,
|
||||
"counts": {key: sum(item["result"] == key for item in audit) for key in ("insert", "update", "skip")},
|
||||
"metadata_conflicts": sum(bool(item.get("conflict_fields")) for item in audit),
|
||||
"skip_reasons": dict(Counter(item["reason"] for item in audit if item["result"] == "skip"))}
|
||||
pending_report.write_text(json.dumps({"summary": summary, "records": audit}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if not args.apply:
|
||||
connection.rollback()
|
||||
pending_report.replace(args.report)
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
# Driver errors may contain connection credentials or personal data.
|
||||
print(f"Import failed ({type(error).__name__}); transaction not partially applied. Check configuration and permissions.")
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,662 @@
|
||||
[
|
||||
{
|
||||
"k1_name": "Acta Biomedica Scientifica (East Siberian Biomedical Journal)",
|
||||
"vak_row": 3,
|
||||
"vak_name": "Acta biomedica scientifica (перевод наименования на государственный язык Российской Федерации: Научный биомедицинский журнал) (До 02.10.2017 г. наименование в Перечне «Бюллетень Восточно-Сибирского научного центра СО РАМН (Bulletin of the East Siberian Scientific Center SB RAMS\" ISSN 1811-0649)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Acta Linguistica Petropolitana. Труды института лингвистических исследований",
|
||||
"vak_row": 4,
|
||||
"vak_name": "Acta Linguistica Petropolitana. Труды Института лингвистических исследований РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ars Administrandi. Искусство управления",
|
||||
"vak_row": 11,
|
||||
"vak_name": "Ars Administrandi (перевод наименование на государственный язык Российской Федерации: Искусство управления)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Iberoamerica",
|
||||
"vak_row": 1194,
|
||||
"vak_name": "Журнал \"IВЕRОАМЕRIСА\" на испанском языке (перевод названия на государственный язык Российской Федерации: ИБЕРОАМЕРИКА)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "International Journal for Computational Civil and Structural Engineering",
|
||||
"vak_row": 57,
|
||||
"vak_name": "International Journal for Computation Civil and Structural Engineering (Международный журнал по расчету гражданских и строительных конструкций)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Economic Regulation",
|
||||
"vak_row": 67,
|
||||
"vak_name": "Journal of Economic Regulation (\"Вопросы регулирования экономики\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Institutional Studies",
|
||||
"vak_row": 69,
|
||||
"vak_name": "Journal of Institutional Studies (\"Журнал институциональных исследований\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Siberian Joumal of Life Sciences and Agriculture",
|
||||
"vak_row": 145,
|
||||
"vak_name": "Siberian Journal of Life Sciences and Agriculture (перевод наименования на государственный язык Российской Федерации: Сибирский журнал медико- биологических и сельскохозяйственных исследований)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Terra Economicus",
|
||||
"vak_row": 158,
|
||||
"vak_name": "Terra Economicus («ПРОСТРАНСТВО ЭКОНОМИКИ»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Theory and Practice of Meat Processing",
|
||||
"vak_row": 2748,
|
||||
"vak_name": "Теория и практика переработки мяса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Turczaninowia",
|
||||
"vak_row": 166,
|
||||
"vak_name": "Turczaninowia (Турчаниновия)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Альманах Института коррекционной педагогики",
|
||||
"vak_row": 239,
|
||||
"vak_name": "Альманах Института коррекционной педагогики / Almanac Institute of special education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Анестезиология и реаниматология (Медиа Сфера)",
|
||||
"vak_row": 253,
|
||||
"vak_name": "Анестезиология и реаниматология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бизнес-информатика",
|
||||
"vak_row": 325,
|
||||
"vak_name": "Бизнес-информатика Business Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень НЦССХ им. А.Н. Бакулева РАМН. Сердечно-сосудистые заболевания",
|
||||
"vak_row": 353,
|
||||
"vak_name": "Бюллетень Научного центра сердечно-сосудистой хирургии им. А.Н. Бакулева РАМН \"Сердечно-сосудистые заболевания\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень Почвенного института им. В.В. Докучаева",
|
||||
"vak_row": 355,
|
||||
"vak_name": "Бюллетень Почвенного института имени В.В. Докучаева",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень сибирской медицины",
|
||||
"vak_row": 357,
|
||||
"vak_name": "Бюллетень сибирской медицины/Bulletin of Siberian Medicine",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств",
|
||||
"vak_row": 2421,
|
||||
"vak_name": "Регуляторные исследования и экспертиза лекарственных средств (до 30.10.2024 наименование в Перечене «Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств» ISSN 1991-2919)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник государственного университета морского и речного флота им. адмирала С.О. Макарова",
|
||||
"vak_row": 468,
|
||||
"vak_name": "Вестник Государственного университета морского и речного флота имени адмирала С.О. Макарова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Ленинградского государственного университета им. А.С. Пушкина",
|
||||
"vak_row": 548,
|
||||
"vak_name": "Вестник Ленинградского государственного университета имени А.С. Пушкина",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова",
|
||||
"vak_row": 554,
|
||||
"vak_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова, Vestnik of Nosov Magnitogorsk State Technical University",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник международных организаций: образование, наука, новая экономика",
|
||||
"vak_row": 570,
|
||||
"vak_name": "Вестник международных организаций: образование, наука, новая экономика International Organisations Research Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 23: Антропология",
|
||||
"vak_row": 612,
|
||||
"vak_name": "Вестник Московского университета. Серия ХХIII. Антропология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 25: Международные отношения и мировая политика",
|
||||
"vak_row": 611,
|
||||
"vak_name": "Вестник Московского университета. Серия XXV. Международные отношения и мировая политика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник НПО им. С.А. Лавочкина",
|
||||
"vak_row": 642,
|
||||
"vak_name": "Вестник «НПО имени С.А. Лавочкина»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. История",
|
||||
"vak_row": 672,
|
||||
"vak_name": "Вестник Пермского университета. Серия История",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Политология",
|
||||
"vak_row": 670,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Политология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Серия: Экономика",
|
||||
"vak_row": 671,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Экономика\" = Perm University Herald. ECONOMY",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Православного Свято-Тихоновского гуманитарного университета. Серия 1: Богословие. Философия. Религиоведение",
|
||||
"vak_row": 691,
|
||||
"vak_name": "Вестник Православного Свято- Тихоновского гуманитарного университета. Серия I: Богословие. Философия. Религиоведение (До 11.05.2017 г. наименование в Перечне «Вестник Православного Свято- Тихоновского гуманитарного университета. Серия 1: Богословие. Философия» ISSN 1991-640X)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник РГГУ. Серия: Политология. История. Международные отношения",
|
||||
"vak_row": 702,
|
||||
"vak_name": "Вестник РГГУ. Серия \"Политология. История. Международные отношения\" ( до 26.12.2019 г наименование в Перечне «Вестник РГГУ. Серия \"Политология. История. Международные отношения. Зарубежное регионоведение. Востоковедение\"»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Томского государственного педагогического университета",
|
||||
"vak_row": 836,
|
||||
"vak_name": "Вестник Томского государственного педагогического университета (Tomsk State Pedagogical University Bulletin)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ветеринария, зоотехния и биотехнология",
|
||||
"vak_row": 1926,
|
||||
"vak_name": "Научно-практический, теоретический журнал \"Ветеринария, зоотехния и биотехнология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Власть",
|
||||
"vak_row": 2071,
|
||||
"vak_name": "Общенациональный научно- политический журнал \"Власть\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы государственного и муниципального управления",
|
||||
"vak_row": 960,
|
||||
"vak_name": "Вопросы государственного и муниципального управления. Public Administration Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы литературы",
|
||||
"vak_row": 1208,
|
||||
"vak_name": "Журнал критики и литературоведения \"Вопросы литературы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы образования",
|
||||
"vak_row": 983,
|
||||
"vak_name": "Вопросы образования Educational Studies",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы теории и практики журналистики",
|
||||
"vak_row": 1009,
|
||||
"vak_name": "Вопросы теории и практики журналистики (Theoretical and Practical Issues of Journalism)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы управления",
|
||||
"vak_row": 1011,
|
||||
"vak_name": "Вопросы управления / Management Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительная механика сплошных сред",
|
||||
"vak_row": 1039,
|
||||
"vak_name": "Вычислительная механика сплошных сред — Computational continuum mechanics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительные методы и программирование",
|
||||
"vak_row": 1040,
|
||||
"vak_name": "Вычислительные методы и программирование: Новые вычислительные технологии (Электронный научный журнал)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гений ортопедии",
|
||||
"vak_row": 1047,
|
||||
"vak_name": "Гений Ортопедии ORTHOPAEDIC QENIUS",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Геология, геофизика и разработка нефтяных и газовых месторождений",
|
||||
"vak_row": 1934,
|
||||
"vak_name": "Научно-технический журнал \"Геология, геофизика и разработка нефтяных и газовых месторождений\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гидрометеорологические исследования и прогнозы",
|
||||
"vak_row": 1067,
|
||||
"vak_name": "Гидрометеорологические исследования и прогнозы Hydrometeorological research and forecasting ( до 03.04.2019 наименование в Перечне «Труды Гидрометеорологического научно-исследовательского центра Российской Федерации» ISSN 0371-7089)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Горный информационно-аналитический бюллетень (научно-технический журнал)",
|
||||
"vak_row": 1085,
|
||||
"vak_name": "Горный информационно- аналитический бюллетень (научно-технический журнал) Mining informational and analytical bulletin (scientific and technical journal)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Государственное управление. Электронный вестник",
|
||||
"vak_row": 1093,
|
||||
"vak_name": "Государственное управление. Электронный вестник (E-journal \"Public Administration\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Два века русской классики",
|
||||
"vak_row": 1129,
|
||||
"vak_name": "Два века русской классики/ Two centuries of the Russian classics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Диалог со временем",
|
||||
"vak_row": 1145,
|
||||
"vak_name": "Диалог со временем. Альманах интеллектуальной истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Животноводство и кормопроизводство",
|
||||
"vak_row": 1189,
|
||||
"vak_name": "Животноводство и кормопроизводство ( до 03.04.2019 наименование в перечне «Вестник мясного скотоводства (Herald of Beef Cattle Breeding)» ISSN 2079- 6250)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал зарубежного законодательства и сравнительного правоведения",
|
||||
"vak_row": 1200,
|
||||
"vak_name": "Журнал зарубежного законодательства и сравнительного правоведения / Journal of Foreign Legislation and Comparative Law",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал неврологии и психиатрии им. C.C. Корсакова",
|
||||
"vak_row": 1212,
|
||||
"vak_name": "Журнал неврологии и психиатрии им. С.С. Корсакова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Биология",
|
||||
"vak_row": 1224,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Биология. Journal of Siberian Federal University. Biology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Серия: Гуманитарные науки",
|
||||
"vak_row": 1225,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Гуманитарные науки. Journal of Siberian Federal University. Humanities & Social Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Записки Института истории материальной культуры",
|
||||
"vak_row": 1247,
|
||||
"vak_name": "Записки Института истории материальной культуры Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Здоровье населения и среда обитания - ЗНиСО",
|
||||
"vak_row": 1252,
|
||||
"vak_name": "Здоровье населения и среда обитания",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Известия Тульского государственного университета. Науки о Земле",
|
||||
"vak_row": 1379,
|
||||
"vak_name": "Известия Тульского государственного университета. Серия \"Науки о Земле\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Интеграция образования",
|
||||
"vak_row": 1438,
|
||||
"vak_name": "Интеграция образования Integration of Education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "История философии",
|
||||
"vak_row": 1518,
|
||||
"vak_name": "История философии / History of Philosophy",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Кардиологический вестник",
|
||||
"vak_row": 1531,
|
||||
"vak_name": "Кардиологический вестник - бюллетень Российского кардиологического научно- производственного комплекса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Медицинские технологии. Оценка и выбор",
|
||||
"vak_row": 1704,
|
||||
"vak_name": "Медицинские технологии. Оценка и выбор (MEDICAL TECHNOLOGY ASSESSMENT AND CHOICE)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Мелиорация и гидротехника",
|
||||
"vak_row": 1757,
|
||||
"vak_name": "Мелиорация и гидротехника/Land Reclamation and Hydraulic Engineering (До 28.09.2021 г. наименование в Перечне «Научный журнал Российского НИИ проблем мелиорации» ISSN 2222-1816)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "МИР (Модернизация. Инновации. Развитие)",
|
||||
"vak_row": 1776,
|
||||
"vak_name": "МИР (Модернизация. Инновации. Развитие). Научно- практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Музыкальное искусство и образование",
|
||||
"vak_row": 1834,
|
||||
"vak_name": "Музыкальное искусство и образование / Musical Art and Education (до 04.02.2020 наименование в Перечне «Вестник кафедры ЮНЕСКО \"Музыкальное искусство и образование\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научно-методический журнал \"Физическая культура, спорт - наука и практика\"",
|
||||
"vak_row": 2962,
|
||||
"vak_name": "Физическая культура, спорт – наука и практика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научные проблемы материально-технического обеспечения Вооружённых Сил Российской Федерации",
|
||||
"vak_row": 2531,
|
||||
"vak_name": "Сборник научных трудов «Научные проблемы материально-технического обеспечения Вооруженных Сил Российской Федерации»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Национальный психологический журнал / National Psychological Journal",
|
||||
"vak_row": 1999,
|
||||
"vak_name": "Национальный психологический журнал/ National psychologica ljournal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Нефтепромысловое дело. Oilfiеld Engineering",
|
||||
"vak_row": 2029,
|
||||
"vak_name": "Нефтепромысловое дело. Oilfield Engineering",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Общественное здоровье и здравоохранение",
|
||||
"vak_row": 1893,
|
||||
"vak_name": "Научно – практический журнал \"Общественное здоровье и здравоохранение\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "ОРГЗДРАВ: новости. мнения. обучения. Вестник ВШОУЗ",
|
||||
"vak_row": 2108,
|
||||
"vak_name": "ОРГЗДРАВ: новости, мнения, обучение. Вестник ВШОУЗ",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Отечественные архивы",
|
||||
"vak_row": 1919,
|
||||
"vak_name": "Научно-практический журнал \"Отечественные архивы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "П-Economy",
|
||||
"vak_row": 104,
|
||||
"vak_name": "π-Economy (до 20.12.2022 наименование издания в Перечне «Научно- технические ведомости Санкт- Петербургского государственного политехнического университета. Экономические науки = St. Petersburg State Polytechnical University Journal. Economics» ISSN 1994-2354, 2304-9774)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Педиатрия. Журнал им. Г.Н. Сперанского",
|
||||
"vak_row": 2156,
|
||||
"vak_name": "Педиатрия. Журнал имени Г.Н. Сперанского",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Петербургский исторический журнал",
|
||||
"vak_row": 2164,
|
||||
"vak_name": "Петербургский исторический журнал. Исследования по российской и всеобщей истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Пищевые системы",
|
||||
"vak_row": 2175,
|
||||
"vak_name": "Пищевые системы – Food systems",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полет. Общероссийский научно-технический журнал",
|
||||
"vak_row": 2072,
|
||||
"vak_name": "Общероссийский научно- технический журнал \"Полет\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полития: Анализ. Хроника. Прогноз (Журнал политической философии и социологии политики)",
|
||||
"vak_row": 2203,
|
||||
"vak_name": "ПОЛИТИЯ. Журнал политической философии и социологии политики",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Правосудие",
|
||||
"vak_row": 2236,
|
||||
"vak_name": "Правосудие / Justice",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Прикладная информатика",
|
||||
"vak_row": 2261,
|
||||
"vak_name": "Прикладная информатика / Journal of Applied Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы анализа риска",
|
||||
"vak_row": 1921,
|
||||
"vak_name": "Научно-практический журнал \"Проблемы анализа риска\" Scientific and Practical Journal \"Issues of Risk Analysis\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы управления",
|
||||
"vak_row": 2314,
|
||||
"vak_name": "Проблемы управления/Control Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птица и птицепродукты",
|
||||
"vak_row": 2372,
|
||||
"vak_name": "Птица и птицепродукты / Poultry & Chicken Products",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птицеводство",
|
||||
"vak_row": 1927,
|
||||
"vak_name": "Научно-производственный журнал \"Птицеводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский нейрохирургический журнал им. профессора А.Л. Поленова",
|
||||
"vak_row": 2473,
|
||||
"vak_name": "Российский нейрохирургический журнал имени профессора А.Л. Поленова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский офтальмологический журнал",
|
||||
"vak_row": 2476,
|
||||
"vak_name": "Российский офтальмологический журнал (РОЖ)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский электронный журнал лучевой диагностики",
|
||||
"vak_row": 127,
|
||||
"vak_name": "Russian Electronic Journal of Radiology (Российский Электронный Журнал Лучевой диагностики)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Русистика",
|
||||
"vak_row": 2503,
|
||||
"vak_name": "Русистика/ Russian language studies (до 03.04.2019 наименование в перечне «Вестник Российского университета дружбы народов. Серия «Русский и иностранные языки и методика их преподавания» ISSN 2313-2264)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Свиноводство",
|
||||
"vak_row": 1928,
|
||||
"vak_name": "Научно-производственный журнал \"Свиноводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "СибСкрипт",
|
||||
"vak_row": 2577,
|
||||
"vak_name": "СибСкрипт = SibScript (до 25.04.2023 наименование издания в Перечне «Вестник Кемеровского государственного университета» ISSN 2078-8975)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Современная конкуренция",
|
||||
"vak_row": 2599,
|
||||
"vak_name": "Современная конкуренция / Journal of Modern Competition",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология власти",
|
||||
"vak_row": 1980,
|
||||
"vak_name": "Научный и общественно- политический журнал \"Социология власти\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология науки и технологий",
|
||||
"vak_row": 2667,
|
||||
"vak_name": "Социология науки и технологий Sociology of Science and Technology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Средние века",
|
||||
"vak_row": 2684,
|
||||
"vak_name": "Средние века. Исследования по истории Средневековья и раннего Нового времени",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Техника и технология пищевых производств",
|
||||
"vak_row": 2765,
|
||||
"vak_name": "Техника и технология пищевых производств (Food Processing: Techniques and Technology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Томский журнал лингвистических и антропологических исследований",
|
||||
"vak_row": 2788,
|
||||
"vak_name": "Томский журнал лингвистических и антропологических исследований. Tomsk Journal of Linguistics and Anthropology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Транспорт Российской Федерации",
|
||||
"vak_row": 2802,
|
||||
"vak_name": "Транспорт Российской Федерации. Журнал о науке, практике, экономике",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Зоологического института РАН",
|
||||
"vak_row": 2829,
|
||||
"vak_name": "Труды зоологического института Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института государства и права Российской академии наук",
|
||||
"vak_row": 2830,
|
||||
"vak_name": "Труды Института государства и права РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института математики и механики УpО РАН",
|
||||
"vak_row": 2832,
|
||||
"vak_name": "Труды Института математики и механики УрО РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Крыловского государственного научного центра",
|
||||
"vak_row": 2839,
|
||||
"vak_name": "Труды Крыловского государственного научного центра. Transactions Of The Krylov State Research Centre (До 29.05.2017 г. наименование в Перечне «Труды Центрального научно- исследовательского института имени академика А.Н. Крылова» ISSN 0869-8422)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Управление большими системами: сборник трудов",
|
||||
"vak_row": 2877,
|
||||
"vak_name": "Управление большими системами",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ученые записки университета им. П.Ф. Лесгафта",
|
||||
"vak_row": 2933,
|
||||
"vak_name": "Ученые записки университета имени П.Ф. Лесгафта",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Фармация и фармакология",
|
||||
"vak_row": 2948,
|
||||
"vak_name": "Фармация и фармакология / Pharmacy&Pharmacology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Философский журнал",
|
||||
"vak_row": 2990,
|
||||
"vak_name": "Философский журнал / Philosophy Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Финансы: теория и практика",
|
||||
"vak_row": 3006,
|
||||
"vak_name": "Финансы: теория и практика / Finance: Theory and Practice (До 11.10.2017 г. наименование в Перечне «Вестник Финансового университета» ISSN 2221-1632)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Форсайт",
|
||||
"vak_row": 3012,
|
||||
"vak_name": "ФОРСАЙТ/Foresight and STI Governance",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Человек. Спорт. Медицина",
|
||||
"vak_row": 3062,
|
||||
"vak_name": "Человек. Спорт. Медицина / Human. Sport. Medicine (До 14.12.2016 г. наименование в Перечне Вестник Южно- Уральского государственного университета. Серия \"Образование, здравоохранение, физическая культура\" ISSN 1991-9786, 2410-7026)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экология промышленного производства",
|
||||
"vak_row": 3080,
|
||||
"vak_name": "Экология промышленного производства – Межотраслевой научно-практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экономическая политика",
|
||||
"vak_row": 3122,
|
||||
"vak_name": "Экономическая политика (Economic Policy)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экспериментальная и клиническая урология",
|
||||
"vak_row": 3143,
|
||||
"vak_name": "Экспериментальная и клиническая урология (Experimental and Сlinical Urology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Эпистемология и философия науки",
|
||||
"vak_row": 40,
|
||||
"vak_name": "Epistemology & Philosophy of Sciences /Эпистемология и философия науки",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Японские исследования",
|
||||
"vak_row": 3169,
|
||||
"vak_name": "Электронный научный журнал «Японские исследования»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Prepare a readable, indexed council JSON from the owner-supplied CSV.
|
||||
|
||||
python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
No database access or app startup; only Python's standard library is required.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.services.council_source import load_reference, prepare_reference
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("app/data/council_articles.json"))
|
||||
args = parser.parse_args()
|
||||
if args.source.resolve() == args.output.resolve():
|
||||
parser.error("Output must not overwrite the source CSV")
|
||||
result = prepare_reference(args.source)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=args.output.parent,
|
||||
prefix=args.output.name + ".", suffix=".tmp", delete=False) as stream:
|
||||
stream.write(text)
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
load_reference(temporary)
|
||||
temporary.replace(args.output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print(f"Prepared {result['source_rows']} publications and {len(result['by_author'])} author names")
|
||||
+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()
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Offline public council UX check: python tests/browser_council.py.
|
||||
|
||||
Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser.
|
||||
Synthetic employees and intercepted requests; no production access.
|
||||
"""
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
for number in range(28):
|
||||
person = Employee(profile_key=f"test:{number}", full_name=f"Сотрудник {number:02d}",
|
||||
canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Доктор технических наук"]}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах",
|
||||
year=2024, publication_type="ARTICLE", publication_id=f"{number}",
|
||||
raw_data={"journal": "Бизнес-информатика"} if number == 27 else
|
||||
{"journal": "BIT Numerical Mathematics", "issn": "0006-3835"} if number == 25 else {},
|
||||
url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}"))
|
||||
if number == 27:
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Дополнительная статья", year=2024,
|
||||
publication_type="ARTICLE", source_hash="test:extra"))
|
||||
for name, degree in [("Без степени", None), ("Только звание", "Профессор")]:
|
||||
person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Не включать в совет", year=2024, source_hash=name))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {"сотрудник 26": [
|
||||
{"title": f"Публикация из данных ВШЭ {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "journals": [], "quartiles": ["Scopus Q1"], "origins": ["Данные ВШЭ"], "rows": [number + 2],
|
||||
"in_csv": True, "in_db": False, "url": None} for number in range(2)]}
|
||||
app.dependency_overrides[get_db] = database
|
||||
client = TestClient(app)
|
||||
output = Path(".impeccable/review")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
requests = {"articles": 0, "fail": False}
|
||||
|
||||
def serve(route):
|
||||
url = urlsplit(route.request.url)
|
||||
if '/council/employees/' in url.path:
|
||||
requests['articles'] += 1
|
||||
if requests['fail']:
|
||||
route.fulfill(status=503, body='Temporary failure')
|
||||
return
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={key: value for key, value in response.headers.items()
|
||||
if key in {"content-type", "content-disposition"}})
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv("BROWSER_EXECUTABLE"))
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||
page.route("**/*", serve)
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.goto("http://miem.test/admin/council")
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28")
|
||||
assert "Без степени" not in page.content() and "Только звание" not in page.content()
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
branches = page.locator('#council-branch')
|
||||
specialty = page.locator('#council-specialty')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
specialty.select_option('1.1.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
specialty.select_option('2.3.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('технические')
|
||||
specialty.select_option('1.2.2')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
branches.select_option('физико-математические')
|
||||
specialty.select_option('')
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
branches.select_option('технические')
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
assert requests['articles'] == 0
|
||||
requests['fail'] = True
|
||||
summary.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_role('button', name='Повторить загрузку публикаций')).to_be_visible()
|
||||
requests['fail'] = False
|
||||
page.get_by_role('button', name='Повторить загрузку публикаций').click()
|
||||
expect(page.locator(".council__source").filter(has_text="ЛК сотрудника ВШЭ").first).to_be_visible()
|
||||
before = requests['articles']
|
||||
summary.click()
|
||||
summary.click()
|
||||
assert requests['articles'] == before
|
||||
warning = page.get_by_role('button', name='Уточнение: тип материала').first
|
||||
summary.focus()
|
||||
warning.hover()
|
||||
expect(summary).to_be_focused()
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(page.get_by_role('tooltip')).to_have_count(0)
|
||||
expect(warning).to_have_attribute('aria-expanded', 'false')
|
||||
assert warning.evaluate("button => button.matches(':hover')")
|
||||
expect(summary).to_be_focused()
|
||||
page.mouse.move(5, 5)
|
||||
warning.hover()
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
expect(page.get_by_role('tooltip')).to_contain_text('тип материала')
|
||||
page.screenshot(path=str(output / 'warning-desktop.png'))
|
||||
page.set_viewport_size({'width': 320, 'height': 844})
|
||||
warning.focus()
|
||||
page.keyboard.press('Enter')
|
||||
expect(page.get_by_role('tooltip')).to_be_visible()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output / 'warning-mobile.png'))
|
||||
page.keyboard.press('Escape')
|
||||
expect(page.get_by_role('tooltip')).to_have_count(0)
|
||||
page.set_viewport_size({'width': 1440, 'height': 1000})
|
||||
page.get_by_role("button", name="Уточнение: квартиль и основание зачёта").first.click()
|
||||
expect(page.get_by_role("tooltip").first).to_contain_text("Нет подтверждённого основания")
|
||||
page.keyboard.press("Escape")
|
||||
assert page.locator(".council__candidate").first.evaluate("e => e.open")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False)
|
||||
for width in (1024, 768, 700, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 844})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
assert summary.evaluate("e => e.scrollWidth <= e.clientWidth"), width
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.screenshot(path=str(output / "mobile.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-mobile.png"), full_page=False)
|
||||
for sort, direction, first in [("full_name", "asc", "Сотрудник 00"),
|
||||
("full_name", "desc", "Сотрудник 27"),
|
||||
("db_count", "desc", "Сотрудник 27"),
|
||||
("count", "desc", "Сотрудник 26"),
|
||||
("total_count", "desc", "Сотрудник 26")]:
|
||||
page.get_by_label("Сортировать по").select_option(sort)
|
||||
page.get_by_label("Направление").select_option(direction)
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text(first)
|
||||
page.get_by_label("Сортировать по").select_option("full_name")
|
||||
page.get_by_label("Направление").select_option("desc")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
download = pending_download.value
|
||||
assert download.suggested_filename == "council.xlsx"
|
||||
download.save_as(str(output / "council.xlsx"))
|
||||
workbook = load_workbook(output / "council.xlsx")
|
||||
assert workbook["Сотрудники"].max_row == 29
|
||||
assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27"
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 27")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
page.get_by_label("Поиск по ФИО").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
page.get_by_role("link", name="Сбросить поиск").first.click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
page.get_by_label("Специальность ВАК").select_option("1.2.2")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27")
|
||||
page.locator(".council__candidate-summary").click()
|
||||
expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible()
|
||||
expect(page.get_by_text("Специальность: 1.2.2", exact=False).first).to_be_visible()
|
||||
for width in (1440, 768, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
page.screenshot(path=str(output / f"specialty-{width}.png"), full_page=False)
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
|
||||
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
|
||||
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
|
||||
assert "Работ по выбранной специальности" not in headers
|
||||
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
|
||||
page.get_by_label("Специальность ВАК").select_option("")
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 25")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
page.locator(".council__candidate-summary").click()
|
||||
expect(page.get_by_text("BIT Numerical Mathematics", exact=False).first).to_be_visible()
|
||||
for width in (1440, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
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
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f"Council browser checks passed; screenshots: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Offline editor UX check: python tests/browser_council_editor.py."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
person = Employee(profile_key='editor', full_name='Иванов Иван Иванович',
|
||||
canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title='Исследование передачи данных',
|
||||
year=2024, publication_type='ARTICLE', raw_data={'journal': 'Журнал связи'}, source_hash='editor'))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
settings = Settings(_env_file=None, session_secret='browser-editor-test', admin_username='admin', admin_password='test')
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
output = Path('.impeccable/review/council-editor')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def serve(route):
|
||||
request = route.request
|
||||
url = urlsplit(request.url)
|
||||
response = client.request(request.method, url.path + ('?' + url.query if url.query else ''),
|
||||
content=request.post_data_buffer,
|
||||
headers={k: v for k, v in request.headers.items() if k in {'content-type', 'x-council-edit'}})
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={k: v for k, v in response.headers.items() if k in {'content-type', 'content-disposition'}})
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width': 1440, 'height': 1000})
|
||||
context.route('**/*', serve)
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto('http://miem.test/admin/council')
|
||||
page.locator('.council__candidate-summary').click()
|
||||
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]')
|
||||
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
|
||||
page.reload()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
expect(pencil).to_be_focused()
|
||||
page.keyboard.press('Enter')
|
||||
expect(page.get_by_label('Название статьи', exact=True)).to_have_value('Исследование передачи данных')
|
||||
page.get_by_label('Название статьи', exact=True).fill('Уточнённое исследование')
|
||||
page.get_by_label('Название журнала', exact=True).fill('Новый журнал')
|
||||
page.get_by_role('dialog').get_by_label('Квартиль').select_option('Q1')
|
||||
page.screenshot(path=str(output / 'edit-desktop.png'))
|
||||
page.set_viewport_size({'width': 320, 'height': 844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
assert page.get_by_role('dialog').evaluate('e => e.scrollWidth <= e.clientWidth')
|
||||
page.screenshot(path=str(output / 'edit-mobile.png'))
|
||||
page.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Уточнённое исследование')
|
||||
expect(page.locator('.council__candidate')).to_have_attribute('open', '')
|
||||
with context.expect_page() as opened:
|
||||
page.get_by_role('link', name='Статьи, журналы и квартили').click()
|
||||
window = opened.value
|
||||
window.wait_for_load_state()
|
||||
window.on('pageerror', lambda error: errors.append(str(error)))
|
||||
window.get_by_label('ФИО сотрудника').fill('Иванов')
|
||||
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('уточнённое')
|
||||
window.get_by_role('searchbox', name='Название журнала', exact=True).fill('новый')
|
||||
window.get_by_role('button', name='Найти', exact=True).click()
|
||||
expect(window.locator('[data-council-article]')).to_have_count(1)
|
||||
for width in (1440, 390, 320):
|
||||
window.set_viewport_size({'width': width, 'height': 900})
|
||||
assert window.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
if width in (1440, 320):
|
||||
window.screenshot(path=str(output / f'list-{width}.png'), full_page=True)
|
||||
window.locator('[data-council-article] strong').click()
|
||||
window.locator('[data-council-edit]').click()
|
||||
window.get_by_role('dialog').get_by_label('Квартиль').select_option('Q2')
|
||||
window.locator('[data-edit-specialties] summary').click()
|
||||
window.get_by_label('Использовать связь из справочников').uncheck()
|
||||
window.locator('[name="specialty_codes"][value="1.2.2"]').check()
|
||||
window.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(window.get_by_text('Журнал: Новый журнал · Квартиль: Q2', exact=False)).to_be_visible()
|
||||
expect(window.get_by_text('Специальности журнала: 1.2.2 · ручная связь', exact=True)).to_be_visible()
|
||||
# The original tab now carries a stale revision.
|
||||
article.locator('strong').click()
|
||||
pencil.click()
|
||||
page.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(page.get_by_role('alert')).to_contain_text('Данные уже изменены')
|
||||
page.keyboard.press('Escape')
|
||||
page.reload()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
expect(page.get_by_text('Квартиль: Q2', exact=False)).to_be_visible()
|
||||
page.get_by_label('Отрасль оценки').select_option('физико-математические')
|
||||
page.get_by_role('button', name='Применить', exact=True).click()
|
||||
expect(page.get_by_label('Отрасль оценки')).to_have_value('физико-математические')
|
||||
page.get_by_label('Специальность ВАК').select_option('1.2.2')
|
||||
page.get_by_role('button', name='Применить', exact=True).click()
|
||||
expect(page.locator('.council__candidate')).to_have_count(1)
|
||||
page.locator('.council__candidate-summary').click()
|
||||
expect(page.get_by_text('Публикаций по выбранной специальности: 1', exact=True)).to_be_visible()
|
||||
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('Нет совпадений')
|
||||
window.get_by_role('button', name='Найти', exact=True).click()
|
||||
expect(window.get_by_text('Статьи не найдены.', exact=False)).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f'Council editor browser checks passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Offline users/roles/password/audit browser scenario; synthetic SQLite data only."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uvicorn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication, utcnow
|
||||
from app.security import ensure_bootstrap_admin
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='Browser initial admin', session_secret='browser-users-secret')
|
||||
with factory() as db:
|
||||
admin = ensure_bootstrap_admin(db, settings)
|
||||
admin.first_login_at = utcnow()
|
||||
employee = Employee(profile_key='browser-users', full_name='Сотрудник для проверки', canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=employee.id, title='Статья для проверки', year=2024, publication_type='ARTICLE',
|
||||
source_hash='browser-users', raw_data={'journal': 'Журнал для проверки'}))
|
||||
db.commit()
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
output = Path('.impeccable/review/users')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
listener = socket.socket()
|
||||
listener.bind(('127.0.0.1', 0))
|
||||
server = uvicorn.Server(uvicorn.Config(app, lifespan='off', access_log=False, log_level='critical'))
|
||||
thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(1000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(.01)
|
||||
assert server.started
|
||||
base_url = f'http://127.0.0.1:{listener.getsockname()[1]}'
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width':1440,'height':1000})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_be_visible()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
creation = page.locator('form[action="/admin/users"]')
|
||||
creation.get_by_label('Логин', exact=True).fill('reader')
|
||||
creation.get_by_label('Имя', exact=True).fill('Тестовый читатель')
|
||||
creation.get_by_role('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()
|
||||
password = temporary.input_value()
|
||||
page.screenshot(path=str(output/'users-desktop.png'), mask=[temporary], full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
|
||||
creation.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
creation.get_by_role('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')
|
||||
page.get_by_label('Пароль', exact=True).fill(password)
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
modal = page.get_by_role('dialog', name='Смените временный пароль')
|
||||
expect(modal).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.get_by_label('Текущий пароль', exact=True)).to_be_focused()
|
||||
assert modal.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
page.screenshot(path=str(output/'required-mobile.png'))
|
||||
page.set_viewport_size({'width':1440,'height':1000})
|
||||
page.screenshot(path=str(output/'required-desktop.png'))
|
||||
modal.get_by_label('Текущий пароль', exact=True).fill(password)
|
||||
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
guide = page.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()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
article = page.locator('[data-council-article]')
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
page.keyboard.press('Enter')
|
||||
editor = page.get_by_role('dialog', name='Редактирование статьи')
|
||||
editor.get_by_label('Название статьи', exact=True).fill('Статья изменена читателем')
|
||||
editor.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Статья изменена читателем')
|
||||
page.get_by_role('link', name='Личный кабинет', exact=True).click()
|
||||
expect(page.get_by_text('Редактирование статьи', exact=True)).to_be_visible()
|
||||
expect(page.get_by_text('Статья изменена читателем', exact=False)).to_be_visible()
|
||||
page.screenshot(path=str(output/'account-desktop.png'), full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'account-mobile.png'), full_page=True)
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Сбросить пароль reader', exact=True).click()
|
||||
expect(page.locator('[data-temporary-password]')).to_be_visible()
|
||||
page.locator('[data-temporary-password]').evaluate("input => input.value = '[скрыто]'")
|
||||
expect(page.locator('.table__cell').filter(has_text='Требуется смена пароля')).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
listener.close()
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
print(f'Users browser scenario passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN python -m pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY scripts/import_publications.py ./scripts/import_publications.py
|
||||
COPY tests/conftest.py tests/test_publication_import_postgres.py ./tests/
|
||||
@@ -0,0 +1,18 @@
|
||||
**
|
||||
!requirements.txt
|
||||
!app/
|
||||
!app/**
|
||||
!scripts/
|
||||
!scripts/import_publications.py
|
||||
!tests/
|
||||
!tests/conftest.py
|
||||
!tests/test_publication_import_postgres.py
|
||||
!tests/postgres/
|
||||
!tests/postgres/Dockerfile
|
||||
!tests/postgres/Dockerfile.dockerignore
|
||||
app/data/
|
||||
**/__pycache__/
|
||||
**/.env*
|
||||
**/*credentials*
|
||||
**/*.key
|
||||
**/*.pem
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM postgres:16-alpine
|
||||
COPY init.sql /docker-entrypoint-initdb.d/10-test.sql
|
||||
@@ -0,0 +1,4 @@
|
||||
**
|
||||
!init.sql
|
||||
!Dockerfile.postgres
|
||||
!Dockerfile.postgres.dockerignore
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user