Compare commits

...
Author SHA1 Message Date
codex 7eaed9aa8a Merge main into council specialty selection
CI / test (pull_request) Failing after 4m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 12:41:34 +03:00
codex d92338d252 docs: record council specialty selection release
CI / test (pull_request) Failing after 4m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 12:38:28 +03:00
codex 64618354be feat: select council candidates by VAK specialty 2026-10-08 12:38:27 +03:00
admin ff11da2a56 Merge pull request 'feature/council-journal-counts' (#62) from feature/council-journal-counts into main
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been cancelled
CI / test (push) Has been cancelled
Reviewed-on: #62
2026-10-08 09:37:55 +00:00
admin 6bcef05d27 Merge pull request 'Экспорт совета: убрать статус и переименовать колонку' (#61) from fix/council-xlsx-columns into feature/council-journal-counts
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
Reviewed-on: #61
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-08 09:29:33 +00:00
codex 2bfed87f98 docs: record council XLSX column changes 2026-10-08 12:14:26 +03:00
admin 8ab36a7f52 Merge pull request 'Кандидаты в совет: счётчики Q1, Q2, К1 и ВСЕГО' (#60) from feature/council-journal-counts into main
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been cancelled
CI / test (push) Has been cancelled
Reviewed-on: #60
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-08 09:12:27 +00:00
codex 784dd68a3b fix: simplify council XLSX columns 2026-10-08 12:11:46 +03:00
codex a56fb723dd docs: record K1 renamed journal matching fix
CI / test (pull_request) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 12:04:37 +03:00
codex 5bda837ee2 fix: match documented previous K1 journal names 2026-10-08 12:02:00 +03:00
codex 9324639c58 docs: add release notes for council publication categories
CI / test (pull_request) Successful in 9m9s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 11:49:36 +03:00
codex 2a99ff4df2 docs: record council journal category counts
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 11:48:49 +03:00
codex 30ea1c9257 fix: keep council criteria tied to source publication years
CI / test (pull_request) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 11:46:40 +03:00
codex 0af278247a feat: count council publications by Q1 Q2 and K1
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 11:41:40 +03:00
admin 2dc63098e4 Merge pull request 'Диссертационный совет: экспорт сотрудников и публикаций в XLSX' (#59) from feature/council-xlsx-export into main
CI / test (push) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #59
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-08 07:50:42 +00:00
codex 339dd71f63 fix: avoid repeated worksheet scans during XLSX export
CI / test (pull_request) Successful in 9m9s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 10:48:17 +03:00
codex 4aeb26abe9 feat: export council employees and publication evidence to XLSX
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-08 10:33:59 +03:00
admin 6bd1f31f3a Merge pull request 'Диссертационный совет: сортировка по ФИО и количеству публикаций' (#58) from feature/council-sorting into main
CI / test (push) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #58
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-08 07:26:19 +00:00
codex 32e26ccdd2 feat: add council employee sorting by name and publication counts
CI / test (pull_request) Successful in 9m15s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-07 16:50:29 +03:00
admin fac0431a03 Merge pull request 'Диссертационный совет: публичные публикации из CSV и БД' (#57) from feature/public-council-publications into main
CI / test (push) Successful in 9m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #57
Reviewed-by: admin <av_progs@mail.ru>
2026-10-07 09:56:33 +00:00
agent ed9d0b48c9 fix: restore doctoral degree requirement for council candidates
CI / test (pull_request) Successful in 9m13s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-07 11:58:18 +03:00
agent c6c6866235 feat: publish council publication evidence from CSV and database
CI / test (pull_request) Successful in 9m13s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-07 11:35:28 +03:00
admin 66c8e9e0c3 Merge pull request 'Временно отключить production deploy и оставить диагностику fingerprint' (#56) from fix/deploy-key-fingerprint-diagnostic into main
CI / test (push) Successful in 9m12s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #56
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-06 07:08:04 +00:00
codex f39b5dd512 fix: temporarily disable production deployment for all events
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-05 18:35:19 +03:00
codex b8a27758b6 fix: add deployment key fingerprint diagnostic mode
CI / test (pull_request) Successful in 9m13s
CI / deploy (pull_request) Has been skipped
2026-10-05 16:28:06 +03:00
admin c975e7418b Merge pull request 'Изолированная PostgreSQL-проверка импорта и сохранности публикаций' (#55) from feature/postgresql-import-verification into main
CI / test (push) Successful in 9m12s
CI / deploy (push) Failing after 11s
Reviewed-on: #55
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-05 12:12:32 +00:00
24 changed files with 45643 additions and 158 deletions

No files matched your search

+18 -1
View File
@@ -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:
@@ -27,7 +32,9 @@ jobs:
- 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:
@@ -42,6 +49,7 @@ jobs:
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
@@ -55,7 +63,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
@@ -68,6 +84,7 @@ jobs:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
- name: Deploy and verify
if: ${{ false }}
shell: bash
run: |
set -euo pipefail
+2
View File
@@ -9,3 +9,5 @@ pytest-cache-files-*/
htmlcov/
postgres_data/
MCP_DESCRIPTION.md
.impeccable/review/
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 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-бита.
+49 -5
View File
@@ -88,10 +88,22 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
## Кандидаты в диссертационный совет
Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве.
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`; минимальный порог не применяется. Уволенные сотрудники отмечены отдельно. Доступны поиск по ФИО, сортировка по каждому счётчику и страницы по 25 сотрудников. XLSX по `/admin/council/export.xlsx` содержит всех найденных сотрудников с выбранной сортировкой, их публикации и параметры расчёта.
Счётчики **Q1**, **Q2**, **К1** и **ВСЕГО** учитывают уникальные публикации за последние пять лет по году. Q1/Q2 берутся из отметок Scopus или Web of Science в CSV и сохранённом CSV-обогащении БД. К1 определяется по совпадению нормализованного названия журнала с итоговой категорией в предоставленном справочнике ВАК. ВСЕГО — объединение Q1 ИЛИ Q2 ИЛИ К1: работа в нескольких категориях учитывается один раз. При расхождении годов источников каждый критерий проверяется по году своей записи; неизвестный год наследуется только при единственном согласованном годе. Счётчики «В БД» и «Все публикации» включают все годы независимо от категорий.
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Импорт CSV в БД не выдаётся за данные сайта. Для К1 поддерживаются необязательные колонки CSV «Название журнала» или «Журнал»; в исходном CSV их нет. В БД название берётся из поля `journal` в `raw_data` или короткого описания ВШЭ (`description.short`/`shortLeft`, название перед годом). Название самой публикации для определения журнала не используется. Неизвестный или несовпавший журнал не считается К1; перевод и нечёткое сопоставление не применяются. Явно указанное в PDF прежнее название журнала сохраняется как проверенный алиас; служебное примечание о переименовании хранится отдельно от названия.
Справочник `app/data/k1_journals.json` содержит 773 журнала К1, номера строк, даты категорирования и SHA256 исходного «ВАК категории.pdf». При извлечении проверены все 3134 строки; для записей с историей смены категории взята последняя категория. PDF при запросах не читается. Для обновления справочника разово установите `pdfplumber` в среду разработки и выполните:
```powershell
python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf"
```
После замены CSV или JSON нужно пересобрать Docker-образ и пересоздать API: данные включены в образ и кешируются в процессе.
Счётчики предварительные: для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статьи. Материалы с известным другим типом не входят в Q1/Q2/К1 и ВСЕГО. К1 означает итоговую категорию в предоставленном справочнике, а не историческую категорию на дату публикации. Отсутствие отметок не доказывает недостаточную активность. Публичная страница не открывает контакты и служебные операции.
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
## Парсинг
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
@@ -128,7 +140,9 @@ 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` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy.
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`, `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
## Проверки
@@ -141,7 +155,7 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.10.4`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.15.0`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
@@ -150,3 +164,33 @@ node --check app/static/admin.js
Интеграционная проверка импорта на отдельном 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.
`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` при запуске.
+20 -6
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select
from sqlalchemy.exc import SQLAlchemyError
@@ -20,6 +20,7 @@ from app.services.admin_data import (
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.services.council import council_candidates
from app.services.council_export import council_xlsx
from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin")
@@ -134,14 +135,27 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_admin(request, settings)
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db)
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True}, status_code=503)
return _render(request, "council.html", {"error": False, **result})
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
return _render(request, "council.html", {"error": False, "public_page": True, **result})
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty)
content = council_xlsx(result)
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)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+225 -57
View File
@@ -1,37 +1,52 @@
"""Preliminary council candidates from the supplied publication export."""
"""Public publication evidence from the council export and employee database."""
import calendar
import csv
import json
import re
import unicodedata
from collections import Counter, defaultdict
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, urlsplit
from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app.models import Employee
from app.services.academic_degrees import academic_degrees
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
K1_JOURNALS = EXPORT.with_name("k1_journals.json")
K1_SPECIALTIES = EXPORT.with_name("k1_specialties.json")
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
def _name(value: str) -> str:
return " ".join(value.casefold().replace("ё", "е").split())
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
def _doi(value: str) -> str:
value = value.strip().lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if value.startswith(prefix):
value = value[len(prefix):]
return value
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
return value if DOI.fullmatch(value) else ""
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]:
@@ -39,79 +54,232 @@ def period(today: date) -> tuple[date, date]:
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
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_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"))
return {**data, "by_journal": {j["name"]: j["specialty_codes"] for j in data["journals"]}}
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]:
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)
# 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
@lru_cache(maxsize=1)
def _articles_by_author() -> dict[str, list[dict]]:
articles = defaultdict(list)
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2):
sources = []
for label, quartile, identifier in (
("Scopus", "Квартиль scopus", "EID (Scopus)"),
("Web of Science", "Квартиль wos", "UT (WoS)"),
):
value = (row[quartile] or "").strip().upper()
if value in {"Q1", "Q2"}:
sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()})
if not sources:
continue
try:
year = int(row["Год"])
except (TypeError, ValueError):
continue
title = (row["Название публикации"] or "").strip()
reader = csv.DictReader(source, delimiter=";")
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
if not required <= set(reader.fieldnames or []):
raise ValueError("Missing 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")
title = row["Название публикации"].strip()
if not title:
continue
doi = _doi(row["DOI"] or "")
article = {
"title": title,
"year": year,
"doi": doi,
"doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None,
"sources": sources,
"row_number": row_number,
}
for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}:
year = int(row["Год"]) if row["Год"].strip().isdigit() else None
article = {"title": title, "year": year, "doi": _doi(row["DOI"]),
"types": [], "quartiles": _quartiles(row), "journals": _journal_names(row), "origins": ["Файл CSV"],
"rows": [row_number], "in_csv": True, "in_db": False, "url": None}
for author in set(_name(value) for value in row["Авторы НИУ ВШЭ"].split(";")) - {""}:
articles[author].append(article)
return articles
def _database_article(publication) -> dict:
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
imported = raw.get("csv_enrichment")
from_site = bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
origins = ["БД miem-workers · сайт ВШЭ"] if from_site else []
if imported:
origins.append("БД miem-workers · импорт CSV")
kind = publication.publication_type
description = publication.description if isinstance(publication.description, dict) else raw.get("description")
journals = _journal_names(raw)
if isinstance(imported, dict):
journals.extend(_journal_names(imported))
if isinstance(description, dict):
journals.extend(_description_journals(description))
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
"quartiles": _quartiles(imported) if isinstance(imported, dict) else [],
"journals": journals,
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
"url": _safe_url(publication.url)}
def _unique_articles(articles: list[dict], start: date, end: date) -> 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:
if not start.year <= article["year"] <= end.year:
continue
key = article["doi"] or (_name(article["title"]), article["year"])
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, "sources": list(article["sources"])}
unique[key] = {**article, "doi": identifier,
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
"evidence": []}
merged = unique[key]
merged["evidence"].append(article)
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 article in unique.values():
evidence = article.pop("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["criteria"] = list(article["quartiles"])
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"]
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("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
else "В пределах пяти лет")
else:
known = {item["name"] for item in unique[key]["sources"]}
unique[key]["sources"].extend(
item for item in article["sources"] if item["name"] not in known
)
return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold()))
article["criteria"].append("Вне пятилетнего периода")
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) -> dict:
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "") -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
employees = db.scalars(select(Employee).where(Employee.status == "active")).all()
specialties = _k1_specialties()["specialties"]
specialty = specialty if specialty in {s["code"] for s in specialties} else ""
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
if not author or name_counts[author] != 1:
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 []
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
if not articles:
continue
data = employee.current_data if isinstance(employee.current_data, dict) else {}
try:
degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {})
degrees = academic_degrees(data)
except (AttributeError, TypeError):
degrees = []
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
continue
degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None)
if not degree:
continue
articles = _unique_articles(by_author.get(author, []), start, end)
if len(articles) >= 10:
candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree,
"count": len(articles), "articles": articles})
candidates.sort(key=lambda item: (-item["count"], item["name"].casefold()))
return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)}
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
for article in articles:
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
if article["matches_specialty"]:
article["criteria"].append(f"По специальности {specialty}: {specialty_name} (журнал К1, Перечень ВАК)")
candidates.append({"specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
"count": len(articles),
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1")},
"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", "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")
total = len(candidates)
query = _name(q)
candidates = [item for item in candidates if query in _name(item["name"])
and (not specialty or item["specialty_count"] > 0)]
matched = len(candidates)
limit = 25
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
+78
View File
@@ -0,0 +1,78 @@
"""XLSX report containing the same public evidence as the council page."""
from io import BytesIO
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
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)
def council_xlsx(result: dict) -> bytes:
workbook = Workbook()
workbook.properties.creator = "MIEM Employees"
employees = workbook.active
employees.title = "Сотрудники"
publications = workbook.create_sheet("Публикации")
parameters = workbook.create_sheet("Параметры")
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
candidate["k1_count"], candidate["total_count"],
candidate["db_count"], candidate["count"],
candidate["profile_url"], note])
for article in candidate["articles"]:
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
"; ".join(article["origins"]), "; ".join(article["criteria"]),
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Специальность ВАК", 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",
"total_count": "ВСЕГО", "db_count": "Страница ВШЭ", "count": "Все публикации"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
["Отбор по степени", "Доктор наук в профиле; достаточно хотя бы одной публикации из CSV или БД"],
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
"Без названия журнала К1 не подтверждён."],
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
]:
_append(parameters, values)
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22]),
(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:
if isinstance(cell.value, str):
# CSV/profile content is text, never an Excel formula or error code.
cell.data_type = "s"
cell.alignment = Alignment(vertical="top", wrap_text=True)
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="263640")
output = BytesIO()
workbook.save(output)
return output.getvalue()
+28 -1
View File
@@ -819,6 +819,7 @@
.council {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
color: #25313a;
}
@@ -888,6 +889,32 @@
.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-count small { max-width: 72px; }
.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 { max-width: 75ch; 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; }
}
+6 -1
View File
@@ -9,8 +9,12 @@
<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 %}
<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>
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
@@ -18,6 +22,7 @@
<form method="post" action="/admin/logout">
<button class="button button--ghost" type="submit">Выйти</button>
</form>
{% endif %}
</nav>
</header>
<main class="admin__main" id="main-content">
+76 -26
View File
@@ -1,59 +1,101 @@
{% extends "base.html" %}
{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %}
{% block title %}Диссертационный совет · MIEM Employees{% endblock %}
{% block content %}
<section class="council">
<section class="council" aria-labelledby="council-title">
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title">Кандидаты в диссертационный совет</h2>
<p class="council__intro">Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.</p>
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке сотрудники с докторской степенью в профиле и хотя бы одной публикацией в файле CSV или базе miem-workers. Минимальный порог количества работ не применяется.</p>
</div>
{% if not error %}<p class="council__total"><strong>{{ total }}</strong> предварительно подходят</p>{% endif %}
</header>
{% if error %}
<div class="council__message" role="alert">
<h3>Не удалось загрузить список</h3>
<p>Проверьте доступность базы сотрудников и файла публикаций, затем обновите страницу.</p>
<h3>{% if export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
</div>
{% else %}
<div class="council__criteria" aria-label="Правила предварительного отбора">
<p><strong>10 и более</strong><span>различных публикаций</span></p>
<p><strong>{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}</strong><span>скользящие пять лет</span></p>
<p><strong>К1 · RSCI · Q1/Q2</strong><span>итоговые основания отбора</span></p>
</div>
<div class="council__notice" role="note">
<strong>Предварительный список.</strong> Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать.
</div>
<details class="council__notice">
<summary><strong>Как читать счётчики и критерии</strong></summary>
<p>Q1 и Q2 — уникальные публикации с соответствующей отметкой Scopus или Web of Science. К1 — публикации в журналах, название которых совпало со справочником из «ВАК категории.pdf». ВСЕГО — количество уникальных публикаций, входящих хотя бы в одну из категорий Q1, Q2 или К1. Работа в нескольких категориях учитывается в итоге один раз.</p>
<p>Все четыре счётчика используют период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} по году публикации. Для пограничных лет нужно проверить точную дату, для неизвестного типа — подтвердить, что это статья. Материалы с известным другим типом исключены. К1 определяется по итоговой категории журнала в предоставленном справочнике, а не по его исторической категории на дату публикации. Если название журнала отсутствует или не совпало, К1 не подтверждён. Отсутствие отметки означает недостаток данных.</p>
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
{% for item in specialties %}
<option value="{{ item.code }}"{% 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', 'ФИО'), ('q1_count', 'Q1'), ('q2_count', 'Q2'), ('k1_count', 'К1'), ('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="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</form>
<div class="council__list-heading">
<h3>Сотрудники</h3>
<p>Откройте строку, чтобы увидеть публикации и основания.</p>
<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">
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>публикаций по выгрузке</small></span>
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small></span>
<span class="council__counts">
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО</small></span>
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>В БД</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
</span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
<a class="admin__link" href="/admin/employees/{{ candidate.id }}">Открыть профиль сотрудника</a>
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
{% if candidate.ambiguous %}<p class="council__hint">В базе есть сотрудники с одинаковым ФИО. Публикации из файла не присвоены автоматически; показаны записи, связанные с этим сотрудником в БД.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article">
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year }}</span>
<span>{{ article.year or 'Год не указан' }}</span>
</div>
<div class="council__article-meta">
{% for source in article.sources %}
<span class="council__source">{{ source.name }} {{ source.quartile }}{% if source.identifier %} · {{ source.identifier }}{% else %} · ID не указан{% endif %}</span>
{% endfor %}
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
<span>{{ article.type_label }}</span>
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
<span>Строка {{ article.row_number }} в CSV</span>
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
{% if article.rows %}<span>Строки CSV: {{ article.rows|join(', ') }}</span>{% endif %}
</div>
<ul class="council__evidence" aria-label="Критерии и подтверждения">
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
</ul>
</li>
{% endfor %}
</ol>
@@ -61,10 +103,18 @@
</details>
{% endfor %}
</div>
{% if matched > limit %}
<nav class="directory__pagination" aria-label="Страницы сотрудников">
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;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 }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</nav>
{% endif %}
{% else %}
<div class="council__message">
<h4>Кандидаты пока не найдены</h4>
<p>По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.</p>
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Без названия журнала, года или подтверждённого соответствия журналу К1 публикация не подтверждает специальность.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.10.4"
FRONTEND_VERSION = "0.10.4"
BACKEND_VERSION = "0.10.4"
APP_VERSION = "0.15.0"
FRONTEND_VERSION = "0.15.0"
BACKEND_VERSION = "0.15.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.10.4"
version = "0.15.0"
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",
+1
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
"""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) -> dict:
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] != "К1":
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("No K1 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"))
args = parser.parse_args()
result = extract(args.source)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Saved {len(result['journals'])} K1 journals; verified {result['source_rows']} source rows")
+176
View File
@@ -0,0 +1,176 @@
"""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()).hexdigest(), "source_rows": len(journals),
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
from collections import Counter
print(len(entries), dict(Counter(j['status'] for j in entries)))
+662
View File
@@ -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": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
}
]
+171
View File
@@ -0,0 +1,171 @@
"""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 {},
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"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}",
"types": [], "journals": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "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)
def serve(route):
url = urlsplit(route.request.url)
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.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
assert "Без степени" not in page.content() and "Кандидат наук" not in page.content()
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
page.get_by_role("link", name="Далее").click()
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
page.get_by_role("link", name="Назад").click()
summary = page.locator(".council__candidate-summary").first
summary.focus()
page.keyboard.press("Enter")
expect(page.get_by_text("БД miem-workers · сайт ВШЭ").first).to_be_visible()
expect(page.get_by_text("Q1/Q2 и К1 не подтверждены доступными данными").first).to_be_visible()
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.get_by_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.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
expect(page.get_by_label("Направление")).to_have_value("desc")
page.get_by_label("Специальность ВАК").select_option("1.2.2")
page.get_by_role("button", name="Применить", exact=True).click()
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27")
page.locator(".council__candidate-summary").click()
expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible()
expect(page.get_by_text("По специальности 1.2.2:", exact=False)).to_be_visible()
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.")
assert not errors, errors
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()
+7 -2
View File
@@ -1,8 +1,13 @@
from pathlib import Path
from pathlib import Path
from types import SimpleNamespace
from jinja2 import Environment, FileSystemLoader
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
template = Path("app/templates/base.html").read_text(encoding="utf-8")
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
)
assert "Обзор" in template
assert "Сотрудники" in template
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.10.4"
assert response.json()["backend_version"] == "0.15.0"
def test_api_employees_and_stats_require_admin_session():
+509 -54
View File
@@ -1,76 +1,531 @@
import csv
from datetime import date
from io import BytesIO
from zipfile import ZipFile
import pytest
from fastapi.testclient import TestClient
from openpyxl import Workbook, load_workbook
from openpyxl.worksheet.worksheet import Worksheet
from sqlalchemy.exc import OperationalError
from app.config import Settings, get_settings
from app.db import get_db
from app.main import app
from app.models import Employee
from app.security import SESSION_COOKIE, sign_session
from app.models import Employee, EmployeePublication
from app.services import council
from app.services.council_export import _append
def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path):
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
export = tmp_path / "articles.csv"
with export.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
writer.writeheader()
for number in range(10):
writer.writerow({"Название публикации": f"Article {number}", "Год": 2024,
"DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}",
"Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович",
"Квартиль scopus": "Q1"})
writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0",
"UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович",
"Квартиль wos": "Q2"})
monkeypatch.setattr(council, "EXPORT", export)
@pytest.fixture()
def export(monkeypatch, tmp_path):
path = tmp_path / "articles.csv"
def write(rows):
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", "Название журнала",
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
with path.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
writer.writeheader()
writer.writerows(rows)
council._articles_by_author.cache_clear()
monkeypatch.setattr(council, "EXPORT", path)
write([])
yield write
council._articles_by_author.cache_clear()
db_session.add_all([
Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Доктор технических наук"]}]}),
Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Кандидат технических наук"]}]}),
])
def employee(db, name="Иванов Иван Иванович", degree="Доктор технических наук", status="active", key="doctor"):
person = Employee(profile_key=key, canonical_url="https://www.hse.ru/staff/test", full_name=name,
status=status, current_data={"contacts": {"emails": ["private@example.org"]},
"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
db.add(person)
db.flush()
return person
def publication(db, person, title="Shared work", doi=None, year=2024, kind=None, **extra):
item = EmployeePublication(employee_id=person.id, title=title, year=year, doi_url=doi,
publication_type=kind, source_hash=f"hash-{len(db.new)}-{title}-{doi}", **extra)
db.add(item)
return item
def row(title="Shared work", doi="10.1234/shared", year="2024", author="Иванов Иван Иванович", **extra):
return {"Название публикации": title, "Год": year, "DOI": doi, "Авторы НИУ ВШЭ": author,
"Квартиль scopus": "Q1", **extra}
def test_council_union_counts_provenance_and_types(db_session, export):
export([row(), row(**{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
row(title="CSV only", doi="", year="2022"),
row(title="Not eligible", doi="10.1234/old", year="2019")])
person = employee(db_session, degree="Доктор технических наук")
publication(db_session, person, doi="https://dx.doi.org/10.1234/SHARED", kind="ARTICLE",
publication_id="100", url="https://publications.hse.ru/view/100")
publication(db_session, person, title=" shared WORK ", kind="ARTICLE")
publication(db_session, person, title="Book only", kind="BOOK", year=2020)
publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}})
db_session.commit()
result = council.council_candidates(db_session, date(2026, 9, 30))
assert result["total"] == 1
assert result["candidates"][0]["count"] == 10
assert len(result["candidates"][0]["articles"][0]["sources"]) == 2
candidate = result["candidates"][0]
assert (candidate["total_count"], candidate["db_count"], candidate["count"]) == (3, 3, 5)
shared = next(a for a in candidate["articles"] if a["doi"] == "10.1234/shared")
assert shared["type_label"] == "Статья"
assert shared["quartiles"] == ["Scopus Q1", "Web of Science Q2"]
assert shared["rows"] == [2, 3]
assert "Файл CSV" in shared["origins"] and "БД miem-workers · сайт ВШЭ" in shared["origins"]
imported = next(a for a in candidate["articles"] if a["title"] == "Imported")
assert imported["origins"] == ["БД miem-workers · импорт CSV"]
assert imported["qualifies"] and imported["q2"]
assert "Тип не указан" in imported["type_label"]
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
settings = Settings(_env_file=None, session_secret="test-session-secret")
monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30)))
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text
assert "Article 0" in response.text
assert "публикаций по выгрузке" in response.text
finally:
app.dependency_overrides.clear()
council._articles_by_author.cache_clear()
def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch):
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
settings = Settings(_env_file=None, session_secret="test-session-secret")
def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
export([row(author="Доктор", title="Single qualifying"),
row(author="Без степени", title="No degree"),
row(author="Кандидат", title="Candidate work"),
row(author="PhD", title="PhD work"),
row(author="Две степени", title="Mixed degrees", **{"Квартиль scopus": "Q3"}),
row(author="Уволен", title="Old", year="2010")])
employee(db_session, "Доктор", key="csv-only")
for name, degree in [("Без степени", None), ("Кандидат", "Кандидат технических наук"), ("PhD", "Ph.D.")]:
person = employee(db_session, name, degree, key=name)
publication(db_session, person)
employee(db_session, "Две степени", "Кандидат технических наук; Доктор физико-математических наук", key="mixed")
employee(db_session, "Уволен", status="dismissed", key="dismissed")
only_db = employee(db_session, "Только БД", key="database")
publication(db_session, only_db, kind="PATENT")
employee(db_session, "Без публикаций", key="empty")
db_session.commit()
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД"}
assert next(c for c in items if c["name"] == "Доктор")["total_count"] == 1
assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1
assert next(c for c in items if c["name"] == "Две степени")["total_count"] == 0
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
def test_no_false_merge_when_title_has_conflicting_dois(db_session, export):
export([row(doi="10.1234/one"), row(doi="10.1234/two"), row(doi="")])
person = employee(db_session)
publication(db_session, person, title="Renamed work", doi="https://doi.org/10.1234/one")
db_session.commit()
item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
assert item["count"] == 3
assert item["total_count"] == 3 and item["db_count"] == 1
def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
export([row()])
for key in ["one", "two"]:
person = employee(db_session, key=key)
publication(db_session, person, title=key)
db_session.commit()
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
assert len(items) == 2
assert all(c["ambiguous"] and c["total_count"] == 0 and c["count"] == 1 for c in items)
def test_missing_database_year_merges_only_unambiguous_title_and_non_article_excluded(db_session, export):
export([row(title="Known book"), row(title="Article", doi="10.1234/article")])
person = employee(db_session)
publication(db_session, person, title="Known book", year=None, kind="BOOK")
publication(db_session, person, title="Article", year=None, kind="ARTICLE")
db_session.commit()
candidate = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
assert (candidate["count"], candidate["total_count"], candidate["db_count"]) == (2, 1, 2)
book = next(a for a in candidate["articles"] if a["type_label"] == "Книга")
assert not book["qualifies"]
assert "не включён в счётчики Q1/Q2/К1" in book["criteria"][-1]
def test_search_and_pagination_include_all_workers(db_session, export):
for number in range(28):
person = employee(db_session, f"Петров {number:02d}", key=f"person-{number}")
publication(db_session, person, title=f"Work {number}")
db_session.commit()
first = council.council_candidates(db_session)
last = council.council_candidates(db_session, offset=25)
assert first["total"] == 28 and len(first["candidates"]) == 25
assert len(last["candidates"]) == 3
assert {c["name"] for c in first["candidates"]}.isdisjoint(c["name"] for c in last["candidates"])
assert council.council_candidates(db_session, q="петров 27")["matched"] == 1
assert council.council_candidates(db_session, offset=-1)["offset"] == 0
assert council.council_candidates(db_session, offset=999)["offset"] == 25
descending = council.council_candidates(db_session, sort="full_name", direction="desc")
last_descending = council.council_candidates(db_session, sort="full_name", direction="desc", offset=25)
assert descending["candidates"][0]["name"] == "Петров 27"
assert [c["name"] for c in last_descending["candidates"]] == ["Петров 02", "Петров 01", "Петров 00"]
def test_sort_by_name_and_each_unique_publication_count(db_session, export):
export([row(author="Анна", title=f"CSV {number}", doi=f"10.1234/csv-{number}") for number in range(3)]
+ [row(author="Борис", title="CSV Boris", doi="10.1234/boris")])
anna = employee(db_session, "Анна", key="anna")
publication(db_session, anna, title="CSV 0", doi="https://doi.org/10.1234/csv-0")
for name, count in [("Борис", 2), ("Вера", 4)]:
person = employee(db_session, name, key=name)
for number in range(count):
publication(db_session, person, title=f"DB {name} {number}")
db_session.commit()
expected = {
"full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
"total_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
"db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
"count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]},
}
for sort, directions in expected.items():
for direction, names in directions.items():
result = council.council_candidates(db_session, sort=sort, direction=direction)
assert [c["name"] for c in result["candidates"]] == names
invalid = council.council_candidates(db_session, sort="unknown", direction="unknown")
assert (invalid["sort"], invalid["direction"]) == ("total_count", "desc")
assert [c["name"] for c in invalid["candidates"]] == expected["total_count"]["desc"]
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
response = TestClient(app).get("/admin/council?sort=full_name&direction=desc")
assert response.status_code == 200
assert "Предварительный список" in response.text
assert "К1 или RSCI могут отсутствовать" in response.text
assert response.text.index("Вера") < response.text.index("Борис") < response.text.index("Анна")
assert '<option value="full_name" selected>' in response.text
assert '<option value="desc" selected>' in response.text
finally:
app.dependency_overrides.clear()
def test_public_page_data_minimization_and_safe_links(db_session, export):
export([row(title='<script>alert("csv")</script>', doi="javascript:bad")])
person = employee(db_session)
person.canonical_url = "javascript:bad"
publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK")
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council", follow_redirects=False)
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
assert "Минимальный порог количества работ не применяется" in response.text
assert "CSV по критериям" not in response.text and "ВСЕГО" in response.text and "Все публикации" in response.text
assert "Книга" in response.text and "Тип не указан" in response.text
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.15.0 · Frontend 0.15.0" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
finally:
app.dependency_overrides.clear()
def test_public_error_state_missing_export_and_database_failure(db_session, export, monkeypatch):
app.dependency_overrides[get_db] = lambda: db_session
try:
council.EXPORT.unlink()
response = TestClient(app).get("/admin/council")
assert response.status_code == 503 and "Повторить загрузку" in response.text
def fail(*args, **kwargs):
raise OperationalError("query", {}, Exception("private error"))
monkeypatch.setattr("app.admin.council_candidates", fail)
response = TestClient(app).get("/admin/council")
assert response.status_code == 503 and "private error" not in response.text
finally:
app.dependency_overrides.clear()
def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db_session, export):
export([row(author="Петров 27"), row(author="Петров 27", **{"Квартиль wos": "Q2"})])
for number in range(28):
person = employee(db_session, f"Петров {number:02d}", key=f"xlsx-{number}")
publication(db_session, person, title="Shared work" if number == 27 else f"Work {number}",
doi="https://doi.org/10.1234/shared" if number == 27 else None,
kind="ARTICLE", publication_id=str(number), url=f"https://publications.hse.ru/view/{number}")
excluded = employee(db_session, "Без степени", degree=None, key="excluded")
publication(db_session, excluded)
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council/export.xlsx?q=Петров&sort=full_name&direction=desc&offset=25")
assert response.status_code == 200
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
book = load_workbook(BytesIO(response.content))
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]] == [
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 11
assert works["L1"].value == "Страница ВШЭ"
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
assert [people.cell(2, n).value for n in [4, 5, 6, 7, 8, 9]] == [1, 1, 0, 1, 1, 1]
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
assert "Файл CSV" in works["F2"].value and "сайт ВШЭ" in works["F2"].value
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
assert works["M2"].value == "https://doi.org/10.1234/shared" and works["O2"].value == "2, 3"
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
assert works.auto_filter.ref == works.dimensions
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
assert "private@example.org" not in text and "Без степени" not in text
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
finally:
app.dependency_overrides.clear()
def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_session, export):
person = employee(db_session, '=HYPERLINK("https://example.org","name")')
publication(db_session, person, title="=1+1\x01")
publication(db_session, person, title="#N/A")
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council/export.xlsx")
assert response.status_code == 200
book = load_workbook(BytesIO(response.content))
assert book["Сотрудники"]["B2"].value == person.full_name
assert book["Сотрудники"]["B2"].data_type == "s"
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
assert titles == {"=1+1": "s", "#N/A": "s"}
with ZipFile(BytesIO(response.content)) as archive:
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
person.full_name = "Long " + "x" * 32768
db_session.commit()
failure = client.get("/admin/council/export.xlsx")
assert failure.status_code == 503 and "Не удалось создать XLSX" in failure.text
assert "content-disposition" not in failure.headers
finally:
app.dependency_overrides.clear()
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
def fail_on_scan(sheet):
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
monkeypatch.setattr(Worksheet, "max_row", property(fail_on_scan))
monkeypatch.setattr(Worksheet, "max_column", property(fail_on_scan))
sheet = Workbook().active
for number in range(1000):
_append(sheet, [number, "text"])
assert sheet.cell(1000, 1).value == 999
assert sheet.cell(1000, 2).value == "text"
def test_category_counts_union_k1_journal_matching_and_period(db_session, export):
export([
row(title="All categories", doi="10.1234/all", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"}),
row(title="All categories", doi="10.1234/all"),
row(title="Q2 only", doi="10.1234/q2", **{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
row(title="Old K1", doi="10.1234/old", year="2020", **{"Квартиль scopus": "", "Название журнала": "Бизнес-информатика"}),
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
row(title="Future", doi="10.1234/future", year="2027"),
row(title="Known book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
])
person = employee(db_session)
publication(db_session, person, title="All categories", doi="https://doi.org/10.1234/all", kind="ARTICLE")
publication(db_session, person, title="Known book", doi="https://doi.org/10.1234/book", kind="BOOK")
publication(db_session, person, title="K1 only", kind="ARTICLE",
description={"short": {"ru": "<b>Бизнес-информатика</b>. 2024. № 2."}})
publication(db_session, person, title="Similar journal", kind="ARTICLE",
description={"short": {"ru": "Бизнес-информатика и технологии. 2024."}})
publication(db_session, person, title="Mention in title is not journal Бизнес-информатика", kind="ARTICLE")
db_session.commit()
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [1, 2, 2, 3]
assert candidate["count"] == 9
works = {article["title"]: article for article in candidate["articles"]}
assert works["All categories"]["q1"] and works["All categories"]["q2"] and works["All categories"]["k1"]
assert works["K1 only"]["k1"] and "К1 · Бизнес-информатика (справочник ВАК)" in works["K1 only"]["criteria"]
for title in ("Old K1", "Unknown year", "Future", "Known book", "Similar journal"):
assert not works[title]["qualifies"]
def test_k1_reference_integrity():
import json
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
assert data["source_rows"] == 3134
assert sum(data["category_counts"].values()) == 3134
assert data["category_counts"]["К1"] == len(data["journals"]) == 773
assert len(council._k1_journals()) == 774 # 773 journals and one documented previous name.
assert "вестник российских университетов математика" not in council._k1_journals()
assert "бизнес информатика" in council._k1_journals()
@pytest.mark.parametrize("journal", [
"Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана",
"Ученые записки Казанской государственной академии ветеринарной медицины им. Н.Э. Баумана",
])
@pytest.mark.parametrize("source", ["csv", "journal", "description"])
def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_session, export, journal, source):
person = employee(db_session)
if source == "csv":
export([row(**{"Квартиль scopus": "", "Название журнала": journal})])
else:
fields = {"raw_data": {"journal": journal}} if source == "journal" else {
"description": {"short": {"ru": f"{journal}. 2024. № 1."}}
}
publication(db_session, person, kind="ARTICLE", **fields)
db_session.commit()
result = council.council_candidates(db_session, date(2026, 10, 8))
candidate = result["candidates"][0]
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
assert [book["Сотрудники"].cell(2, column).value for column in (6, 7)] == [1, 1]
def test_k1_rename_note_is_preserved_separately():
import json
data = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
journal = next(item for item in data["journals"] if item["source_row"] == 699)
assert "наименование в Перечне" not in journal["name"]
assert journal["note"].startswith("до 10.04.2026 г. наименование в Перечне")
assert len(journal["aliases"]) == 1
assert council._k1_journals()[council._journal_key(journal["aliases"][0])] == journal["name"]
bilingual = next(item for item in data["journals"] if item["source_row"] == 2)
assert bilingual["name"] == "Acta Biomedica Scientifica (East Siberian Biomedical Journal)"
def test_ambiguous_k1_alias_is_rejected(monkeypatch, tmp_path):
import json
reference = tmp_path / "k1.json"
reference.write_text(json.dumps({"journals": [
{"name": "One", "aliases": ["Previous"]}, {"name": "Two", "aliases": ["Previous"]}
]}), encoding="utf-8")
monkeypatch.setattr(council, "K1_JOURNALS", reference)
council._k1_journals.cache_clear()
try:
with pytest.raises(ValueError, match="Ambiguous K1 journal alias"):
council._k1_journals()
finally:
council._k1_journals.cache_clear()
@pytest.mark.parametrize("reverse", [False, True])
def test_conflicting_source_years_keep_categories_with_their_year(db_session, export, reverse):
rows = [row(year="2020", **{"Название журнала": "Бизнес-информатика"}),
row(year="2024", **{"Квартиль scopus": "Q2"})]
export(list(reversed(rows)) if reverse else rows)
person = employee(db_session)
# Evidence with no year must not inherit an arbitrary conflicting year.
publication(db_session, person, doi="https://doi.org/10.1234/shared", year=None,
raw_data={"csv_enrichment": {"Квартиль scopus": "Q1", "Журнал": "Бизнес-информатика"}})
db_session.commit()
candidate = council.council_candidates(db_session, date(2026, 10, 8))["candidates"][0]
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 1, 0, 1]
assert candidate["count"] == 1
article = candidate["articles"][0]
assert article["year"] is None
assert any("Годы источников расходятся: 2020, 2024" in criterion for criterion in article["criteria"])
@pytest.mark.parametrize("sort", ["q1_count", "q2_count", "k1_count", "total_count"])
@pytest.mark.parametrize("direction", ["asc", "desc"])
def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
export([row(author="Анна", **{"Квартиль wos": "Q2", "Название журнала": "Бизнес-информатика"})])
employee(db_session, "Анна", key="anna")
person = employee(db_session, "Борис", key="boris")
publication(db_session, person, title="Without criteria")
db_session.commit()
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
assert [candidate["name"] for candidate in result["candidates"]] == expected
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
headers = [cell.value for cell in book["Сотрудники"][1]]
assert headers[3:7] == ["Q1", "Q2", "К1", "ВСЕГО"]
assert "CSV по критериям" not in headers
anna_row = 3 if direction == "asc" else 2
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 8)] == [1, 1, 1, 1]
def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_session, export):
export([
row(title="Matching", doi="10.1234/matching", **{"Название журнала": "Бизнес-информатика"}),
row(title="Matching", doi="10.1234/matching"),
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
row(title="Old journal, new Q1", doi="10.1234/conflict", year="2024"),
row(title="Book", doi="10.1234/book", **{"Название журнала": "Бизнес-информатика"}),
row(title="Unknown year", doi="10.1234/unknown", year="", **{"Название журнала": "Бизнес-информатика"}),
])
person = employee(db_session)
publication(db_session, person, title="Book", doi="10.1234/book", kind="BOOK")
publication(db_session, person, title="Unknown year", doi="10.1234/unknown", year=None)
other = employee(db_session, name="Другой доктор", key="other")
publication(db_session, other, title="Q1 alone", raw_data={"csv_enrichment": {"Квартиль scopus": "Q1"}})
db_session.commit()
result = council.council_candidates(db_session, date(2026, 10, 8), specialty="1.2.2")
assert (result["total"], result["matched"]) == (2, 1)
candidate = result["candidates"][0]
assert candidate["specialty_count"] == 1
assert [a["title"] for a in candidate["articles"] if a["matches_specialty"]] == ["Matching"]
assert "По специальности 1.2.2" in " ".join(candidate["articles"][0]["criteria"])
assert council.council_candidates(db_session, specialty="2.2.14")["matched"] == 0
invalid = council.council_candidates(db_session, specialty="<script>")
assert invalid["specialty"] == "" and invalid["matched"] == 2
def test_specialty_page_pagination_search_and_export_agree(db_session, export):
for number in range(28):
person = employee(db_session, name=f"Доктор {number:02d}", key=str(number))
publication(db_session, person, title=f"Work {number}", kind="ARTICLE",
raw_data={"journal": "Бизнес-информатика"})
person = employee(db_session, name="Не подходит", key="unrelated")
publication(db_session, person, raw_data={"journal": "Неизвестный журнал"})
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
assert response.status_code == 200
assert '<option value="1.2.2" selected>' in response.text
assert response.text.count('<option value="2.3.5"') == 1
assert "Публикаций по выбранной специальности: 1" in response.text
assert "Не подходит" not in response.text
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "html.parser")
next_page = soup.find("a", string="Далее")["href"]
assert "specialty=1.2.2" in next_page
assert "Доктор 27" in client.get("/admin/council" + next_page).text
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
assert "Показаны 1–1 из 1" in filtered.text
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
assert book["Сотрудники"].max_row == 29
params = dict(book["Параметры"].values)
assert params["Специальность ВАК"].startswith("1.2.2.")
assert "По специальности 1.2.2" in book["Публикации"]["G2"].value
empty = client.get("/admin/council?specialty=2.2.14")
assert "Сотрудники не найдены" in empty.text
finally:
app.dependency_overrides.clear()
def test_specialty_reference_integrity():
import hashlib
import json
from scripts.extract_k1_specialties import active_codes
data = json.loads(council.K1_SPECIALTIES.read_text(encoding="utf-8"))
k1 = json.loads(council.K1_JOURNALS.read_text(encoding="utf-8"))
assert data["k1_source_sha256"] == hashlib.sha256(council.K1_JOURNALS.read_bytes()).hexdigest()
assert [j["name"] for j in data["journals"]] == [j["name"] for j in k1["journals"]]
assert len(data["journals"]) == 773 and len(data["specialties"]) == 12
assert data["source_rows"] == 3231
for journal in data["journals"]:
assert journal["vak_entries"]
if journal["status"] == "matched":
active = [entry for entry in journal["vak_entries"] if active_codes(entry, data["as_of"])]
assert len(active) == 1
assert journal["specialty_codes"] == active_codes(active[0], data["as_of"])
else:
assert journal["status"] == "ambiguous" and not journal["specialty_codes"]
for specialty in data["specialties"]:
assert any(specialty["code"] in j["specialty_codes"] for j in data["journals"])
+70
View File
@@ -0,0 +1,70 @@
import os
from pathlib import Path
import re
import subprocess
import textwrap
import pytest
WORKFLOW = (Path(__file__).parents[1] / ".gitea/workflows/ci.yml").read_text()
@pytest.mark.parametrize("keygen_exit", [0, 1])
def test_fingerprint_output_and_cleanup(tmp_path, keygen_exit):
step = WORKFLOW.split(" - name: Deployment key fingerprint\n", 1)[1]
step = step.split(" - name:", 1)[0]
script = textwrap.dedent(step.split(" run: |\n", 1)[1])
keygen = tmp_path / "ssh-keygen"
keygen.write_text(
"#!/bin/sh\n"
'case "$1" in\n'
'-y) printf "PUBLIC_MATERIAL\\n"; exit "$KEYGEN_EXIT" ;;\n'
'-lf) cat >/dev/null; printf "256 SHA256:test comment (ED25519)\\n" ;;\n'
'*) exit 99 ;;\n'
"esac\n"
)
keygen.chmod(0o700)
(tmp_path / "deploy_key").write_text("PRIVATE_MATERIAL")
(tmp_path / "known_hosts").write_text("HOST_MATERIAL")
result = subprocess.run(
["bash", "-x", "-c", script], capture_output=True, text=True,
env={**os.environ, "RUNNER_TEMP": str(tmp_path),
"PATH": f"{tmp_path}:{os.environ['PATH']}",
"KEYGEN_EXIT": str(keygen_exit)},
)
assert (result.returncode == 0) == (keygen_exit == 0)
if keygen_exit == 0:
assert result.stdout == "SHA256:test\n"
assert "MATERIAL" not in result.stdout + result.stderr
assert "comment" not in result.stdout + result.stderr
assert not (tmp_path / "deploy_key").exists()
assert not (tmp_path / "known_hosts").exists()
@pytest.mark.parametrize("event", ["push", "pull_request", "workflow_dispatch"])
@pytest.mark.parametrize("diagnostic_only", ["true", "false", ""])
def test_launch_conditions(event, diagnostic_only):
job = WORKFLOW.split("\n deploy:\n", 1)[1]
condition = re.search(r"^ if: (.+)$", job, re.MULTILINE).group(1)
terms = re.findall(r"(github\.[a-z_.]+) == '([^']*)'", condition)
assert condition == " && ".join(f"{name} == '{value}'" for name, value in terms)
context = {"github.event_name": event,
"github.event.inputs.diagnostic_only": diagnostic_only}
runs = all(context[name] == value for name, value in terms)
assert runs == (event == "workflow_dispatch" and diagnostic_only == "true")
def test_both_vps_steps_are_unconditionally_disabled():
for name in ("Upload release", "Deploy and verify"):
step = WORKFLOW.split(f" - name: {name}\n", 1)[1]
assert step.startswith(" if: ${{ false }}\n")
def test_push_and_pull_request_keep_tests():
triggers = WORKFLOW.split("\njobs:\n", 1)[0]
for event in ("push", "pull_request"):
assert f" {event}:\n branches: [main]\n" in triggers
test_job = WORKFLOW.split("\n test:\n", 1)[1].split("\n deploy:\n", 1)[0]
assert " if:" not in test_job
assert "python -m pytest -q" in test_job
+38
View File
@@ -0,0 +1,38 @@
from scripts.extract_k1_specialties import active_codes, build_reference, parse_rows
import pytest
def test_vak_continuation_and_historical_dates():
rows = [
{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.2.2. First", "с 01.02.2022\nпо 01.02.2023"]},
{"page": 2, "cells": ["", "continued", "", "2.3.5. Second", ""]},
{"page": 2, "cells": ["", "", "", "2.3.6. Current", "с 02.02.2023"]},
{"page": 3, "cells": ["", "", "", "2.3.1. Future", "с 01.02.2027"]},
]
journal = parse_rows(rows)[0]
assert journal["name"] == "Journal continued" and journal["pages"] == [1, 2, 3]
assert journal["assignments"][0]["codes"] == ["1.2.2", "2.3.5"]
assert active_codes(journal, "2026-09-15") == ["2.3.6"]
assert active_codes(journal, "2023-02-01") == ["1.2.2", "2.3.5"]
with pytest.raises(ValueError, match="numbering"):
parse_rows([{"page": 1, "cells": ["2.", "Journal", "1234-5678", "1.2.2. Test", "с 01.02.2022"]}])
def test_ambiguous_titles_do_not_union_specialties_and_reviewed_matches_are_checked():
rows = [{"page": n, "cells": [f"{n}.", "Same title", str(n), code + ". Test", "с 01.02.2022"]}
for n, code in [(1, "1.2.2"), (2, "2.3.1")]]
journals = parse_rows(rows)
k1 = {"journals": [{"name": "Same title"}, {"name": "Similar title"}]}
result = build_reference(journals, k1, [], "2026-09-15")
assert result[0]["status"] == "ambiguous" and result[0]["specialty_codes"] == []
assert result[1]["status"] == "not_found" and result[1]["specialty_codes"] == []
with pytest.raises(ValueError, match="title changed"):
build_reference(journals, k1, [{"k1_name": "Similar title", "vak_row": 1, "vak_name": "Wrong title"}], "2026-09-15")
def test_malformed_source_start_date_is_preserved_without_inventing_date():
journal = parse_rows([{"page": 1, "cells": ["1.", "Journal", "1234-5678", "1.3.8. Test", "с 01.02.200"]}])[0]
assignment = journal["assignments"][0]
assert assignment["source_dates"] == "с 01.02.200"
assert assignment["valid_from"] is None and assignment["date_note"]
assert active_codes(journal, "2026-09-15") == ["1.3.8"]