Merge pull request 'Редактор публикаций диссовета, настройка оценки и компактный XLSX' (#67) from feature/council-publication-editor into main
CI / test (push) Successful in 9m30s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped

Reviewed-on: #67
Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #67.
This commit is contained in:
admin committed 2026-10-09 11:50:52 +00:00
commit 2554cbf39a
25 files changed
+1107 -179

No files matched your search

+1
View File
@@ -28,6 +28,7 @@ jobs:
- run: python -m pip install -r requirements.txt
- run: python -m pytest -q
- run: node --check app/static/admin.js
- run: node --check app/static/council.js
- run: sh -n infra/postgres-backup.sh
- run: sh -n infra/install-postgres-backup-cron.sh
+16 -5
View File
@@ -90,17 +90,29 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора.
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
Расчёт использует пять календарных лет, включая текущий год Москвы: в 2026 году — 2022–2026. Точные день и месяц для периода не нужны; конфликт годов остаётся причиной проверки. В форме фильтров выбирается отрасль оценки, которая задаёт порог для доктора (9 для технических, 11 для физико-математических и т. д.); для кандидата/PhD минимум 20. Выбранная отрасль сохраняется при переходах страниц и выгрузке XLSX, а все пороги вынесены в пояснения над списком.
Статья с известным видом материала, журналом и квартилем Q1/Q2 из источника, применимым к году публикации, автоматически входит в «Подтверждено», если год входит в период и нет конфликтов идентичности, вида и годов. Неизвестный вид, отсутствующий журнал, квартиль другого года или ручная замена квартиля без источника не дают автоматического зачёта. Подтверждение степени и рассмотрение содержания статьи по специальности оцениваются отдельно.
Редактор позволяет заменить связь журнала с одной или несколькими из доступных специальностей. Ручная связь применяется ко всем статьям журнала независимо от года; в фильтр попадают допустимые публикации за календарный период. Пустой ручной список исключает связь, переключатель «Использовать связь из справочников» восстанавливает справочные данные. Связь тематики журнала не заменяет рассмотрение содержания статьи. Ручные специальности сохраняются в той же таблице, в разделе `specialties`; дополнительная миграция не нужна.
Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами.
К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту.
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, пограничные даты и неподтверждённые основания не создают положительного заключения.
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Дата, отрасль и остальные параметры сохраняются при переходах и экспорте. Листы «Сотрудники», «Публикации» и «Параметры» используют тот же расчёт, включают причины проверки и источники; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
@@ -174,8 +186,7 @@ node --check app/static/admin.js
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
подтверждённых специальностей, включая выгрузку без фильтра. Число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации в компактный XLSX не включаются.
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
+102 -12
View File
@@ -1,14 +1,16 @@
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
from copy import deepcopy
from pydantic import BaseModel, Field, ConfigDict
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import desc, func, select, update
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db import SessionLocal, get_db
from app.models import CrawlError, CrawlRun, Employee
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin, read_session
from app.services.admin_data import (
employee_detail_payload,
format_admin_datetime,
@@ -19,7 +21,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 CouncilInputError, council_candidates
from app.services.council import CouncilInputError, council_candidates, _journal_key
from app.services.council_export import council_xlsx
from app.version import BACKEND_VERSION, FRONTEND_VERSION
@@ -135,25 +137,113 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
db: Session = Depends(get_db)):
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
branch="технические")
branch=branch)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
return _render(request, "council.html", {"error": False, "public_page": True, **result})
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
class CouncilArticleEdit(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True)
employee_id: int = Field(gt=0)
edit_key: str = Field(pattern=r"^[a-f0-9]{64}$")
revision: int = Field(ge=0)
title: str = Field(min_length=1, max_length=2000)
journal: str = Field(min_length=1, max_length=500)
quartile: str = Field(pattern=r"^(Q[1-4])?$")
specialty_codes: list[str] | None = Field(default=None, max_length=12)
@router.get("/council/publications", response_class=HTMLResponse)
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
"can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username}
try:
result = council_candidates(db, paginate=False, sort="full_name", direction="asc")
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
for candidate in result["candidates"] for article in candidate["articles"]
if employee.casefold().strip() in candidate["name"].casefold()
and title.casefold().strip() in article["title"].casefold()
and journal.casefold().strip() in " ".join(article["journals"]).casefold()]
total, limit = len(articles), 50
offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0
return _render(request, "council_publications.html", {**context, "error": False,
"articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit,
"revision": result["revision"], "journal_names": result["journal_names"], "specialties": result["specialties"]})
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council_publications.html", {**context, "error": True}, status_code=503)
@router.post("/council/publications")
def edit_council_publication(request: Request, change: CouncilArticleEdit,
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
if username != settings.admin_username:
raise HTTPException(401, "Для сохранения войдите в админку")
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
if request.headers.get("X-Council-Edit") != "1":
raise HTTPException(403, "Недопустимый запрос на изменение")
if not _journal_key(change.journal):
raise HTTPException(422, "Укажите название журнала")
try:
result = council_candidates(db, paginate=False)
article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id
for a in c["articles"] if a["edit_key"] == change.edit_key), None)
if not article:
raise HTTPException(404, "Статья не найдена. Обновите страницу")
if article["year"] is None and change.quartile:
raise HTTPException(422, "Для квартиля необходим однозначный год публикации")
if change.specialty_codes is not None and not set(change.specialty_codes) <= {item["code"] for item in result["specialties"]}:
raise HTTPException(422, "Неизвестная специальность ВАК")
record = db.get(CouncilCorrections, 1)
revision = record.revision if record else 0
if revision != change.revision:
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
data = deepcopy(record.data) if record else {"articles": {}, "journals": {}}
if change.title != article["title"] or change.journal != article["edit_journal"]:
data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal}
specialty_data = data.setdefault("specialties", {})
if "specialty_codes" in change.model_fields_set:
if change.specialty_codes is None:
specialty_data.pop(_journal_key(change.journal), None)
else:
specialty_data[_journal_key(change.journal)] = sorted(set(change.specialty_codes))
if article["year"] is not None and (change.quartile != article["edit_quartile"] or change.journal != article["edit_journal"]):
data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile
if record:
count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1,
CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1,
updated_by=username)).rowcount
if not count:
db.rollback()
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
else:
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз")
return {"saved": True}
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
branch="технические")
branch=branch)
content = council_xlsx(result)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
+11
View File
@@ -243,3 +243,14 @@ class ParseResourceCache(Base):
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String(32))
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
class CouncilCorrections(Base):
"""Manual council data, outside crawler-owned publications."""
__tablename__ = "council_corrections"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
data: Mapped[dict] = mapped_column(json_type, nullable=False)
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
+101 -20
View File
@@ -1,6 +1,5 @@
"""Public publication evidence from the council export and employee database."""
import calendar
import hashlib
import json
import re
@@ -14,11 +13,11 @@ from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import Employee
from app.models import Employee, CouncilCorrections
from app.services.academic_degrees import academic_degrees
from app.services.council_source import _doi, _journal_names, _name, load_reference, _description_journals
from app.services.council_catalog import catalog, publication_catalog
from app.services.publication_quartiles import labels as quartile_labels, identity_issues, hse_entries, merge as merge_quartiles
from app.services.publication_quartiles import qualifying_entries, identity_issues, hse_entries, merge as merge_quartiles
from app.services.council_assessment import (
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
publication_type_allowed, sourced,
@@ -50,8 +49,7 @@ def _safe_url(value: str | None) -> str | None:
def period(today: date) -> tuple[date, date]:
year = today.year - 5
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
return date(today.year - 4, 1, 1), date(today.year, 12, 31)
def _journal_key(value: str) -> str:
@@ -122,10 +120,12 @@ def _database_article(publication) -> dict:
if isinstance(imported, dict) and not imported_issues and (not imported_doi or imported_doi == doi):
journals.extend(_journal_names(imported))
issues = list(dict.fromkeys([*issues, *imported_issues]))
quartile_entries = [*qualifying_entries(publication.quartiles, publication.year, doi, current_journals, identifiers),
*qualifying_entries(imported_quartiles, publication.year, doi, current_journals, identifiers)]
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
"quartiles": list(dict.fromkeys([*quartile_labels(publication.quartiles, publication.year, doi, current_journals, identifiers),
*quartile_labels(imported_quartiles, publication.year, doi, current_journals, identifiers)])),
"quartiles": list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in quartile_entries)),
"quartile_entries": quartile_entries,
"quartile_issues": issues,
"journals": journals, "issns": [raw[key] for key in ("issn", "eissn", "ISSN", "EISSN") if isinstance(raw.get(key), str)],
"source_hashes": [publication.source_hash],
@@ -133,7 +133,7 @@ def _database_article(publication) -> dict:
"url": _safe_url(publication.url)}
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
def _unique_articles(articles: list[dict], start: date, end: date, corrections=None, employee_id=None) -> list[dict]:
k1_journals = _k1_journals()
specialties = _k1_specialties()["by_journal"]
title_years = defaultdict(set)
@@ -171,12 +171,41 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
merged["in_csv"] |= article["in_csv"]
merged["in_db"] |= article["in_db"]
merged["url"] = merged["url"] or article["url"]
for article in unique.values():
for identity, article in unique.items():
evidence = article.pop("evidence")
article["edit_key"] = hashlib.sha256(json.dumps(
identity,
ensure_ascii=False).encode()).hexdigest()
correction = (corrections or {}).get("articles", {}).get(f"{employee_id}:{article['edit_key']}")
article["manually_corrected"] = bool(correction)
article["journal_corrected"] = bool(correction and article["journals"] != [correction["journal"]])
if correction:
journal_changed = article["journal_corrected"]
article["title"] = correction["title"]
article["journals"] = [correction["journal"]]
if journal_changed:
article["issns"] = []
article["quartiles"] = []
article["quartile_issues"] = []
evidence = [{**item, "journals": article["journals"],
"quartiles": [] if journal_changed else item["quartiles"]} for item in evidence]
years = sorted({item["year"] for item in evidence if item["year"] is not None})
# Never pick a conflicting source year merely because that row came first.
year = article["year"] = years[0] if len(years) == 1 else None
article["source_years"] = years
article["automatic_quartile_bases"] = []
if len(article["journals"]) == 1 and not article["quartile_issues"] and not article["journal_corrected"]:
for item in evidence:
if item["year"] != year:
continue
details = item.get("quartile_entries")
if details is None:
# Prepared CSV evidence has no category or metric metadata.
details = [{"quartile": label[-2:], "system": label[:-3],
"source": " / ".join(item["origins"]), "source_url": item.get("url")}
for label in item["quartiles"] if label.endswith((" Q1", " Q2"))]
article["automatic_quartile_bases"].extend(
{**entry, "basis": entry["quartile"], "applicable_year": year} for entry in details)
article["criteria"] = [*article["quartiles"], *article["quartile_issues"]]
matches = list(dict.fromkeys(k1_journals[key] for name in article["journals"]
if (key := _journal_key(name)) in k1_journals))
@@ -209,10 +238,9 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
elif year is None:
article["criteria"].append("Год не указан — период не подтверждён")
elif start.year <= year <= end.year:
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
else "В пределах пяти лет")
article["criteria"].append(f"В пределах пятилетнего периода {start.year}–{end.year}")
else:
article["criteria"].append("Вне пятилетнего периода")
article["criteria"].append(f"Вне пятилетнего периода {start.year}–{end.year}")
if not article["quartiles"] and not matches:
article["criteria"].append("Q1/Q2 и К1 не подтверждены доступными данными")
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
@@ -237,6 +265,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
verified = evidence()
corrections_record = db.get(CouncilCorrections, 1)
corrections = corrections_record.data if corrections_record else {}
revision = corrections_record.revision if corrections_record else 0
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
@@ -250,9 +281,11 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
employee_evidence = verified["employees"].get(employee.profile_key, {})
if not any(degree_level(degree) for degree in degrees) and not employee_evidence.get("degrees"):
continue
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end, corrections, employee.id)
for article in articles:
proofs = publication_proofs(employee_evidence, article)
article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and
f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {}))
proofs = [] if article["manual_review"] else publication_proofs(employee_evidence, article)
kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
kind = next(iter(kinds)) if len(kinds) == 1 else None
preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed(
@@ -260,7 +293,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
if not preliminary_type_allowed:
for flag in ("q1", "q2", "k1", "k2", "rsci"):
article[flag] = False
identifiers = [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
identifiers = [] if article["journal_corrected"] else [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))]
for name in article["journals"]:
canonical = _k1_journals().get(_journal_key(name))
identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, []))
@@ -278,13 +311,57 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
if key:
article[key] = True
assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end)
if not article["catalog_issues"] and not article["journal_corrected"]:
article["automatic_quartile_bases"].extend({"basis": metric["quartile"], "system": metric["system"],
"metric": metric["metric"], "category": metric["category"], "metric_year": metric["year"],
"applicable_year": article["year"], "source": metric["source"], "source_url": metric["source_url"]}
for record in article["catalog_records"] for metric in record["metrics"]
if article["year"] == metric["year"] and metric["quartile"] in {"Q1", "Q2"})
journal_key = _journal_key(article["journals"][0]) if len(article["journals"]) == 1 else ""
manual_quartile = corrections.get("journals", {}).get(f"{journal_key}:{article['year']}")
if manual_quartile is not None:
article["quartiles"] = [f"Ручная корректировка {manual_quartile}"] if manual_quartile else []
article["q1"] = bool(manual_quartile == "Q1" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
article["q2"] = bool(manual_quartile == "Q2" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year)
article["criteria"] = [c for c in article["criteria"] if not c.endswith((" Q1", " Q2", " Q3", " Q4")) and c != "Q1/Q2 и К1 не подтверждены доступными данными"]
article["criteria"].append(f"Квартиль журнала за {article['year']}: {manual_quartile or 'не указан'} · ручная корректировка, требует подтверждения")
if article["manual_review"]:
article["automatic_quartile_bases"] = []
manual_specialties = corrections.get("specialties", {}).get(journal_key) if journal_key else None
article["manual_specialties"] = manual_specialties is not None
default_specialties = sorted(set().union(
*(_k1_specialties()["by_journal"].get(_k1_journals().get(_journal_key(name)), []) for name in article["journals"]),
*(record["specialty_codes"] for record in article["catalog_records"])))
article["edit_specialty_codes"] = manual_specialties if manual_specialties is not None else default_specialties
if manual_specialties is not None:
eligible = preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year
article["specialty_codes"] = manual_specialties if eligible else []
for record in article["catalog_records"]:
record["thematic"] = (["Связь журнала со специальностью: ручная корректировка"] if specialty in manual_specialties
else ["Связь журнала с выбранной специальностью исключена вручную"] if specialty else [])
article["qualifies"] = article["q1"] or article["q2"] or article["k1"]
labels = {q[-2:] for q in article["quartiles"] if q.endswith((" Q1", " Q2", " Q3", " Q4"))}
if manual_quartile is None:
labels.update(metric["quartile"] for record in article["catalog_records"] for metric in record["metrics"]
if metric["year"] == article["year"] and metric["quartile"] in {"Q1", "Q2", "Q3", "Q4"})
labels.update(basis["basis"] for proof in proofs for basis in proof.get("bases", [])
if isinstance(basis, dict) and basis.get("applicable_year") == article["year"] and basis.get("basis") in {"Q1", "Q2", "Q3", "Q4"})
article["edit_quartile"] = manual_quartile if manual_quartile is not None else next(iter(labels)) if len(labels) == 1 else ""
article["edit_journal"] = article["journals"][0] if len(article["journals"]) == 1 else ""
changed = [a for a in articles if a["manual_review"]]
adjusted_evidence = {**employee_evidence, "publications": [p for p in employee_evidence.get("publications", [])
if not any((a["doi"] and p.get("doi") == a["doi"]) or p.get("source_hash") in a["source_hashes"] for a in changed)]}
assessment = assess_candidate(degrees, articles, adjusted_evidence, branch, specialty, start, end)
for article in changed:
article["confirmation_reasons"].append("Ручная корректировка требует повторного подтверждения источниками")
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({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано",
article["criteria"].append(f"По специальности {specialty}: {specialty_name} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})")
for article in articles:
article["messages"] = list(dict.fromkeys([*article["criteria"], *article["confirmation_reasons"]]))
candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id,
"count": len(articles),
**{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")},
"total_count": sum(a["qualifies"] for a in articles),
@@ -303,10 +380,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
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,
criteria_groups = {}
for name, (minimum, monographs) in BRANCHES.items():
criteria_groups.setdefault((minimum, monographs), []).append(name)
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"branch": branch,
"branch": branch, "branches": list(BRANCHES),
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
for path in (K1_JOURNALS, K2_JOURNALS)
+20 -23
View File
@@ -129,34 +129,29 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])]
years = article["source_years"]
dates = set()
date_conflict = False
for proof in proofs:
if proof.get("publication_date"):
if proof.get("publication_date") and sourced(proof.get("date_evidence")):
try:
value = iso_date(proof["publication_date"])
if sourced(proof.get("date_evidence")):
dates.add(value)
else:
reasons.append("Источник точной даты не подтверждён")
dates.add(iso_date(proof["publication_date"]))
except ValueError:
reasons.append("Некорректная точная дата")
date_conflict = True
if len(years) > 1 or len(dates) > 1 or (dates and years and next(iter(dates)).year not in years):
reasons.append("Даты публикации в источниках расходятся")
date_conflict = True
pass # Exact dates are optional; the calendar-year window uses the publication year.
date_years = {value.year for value in dates}
date_conflict = len(years) > 1 or len(date_years) > 1 or bool(date_years and years and not date_years <= set(years))
if date_conflict:
reasons.append("Годы публикации в источниках расходятся")
exact = next(iter(dates)) if len(dates) == 1 else None
in_period = start < exact < end if exact else len(years) == 1 and start.year < years[0] < end.year
if exact and exact in {start, end}:
boundary = policy.get("boundary_dates", {})
in_period = sourced(boundary) and boundary.get("value") == "inclusive"
if not in_period:
reasons.append("Включение граничной даты не подтверждено")
publication_year = article["year"] if article["year"] is not None else next(iter(date_years)) if len(date_years) == 1 else None
in_period = publication_year is not None and start.year <= publication_year <= end.year
if not in_period:
reasons.append("Пятилетний период не подтверждён")
reasons.append(f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None
else "Год не указан — период не подтверждён")
types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))}
if len(types) != 1:
automatic_bases = [b for b in article.get("automatic_quartile_bases", [])
if b["applicable_year"] == (exact.year if exact else article["year"])
and not article.get("catalog_issues") and not article.get("quartile_issues")]
if len(types) != 1 and not (not types and article["types"] == ["Статья"] and automatic_bases):
reasons.append("Вид материала не подтверждён или расходится")
kind = next(iter(types)) if len(types) == 1 else None
kind = next(iter(types)) if len(types) == 1 else "article" if not types and article["types"] == ["Статья"] and automatic_bases else None
type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)])
if type_conflict:
reasons.append("Виды материала в источниках расходятся")
@@ -180,13 +175,15 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
bases.append(basis)
else:
reasons.append("Источник или год применимости основания не подтверждены")
if kind == "article":
bases.extend(automatic_bases)
# Different categories may legitimately have different quartiles; conflicting
# claims for the same system/category/year must not be resolved by ordering.
claims = {}
basis_conflict = False
for basis in bases:
key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else (
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric_year"), basis_year)
basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric"), basis.get("metric_year"), basis_year)
if key in claims and claims[key] != basis["basis"]:
reasons.append("Подтверждённые основания источников расходятся")
basis_conflict = True
@@ -196,7 +193,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
reasons.append("Нет подтверждённого допустимого основания выбранного пункта")
# Missing optional evidence is not a veto on another sufficient independent
# basis. Identity/date/type conflicts, however, invalidate the whole work.
identity_conflict = any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
identity_conflict = bool(article.get("quartile_issues")) or any("расход" in reason or "идентичности" in reason or "разные журналы" in reason
for reason in article.get("catalog_issues", []))
if article.get("proof_identity_conflict"):
identity_conflict = True
+1
View File
@@ -166,6 +166,7 @@ def publication_catalog(identifiers, names, specialty=""):
output.append({"id": record["id"], "name": record["name"], "issn": record.get("issn"), "eissn": record.get("eissn"),
"catalog": record["catalog"], "metrics": record["metrics"], "thematic": thematic,
"match_level": link["match_level"] if link else None,
"specialty_codes": list(record["links"]),
"passport_urls": [safe_url(data["passports"][r["passport_id"]]["source_url"]) for r in link["passport_references"]] if link else []})
# A conflict is preserved; it never resolves by load order.
if len(records) > 1:
+35 -45
View File
@@ -1,4 +1,4 @@
"""XLSX report containing the same public evidence as the council page."""
"""Compact XLSX report from council data, with an explicit Q1/Q2/K1 total."""
from io import BytesIO
@@ -26,66 +26,56 @@ def council_xlsx(result: dict) -> bytes:
def specialties_text(codes):
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "ЛК сотрудника ВШЭ", "DOI", "Ссылка на сайте", "Строки выгрузки ВШЭ", "Специальности ВАК", "По выбранной специальности",
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
"Журнал и идентификаторы", "Квартильные категории и источники", "Аналитическая связь журнала", "Рассмотрение статьи"])
_append(employees, ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего",
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: данные ВШЭ не присвоены автоматически" if candidate["ambiguous"] else ""
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
_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, specialties_text(specialty_codes),
candidate["specialty_count"] if result["specialty"] else None,
candidate["selected_degree"], "Да" if candidate["degree_confirmed"] else "Нет", candidate["clause"],
result["branch"], candidate["minimum"], candidate["confirmed_count"],
candidate["k2_count"], candidate["rsci_count"],
candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
total = sum(article["q1"] or article["q2"] or article["k1"] for article in candidate["articles"])
_append(employees, [number, candidate["name"], candidate["selected_degree"],
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
for article in candidate["articles"]:
_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"])), specialties_text(article["specialty_codes"]),
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None,
*["Да" if article[key] else "Нет" for key in ("k2", "rsci", "confirmed")], article["publication_date"],
"; ".join(article["confirmation_reasons"]),
"; ".join(f"{b['basis']} · {b.get('system', '')} · {b.get('category', '')} · {b.get('metric', '')} · год метрики {b.get('metric_year', '')} · применимо к {b['applicable_year']} · {b['source']} · {b.get('source_url') or ''}" for b in article["confirmed_bases"]),
"; ".join(f"{r['name']} · {r['id']} · ISSN {r['issn']} / {r['eissn']} · {r['catalog']}" for r in article["catalog_records"]),
"; ".join(f"{m['system']} · {m['metric']} · {m['category']} · {m['edition'] or ''} · {m['quartile']} · год метрики {m['year']} · выпуск {m['release_year'] or ''} · {m['source']} · {m['source_url'] or ''} · строка {m['source_row'] or ''} · страница {m['source_pdf_page'] or ''}" for r in article["catalog_records"] for m in r["metrics"]),
"; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
criteria = list(dict.fromkeys("В пределах пяти лет" if value.startswith(
("В пределах пяти лет", "В пределах пятилетнего периода")) else value for value in article["criteria"]))
_append(publications, [number, candidate["name"], article["title"], article["year"],
article["type_label"] if article["types"] else "не указан", "; ".join(criteria),
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "k2", "rsci")],
specialties_text(article["specialty_codes"]), article["doi_url"], article["url"]])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Отрасль оценки", result["branch"]],
["Период оценки", "Пять лет относительно текущей даты Москвы"],
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
["Период оценки", "Пять календарных лет, включая текущий год Москвы; день и месяц не учитываются"],
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI показывают наличие соответствующего основания у уникальной работы в календарном периоде. Это не заключение о включении сотрудника в совет; подтверждённый зачёт и причины проверки доступны на сайте."],
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
["Границы и виды", "Спорные граничные даты и допустимость иных видов требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
["Границы и виды", "Год публикации должен быть согласован между источниками; допустимость иных видов требует подтверждения. Без него соответствующие работы не подтверждают минимум."],
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
["Подбор по специальности", "Хотя бы одна публикация за пять календарных лет в журнале К1 по Перечню ВАК на 15.09.2026 либо с ручной тематической связью; содержание статьи не проверяется."],
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
"total_count": "ВСЕГО", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
["Отбор по степени", "Высшая степень из истории: доктор выше кандидата/PhD; отсутствие подтверждения доктора не переводит оценку на пункт 6. Отсутствие публикаций не исключает сотрудника."],
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
"Без названия журнала К1 не подтверждён."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
["ВСЕГО", "Предварительные уникальные работы Q1/Q2/К1/RSCI либо К2 для доктора; пересечения учитываются один раз."],
["ЛК сотрудника ВШЭ / Все публикации", "Все годы и категории. ЛК сотрудника ВШЭ — публикации из личного кабинета. "
"Все публикации объединяет записи личного кабинета и выгрузку из базы ВШЭ без дублей; источники указаны на листе Публикации."],
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Всего", "Число уникальных работ с хотя бы одним основанием Q1 ИЛИ Q2 ИЛИ К1. Пересечения учитываются один раз. К2 и RSCI самостоятельно не увеличивают этот столбец; итог может отличаться от общего счётчика на сайте."],
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
["Автоматический зачёт на сайте", "Известная статья с журналом и Q1/Q2 из источника за применимый год автоматически засчитывается при согласованном годе в периоде и отсутствии конфликтов. Подтверждение степени и тематическое рассмотрение проверяются отдельно."],
["Ручные корректировки", "Учитываются правки статьи, журнала, квартиля и специальностей из редактора. Квартиль относится к году публикации; тематическая связь журнала действует независимо от года. Ручной квартиль без источника требует проверки."],
["Совпадение ФИО", "При одинаковом ФИО нескольких сотрудников данные выгрузки ВШЭ не присваиваются автоматически; используются записи личного кабинета каждого сотрудника."],
]:
_append(parameters, values)
_append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
for group in result["criteria_groups"]:
_append(parameters, ["Доктор · " + group["names"], f"{group['minimum']} публикаций; К1/К2/RSCI/Q1/Q2; обязательных монографий: {group['monographs']}"])
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
for meta in result["catalog_metadata"]:
@@ -94,8 +84,8 @@ def council_xlsx(result: dict) -> bytes:
_append(parameters, ["Проверка каталога", issue])
for meta in result["category_metadata"]:
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 12, 16, 16, 16, 50, 70]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28, 10, 10, 20, 18, 70, 75, 60, 90, 75, 60]),
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 10, 12, 18, 28, 70, 45]),
(publications, [7, 34, 65, 12, 24, 80, 10, 10, 10, 10, 10, 70, 45, 45]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
+11 -6
View File
@@ -129,13 +129,18 @@ def identity_issues(payload, doi="", journals=(), identifiers=()):
if (issue := binding_issue(item, payload, doi, journals, identifiers))))
def qualifying_entries(payload, publication_year, doi="", journals=(), identifiers=()):
return [item for item in entries(payload)
if item["quartile"] in {"Q1", "Q2"}
and item["system"] in SYSTEM_FIELDS
and item.get("publication_year") == publication_year
and item.get("metric_year") in {None, publication_year}
and not binding_issue(item, payload, doi, journals, identifiers)]
def labels(payload, publication_year, doi="", journals=(), identifiers=()):
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in entries(payload)
if item["quartile"] in {"Q1", "Q2"}
and item["system"] in SYSTEM_FIELDS
and item.get("publication_year") == publication_year
and item.get("metric_year") in {None, publication_year}
and not binding_issue(item, payload, doi, journals, identifiers)))
return list(dict.fromkeys(f"{item['system']} {item['quartile']}" for item in
qualifying_entries(payload, publication_year, doi, journals, identifiers)))
def index_sjr(rankings):
+25
View File
@@ -913,3 +913,28 @@
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
.council__article-heading { flex-direction: column; gap: 4px; }
}
.council__article[data-council-article] { position: relative; padding-inline-end: 60px; cursor: pointer; }
.council__article[data-council-article]:hover { background: #f3f7f5; }
.council__article--active { background: #e7f0eb; }
.council__article[data-council-article]:focus-visible { outline: 3px solid #0f766e; outline-offset: 2px; }
.council__edit { position: absolute; inset-inline-end: 4px; top: 12px; min-width: 44px; min-height: 44px; visibility: hidden; display: inline-flex; align-items: center; justify-content: center; }
.council__article--active > .council__edit,
.council__article:focus-within > .council__edit { visibility: visible; }
.council__articles--editor { padding: 20px 20px 20px 42px; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
.council__articles--editor .council__hint { margin-block: 8px; }
.council-editor { display: grid; gap: 16px; margin-top: 20px; }
.council-editor .directory__field { min-width: 0; }
.council-editor textarea { resize: vertical; min-height: 90px; font: inherit; }
.council-editor__actions { display: flex; flex-wrap: wrap; gap: 12px; }
.council-editor__error { margin: 0; color: #991b1b; line-height: 1.5; }
@media (max-width: 600px) {
.council__articles--editor { padding-inline: 28px 12px; }
.council-editor .directory__input { font-size: 16px; }
}
.council-editor__specialties { min-width: 0; }
.council-editor__specialties summary { cursor: pointer; min-height: 44px; align-content: center; }
.council-editor__specialties fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid #d6dddc; border-radius: 8px; }
.council-editor__specialties .columns-modal__grid { grid-template-columns: 1fr; margin-block: 12px; }
.council__criteria-list { line-height: 1.55; }
+116
View File
@@ -0,0 +1,116 @@
(() => {
const dialog = document.querySelector('[data-council-dialog]');
if (!dialog) return;
const form = dialog.querySelector('[data-edit-form]');
const autoSpecialties = form?.querySelector('[data-specialty-auto]');
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
autoSpecialties?.addEventListener('change', () => {
specialtyCheckboxes.forEach(input => { input.disabled = autoSpecialties.checked; });
});
let selected;
let trigger;
let saving = false;
const openKey = 'miem.council.openCandidates';
try {
const opened = JSON.parse(sessionStorage.getItem(openKey) || '[]');
document.querySelectorAll('[data-employee-id]').forEach(node => {
if (opened.includes(node.dataset.employeeId)) node.open = true;
});
sessionStorage.removeItem(openKey);
} catch (_error) { /* Storage is optional. */ }
function activate(article) {
document.querySelectorAll('.council__article--active').forEach(node => {
node.classList.remove('council__article--active');
});
article.classList.add('council__article--active');
}
document.querySelectorAll('[data-council-article]').forEach(article => {
article.addEventListener('click', event => {
if (!event.target.closest('a, button')) activate(article);
});
article.addEventListener('keydown', event => {
if (event.target === article && ['Enter', ' '].includes(event.key)) {
event.preventDefault();
activate(article);
article.querySelector('[data-council-edit]').focus();
}
});
});
document.querySelectorAll('[data-council-edit]').forEach(button => {
button.addEventListener('click', () => {
trigger = button;
selected = JSON.parse(button.dataset.article);
if (form) {
form.elements.title.value = selected.title;
form.elements.journal.value = selected.journal;
form.elements.quartile.value = selected.quartile;
autoSpecialties.checked = !selected.manual_specialties;
form.querySelector("[data-edit-specialties]").open = selected.manual_specialties;
specialtyCheckboxes.forEach(input => {
input.checked = selected.specialty_codes.includes(input.value);
input.disabled = autoSpecialties.checked;
});
form.elements.quartile.disabled = selected.year === null;
form.querySelector('[data-edit-error]').hidden = true;
form.querySelector('[data-edit-note]').textContent = selected.year === null
? 'Год публикации не указан или неоднозначен. Можно изменить название статьи и журнал; квартиль недоступен.'
: `Изменение квартиля применяется ко всем статьям выбранного журнала за ${selected.year} год. Проверьте квартиль при смене журнала.`;
}
dialog.showModal();
});
});
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
button.addEventListener('click', () => { if (!saving) dialog.close(); });
});
dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); });
dialog.addEventListener('click', event => { if (event.target === dialog && !saving) dialog.close(); });
dialog.addEventListener('close', () => trigger?.focus());
form?.addEventListener('submit', async event => {
event.preventDefault();
if (saving) return;
const error = form.querySelector('[data-edit-error]');
const submit = form.querySelector('[type="submit"]');
const payload = {
employee_id: selected.employee_id, edit_key: selected.edit_key, revision: selected.revision,
title: form.elements.title.value.trim(), journal: form.elements.journal.value.trim(),
quartile: selected.year === null ? '' : form.elements.quartile.value,
specialty_codes: autoSpecialties.checked ? null : specialtyCheckboxes.filter(input => input.checked).map(input => input.value),
};
saving = true;
submit.disabled = true;
submit.textContent = 'Сохраняем…';
form.setAttribute('aria-busy', 'true');
error.hidden = true;
try {
const response = await fetch('/admin/council/publications', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-Council-Edit': '1'},
body: JSON.stringify(payload), signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
const data = await response.json();
throw new Error(typeof data.detail === 'string' ? data.detail : 'Проверьте название статьи, журнал и квартиль.');
}
try {
sessionStorage.setItem(openKey, JSON.stringify(Array.from(document.querySelectorAll('[data-employee-id][open]')).map(node => node.dataset.employeeId)));
} catch (_error) { /* Storage is optional. */ }
window.location.reload();
} catch (failure) {
error.textContent = failure.name === 'TimeoutError'
? 'Не получен ответ сервера. Обновите страницу, чтобы проверить сохранение.'
: failure.message === 'Failed to fetch' ? 'Нет связи с сервером. Проверьте подключение и попробуйте ещё раз.' : failure.message;
error.hidden = false;
} finally {
saving = false;
submit.disabled = false;
submit.textContent = 'Сохранить';
form.removeAttribute('aria-busy');
}
});
})();
+31 -16
View File
@@ -5,10 +5,12 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за последние пять лет сопоставляются с данными о квартилях и категориях журналов. Минимум — 9 работ для доктора наук и 20 для кандидата наук или PhD. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
</div>
</header>
<a class="button button--secondary" href="/admin/council/publications" target="_blank" rel="noopener noreferrer">Статьи, журналы и квартили ↗</a>
{% if error %}
<div class="council__message" role="alert">
<h3>{% if input_error %}Проверьте параметры оценки{% elif export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
@@ -20,15 +22,23 @@
{% if catalog_issues %}<div class="council__message" role="alert"><p>{{ catalog_issues|join('; ') }}. Повреждённые сведения не используются для зачёта.</p></div>{% endif %}
<details class="council__notice">
<summary><strong>Как рассчитываются показатели</strong></summary>
<p>Период оценки: {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}, последние пять лет относительно текущей даты Москвы. Для доктора наук применяется минимум 9 публикаций по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
<p>Период оценки: {{ start.year }}–{{ end.year }}, пять календарных лет, включая текущий год Москвы; день и месяц публикации не влияют на зачёт. Для доктора наук минимум зависит от отрасли науки по пункту 5, для кандидата наук или PhD — 20 по пункту 6. Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно.</p>
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Для пограничных лет нужно уточнить дату публикации; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, даты или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.</p>
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
<p>«ЛК сотрудника ВШЭ» — количество публикаций из личного кабинета сотрудника за все годы. «Данные ВШЭ» — выгрузка из базы публикаций ВШЭ, которая дополняет сведения из личного кабинета. «Все публикации» объединяет оба источника без дублей за все годы и независимо от категории. При совпадении ФИО нескольких сотрудников данные ВШЭ не присваиваются автоматически. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<label class="directory__field" for="council-branch">Отрасль оценки
<select class="directory__input" id="council-branch" name="branch">
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
</select>
</label>
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
@@ -55,7 +65,7 @@
</label>
<button class="button" type="submit">Применить</button>
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</form>
<div class="council__list-heading">
<h3>Сотрудники</h3>
@@ -66,9 +76,9 @@
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
<div class="council__list">
{% for candidate in candidates %}
<details class="council__candidate">
<details class="council__candidate" data-employee-id="{{ candidate.employee_id }}">
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}{% if candidate.minimum is not none %} из {{ candidate.minimum }}{% endif %}</small></span>
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}</small></span>
<span class="council__counts">
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
@@ -81,15 +91,17 @@
</summary>
<div class="council__candidate-body">
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}<li>{{ reason }}</li>{% endfor %}</ul>{% endif %}
<p>Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}{% if not reason.startswith("Подтверждено ") %}<li>{{ reason }}</li>{% endif %}{% endfor %}</ul>{% endif %}
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article">
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = candidate.employee_id %}
{% include "council_edit_button.html" %}
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year or 'Год не указан' }}</span>
@@ -101,11 +113,11 @@
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
</div>
<p class="council__hint">Журнал: {{ article.journals|join(" / ") or "не указан" }} · Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}</p>
<ul class="council__evidence" aria-label="Критерии и подтверждения">
<li>Зачёт в подтверждённое число: {{ 'да' if article.confirmed else 'требует проверки' }}{% if article.publication_date %}; точная дата: {{ article.publication_date.strftime('%d.%m.%Y') }}{% endif %}</li>
<li>{{ article.thematic_status }}</li>
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
{% for reason in article.confirmation_reasons %}<li>{{ reason }}</li>{% endfor %}
{% if specialty %}<li>{{ article.thematic_status }}</li>{% endif %}
{% for message in article.messages %}<li>{{ message }}</li>{% endfor %}
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for record in article.catalog_records %}
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
@@ -124,18 +136,21 @@
</div>
{% if matched > limit %}
<nav class="directory__pagination" aria-label="Страницы сотрудников">
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&amp;branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</nav>
{% endif %}
{% else %}
<div class="council__message">
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&amp;specialty={{ specialty|urlencode }}&amp;sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
{% endif %}
{% if not error %}{% include "council_edit_dialog.html" %}{% endif %}
</section>
{% endblock %}
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
<button class="button button--ghost council__edit" type="button" aria-label="Редактировать статью: {{ article.title }}" aria-haspopup="dialog" data-council-edit data-article='{{ {"employee_id": employee_id, "edit_key": article.edit_key, "revision": revision, "title": article.title, "journal": article.edit_journal, "quartile": article.edit_quartile, "year": article.year, "specialty_codes": article.edit_specialty_codes, "manual_specialties": article.manual_specialties}|tojson|forceescape }}'>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-12 12-6 1 1-6Z"/><path d="m14 5 5 5"/></svg>
</button>
+45
View File
@@ -0,0 +1,45 @@
<dialog class="columns-modal" data-council-dialog aria-labelledby="article-edit-title">
<div class="columns-modal__panel">
<div class="columns-modal__header">
<h3 class="columns-modal__title" id="article-edit-title">Редактирование статьи</h3>
<button class="button button--ghost" type="button" data-edit-close aria-label="Закрыть редактирование">✕</button>
</div>
{% if can_edit %}
<form class="council-editor" data-edit-form>
<label class="directory__field" for="article-edit-name">Название статьи
<textarea class="directory__input" id="article-edit-name" name="title" required maxlength="2000" rows="3"></textarea>
</label>
<label class="directory__field" for="article-edit-journal">Название журнала
<input class="directory__input" id="article-edit-journal" name="journal" required maxlength="500" list="council-journals">
</label>
<label class="directory__field" for="article-edit-quartile">Квартиль
<select class="directory__input" id="article-edit-quartile" name="quartile">
<option value="">Не указан</option>{% for q in ['Q1', 'Q2', 'Q3', 'Q4'] %}<option value="{{ q }}">{{ q }}</option>{% endfor %}
</select>
</label>
<details class="council-editor__specialties" data-edit-specialties>
<summary>Специальности журнала</summary>
<fieldset>
<legend>Специальности журнала</legend>
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_auto" data-specialty-auto> Использовать связь из справочников</label>
<div class="columns-modal__grid">{% for item in specialties %}
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" name="specialty_codes" value="{{ item.code }}"> {{ item.code }} — {{ item.name }}</label>
{% endfor %}</div>
<p class="council__hint">Ручная связь заменяет справочную для всех статей выбранного журнала, независимо от года. Без отмеченных специальностей связь исключается.</p>
</fieldset>
</details>
<p class="council__hint" data-edit-note></p>
<p class="council__hint">Ручная корректировка требует повторного подтверждения источниками для зачёта работы.</p>
<p class="council-editor__error" data-edit-error role="alert" hidden></p>
<div class="council-editor__actions">
<button class="button" type="submit">Сохранить</button>
<button class="button button--secondary" type="button" data-edit-close>Отмена</button>
</div>
</form>
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
{% else %}
<p>Для редактирования статей и квартилей войдите в админку.</p>
<a class="button" href="/admin/login">Войти в админку</a>
{% endif %}
</div>
</dialog>
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Статьи, журналы и квартили · MIEM Employees{% endblock %}
{% block content %}
<section class="council" aria-labelledby="publications-title">
<header class="council__hero"><div class="council__hero-copy">
<h2 class="council__title" id="publications-title">Статьи, журналы и квартили</h2>
<p class="council__intro">Публикации сотрудников из списка диссовета. Квартиль журнала относится к году публикации.</p>
</div></header>
<a class="admin__link" href="/admin/council">Вернуться к диссовету</a>
<form class="council__search" method="get" role="search">
<label class="directory__field" for="publications-employee">ФИО сотрудника<input class="directory__input" type="search" id="publications-employee" name="employee" value="{{ employee }}"></label>
<label class="directory__field" for="publications-article">Название статьи<input class="directory__input" type="search" id="publications-article" name="title" value="{{ title }}"></label>
<label class="directory__field" for="publications-journal">Название журнала<input class="directory__input" type="search" id="publications-journal" name="journal" value="{{ journal }}"></label>
<button class="button" type="submit">Найти</button>
{% if employee or title or journal %}<a class="admin__link" href="/admin/council/publications">Сбросить фильтры</a>{% endif %}
</form>
{% if error %}
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
{% else %}
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в админку</a>.</p>{% endif %}
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
{% for article in articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}
<p class="council__hint">{{ article.employee_name }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}</span></div>
<p class="council__hint">Журнал: {{ article.journals|join(' / ') or 'не указан' }} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}</p>
<p class="council__hint">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
{% if article.manual_review %}<p class="council__hint">Ручная корректировка · требует подтверждения</p>{% endif %}
</li>
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
</ol>
{% if matched > limit %}<nav class="directory__pagination" aria-label="Страницы статей">
{% set filters = 'employee=' ~ (employee|urlencode) ~ '&title=' ~ (title|urlencode) ~ '&journal=' ~ (journal|urlencode) %}
{% if offset > 0 %}<a class="admin__link" href="?{{ filters }}&amp;offset={{ offset - limit }}">Назад</a>{% endif %}
<span>{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
{% if offset + limit < matched %}<a class="admin__link" href="?{{ filters }}&amp;offset={{ offset + limit }}">Далее</a>{% endif %}
</nav>{% endif %}
{% include "council_edit_dialog.html" %}
{% endif %}
</section>
{% endblock %}
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.18.1"
FRONTEND_VERSION = "0.18.1"
BACKEND_VERSION = "0.18.1"
APP_VERSION = "1.0.1"
FRONTEND_VERSION = "1.0.1"
BACKEND_VERSION = "1.0.1"
+7 -5
View File
@@ -1,12 +1,12 @@
# Подтверждения публикационных критериев совета
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX настроены на **технические науки**: для доктора — 9 публикаций, для кандидата/PhD — 20. Пятилетний период рассчитывается относительно текущей даты Москвы. Дата ходатайства, монографии и связанные причины проверки не отображаются; подтверждение степени, вида, периода и основания работ по-прежнему требуется. Внутренний сервис сохраняет правила других отраслей для проверки исходной инструкции.
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX используют выбранную **отрасль оценки**, по умолчанию технические науки: для доктора порог зависит от отрасли, для кандидата/PhD — 20. Период — пять календарных лет, включая текущий год Москвы (2022–2026 в 2026 году); день и месяц не учитываются. Пороги и требования по отраслям показаны в шапке. Статья с известным видом, журналом и применимым к её году квартилем Q1/Q2 засчитывается автоматически без отдельной ручной записи, если нет конфликтов. Подтверждение степени и тематическое рассмотрение статьи остаются отдельными проверками.
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, данные ВШЭ и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
## Хранение и привязка
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице и в XLSX.
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. Компактный XLSX содержит только выбранные столбцы; подробные подтверждения и причины проверки в него не включаются. Экспортный «Всего» считает уникальное объединение Q1/Q2/К1 без самостоятельных К2/RSCI.
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
@@ -49,7 +49,7 @@
}
```
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точную дату можно опустить: единственный согласованный внутренний год пятилетнего периода подтверждает период; оба пограничных года требуют точной даты. Некорректная дата и расхождения источников не разрешаются порядком записей. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
Для подтверждений обязательны непустой `source` и дата `checked_on` в формате `ГГГГ-ММ-ДД`. `kind` — `article`, `monograph` либо иной явно проверенный вид. `BOOK` или глава сами по себе монографию не подтверждают. Точная дата для периода не требуется: любой согласованный год из пяти календарных лет, включая текущий, подтверждает период. Разные дни одного года не создают конфликт; разные годы источников требуют проверки. Некорректная необязательная точная дата не отменяет известный год. Ссылка `source_url` необязательна, разрешены только безопасные HTTP(S)-адреса без встроенных credentials.
`bases[].basis` — `К1`, `К2`, `RSCI`, `Q1` или `Q2`. `applicable_year` должен совпадать с подтверждаемым годом публикации. Для Q1/Q2 обязательны система, метрика, категория и год метрики. Допустимые системы: Web of Science, Scopus, PubMed, MathSciNet, zbMATH, Chemical Abstracts, Springer, GeoRef. Заполнение названия системы не устанавливает наличие квартильной методики в ней: администратор должен проверить применимость конкретной метрики и основания по инструкции. Простая индексация, РИНЦ, `hse_list` и числовой CiteScore не подтверждают основание. Если год метрики отличается от года публикации, `source` должен обосновывать применимость: сервис не распространяет снимок автоматически.
@@ -57,9 +57,9 @@
## Нормативные уточнения
До получения источника спорные границы, иные виды работ и период зачёта монографии не считаются подтверждёнными. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
Период сайта задан как пять календарных лет по продуктовому правилу. Иные виды работ и период зачёта монографии требуют подтверждения источником. После проверки исходного документа можно явно задать следующие записи в `policy`, каждая с `source` и `checked_on`:
- `boundary_dates`: `value: "inclusive"` разрешает обе точные граничные даты. По умолчанию подтверждаются только даты строго между ними. Если источник устанавливает разные режимы начала и конца, этот формат не подходит: требуется отдельная правка, границы пока оставляют на проверке.
- `boundary_dates`: устаревшее поле; календарный период не использует точные граничные даты, запись не влияет на подсчёт.
- `monograph_period`: `value: "five_years"` либо `"all_years"` определяет период отдельного зачёта подтверждённых монографий. Без него монографический минимум не подтверждается.
- `publication_types`: `values: ["monograph", "chapter", ...]` разрешает явно проверенные дополнительные виды в общий публикационный минимум. Статьи учитываются при подтверждённом виде и основании. Не считайте исключение других видов нормативным запретом: допустимость пока не установлена. Монография отдельно и в общем числе может учитываться одновременно только после такого уточнения.
@@ -76,3 +76,5 @@
Проверяются уникальность ID и пар, источники, паспорта, пункты, SHA-256 локальных PDF и безопасность относительных путей. Повреждённый снимок не даёт оснований и показывается как требующий проверки; второй исправный снимок сохраняется. Связь К1 со справочником специальностей проверяется по SHA-256. К1 и К2 остаются предварительными снимками категории; ручное основание должно иметь свой применимый год и источник. Подбор специальности по К1 остаётся вспомогательным и может скрывать сотрудников с иными основаниями; он не является условием пунктов 5–6.
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
+8
View File
@@ -0,0 +1,8 @@
-- Additive storage: original employee publications remain unchanged.
CREATE TABLE IF NOT EXISTS council_corrections (
id INTEGER PRIMARY KEY,
revision INTEGER NOT NULL,
data JSONB NOT NULL,
updated_by VARCHAR(255) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.18.1"
version = "1.0.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -163,7 +163,7 @@ def main():
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
assert "Работ по выбранной специальности" not in headers
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
page.get_by_label("Специальность ВАК").select_option("")
page.get_by_label("Поиск по ФИО").fill("Сотрудник 25")
+153
View File
@@ -0,0 +1,153 @@
"""Offline editor UX check: python tests/browser_council_editor.py."""
import os
from pathlib import Path
import sys
from urllib.parse import urlsplit
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi.testclient import TestClient
from playwright.sync_api import expect, sync_playwright
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.config import Settings, get_settings
from app.db import Base, get_db
from app.main import app
from app.models import Employee, EmployeePublication
from app.security import SESSION_COOKIE, sign_session
from app.services import council
def main():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
with factory() as db:
person = Employee(profile_key='editor', full_name='Иванов Иван Иванович',
canonical_url='https://www.hse.ru/staff/test',
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
db.add(person)
db.flush()
db.add(EmployeePublication(employee_id=person.id, title='Исследование передачи данных',
year=2024, publication_type='ARTICLE', raw_data={'journal': 'Журнал связи'}, source_hash='editor'))
db.commit()
def database():
with factory() as db:
yield db
settings = Settings(_env_file=None, session_secret='browser-editor-test', admin_username='admin', admin_password='test')
original = council._articles_by_author
council._articles_by_author = lambda: {}
app.dependency_overrides[get_db] = database
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
output = Path('.impeccable/review/council-editor')
output.mkdir(parents=True, exist_ok=True)
def serve(route):
request = route.request
url = urlsplit(request.url)
response = client.request(request.method, url.path + ('?' + url.query if url.query else ''),
content=request.post_data_buffer,
headers={k: v for k, v in request.headers.items() if k in {'content-type', 'x-council-edit'}})
route.fulfill(status=response.status_code, body=response.content,
headers={k: v for k, v in response.headers.items() if k in {'content-type', 'content-disposition'}})
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
context = browser.new_context(viewport={'width': 1440, 'height': 1000})
context.route('**/*', serve)
page = context.new_page()
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.goto('http://miem.test/admin/council')
page.locator('.council__candidate-summary').click()
article = page.locator('[data-council-article]')
pencil = article.locator('[data-council-edit]')
expect(pencil).to_be_hidden()
article.locator('strong').click()
expect(pencil).to_be_visible()
pencil.click()
expect(page.get_by_role('dialog')).to_be_visible()
expect(page.get_by_role('link', name='Войти в админку', exact=True)).to_be_visible()
page.keyboard.press('Escape')
expect(pencil).to_be_focused()
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
page.reload()
page.locator('.council__candidate-summary').click()
article.focus()
page.keyboard.press('Enter')
expect(pencil).to_be_focused()
page.keyboard.press('Enter')
expect(page.get_by_label('Название статьи', exact=True)).to_have_value('Исследование передачи данных')
page.get_by_label('Название статьи', exact=True).fill('Уточнённое исследование')
page.get_by_label('Название журнала', exact=True).fill('Новый журнал')
page.get_by_label('Квартиль').select_option('Q1')
page.screenshot(path=str(output / 'edit-desktop.png'))
page.set_viewport_size({'width': 320, 'height': 844})
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
assert page.get_by_role('dialog').evaluate('e => e.scrollWidth <= e.clientWidth')
page.screenshot(path=str(output / 'edit-mobile.png'))
page.get_by_role('button', name='Сохранить', exact=True).click()
expect(article.locator('strong')).to_have_text('Уточнённое исследование')
expect(page.locator('.council__candidate')).to_have_attribute('open', '')
with context.expect_page() as opened:
page.get_by_role('link', name='Статьи, журналы и квартили').click()
window = opened.value
window.wait_for_load_state()
window.on('pageerror', lambda error: errors.append(str(error)))
window.get_by_label('ФИО сотрудника').fill('Иванов')
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('уточнённое')
window.get_by_role('searchbox', name='Название журнала', exact=True).fill('новый')
window.get_by_role('button', name='Найти', exact=True).click()
expect(window.locator('[data-council-article]')).to_have_count(1)
for width in (1440, 390, 320):
window.set_viewport_size({'width': width, 'height': 900})
assert window.evaluate('document.documentElement.scrollWidth <= innerWidth')
if width in (1440, 320):
window.screenshot(path=str(output / f'list-{width}.png'), full_page=True)
window.locator('[data-council-article] strong').click()
window.locator('[data-council-edit]').click()
window.get_by_label('Квартиль').select_option('Q2')
window.locator('[data-edit-specialties] summary').click()
window.get_by_label('Использовать связь из справочников').uncheck()
window.locator('[name="specialty_codes"][value="1.2.2"]').check()
window.get_by_role('button', name='Сохранить', exact=True).click()
expect(window.get_by_text('Журнал: Новый журнал · Квартиль: Q2', exact=True)).to_be_visible()
expect(window.get_by_text('Специальности журнала: 1.2.2 · ручная связь', exact=True)).to_be_visible()
# The original tab now carries a stale revision.
article.locator('strong').click()
pencil.click()
page.get_by_role('button', name='Сохранить', exact=True).click()
expect(page.get_by_role('alert')).to_contain_text('Данные уже изменены')
page.keyboard.press('Escape')
page.reload()
page.locator('.council__candidate-summary').click()
expect(page.get_by_text('Журнал: Новый журнал · Квартиль: Q2', exact=True)).to_be_visible()
page.get_by_label('Отрасль оценки').select_option('физико-математические')
page.get_by_role('button', name='Применить', exact=True).click()
expect(page.get_by_label('Отрасль оценки')).to_have_value('физико-математические')
page.get_by_label('Специальность ВАК').select_option('1.2.2')
page.get_by_role('button', name='Применить', exact=True).click()
expect(page.locator('.council__candidate')).to_have_count(1)
page.locator('.council__candidate-summary').click()
expect(page.get_by_text('Публикаций по выбранной специальности: 1', exact=True)).to_be_visible()
window.get_by_role('searchbox', name='Название статьи', exact=True).fill('Нет совпадений')
window.get_by_role('button', name='Найти', exact=True).click()
expect(window.get_by_text('Статьи не найдены.', exact=False)).to_be_visible()
assert not errors, errors
browser.close()
finally:
council._articles_by_author = original
app.dependency_overrides.clear()
client.close()
engine.dispose()
print(f'Council editor browser checks passed; screenshots: {output}')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.18.1"
assert response.json()["backend_version"] == "1.0.1"
def test_api_employees_and_stats_require_admin_session():
+58 -24
View File
@@ -83,7 +83,7 @@ def test_council_union_counts_provenance_and_types(db_session, export):
assert imported["origins"] == ["Данные ВШЭ"]
assert imported["qualifies"] and imported["q2"]
assert "Тип не указан" in imported["type_label"]
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
assert council.period(date(2024, 2, 29))[0] == date(2020, 1, 1)
def test_all_degree_levels_and_missing_publications_remain_visible(db_session, export):
@@ -219,7 +219,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.18.1 · Frontend 0.18.1" in response.text
assert "Backend 1.0.1 · Frontend 1.0.1" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
@@ -262,21 +262,23 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
book = load_workbook(BytesIO(response.content))
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]][:13] == [
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "ЛК сотрудника ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
assert [cell.value for cell in people[1]] == [
"№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего", "Всего из ЛК",
"Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 23
assert works["L1"].value == "ЛК сотрудника ВШЭ"
assert [cell.value for cell in works[1]] == [
"№", "ФИО", "Название", "Год", "Тип материала", "Критерии", "Q1", "Q2", "К1", "К2",
"RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"
]
assert people["L2"].value == "https://www.hse.ru/staff/test"
assert people.max_column == 12 and works.max_column == 14
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 [people.cell(2, n).value for n in range(4, 11)] == [1, 1, 0, 0, 1, 1, 1]
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
assert "Данные ВШЭ" 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 "Scopus Q1" in works["F2"].value and "Web of Science Q2" in works["F2"].value
assert works["M2"].value == "https://doi.org/10.1234/shared"
assert works["N2"].value == "https://publications.hse.ru/view/27"
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)
@@ -388,7 +390,7 @@ def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_sess
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]
assert [book["Сотрудники"].cell(2, column).value for column in (6, 8)] == [1, 1]
def test_k1_rename_note_is_preserved_separately():
@@ -450,10 +452,10 @@ def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
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 headers[3:8] == ["Q1", "Q2", "К1", "К2", "Всего"]
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]
assert [book["Сотрудники"].cell(anna_row, column).value for column in range(4, 9)] == [1, 1, 1, 0, 1]
def test_specialty_filters_use_eligible_source_journal_not_merged_evidence(db_session, export):
@@ -510,7 +512,7 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
assert book["Сотрудники"].max_row == 29
params = dict(book["Параметры"].values)
assert params["Специальность ВАК"].startswith("1.2.2.")
assert "По специальности 1.2.2" in book["Публикации"]["G2"].value
assert "По специальности 1.2.2" in book["Публикации"]["F2"].value
empty = client.get("/admin/council?specialty=2.2.14")
assert "Сотрудники не найдены" in empty.text
finally:
@@ -561,18 +563,17 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
assert 'Математическое моделирование' in candidate['Специальности ВАК']
assert '2.3.5.' in candidate['Специальности ВАК']
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
assert 'Работ по выбранной специальности' not in staff[0]
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
assert '1.2.2.' in records['Matching']['Специальности ВАК']
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
assert '1.2.2.' in records['Matching']['Специальность ВАК']
assert 'По выбранной специальности' not in works[0]
for title in ('Book', 'Unknown journal', 'Old journal'):
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
assert records[title]['Специальность ВАК'] == 'Не подтверждены'
if not specialty:
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
assert book['Сотрудники'].auto_filter.ref.endswith(f'W{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
assert book['Сотрудники'].auto_filter.ref.endswith(f'L{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'N{len(works)}')
def test_employee_without_degree_publications_are_not_parsed(db_session, export, monkeypatch):
@@ -583,3 +584,36 @@ def test_employee_without_degree_publications_are_not_parsed(db_session, export,
pytest.fail('Ineligible employee publications must not be parsed')
monkeypatch.setattr(council, '_database_article', unexpected)
assert council.council_candidates(db_session)['total'] == 0
def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, export):
export([row(**{'Название журнала': 'Бизнес-информатика', 'Квартиль wos': 'Q2'})])
person = employee(db_session)
person.current_data = {'sections': [{'title': 'Учёные степени', 'items': [
'Кандидат технических наук', 'Доктор технических наук']} ]}
publication(db_session, person, title='Shared work', doi='https://doi.org/10.1234/shared', kind='ARTICLE')
publication(db_session, person, title='Other publication', kind=None)
db_session.commit()
result = council.council_candidates(db_session, date(2026, 10, 8))
candidate = result['candidates'][0]
other = next(a for a in candidate['articles'] if a['title'] == 'Other publication')
# Isolate the export rule: other qualifying council bases do not add to its total.
other.update(k2=True, rsci=True, qualifies=True)
candidate.update(k2_count=1, rsci_count=1, total_count=2)
shared = next(a for a in candidate['articles'] if a['title'] == 'Shared work')
shared['criteria'].extend(['В пределах пяти лет', 'В пределах пяти лет по году; точную дату нужно проверить'])
from app.services.council_export import council_xlsx
book = load_workbook(BytesIO(council_xlsx(result)))
staff = dict(zip([c.value for c in book['Сотрудники'][1]], [c.value for c in book['Сотрудники'][2]]))
assert staff['Научная степень'] == 'Доктор технических наук'
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == staff['Всего'] == 1
assert staff['Всего из ЛК'] == staff['Общее количество всех публикаций без дублей'] == 2
rows = {values[2]: dict(zip(next(book['Публикации'].values), values)) for values in list(book['Публикации'].values)[1:]}
assert rows['Other publication']['Тип материала'] == 'не указан'
assert rows['Other publication']['К2'] == rows['Other publication']['RSCI'] == 'Да'
assert rows['Shared work']['Критерии'].count('В пределах пяти лет') == 1
assert 'точную дату' not in rows['Shared work']['Критерии']
params = dict(book['Параметры'].values)
assert 'Q1 ИЛИ Q2 ИЛИ К1' in params['Всего']
assert 'источники указаны на листе' not in ' '.join(str(v) for v in params.values())
assert not any('Работ / По выбранной специальности' == key for key in params)
+120 -17
View File
@@ -110,10 +110,10 @@ def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branc
assert value["assessment_status"].startswith("Соответствует")
@pytest.mark.parametrize("year,exact,expected", [(2021, None, False), (2026, None, False),
(2022, None, True), (2021, "2021-10-09", True), (2021, "2021-10-08", False),
(2026, "2026-10-07", True), (2026, "2026-10-08", False), (2026, "2026-10-09", False),
(2024, "2025-01-01", False), (2024, "invalid", False)])
@pytest.mark.parametrize("year,exact,expected", [(2021, None, False), (2026, None, True),
(2022, None, True), (2021, "2021-10-09", False), (2021, "2021-10-08", False),
(2026, "2026-10-07", True), (2026, "2026-10-08", True), (2026, "2026-10-09", True),
(2024, "2025-01-01", False), (2024, "invalid", True)])
def test_boundary_dates_and_conflicting_year(db_session, proofs, year, exact, expected):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, year=year)
@@ -160,7 +160,7 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
app.dependency_overrides[get_db] = database
try:
client = TestClient(app)
for query in ["specialty=oops"]:
for query in ["specialty=oops", "branch=oops"]:
assert client.get("/admin/council?" + query).status_code == 422
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
query = "?sort=confirmed_count"
@@ -169,15 +169,14 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx" + query).content))
headers = [c.value for c in book["Сотрудники"][1]]
row = dict(zip(headers, [c.value for c in book["Сотрудники"][2]]))
assert row["Подтверждено"] == 9 and row["Минимум"] == 9
assert row["Итог"] in page.text
assert row["Отрасль оценки"] == "технические"
assert not any("ходатайств" in str(c.value).lower() or "монограф" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
assert row["Всего"] == 9 and row["Научная степень"] == degree
assert dict(book["Параметры"].values)["Отрасль оценки"] == "технические"
assert not any("ходатайств" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
assert "council-date" not in page.text and "Монографии" not in page.text
assert "Дата ходатайства" not in page.text
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
legacy_book = load_workbook(BytesIO(legacy.content))
assert legacy_book["Сотрудники"]["Q2"].value == "технические"
assert dict(legacy_book["Параметры"].values)["Отрасль оценки"] == "экономические"
finally:
app.dependency_overrides.clear()
@@ -198,7 +197,7 @@ def test_no_degree_or_only_title_does_not_form_conclusion(db_session, proofs):
assert council.council_candidates(db_session)["total"] == 0
def test_exact_dates_conflict_and_bases_intersection_count_once(db_session, proofs):
def test_days_within_same_year_do_not_conflict_and_bases_count_once(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee)
record["bases"].append({**SOURCE, "basis": "RSCI", "applicable_year": 2024})
@@ -206,7 +205,7 @@ def test_exact_dates_conflict_and_bases_intersection_count_once(db_session, proo
assert result(db_session)["confirmed_count"] == 1
proofs(publications=[{**record, "publication_date": "2024-01-01", "date_evidence": SOURCE},
{**record, "publication_date": "2024-02-01", "date_evidence": SOURCE}])
assert result(db_session)["confirmed_count"] == 0
assert result(db_session)["confirmed_count"] == 1
def test_exact_date_can_confirm_missing_year_without_losing_preliminary_count(db_session, proofs):
@@ -285,13 +284,13 @@ def test_technical_assessment_uses_current_period_without_petition(db_session, p
assert value["assessment_reasons"] == []
def test_boundary_dates_require_sourced_policy(db_session, proofs):
def test_calendar_years_ignore_legacy_boundary_policy(db_session, proofs):
employee = person(db_session, ["Доктор технических наук"])
record = work(db_session, employee, year=2021, publication_date="2021-10-08", date_evidence=SOURCE)
proofs(publications=[record], policy={"boundary_dates": {"value": "inclusive"}})
assert result(db_session)["confirmed_count"] == 0
proofs(publications=[record], policy={"boundary_dates": {**SOURCE, "value": "inclusive"}})
assert result(db_session)["confirmed_count"] == 1
assert result(db_session)["confirmed_count"] == 0
def test_invalid_evidence_schema_is_rejected(monkeypatch, tmp_path):
@@ -323,8 +322,11 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
book = load_workbook(BytesIO(council_xlsx(full_result)))
headers = [cell.value for cell in book["Сотрудники"][1]]
exported = dict(zip(headers, [cell.value for cell in book["Сотрудники"][2]]))
assert exported["ВСЕГО"] == int(admitted)
assert exported[{"К1": "К1", "К2": "К2 предварительно", "RSCI": "RSCI предварительно", "Q1": "Q1", "Q2": "Q2"}[basis]] == int(admitted)
assert exported["Всего"] == (int(admitted) if basis in {"Q1", "Q2", "К1"} else 0)
publication = dict(zip([cell.value for cell in book["Публикации"][1]], [cell.value for cell in book["Публикации"][2]]))
assert publication[basis] == ("Да" if admitted else "Нет")
if basis != "RSCI":
assert exported[basis] == int(admitted)
def test_admitted_type_needs_sourced_policy_and_consistent_material(db_session, proofs):
@@ -361,7 +363,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
article = candidate["articles"][0]
flag = "q2" if broken_index == 0 else "q1"
assert candidate[f"{flag}_count"] == candidate["total_count"] == 1
assert candidate["confirmed_count"] == 0
assert candidate["confirmed_count"] == 1
assert full_result["catalog_issues"] and not article["catalog_issues"]
assert {r["catalog"] for r in article["catalog_records"]} == {valid_path.parent.name}
book = load_workbook(BytesIO(council_xlsx(full_result)))
@@ -376,3 +378,104 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
app.dependency_overrides.clear()
finally:
catalogs.catalog.cache_clear()
@pytest.mark.parametrize('year,kind,expected', [(2021, 'ARTICLE', False), (2022, 'ARTICLE', True),
(2026, 'ARTICLE', True), (2027, 'ARTICLE', False), (2024, None, False), (2024, 'BOOK', False)])
def test_automatic_source_quartile_calendar_years(db_session, proofs, year, kind, expected):
employee = person(db_session, ['Доктор технических наук'])
db_session.add(EmployeePublication(employee_id=employee.id, title='Automatic', year=year,
publication_type=kind, source_hash='automatic', raw_data={'journal': 'Журнал',
'csv_enrichment': {'Название журнала': 'Журнал', 'Квартиль scopus': 'Q2', 'Год': str(year)}}))
candidate = result(db_session)
assert candidate['confirmed_count'] == int(expected)
assert not candidate['degree_confirmed']
assert candidate['assessment_status'] == 'Требует проверки'
article = candidate['articles'][0]
assert len([m for m in article['messages'] if 'период' in m.lower()]) == 1
if expected:
assert article['confirmed_bases'][0]['basis'] == 'Q2'
assert not any('Вид материала не подтверждён' in m for m in article['messages'])
book = load_workbook(BytesIO(council_xlsx(council.council_candidates(db_session, today=date(2026, 10, 8)))))
headers = [cell.value for cell in book['Сотрудники'][1]]
exported = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
assert exported['Q2'] == candidate['q2_count']
assert exported['Всего'] == candidate['q2_count']
def test_automatic_quartile_identity_conflict_and_messages(db_session, proofs):
employee = person(db_session, ['Доктор технических наук'])
db_session.add(EmployeePublication(employee_id=employee.id, title='Conflict', year=2024,
publication_type='ARTICLE', source_hash='conflict', doi_url='https://doi.org/10.1234/conflict',
raw_data={'journal': 'Текущий журнал', 'csv_enrichment': {'Название журнала': 'Текущий журнал', 'Квартиль scopus': 'Q2'}},
quartiles={'schema_version': 1, 'entries': [{'system': 'Scopus', 'quartile': 'Q1',
'doi': '10.1234/conflict', 'journal': 'Другой журнал', 'publication_year': 2024}]}))
article = result(db_session)['articles'][0]
assert not article['confirmed']
assert len(article['messages']) == len(set(article['messages']))
issue = 'Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка'
assert article['messages'].count(issue) == 1
app.dependency_overrides[get_db] = lambda: db_session
try:
response = TestClient(app).get('/admin/council')
from bs4 import BeautifulSoup
card = BeautifulSoup(response.text, 'html.parser').select_one('li.council__article')
assert card.get_text().count(issue) == 1
assert 'Специальность для рассмотрения статьи не выбрана' not in card.get_text()
assert 'Пункт:' not in response.text and 'Минимум:' not in response.text
finally:
app.dependency_overrides.clear()
def test_branch_selection_threshold_and_export(db_session, proofs):
employee = person(db_session, ['Доктор технических наук'])
proofs([{**SOURCE, 'degree': 'Доктор технических наук'}], [work(db_session, employee, n) for n in range(10)])
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
for branch, expected in [('технические', 'Соответствует'), ('физико-математические', 'Требует проверки')]:
response = client.get('/admin/council', params={'branch': branch})
assert response.status_code == 200
from bs4 import BeautifulSoup
parsed = BeautifulSoup(response.text, 'html.parser')
assert parsed.select_one('#council-branch option[selected]').get('value') == branch
assert expected in parsed.select_one('.council__candidate').get_text()
book = load_workbook(BytesIO(client.get('/admin/council/export.xlsx', params={'branch': branch}).content))
headers = [cell.value for cell in book['Сотрудники'][1]]
row = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
assert row['Всего'] == 10
parameters = dict(book['Параметры'].values)
assert parameters['Отрасль оценки'] == branch
threshold = next(value for key, value in parameters.items() if key.startswith('Доктор · ') and branch in key.split(' · ')[1].split(', '))
assert threshold.startswith('9 ' if branch == 'технические' else '11 ')
assert parameters['Начало периода'] == datetime(2022, 1, 1)
assert parameters['Конец периода'] == datetime(2026, 12, 31)
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize('category,metric,expected', [
('Applied Mathematics', 'CiteScore', True),
('Analysis', 'CiteScore', False),
('Analysis', 'SJR', True),
])
def test_automatic_quartile_categories_and_metrics_preserve_conflicts(db_session, proofs, category, metric, expected):
employee = person(db_session, ['Доктор технических наук'])
first = {'system': 'Scopus', 'quartile': 'Q1', 'category': 'Analysis', 'metric': 'CiteScore',
'metric_year': 2024, 'publication_year': 2024, 'doi': '10.1234/categories',
'journal': 'Journal of Mathematics', 'source': 'Journal metrics 2024',
'source_url': 'https://example.org/metrics', 'checked_on': '2026-10-08'}
second = {**first, 'quartile': 'Q2', 'category': category, 'metric': metric}
db_session.add(EmployeePublication(employee_id=employee.id, title='Categorized article', year=2024,
publication_type='ARTICLE', source_hash='categories', doi_url='https://doi.org/10.1234/categories',
raw_data={'journal': first['journal']}, quartiles={'schema_version': 1, 'entries': [first, second]}))
candidate = result(db_session)
assert candidate['q1_count'] == candidate['q2_count'] == candidate['total_count'] == 1
assert candidate['confirmed_count'] == int(expected)
article = candidate['articles'][0]
assert ('Подтверждённые основания источников расходятся' in article['confirmation_reasons']) == (not expected)
assert [(basis.get('category'), basis.get('metric'), basis.get('metric_year'), basis['source'], basis['source_url'])
for basis in article['automatic_quartile_bases']] == [
('Analysis', 'CiteScore', 2024, first['source'], first['source_url']),
(category, metric, 2024, second['source'], second['source_url'])]
+193
View File
@@ -0,0 +1,193 @@
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.config import Settings, get_settings
from app.db import get_db
from app.main import app
from app.models import CouncilCorrections, Employee, EmployeePublication
from app.security import SESSION_COOKIE, sign_session
from app.services import council
@pytest.fixture()
def editor(db_session, monkeypatch):
monkeypatch.setattr(council, '_articles_by_author', lambda: {})
for number in range(2):
person = Employee(profile_key=f'editor:{number}', full_name=f'Иванов {number}',
canonical_url='https://www.hse.ru/staff/test',
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
db_session.add(person)
db_session.flush()
db_session.add(EmployeePublication(employee_id=person.id, title=f'Статья {number}', year=2024,
publication_type='ARTICLE', source_hash=f'editor:{number}', raw_data={'journal': 'Общий журнал'}))
db_session.commit()
settings = Settings(_env_file=None, admin_username='admin', admin_password='test', session_secret='editor-test')
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
candidate = council.council_candidates(db_session)['candidates'][0]
article = candidate['articles'][0]
payload = {'employee_id': candidate['employee_id'], 'edit_key': article['edit_key'], 'revision': 0,
'title': 'Исправленная статья', 'journal': 'Общий журнал', 'quartile': 'Q1'}
try:
yield client, payload
finally:
client.close()
app.dependency_overrides.clear()
def save(client, payload):
return client.post('/admin/council/publications', json=payload, headers={'X-Council-Edit': '1'})
def test_editor_shared_quartile_persistence_filter_and_export(editor, db_session):
client, payload = editor
assert save(client, payload).status_code == 200
db_session.expire_all()
result = council.council_candidates(db_session, today=date(2026, 10, 9))
assert result['revision'] == 1
assert [c['q1_count'] for c in result['candidates']] == [1, 1]
articles = [a for c in result['candidates'] for a in c['articles']]
assert all(a['manual_review'] and not a['confirmed'] for a in articles)
assert next(a for a in articles if a['edit_key'] == payload['edit_key'] and a['title'] == payload['title'])
assert db_session.scalars(select(EmployeePublication.title)).all() == ['Статья 0', 'Статья 1']
for filters in ({'title': 'исправленная'}, {'employee': 'иванов 0'}, {'journal': 'общий ЖУРНАЛ'}):
response = client.get('/admin/council/publications', params=filters)
assert response.status_code == 200
assert payload['title'] in response.text
assert 'Статьи не найдены' in client.get('/admin/council/publications?title=absent').text
main = client.get('/admin/council')
assert 'data-council-edit' in main.text and payload['title'] in main.text
from io import BytesIO
from openpyxl import load_workbook
workbook = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
assert any(payload['title'] in str(row) for row in workbook['Публикации'].values)
# A stale window must not overwrite another save.
assert save(client, {**payload, 'title': 'Устаревшая правка'}).status_code == 409
assert db_session.get(CouncilCorrections, 1).revision == 1
def test_editor_permissions_csrf_validation_and_missing_article(editor, db_session):
client, payload = editor
assert client.post('/admin/council/publications', json=payload).status_code == 403
for values in ({'title': ' '}, {'journal': ' '}, {'journal': '!!!'}, {'quartile': 'Q5'}, {'quartile': '<script>'}, {'revision': -1}):
assert save(client, {**payload, **values}).status_code == 422
assert save(client, {**payload, 'edit_key': '0' * 64}).status_code == 404
assert db_session.get(CouncilCorrections, 1) is None
client.cookies.clear()
assert save(client, payload).status_code == 401
response = client.get('/admin/council/publications')
assert response.status_code == 200 and 'Для редактирования' in response.text
def test_editor_journal_reassignment_year_scope_and_unknown_year(editor, db_session):
client, payload = editor
old = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id != payload['employee_id']))
old.year = 2023
db_session.commit()
assert save(client, {**payload, 'journal': 'Новый журнал', 'quartile': 'Q2'}).status_code == 200
db_session.expire_all()
result = council.council_candidates(db_session)
corrected = next(c for c in result['candidates'] if c['employee_id'] == payload['employee_id'])
assert corrected['q2_count'] == 1
assert corrected['articles'][0]['journals'] == ['Новый журнал']
untouched = next(c for c in result['candidates'] if c['employee_id'] != payload['employee_id'])
assert untouched['q2_count'] == 0
assert untouched['articles'][0]['journals'] == ['Общий журнал']
old.year = None
db_session.commit()
article = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == old.employee_id)['articles'][0]
unknown = {**payload, 'employee_id': old.employee_id, 'edit_key': article['edit_key'], 'revision': 1}
assert save(client, unknown).status_code == 422
assert save(client, {**unknown, 'quartile': ''}).status_code == 200
def test_editor_csv_only_and_escaping(editor, db_session, monkeypatch):
client, payload = editor
monkeypatch.setattr(council, '_articles_by_author', lambda: {'иванов 0': [{
'title': 'Выгрузка', 'year': 2024, 'doi': '10.1234/csv', 'types': [], 'journals': [],
'quartiles': [], 'origins': ['Данные ВШЭ'], 'rows': [2], 'in_csv': True, 'in_db': False, 'url': None}]})
article = next(a for c in council.council_candidates(db_session)['candidates']
for a in c['articles'] if a['doi'] == '10.1234/csv')
payload.update(edit_key=article['edit_key'], title='<script>alert(1)</script>', journal='Журнал', quartile='Q4')
assert save(client, payload).status_code == 200
page = client.get('/admin/council/publications')
assert '<script>alert(1)</script>' not in page.text
assert '&lt;script&gt;' in page.text
def test_editor_reassignment_clears_old_journal_evidence(editor, db_session):
client, payload = editor
source = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id == payload['employee_id']))
source.raw_data = {'journal': 'Бизнес-информатика', 'issn': '1998-0663'}
db_session.commit()
before = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
assert before['k1_count'] == 1
assert save(client, {**payload, 'journal': 'Неизвестный журнал', 'quartile': 'Q4'}).status_code == 200
after = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
assert (after['k1_count'], after['q1_count'], after['q2_count']) == (0, 0, 0)
assert after['articles'][0]['catalog_records'] == []
assert not after['articles'][0]['specialty_codes']
assert source.raw_data['issn'] == '1998-0663'
def test_editor_pagination_preserves_filters(editor, db_session):
client, payload = editor
for number in range(55):
db_session.add(EmployeePublication(employee_id=payload['employee_id'], title=f'Пагинация {number:02}',
year=2024, source_hash=f'page:{number}', raw_data={'journal': 'Тестовый журнал'}))
db_session.commit()
response = client.get('/admin/council/publications?employee=Иванов&title=Пагинация&journal=Тестовый&offset=999')
assert response.status_code == 200
assert 'Показаны 51–55' in response.text
assert response.text.count('data-council-article') == 5
assert 'offset=0' in response.text
assert 'name="employee" value="Иванов"' in response.text
def test_editor_database_failure_preserves_data(editor, db_session, monkeypatch):
from sqlalchemy.exc import OperationalError
client, payload = editor
def unavailable():
raise OperationalError('UPDATE', {}, Exception('private database details'))
monkeypatch.setattr(db_session, 'commit', unavailable)
response = save(client, payload)
assert response.status_code == 503
assert 'private database details' not in response.text
assert db_session.get(CouncilCorrections, 1) is None
def test_manual_specialty_shared_filter_remove_and_reset(editor, db_session):
client, payload = editor
assert save(client, {**payload, 'specialty_codes': ['<script>']}).status_code == 422
assert save(client, {**payload, 'specialty_codes': ['1.2.2', '1.2.2']}).status_code == 200
result = council.council_candidates(db_session, specialty='1.2.2')
assert result['matched'] == 2
assert all(c['specialty_count'] == 1 and c['articles'][0]['manual_specialties'] for c in result['candidates'])
assert result['candidates'][0]['articles'][0]['edit_specialty_codes'] == ['1.2.2']
# Empty selection removes the journal's thematic association.
assert save(client, {**payload, 'revision': 1, 'specialty_codes': []}).status_code == 200
assert council.council_candidates(db_session, specialty='1.2.2')['matched'] == 0
# The automatic option restores the source catalog association.
assert save(client, {**payload, 'revision': 2, 'specialty_codes': None}).status_code == 200
assert 'общий журнал' not in db_session.get(CouncilCorrections, 1).data['specialties']
def test_specialty_only_edit_preserves_automatic_confirmation(editor, db_session):
client, payload = editor
source = db_session.scalar(select(EmployeePublication).where(EmployeePublication.employee_id == payload['employee_id']))
source.raw_data = {'journal': 'Общий журнал', 'csv_enrichment': {'Название журнала': 'Общий журнал', 'Квартиль scopus': 'Q1'}}
db_session.commit()
candidate = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
assert candidate['confirmed_count'] == 1
payload.update(title=source.title, specialty_codes=['1.2.2'])
assert save(client, payload).status_code == 200
updated = next(c for c in council.council_candidates(db_session)['candidates'] if c['employee_id'] == payload['employee_id'])
assert updated['confirmed_count'] == 1
assert updated['articles'][0]['manual_specialties']
assert not updated['articles'][0]['manual_review']
assert db_session.get(CouncilCorrections, 1).data['journals'] == {}