Merge pull request 'Редактор публикаций диссовета, настройка оценки и компактный XLSX' (#67) from feature/council-publication-editor into main
Reviewed-on: #67 Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #67.
This commit is contained in:
commit
2554cbf39a
25 files changed
+1107
-179
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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 }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
@@ -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 }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not error %}{% include "council_edit_dialog.html" %}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
<button class="button button--ghost council__edit" type="button" aria-label="Редактировать статью: {{ article.title }}" aria-haspopup="dialog" data-council-edit data-article='{{ {"employee_id": employee_id, "edit_key": article.edit_key, "revision": revision, "title": article.title, "journal": article.edit_journal, "quartile": article.edit_quartile, "year": article.year, "specialty_codes": article.edit_specialty_codes, "manual_specialties": article.manual_specialties}|tojson|forceescape }}'>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 5 5-12 12-6 1 1-6Z"/><path d="m14 5 5 5"/></svg>
|
||||
</button>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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 }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span>{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?{{ filters }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
{% include "council_edit_dialog.html" %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/council.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+3
-3
@@ -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"
|
||||
@@ -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, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.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)
|
||||
@@ -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'])]
|
||||
@@ -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 '<script>' 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'] == {}
|
||||
Reference in new issue
Block a user