diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e97eba3..4cda449 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index c40a8a7..ffcf194 100644 --- a/README.md +++ b/README.md @@ -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, страницы и строки исходного перечня, историю diff --git a/app/admin.py b/app/admin.py index 66ce9df..1b9857c 100644 --- a/app/admin.py +++ b/app/admin.py @@ -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) diff --git a/app/models.py b/app/models.py index 0ac7b1c..572f9f1 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/services/council.py b/app/services/council.py index ad1f3cf..27fda30 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -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) diff --git a/app/services/council_assessment.py b/app/services/council_assessment.py index b6acd05..61c5078 100644 --- a/app/services/council_assessment.py +++ b/app/services/council_assessment.py @@ -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 diff --git a/app/services/council_catalog.py b/app/services/council_catalog.py index d859fa2..8fe9b98 100644 --- a/app/services/council_catalog.py +++ b/app/services/council_catalog.py @@ -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: diff --git a/app/services/council_export.py b/app/services/council_export.py index 7564c86..2af1815 100644 --- a/app/services/council_export.py +++ b/app/services/council_export.py @@ -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 diff --git a/app/services/publication_quartiles.py b/app/services/publication_quartiles.py index c547d0e..f9bdbd4 100644 --- a/app/services/publication_quartiles.py +++ b/app/services/publication_quartiles.py @@ -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): diff --git a/app/static/admin.css b/app/static/admin.css index 4dcccae..80d279b 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -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; } diff --git a/app/static/council.js b/app/static/council.js new file mode 100644 index 0000000..4159bfe --- /dev/null +++ b/app/static/council.js @@ -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'); + } + }); +})(); diff --git a/app/templates/council.html b/app/templates/council.html index 55e36d8..d14e58f 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -5,10 +5,12 @@

Диссертационный совет

-

Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за последние пять лет сопоставляются с данными о квартилях и категориях журналов. Минимум — 9 работ для доктора наук и 20 для кандидата наук или PhD. Предварительный подсчёт и подтверждённые работы показаны отдельно.

+

Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.

+ Статьи, журналы и квартили ↗ + {% if error %} + diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html new file mode 100644 index 0000000..9ca62c6 --- /dev/null +++ b/app/templates/council_publications.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Статьи, журналы и квартили · MIEM Employees{% endblock %} +{% block content %} +
+
+

Статьи, журналы и квартили

+

Публикации сотрудников из списка диссовета. Квартиль журнала относится к году публикации.

+
+ Вернуться к диссовету + + {% if error %} + + {% else %} +

Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}

+ {% if not can_edit %}

Просмотр доступен без входа. Для сохранения изменений войдите в админку.

{% endif %} +
    + {% for article in articles %} +
  1. + {% set employee_id = article.employee_id %}{% include "council_edit_button.html" %} +

    {{ article.employee_name }}

    +
    {{ article.title }}{{ article.year or 'Год не указан' }}
    +

    Журнал: {{ article.journals|join(' / ') or 'не указан' }} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}

    +

    Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}

    + {% if article.manual_review %}

    Ручная корректировка · требует подтверждения

    {% endif %} +
  2. + {% else %}
  3. Статьи не найдены. Попробуйте изменить фильтры.
  4. {% endfor %} +
+ {% if matched > limit %}{% endif %} + {% include "council_edit_dialog.html" %} + {% endif %} +
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/version.py b/app/version.py index 3ccdace..02147c1 100644 --- a/app/version.py +++ b/app/version.py @@ -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" diff --git a/docs/council-evidence.md b/docs/council-evidence.md index 5f533ad..9fb91e0 100644 --- a/docs/council-evidence.md +++ b/docs/council-evidence.md @@ -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, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки. diff --git a/migrations/012_council_corrections.sql b/migrations/012_council_corrections.sql new file mode 100644 index 0000000..0ab1226 --- /dev/null +++ b/migrations/012_council_corrections.sql @@ -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 +); diff --git a/pyproject.toml b/pyproject.toml index 658634c..ef316f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/browser_council.py b/tests/browser_council.py index e322ee7..d0b3e1f 100644 --- a/tests/browser_council.py +++ b/tests/browser_council.py @@ -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") diff --git a/tests/browser_council_editor.py b/tests/browser_council_editor.py new file mode 100644 index 0000000..a5d06a7 --- /dev/null +++ b/tests/browser_council_editor.py @@ -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() diff --git a/tests/test_api.py b/tests/test_api.py index 538eb7a..707c8f7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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(): diff --git a/tests/test_council.py b/tests/test_council.py index 7fd9575..f7b26bc 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -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 "', journal='Журнал', quartile='Q4') + assert save(client, payload).status_code == 200 + page = client.get('/admin/council/publications') + assert '' 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': ['