Кандидаты в совет: счётчики Q1, Q2, К1 и ВСЕГО #60
No files matched your search
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 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,12 +88,22 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. Кнопка «Скачать XLSX» выгружает всех найденных сотрудников с выбранными поиском и сортировкой, включая записи на других страницах. Файл содержит листы «Сотрудники», «Публикации» и «Параметры» с источниками, критериями и условиями отбора; экспорт доступен без авторизации по `/admin/council/export.xlsx`. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
|
||||
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`; минимальный порог не применяется. Уволенные сотрудники отмечены отдельно. Доступны поиск по ФИО, сортировка по каждому счётчику и страницы по 25 сотрудников. XLSX по `/admin/council/export.xlsx` содержит всех найденных сотрудников с выбранной сортировкой, их публикации и параметры расчёта.
|
||||
|
||||
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
|
||||
Счётчики **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:
|
||||
@@ -145,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.6`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.14.2`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ def directory(
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_count", direction: str = "desc",
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction)
|
||||
@@ -146,7 +146,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_cou
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "csv_count", direction: str = "desc",
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+87
-17
@@ -2,6 +2,7 @@
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
@@ -11,6 +12,7 @@ from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
@@ -19,6 +21,7 @@ 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")
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
@@ -56,6 +59,47 @@ def _quartiles(row: dict) -> list[str]:
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
@@ -72,7 +116,7 @@ def _articles_by_author() -> dict[str, list[dict]]:
|
||||
continue
|
||||
year = int(row["Год"]) if row["Год"].strip().isdigit() else None
|
||||
article = {"title": title, "year": year, "doi": _doi(row["DOI"]),
|
||||
"types": [], "quartiles": _quartiles(row), "origins": ["Файл CSV"],
|
||||
"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)
|
||||
@@ -87,14 +131,22 @@ def _database_article(publication) -> dict:
|
||||
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()
|
||||
title_years = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["year"] is not None:
|
||||
@@ -118,39 +170,55 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
key = identifier or fallback
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "doi": identifier,
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "origins", "rows")},
|
||||
"csv_qualifies": False}
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "journals", "origins", "rows")},
|
||||
"evidence": []}
|
||||
merged = unique[key]
|
||||
for field in ("types", "quartiles", "origins", "rows"):
|
||||
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"]
|
||||
merged["year"] = merged["year"] or article["year"]
|
||||
in_period = article["year"] is not None and start.year <= article["year"] <= end.year
|
||||
merged["csv_qualifies"] |= bool(article["in_csv"] and article["quartiles"] and in_period)
|
||||
for article in unique.values():
|
||||
year = article["year"]
|
||||
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"])
|
||||
if year is None:
|
||||
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
|
||||
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"])
|
||||
article["k1"] |= any(_journal_key(name) in k1_journals 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:
|
||||
article["criteria"].append("Вне пятилетнего периода")
|
||||
if not article["quartiles"]:
|
||||
article["criteria"].append("Q1/Q2, К1 и RSCI не подтверждены доступными данными")
|
||||
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["csv_qualifies"] = False
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчик CSV")
|
||||
return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчики Q1/Q2/К1")
|
||||
return sorted(unique.values(), key=lambda item: (not item["qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "csv_count", direction: str = "desc", paginate: bool = True) -> dict:
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True) -> dict:
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
|
||||
@@ -172,11 +240,13 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
|
||||
continue
|
||||
candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
|
||||
"count": len(articles), "csv_count": sum(a["csv_qualifies"] for a in articles),
|
||||
"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", "csv_count", "db_count", "count"} else "csv_count"
|
||||
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":
|
||||
|
||||
@@ -22,35 +22,40 @@ def council_xlsx(result: dict) -> bytes:
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "CSV по критериям", "В БД", "Всего без дублей",
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "В БД", "Все публикации",
|
||||
"Статус", "Профиль ВШЭ", "Примечание"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "В счётчике CSV", "В БД", "DOI", "Ссылка на сайте", "Строки CSV"])
|
||||
"Критерии", "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["csv_count"],
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
|
||||
candidate["k1_count"], candidate["total_count"],
|
||||
candidate["db_count"], candidate["count"], "Уволен" if candidate["dismissed"] else "Не отмечен как уволенный",
|
||||
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["csv_qualifies"] else "Нет", "Да" if article["in_db"] else "Нет",
|
||||
*["Да" 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 [
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
["Сортировать по", {"full_name": "ФИО", "csv_count": "CSV по критериям", "db_count": "В БД",
|
||||
"count": "Всего без дублей"}[result["sort"]]],
|
||||
["Сортировать по", {"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 или БД"],
|
||||
["Счётчик CSV", "Предварительный: Q1/Q2 Scopus или Web of Science в пятилетнем периоде по году. "
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1/RSCI не подтверждены доступными данными."],
|
||||
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
|
||||
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
|
||||
["В БД / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for sheet, widths in [(employees, [7, 34, 40, 22, 12, 22, 30, 45, 55]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 22, 12, 45, 45, 22]),
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 12, 22, 30, 45, 55]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 12, 45, 45, 22]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
|
||||
@@ -901,7 +901,10 @@
|
||||
.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(3, minmax(0, 1fr)); gap: clamp(12px, 3vw, 36px); }
|
||||
.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; }
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
{% else %}
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как читать счётчики и критерии</strong></summary>
|
||||
<p>«CSV по критериям» — уникальные публикации с отметкой Scopus или Web of Science Q1/Q2 за {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}. «В БД» — все уникальные публикации сотрудника из miem-workers, включая импортированные из CSV. Общий перечень объединяет оба источника; совпадения не увеличивают итоговое количество.</p>
|
||||
<p>В CSV нет типа материала, точной даты и подтверждения К1 или RSCI. Счётчик CSV предварительный: для пограничных лет нужно проверить дату, для материалов без типа — подтвердить, что это статьи. Материалы, для которых в БД указан другой тип, в этот счётчик не входят. Отсутствие отметки означает недостаток данных. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
|
||||
<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>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
@@ -28,7 +29,7 @@
|
||||
</label>
|
||||
<label class="directory__field">Сортировать по
|
||||
<select class="directory__input" name="sort">
|
||||
{% for value, label in [('full_name', 'ФИО'), ('csv_count', 'CSV по критериям'), ('db_count', 'Публикации в БД'), ('count', 'Всего без дублей')] %}
|
||||
{% 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>
|
||||
@@ -55,9 +56,12 @@
|
||||
<summary class="council__candidate-summary">
|
||||
<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.csv_count }}</strong><small>CSV по критериям</small></span>
|
||||
<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 class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
|
||||
</span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.13.1"
|
||||
FRONTEND_VERSION = "0.13.1"
|
||||
BACKEND_VERSION = "0.13.1"
|
||||
APP_VERSION = "0.14.2"
|
||||
FRONTEND_VERSION = "0.14.2"
|
||||
BACKEND_VERSION = "0.14.2"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.13.1"
|
||||
version = "0.14.2"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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")
|
||||
@@ -57,7 +57,7 @@ def main():
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {"сотрудник 26": [
|
||||
{"title": f"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "rows": [number + 2],
|
||||
"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)
|
||||
@@ -89,7 +89,7 @@ def main():
|
||||
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 и RSCI не подтверждены доступными данными").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)
|
||||
@@ -104,7 +104,7 @@ def main():
|
||||
("full_name", "desc", "Сотрудник 27"),
|
||||
("db_count", "desc", "Сотрудник 27"),
|
||||
("count", "desc", "Сотрудник 26"),
|
||||
("csv_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()
|
||||
|
||||
+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.13.1"
|
||||
assert response.json()["backend_version"] == "0.14.2"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+147
-17
@@ -20,7 +20,7 @@ from app.services.council_export import _append
|
||||
def export(monkeypatch, tmp_path):
|
||||
path = tmp_path / "articles.csv"
|
||||
def write(rows):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
|
||||
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=";")
|
||||
@@ -68,7 +68,7 @@ def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
candidate = result["candidates"][0]
|
||||
assert (candidate["csv_count"], candidate["db_count"], candidate["count"]) == (2, 3, 5)
|
||||
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"]
|
||||
@@ -76,7 +76,7 @@ def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
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 not imported["csv_qualifies"]
|
||||
assert imported["qualifies"] and imported["q2"]
|
||||
assert "Тип не указан" in imported["type_label"]
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
|
||||
@@ -100,9 +100,9 @@ def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
|
||||
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"] == "Доктор")["csv_count"] == 1
|
||||
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"] == "Две степени")["csv_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Две степени")["total_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_no_false_merge_when_title_has_conflicting_dois(db_session, export):
|
||||
db_session.commit()
|
||||
item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert item["count"] == 3
|
||||
assert item["csv_count"] == 3 and item["db_count"] == 1
|
||||
assert item["total_count"] == 3 and item["db_count"] == 1
|
||||
|
||||
|
||||
def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
@@ -124,7 +124,7 @@ def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert len(items) == 2
|
||||
assert all(c["ambiguous"] and c["csv_count"] == 0 and c["count"] == 1 for c in items)
|
||||
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):
|
||||
@@ -134,10 +134,10 @@ def test_missing_database_year_merges_only_unambiguous_title_and_non_article_exc
|
||||
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["csv_count"], candidate["db_count"]) == (2, 1, 2)
|
||||
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["csv_qualifies"]
|
||||
assert "не включён в счётчик CSV" in book["criteria"][-1]
|
||||
assert not book["qualifies"]
|
||||
assert "не включён в счётчики Q1/Q2/К1" in book["criteria"][-1]
|
||||
|
||||
|
||||
def test_search_and_pagination_include_all_workers(db_session, export):
|
||||
@@ -171,7 +171,7 @@ def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
db_session.commit()
|
||||
expected = {
|
||||
"full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"csv_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"total_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]},
|
||||
}
|
||||
@@ -180,8 +180,8 @@ def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
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"]) == ("csv_count", "desc")
|
||||
assert [c["name"] for c in invalid["candidates"]] == expected["csv_count"]["desc"]
|
||||
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
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council?sort=full_name&direction=desc")
|
||||
@@ -206,12 +206,12 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert response.status_code == 200
|
||||
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
|
||||
assert "Минимальный порог количества работ не применяется" in response.text
|
||||
assert "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" 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.13.1 · Frontend 0.13.1" in response.text
|
||||
assert "Backend 0.14.2 · Frontend 0.14.2" 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
|
||||
@@ -256,11 +256,11 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
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]] == [1, 1, 1]
|
||||
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["J2"].value == "https://doi.org/10.1234/shared" and works["L2"].value == "2, 3"
|
||||
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)
|
||||
@@ -308,3 +308,133 @@ def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
_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]
|
||||
Reference in new issue
Block a user