Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eaed9aa8a | ||
|
|
d92338d252 | ||
|
|
64618354be | ||
|
|
ff11da2a56 | ||
|
|
6bcef05d27 | ||
|
|
2bfed87f98 | ||
|
|
8ab36a7f52 | ||
|
|
784dd68a3b | ||
|
|
a56fb723dd | ||
|
|
5bda837ee2 | ||
|
|
9324639c58 | ||
|
|
2a99ff4df2 | ||
|
|
30ea1c9257 | ||
|
|
0af278247a | ||
|
|
2dc63098e4 | ||
|
|
339dd71f63 | ||
|
|
4aeb26abe9 | ||
|
|
6bd1f31f3a | ||
|
|
32e26ccdd2 | ||
|
|
fac0431a03 | ||
|
|
ed9d0b48c9 | ||
|
|
c6c6866235 | ||
|
|
66c8e9e0c3 | ||
|
|
f39b5dd512 | ||
|
|
b8a27758b6 | ||
|
|
c975e7418b |
No files matched your search
+18
-1
@@ -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
|
||||
|
||||
@@ -9,3 +9,5 @@ pytest-cache-files-*/
|
||||
htmlcov/
|
||||
postgres_data/
|
||||
MCP_DESCRIPTION.md
|
||||
|
||||
.impeccable/review/
|
||||
@@ -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-бита.
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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 }}&sort={{ sort }}&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 }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</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 }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ jinja2>=3.1.4
|
||||
lxml>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.4.0
|
||||
openpyxl>=3.1.5,<4
|
||||
python-multipart>=0.0.9
|
||||
requests>=2.32.0
|
||||
sqlalchemy>=2.0.32
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
@@ -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)))
|
||||
@@ -0,0 +1,662 @@
|
||||
[
|
||||
{
|
||||
"k1_name": "Acta Biomedica Scientifica (East Siberian Biomedical Journal)",
|
||||
"vak_row": 3,
|
||||
"vak_name": "Acta biomedica scientifica (перевод наименования на государственный язык Российской Федерации: Научный биомедицинский журнал) (До 02.10.2017 г. наименование в Перечне «Бюллетень Восточно-Сибирского научного центра СО РАМН (Bulletin of the East Siberian Scientific Center SB RAMS\" ISSN 1811-0649)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Acta Linguistica Petropolitana. Труды института лингвистических исследований",
|
||||
"vak_row": 4,
|
||||
"vak_name": "Acta Linguistica Petropolitana. Труды Института лингвистических исследований РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ars Administrandi. Искусство управления",
|
||||
"vak_row": 11,
|
||||
"vak_name": "Ars Administrandi (перевод наименование на государственный язык Российской Федерации: Искусство управления)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Iberoamerica",
|
||||
"vak_row": 1194,
|
||||
"vak_name": "Журнал \"IВЕRОАМЕRIСА\" на испанском языке (перевод названия на государственный язык Российской Федерации: ИБЕРОАМЕРИКА)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "International Journal for Computational Civil and Structural Engineering",
|
||||
"vak_row": 57,
|
||||
"vak_name": "International Journal for Computation Civil and Structural Engineering (Международный журнал по расчету гражданских и строительных конструкций)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Economic Regulation",
|
||||
"vak_row": 67,
|
||||
"vak_name": "Journal of Economic Regulation (\"Вопросы регулирования экономики\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Journal of Institutional Studies",
|
||||
"vak_row": 69,
|
||||
"vak_name": "Journal of Institutional Studies (\"Журнал институциональных исследований\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Siberian Joumal of Life Sciences and Agriculture",
|
||||
"vak_row": 145,
|
||||
"vak_name": "Siberian Journal of Life Sciences and Agriculture (перевод наименования на государственный язык Российской Федерации: Сибирский журнал медико- биологических и сельскохозяйственных исследований)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Terra Economicus",
|
||||
"vak_row": 158,
|
||||
"vak_name": "Terra Economicus («ПРОСТРАНСТВО ЭКОНОМИКИ»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Theory and Practice of Meat Processing",
|
||||
"vak_row": 2748,
|
||||
"vak_name": "Теория и практика переработки мяса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Turczaninowia",
|
||||
"vak_row": 166,
|
||||
"vak_name": "Turczaninowia (Турчаниновия)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Альманах Института коррекционной педагогики",
|
||||
"vak_row": 239,
|
||||
"vak_name": "Альманах Института коррекционной педагогики / Almanac Institute of special education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Анестезиология и реаниматология (Медиа Сфера)",
|
||||
"vak_row": 253,
|
||||
"vak_name": "Анестезиология и реаниматология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бизнес-информатика",
|
||||
"vak_row": 325,
|
||||
"vak_name": "Бизнес-информатика Business Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень НЦССХ им. А.Н. Бакулева РАМН. Сердечно-сосудистые заболевания",
|
||||
"vak_row": 353,
|
||||
"vak_name": "Бюллетень Научного центра сердечно-сосудистой хирургии им. А.Н. Бакулева РАМН \"Сердечно-сосудистые заболевания\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень Почвенного института им. В.В. Докучаева",
|
||||
"vak_row": 355,
|
||||
"vak_name": "Бюллетень Почвенного института имени В.В. Докучаева",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Бюллетень сибирской медицины",
|
||||
"vak_row": 357,
|
||||
"vak_name": "Бюллетень сибирской медицины/Bulletin of Siberian Medicine",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств",
|
||||
"vak_row": 2421,
|
||||
"vak_name": "Регуляторные исследования и экспертиза лекарственных средств (до 30.10.2024 наименование в Перечене «Ведомости Научного центра экспертизы средств медицинского применения. Регуляторные исследования и экспертиза лекарственных средств» ISSN 1991-2919)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник государственного университета морского и речного флота им. адмирала С.О. Макарова",
|
||||
"vak_row": 468,
|
||||
"vak_name": "Вестник Государственного университета морского и речного флота имени адмирала С.О. Макарова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Ленинградского государственного университета им. А.С. Пушкина",
|
||||
"vak_row": 548,
|
||||
"vak_name": "Вестник Ленинградского государственного университета имени А.С. Пушкина",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова",
|
||||
"vak_row": 554,
|
||||
"vak_name": "Вестник Магнитогорского государственного технического университета им. Г.И. Носова, Vestnik of Nosov Magnitogorsk State Technical University",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник международных организаций: образование, наука, новая экономика",
|
||||
"vak_row": 570,
|
||||
"vak_name": "Вестник международных организаций: образование, наука, новая экономика International Organisations Research Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 23: Антропология",
|
||||
"vak_row": 612,
|
||||
"vak_name": "Вестник Московского университета. Серия ХХIII. Антропология",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Московского университета. Серия 25: Международные отношения и мировая политика",
|
||||
"vak_row": 611,
|
||||
"vak_name": "Вестник Московского университета. Серия XXV. Международные отношения и мировая политика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник НПО им. С.А. Лавочкина",
|
||||
"vak_row": 642,
|
||||
"vak_name": "Вестник «НПО имени С.А. Лавочкина»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. История",
|
||||
"vak_row": 672,
|
||||
"vak_name": "Вестник Пермского университета. Серия История",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Политология",
|
||||
"vak_row": 670,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Политология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Пермского университета. Серия: Экономика",
|
||||
"vak_row": 671,
|
||||
"vak_name": "Вестник Пермского университета. Серия \"Экономика\" = Perm University Herald. ECONOMY",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Православного Свято-Тихоновского гуманитарного университета. Серия 1: Богословие. Философия. Религиоведение",
|
||||
"vak_row": 691,
|
||||
"vak_name": "Вестник Православного Свято- Тихоновского гуманитарного университета. Серия I: Богословие. Философия. Религиоведение (До 11.05.2017 г. наименование в Перечне «Вестник Православного Свято- Тихоновского гуманитарного университета. Серия 1: Богословие. Философия» ISSN 1991-640X)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник РГГУ. Серия: Политология. История. Международные отношения",
|
||||
"vak_row": 702,
|
||||
"vak_name": "Вестник РГГУ. Серия \"Политология. История. Международные отношения\" ( до 26.12.2019 г наименование в Перечне «Вестник РГГУ. Серия \"Политология. История. Международные отношения. Зарубежное регионоведение. Востоковедение\"»)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вестник Томского государственного педагогического университета",
|
||||
"vak_row": 836,
|
||||
"vak_name": "Вестник Томского государственного педагогического университета (Tomsk State Pedagogical University Bulletin)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ветеринария, зоотехния и биотехнология",
|
||||
"vak_row": 1926,
|
||||
"vak_name": "Научно-практический, теоретический журнал \"Ветеринария, зоотехния и биотехнология\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Власть",
|
||||
"vak_row": 2071,
|
||||
"vak_name": "Общенациональный научно- политический журнал \"Власть\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы государственного и муниципального управления",
|
||||
"vak_row": 960,
|
||||
"vak_name": "Вопросы государственного и муниципального управления. Public Administration Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы литературы",
|
||||
"vak_row": 1208,
|
||||
"vak_name": "Журнал критики и литературоведения \"Вопросы литературы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы образования",
|
||||
"vak_row": 983,
|
||||
"vak_name": "Вопросы образования Educational Studies",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы теории и практики журналистики",
|
||||
"vak_row": 1009,
|
||||
"vak_name": "Вопросы теории и практики журналистики (Theoretical and Practical Issues of Journalism)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вопросы управления",
|
||||
"vak_row": 1011,
|
||||
"vak_name": "Вопросы управления / Management Issues",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительная механика сплошных сред",
|
||||
"vak_row": 1039,
|
||||
"vak_name": "Вычислительная механика сплошных сред — Computational continuum mechanics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Вычислительные методы и программирование",
|
||||
"vak_row": 1040,
|
||||
"vak_name": "Вычислительные методы и программирование: Новые вычислительные технологии (Электронный научный журнал)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гений ортопедии",
|
||||
"vak_row": 1047,
|
||||
"vak_name": "Гений Ортопедии ORTHOPAEDIC QENIUS",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Геология, геофизика и разработка нефтяных и газовых месторождений",
|
||||
"vak_row": 1934,
|
||||
"vak_name": "Научно-технический журнал \"Геология, геофизика и разработка нефтяных и газовых месторождений\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Гидрометеорологические исследования и прогнозы",
|
||||
"vak_row": 1067,
|
||||
"vak_name": "Гидрометеорологические исследования и прогнозы Hydrometeorological research and forecasting ( до 03.04.2019 наименование в Перечне «Труды Гидрометеорологического научно-исследовательского центра Российской Федерации» ISSN 0371-7089)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Горный информационно-аналитический бюллетень (научно-технический журнал)",
|
||||
"vak_row": 1085,
|
||||
"vak_name": "Горный информационно- аналитический бюллетень (научно-технический журнал) Mining informational and analytical bulletin (scientific and technical journal)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Государственное управление. Электронный вестник",
|
||||
"vak_row": 1093,
|
||||
"vak_name": "Государственное управление. Электронный вестник (E-journal \"Public Administration\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Два века русской классики",
|
||||
"vak_row": 1129,
|
||||
"vak_name": "Два века русской классики/ Two centuries of the Russian classics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Диалог со временем",
|
||||
"vak_row": 1145,
|
||||
"vak_name": "Диалог со временем. Альманах интеллектуальной истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Животноводство и кормопроизводство",
|
||||
"vak_row": 1189,
|
||||
"vak_name": "Животноводство и кормопроизводство ( до 03.04.2019 наименование в перечне «Вестник мясного скотоводства (Herald of Beef Cattle Breeding)» ISSN 2079- 6250)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал зарубежного законодательства и сравнительного правоведения",
|
||||
"vak_row": 1200,
|
||||
"vak_name": "Журнал зарубежного законодательства и сравнительного правоведения / Journal of Foreign Legislation and Comparative Law",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал неврологии и психиатрии им. C.C. Корсакова",
|
||||
"vak_row": 1212,
|
||||
"vak_name": "Журнал неврологии и психиатрии им. С.С. Корсакова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Биология",
|
||||
"vak_row": 1224,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Биология. Journal of Siberian Federal University. Biology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Журнал Сибирского федерального университета. Серия: Гуманитарные науки",
|
||||
"vak_row": 1225,
|
||||
"vak_name": "Журнал Сибирского федерального университета. Гуманитарные науки. Journal of Siberian Federal University. Humanities & Social Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Записки Института истории материальной культуры",
|
||||
"vak_row": 1247,
|
||||
"vak_name": "Записки Института истории материальной культуры Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Здоровье населения и среда обитания - ЗНиСО",
|
||||
"vak_row": 1252,
|
||||
"vak_name": "Здоровье населения и среда обитания",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Известия Тульского государственного университета. Науки о Земле",
|
||||
"vak_row": 1379,
|
||||
"vak_name": "Известия Тульского государственного университета. Серия \"Науки о Земле\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Интеграция образования",
|
||||
"vak_row": 1438,
|
||||
"vak_name": "Интеграция образования Integration of Education",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "История философии",
|
||||
"vak_row": 1518,
|
||||
"vak_name": "История философии / History of Philosophy",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Кардиологический вестник",
|
||||
"vak_row": 1531,
|
||||
"vak_name": "Кардиологический вестник - бюллетень Российского кардиологического научно- производственного комплекса",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Медицинские технологии. Оценка и выбор",
|
||||
"vak_row": 1704,
|
||||
"vak_name": "Медицинские технологии. Оценка и выбор (MEDICAL TECHNOLOGY ASSESSMENT AND CHOICE)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Мелиорация и гидротехника",
|
||||
"vak_row": 1757,
|
||||
"vak_name": "Мелиорация и гидротехника/Land Reclamation and Hydraulic Engineering (До 28.09.2021 г. наименование в Перечне «Научный журнал Российского НИИ проблем мелиорации» ISSN 2222-1816)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "МИР (Модернизация. Инновации. Развитие)",
|
||||
"vak_row": 1776,
|
||||
"vak_name": "МИР (Модернизация. Инновации. Развитие). Научно- практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Музыкальное искусство и образование",
|
||||
"vak_row": 1834,
|
||||
"vak_name": "Музыкальное искусство и образование / Musical Art and Education (до 04.02.2020 наименование в Перечне «Вестник кафедры ЮНЕСКО \"Музыкальное искусство и образование\")",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научно-методический журнал \"Физическая культура, спорт - наука и практика\"",
|
||||
"vak_row": 2962,
|
||||
"vak_name": "Физическая культура, спорт – наука и практика",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Научные проблемы материально-технического обеспечения Вооружённых Сил Российской Федерации",
|
||||
"vak_row": 2531,
|
||||
"vak_name": "Сборник научных трудов «Научные проблемы материально-технического обеспечения Вооруженных Сил Российской Федерации»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Национальный психологический журнал / National Psychological Journal",
|
||||
"vak_row": 1999,
|
||||
"vak_name": "Национальный психологический журнал/ National psychologica ljournal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Нефтепромысловое дело. Oilfiеld Engineering",
|
||||
"vak_row": 2029,
|
||||
"vak_name": "Нефтепромысловое дело. Oilfield Engineering",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Общественное здоровье и здравоохранение",
|
||||
"vak_row": 1893,
|
||||
"vak_name": "Научно – практический журнал \"Общественное здоровье и здравоохранение\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "ОРГЗДРАВ: новости. мнения. обучения. Вестник ВШОУЗ",
|
||||
"vak_row": 2108,
|
||||
"vak_name": "ОРГЗДРАВ: новости, мнения, обучение. Вестник ВШОУЗ",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Отечественные архивы",
|
||||
"vak_row": 1919,
|
||||
"vak_name": "Научно-практический журнал \"Отечественные архивы\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "П-Economy",
|
||||
"vak_row": 104,
|
||||
"vak_name": "π-Economy (до 20.12.2022 наименование издания в Перечне «Научно- технические ведомости Санкт- Петербургского государственного политехнического университета. Экономические науки = St. Petersburg State Polytechnical University Journal. Economics» ISSN 1994-2354, 2304-9774)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Педиатрия. Журнал им. Г.Н. Сперанского",
|
||||
"vak_row": 2156,
|
||||
"vak_name": "Педиатрия. Журнал имени Г.Н. Сперанского",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Петербургский исторический журнал",
|
||||
"vak_row": 2164,
|
||||
"vak_name": "Петербургский исторический журнал. Исследования по российской и всеобщей истории",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Пищевые системы",
|
||||
"vak_row": 2175,
|
||||
"vak_name": "Пищевые системы – Food systems",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полет. Общероссийский научно-технический журнал",
|
||||
"vak_row": 2072,
|
||||
"vak_name": "Общероссийский научно- технический журнал \"Полет\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Полития: Анализ. Хроника. Прогноз (Журнал политической философии и социологии политики)",
|
||||
"vak_row": 2203,
|
||||
"vak_name": "ПОЛИТИЯ. Журнал политической философии и социологии политики",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Правосудие",
|
||||
"vak_row": 2236,
|
||||
"vak_name": "Правосудие / Justice",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Прикладная информатика",
|
||||
"vak_row": 2261,
|
||||
"vak_name": "Прикладная информатика / Journal of Applied Informatics",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы анализа риска",
|
||||
"vak_row": 1921,
|
||||
"vak_name": "Научно-практический журнал \"Проблемы анализа риска\" Scientific and Practical Journal \"Issues of Risk Analysis\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Проблемы управления",
|
||||
"vak_row": 2314,
|
||||
"vak_name": "Проблемы управления/Control Sciences",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птица и птицепродукты",
|
||||
"vak_row": 2372,
|
||||
"vak_name": "Птица и птицепродукты / Poultry & Chicken Products",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Птицеводство",
|
||||
"vak_row": 1927,
|
||||
"vak_name": "Научно-производственный журнал \"Птицеводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский нейрохирургический журнал им. профессора А.Л. Поленова",
|
||||
"vak_row": 2473,
|
||||
"vak_name": "Российский нейрохирургический журнал имени профессора А.Л. Поленова",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский офтальмологический журнал",
|
||||
"vak_row": 2476,
|
||||
"vak_name": "Российский офтальмологический журнал (РОЖ)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Российский электронный журнал лучевой диагностики",
|
||||
"vak_row": 127,
|
||||
"vak_name": "Russian Electronic Journal of Radiology (Российский Электронный Журнал Лучевой диагностики)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Русистика",
|
||||
"vak_row": 2503,
|
||||
"vak_name": "Русистика/ Russian language studies (до 03.04.2019 наименование в перечне «Вестник Российского университета дружбы народов. Серия «Русский и иностранные языки и методика их преподавания» ISSN 2313-2264)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Свиноводство",
|
||||
"vak_row": 1928,
|
||||
"vak_name": "Научно-производственный журнал \"Свиноводство\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "СибСкрипт",
|
||||
"vak_row": 2577,
|
||||
"vak_name": "СибСкрипт = SibScript (до 25.04.2023 наименование издания в Перечне «Вестник Кемеровского государственного университета» ISSN 2078-8975)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Современная конкуренция",
|
||||
"vak_row": 2599,
|
||||
"vak_name": "Современная конкуренция / Journal of Modern Competition",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология власти",
|
||||
"vak_row": 1980,
|
||||
"vak_name": "Научный и общественно- политический журнал \"Социология власти\"",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Социология науки и технологий",
|
||||
"vak_row": 2667,
|
||||
"vak_name": "Социология науки и технологий Sociology of Science and Technology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Средние века",
|
||||
"vak_row": 2684,
|
||||
"vak_name": "Средние века. Исследования по истории Средневековья и раннего Нового времени",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Техника и технология пищевых производств",
|
||||
"vak_row": 2765,
|
||||
"vak_name": "Техника и технология пищевых производств (Food Processing: Techniques and Technology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Томский журнал лингвистических и антропологических исследований",
|
||||
"vak_row": 2788,
|
||||
"vak_name": "Томский журнал лингвистических и антропологических исследований. Tomsk Journal of Linguistics and Anthropology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Транспорт Российской Федерации",
|
||||
"vak_row": 2802,
|
||||
"vak_name": "Транспорт Российской Федерации. Журнал о науке, практике, экономике",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Зоологического института РАН",
|
||||
"vak_row": 2829,
|
||||
"vak_name": "Труды зоологического института Российской академии наук",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института государства и права Российской академии наук",
|
||||
"vak_row": 2830,
|
||||
"vak_name": "Труды Института государства и права РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Института математики и механики УpО РАН",
|
||||
"vak_row": 2832,
|
||||
"vak_name": "Труды Института математики и механики УрО РАН",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Труды Крыловского государственного научного центра",
|
||||
"vak_row": 2839,
|
||||
"vak_name": "Труды Крыловского государственного научного центра. Transactions Of The Krylov State Research Centre (До 29.05.2017 г. наименование в Перечне «Труды Центрального научно- исследовательского института имени академика А.Н. Крылова» ISSN 0869-8422)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Управление большими системами: сборник трудов",
|
||||
"vak_row": 2877,
|
||||
"vak_name": "Управление большими системами",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Ученые записки университета им. П.Ф. Лесгафта",
|
||||
"vak_row": 2933,
|
||||
"vak_name": "Ученые записки университета имени П.Ф. Лесгафта",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Фармация и фармакология",
|
||||
"vak_row": 2948,
|
||||
"vak_name": "Фармация и фармакология / Pharmacy&Pharmacology",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Философский журнал",
|
||||
"vak_row": 2990,
|
||||
"vak_name": "Философский журнал / Philosophy Journal",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Финансы: теория и практика",
|
||||
"vak_row": 3006,
|
||||
"vak_name": "Финансы: теория и практика / Finance: Theory and Practice (До 11.10.2017 г. наименование в Перечне «Вестник Финансового университета» ISSN 2221-1632)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Форсайт",
|
||||
"vak_row": 3012,
|
||||
"vak_name": "ФОРСАЙТ/Foresight and STI Governance",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Человек. Спорт. Медицина",
|
||||
"vak_row": 3062,
|
||||
"vak_name": "Человек. Спорт. Медицина / Human. Sport. Medicine (До 14.12.2016 г. наименование в Перечне Вестник Южно- Уральского государственного университета. Серия \"Образование, здравоохранение, физическая культура\" ISSN 1991-9786, 2410-7026)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экология промышленного производства",
|
||||
"vak_row": 3080,
|
||||
"vak_name": "Экология промышленного производства – Межотраслевой научно-практический журнал",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экономическая политика",
|
||||
"vak_row": 3122,
|
||||
"vak_name": "Экономическая политика (Economic Policy)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Экспериментальная и клиническая урология",
|
||||
"vak_row": 3143,
|
||||
"vak_name": "Экспериментальная и клиническая урология (Experimental and Сlinical Urology)",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Эпистемология и философия науки",
|
||||
"vak_row": 40,
|
||||
"vak_name": "Epistemology & Philosophy of Sciences /Эпистемология и философия науки",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
},
|
||||
{
|
||||
"k1_name": "Японские исследования",
|
||||
"vak_row": 3169,
|
||||
"vak_name": "Электронный научный журнал «Японские исследования»",
|
||||
"reason": "Проверенное различие написания, сокращения, перевода или документированного прежнего названия"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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
@@ -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
@@ -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 "<script>" 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"])
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user