From c6c6866235f6fcaa1c79f397342481ea884fa6dd Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 7 Oct 2026 11:35:28 +0300 Subject: [PATCH 1/2] feat: publish council publication evidence from CSV and database --- .gitignore | 2 + README.md | 4 +- app/admin.py | 9 +- app/services/council.py | 184 +++++++++++++++++++---------- app/static/admin.css | 26 ++++- app/templates/base.html | 7 +- app/templates/council.html | 73 +++++++----- app/version.py | 6 +- pyproject.toml | 2 +- tests/browser_council.py | 104 +++++++++++++++++ tests/test_admin_templates.py | 9 +- tests/test_api.py | 2 +- tests/test_council.py | 212 +++++++++++++++++++++++++--------- 13 files changed, 487 insertions(+), 153 deletions(-) create mode 100644 tests/browser_council.py diff --git a/.gitignore b/.gitignore index 1221d78..568ed2e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ pytest-cache-files-*/ htmlcov/ postgres_data/ MCP_DESCRIPTION.md + +.impeccable/review/ diff --git a/README.md b/README.md index 110125e..9680293 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ Compose запускает `api`, `worker` и `postgres`. API привязан ## Кандидаты в диссертационный совет -Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве. +Страница `/admin/council` доступна без авторизации. Она показывает всех сотрудников с хотя бы одной публикацией в CSV или таблице `employee_publications`, независимо от степени и количества работ; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО и страницы по 25 сотрудников. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями. + +CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки. Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория. diff --git a/app/admin.py b/app/admin.py index 9a85819..6282104 100644 --- a/app/admin.py +++ b/app/admin.py @@ -134,14 +134,13 @@ def directory( @router.get("/council", response_class=HTMLResponse) -def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): - require_admin(request, settings) +def council(request: Request, q: str = "", offset: int = 0, db: Session = Depends(get_db)): try: - result = council_candidates(db) + result = council_candidates(db, q=q, offset=offset) except (SQLAlchemyError, OSError, ValueError, KeyError): db.rollback() - return _render(request, "council.html", {"error": True}, status_code=503) - return _render(request, "council.html", {"error": False, **result}) + return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503) + return _render(request, "council.html", {"error": False, "public_page": True, **result}) @router.get("/employees/{employee_id}", response_class=HTMLResponse) diff --git a/app/services/council.py b/app/services/council.py index a66f89b..3ecdd0a 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -1,37 +1,47 @@ -"""Preliminary council candidates from the supplied publication export.""" +"""Public publication evidence from the council export and employee database.""" import calendar import csv import re +import unicodedata from collections import Counter, defaultdict from datetime import date, datetime from functools import lru_cache from pathlib import Path -from urllib.parse import quote +from urllib.parse import quote, urlsplit from zoneinfo import ZoneInfo from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.models import Employee from app.services.academic_degrees import academic_degrees EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv" -DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE) DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE) +TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги", + "BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент", + "PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"} def _name(value: str) -> str: - return " ".join(value.casefold().replace("ё", "е").split()) + return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split()) def _doi(value: str) -> str: - value = value.strip().lower() - for prefix in ("https://doi.org/", "http://doi.org/", "doi:"): - if value.startswith(prefix): - value = value[len(prefix):] - return value + value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower() + return value if DOI.fullmatch(value) else "" + + +def _safe_url(value: str | None) -> str | None: + try: + url = urlsplit(value or "") + if url.scheme in {"http", "https"} and url.hostname and not url.username and not url.password: + return value + except ValueError: + pass + return None def period(today: date) -> tuple[date, date]: @@ -39,79 +49,135 @@ def period(today: date) -> tuple[date, date]: return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today +def _quartiles(row: dict) -> list[str]: + return [f"{label} {str(row.get(field) or '').strip().upper()}" + for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos")) + if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}] + + @lru_cache(maxsize=1) def _articles_by_author() -> dict[str, list[dict]]: articles = defaultdict(list) with EXPORT.open(encoding="utf-8-sig", newline="") as source: - for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2): - sources = [] - for label, quartile, identifier in ( - ("Scopus", "Квартиль scopus", "EID (Scopus)"), - ("Web of Science", "Квартиль wos", "UT (WoS)"), - ): - value = (row[quartile] or "").strip().upper() - if value in {"Q1", "Q2"}: - sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()}) - if not sources: - continue - try: - year = int(row["Год"]) - except (TypeError, ValueError): - continue - title = (row["Название публикации"] or "").strip() + reader = csv.DictReader(source, delimiter=";") + required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"} + if not required <= set(reader.fieldnames or []): + raise ValueError("Missing council CSV headers") + for row_number, row in enumerate(reader, 2): + if None in row or any(value is None for value in row.values()): + raise ValueError("Malformed council CSV record") + title = row["Название публикации"].strip() if not title: continue - doi = _doi(row["DOI"] or "") - article = { - "title": title, - "year": year, - "doi": doi, - "doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None, - "sources": sources, - "row_number": row_number, - } - for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}: + year = int(row["Год"]) if row["Год"].strip().isdigit() else None + article = {"title": title, "year": year, "doi": _doi(row["DOI"]), + "types": [], "quartiles": _quartiles(row), "origins": ["Файл CSV"], + "rows": [row_number], "in_csv": True, "in_db": False, "url": None} + for author in set(_name(value) for value in row["Авторы НИУ ВШЭ"].split(";")) - {""}: articles[author].append(article) return articles +def _database_article(publication) -> dict: + raw = publication.raw_data if isinstance(publication.raw_data, dict) else {} + imported = raw.get("csv_enrichment") + from_site = bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw)) + origins = ["БД miem-workers · сайт ВШЭ"] if from_site else [] + if imported: + origins.append("БД miem-workers · импорт CSV") + kind = publication.publication_type + return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""), + "types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [], + "quartiles": _quartiles(imported) if isinstance(imported, dict) else [], + "origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True, + "url": _safe_url(publication.url)} + + def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]: + title_years = defaultdict(set) + for article in articles: + if article["year"] is not None: + title_years[_name(article["title"])].add(article["year"]) + def title_key(article): + title = _name(article["title"]) + years = title_years[title] + year = article["year"] + return title, next(iter(years)) if year is None and len(years) == 1 else year + + # A title/year fallback is safe only when it identifies at most one DOI. + title_dois = defaultdict(set) + for article in articles: + if article["doi"]: + title_dois[title_key(article)].add(article["doi"]) unique = {} for article in articles: - if not start.year <= article["year"] <= end.year: - continue - key = article["doi"] or (_name(article["title"]), article["year"]) + fallback = title_key(article) + identifiers = title_dois[fallback] + identifier = article["doi"] or (next(iter(identifiers)) if len(identifiers) == 1 else "") + key = identifier or fallback if key not in unique: - unique[key] = {**article, "sources": list(article["sources"])} + unique[key] = {**article, "doi": identifier, + **{field: list(article[field]) for field in ("types", "quartiles", "origins", "rows")}, + "csv_qualifies": False} + merged = unique[key] + for field in ("types", "quartiles", "origins", "rows"): + merged[field] = list(dict.fromkeys([*merged[field], *article[field]])) + merged["in_csv"] |= article["in_csv"] + merged["in_db"] |= article["in_db"] + merged["url"] = merged["url"] or article["url"] + merged["year"] = merged["year"] or article["year"] + in_period = article["year"] is not None and start.year <= article["year"] <= end.year + merged["csv_qualifies"] |= bool(article["in_csv"] and article["quartiles"] and in_period) + for article in unique.values(): + year = article["year"] + article["criteria"] = list(article["quartiles"]) + if year is None: + article["criteria"].append("Год не указан — период не подтверждён") + elif start.year <= year <= end.year: + article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year} + else "В пределах пяти лет") else: - known = {item["name"] for item in unique[key]["sources"]} - unique[key]["sources"].extend( - item for item in article["sources"] if item["name"] not in known - ) - return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold())) + article["criteria"].append("Вне пятилетнего периода") + if not article["quartiles"]: + article["criteria"].append("Q1/Q2, К1 и RSCI не подтверждены доступными данными") + article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None + article["type_label"] = " / ".join(article["types"]) or "Тип не указан — статья не подтверждена" + if article["types"] and article["types"] != ["Статья"]: + article["csv_qualifies"] = False + article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчик CSV") + return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold())) -def council_candidates(db: Session, today: date | None = None) -> dict: +def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0) -> dict: today = today or datetime.now(ZoneInfo("Europe/Moscow")).date() start, end = period(today) - employees = db.scalars(select(Employee).where(Employee.status == "active")).all() + employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all() name_counts = Counter(_name(employee.full_name or "") for employee in employees) by_author = _articles_by_author() candidates = [] for employee in employees: author = _name(employee.full_name or "") - if not author or name_counts[author] != 1: + ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author)) + csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else [] + articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end) + if not articles: continue + data = employee.current_data if isinstance(employee.current_data, dict) else {} try: - degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {}) + degrees = academic_degrees(data) except (AttributeError, TypeError): - continue - degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None) - if not degree: - continue - articles = _unique_articles(by_author.get(author, []), start, end) - if len(articles) >= 10: - candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree, - "count": len(articles), "articles": articles}) - candidates.sort(key=lambda item: (-item["count"], item["name"].casefold())) - return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)} + degrees = [] + candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана", + "count": len(articles), "csv_count": sum(a["csv_qualifies"] for a in articles), + "db_count": sum(a["in_db"] for a in articles), "articles": articles, + "profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous, + "dismissed": employee.status == "dismissed"}) + candidates.sort(key=lambda item: (-item["csv_count"], -item["count"], item["name"].casefold())) + total = len(candidates) + query = _name(q) + candidates = [item for item in candidates if query in _name(item["name"])] + matched = len(candidates) + limit = 25 + offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 + return {"candidates": candidates[offset:offset + limit], "start": start, "end": end, "total": total, + "matched": matched, "q": q, "offset": offset, "limit": limit} diff --git a/app/static/admin.css b/app/static/admin.css index 7572381..a72a99f 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -819,6 +819,7 @@ .council { display: grid; + grid-template-columns: minmax(0, 1fr); gap: 24px; color: #25313a; } @@ -888,6 +889,29 @@ .council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; } .council__criteria { grid-template-columns: 1fr; gap: 14px; } .council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; } - .council__candidate-count small { max-width: 72px; } + .council__candidate-summary .council__candidate-identity { grid-column: 1; } + .council__candidate-cue { grid-column: 2; grid-row: 1; } + .council__candidate-summary { grid-template-columns: minmax(0, 1fr) 14px; } + .council__counts { grid-column: 1 / -1; justify-content: space-between; } + .council__candidate-count { text-align: start; } .council__candidate-body { padding: 14px; } } + +.council__search { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } +.council__search .directory__field { flex: 1 1 240px; max-width: 520px; } +.council__notice summary { cursor: pointer; padding-block: 6px; } +.council__notice p { max-width: 75ch; margin-bottom: 0; } +.council__counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(12px, 3vw, 36px); } +.council__candidate-count small { max-width: 105px; line-height: 1.4; } +.council__hint { margin: 0; color: #4c5b64; font-size: 14px; line-height: 1.5; } +.council__candidate-body .council__hint { margin-top: 12px; } +.council__evidence { display: flex; flex-wrap: wrap; gap: 4px 20px; padding-inline-start: 18px; margin: 10px 0 0; color: #4c5b64; font-size: 13px; line-height: 1.5; } +.council__article-heading span { flex-shrink: 0; } +.council ::selection { background: #ccfbf1; color: #25313a; } +.council input { caret-color: #0f766e; } +.council a { text-underline-offset: 3px; } +.council__article { overflow-wrap: anywhere; } +@media (max-width: 400px) { + .council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; } + .council__article-heading { flex-direction: column; gap: 4px; } +} diff --git a/app/templates/base.html b/app/templates/base.html index b24e7d1..d60a08f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -9,8 +9,12 @@ Перейти к содержимому
-

MIEM Employees

+

MIEM Employees

diff --git a/app/templates/council.html b/app/templates/council.html index a459208..24d3904 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -1,59 +1,72 @@ {% extends "base.html" %} -{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %} +{% block title %}Диссертационный совет · MIEM Employees{% endblock %} {% block content %} -
+
-

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

-

Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.

+

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

+

Публикации сотрудников для проверки состава совета. В списке все сотрудники с хотя бы одной публикацией в файле CSV или базе miem-workers, независимо от степени и количества работ.

- {% if not error %}

{{ total }} предварительно подходят

{% endif %}
{% if error %} {% else %} -
-

10 и болееразличных публикаций

-

{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}скользящие пять лет

-

К1 · RSCI · Q1/Q2итоговые основания отбора

-
-
- Предварительный список. Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать. -
+
+ Как читать счётчики и критерии +

«CSV по критериям» — уникальные публикации с отметкой Scopus или Web of Science Q1/Q2 за {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}. «В БД» — все уникальные публикации сотрудника из miem-workers, включая импортированные из CSV. Общий перечень объединяет оба источника; совпадения не увеличивают итоговое количество.

+

В CSV нет типа материала, точной даты и подтверждения К1 или RSCI. Счётчик CSV предварительный: для пограничных лет нужно проверить дату, для материалов без типа — подтвердить, что это статьи. Материалы, для которых в БД указан другой тип, в этот счётчик не входят. Отсутствие отметки означает недостаток данных. Наличие публикаций не подтверждает соответствие всем требованиям совета.

+
+

Сотрудники

-

Откройте строку, чтобы увидеть публикации и основания.

+

{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q %} · Всего в списке: {{ total }}{% endif %}

{% if candidates %} +

Откройте сотрудника, чтобы увидеть публикации, источники и основания.

{% for candidate in candidates %}
- {{ candidate.name }}{{ candidate.degree }} - {{ candidate.count }}публикаций по выгрузке + {{ candidate.name }}{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %} + + {{ candidate.csv_count }}CSV по критериям + {{ candidate.db_count }}В БД + {{ candidate.count }}Всего без дублей +
- Открыть профиль сотрудника + {% if candidate.profile_url %}Профиль на сайте ВШЭ{% endif %} + {% if candidate.ambiguous %}

В базе есть сотрудники с одинаковым ФИО. Публикации из файла не присвоены автоматически; показаны записи, связанные с этим сотрудником в БД.

{% endif %}
    {% for article in candidate.articles %}
  1. {{ article.title }} - {{ article.year }} + {{ article.year or 'Год не указан' }}
    +
      + {% for criterion in article.criteria %}
    • {{ criterion }}
    • {% endfor %} +
  2. {% endfor %}
@@ -61,10 +74,18 @@
{% endfor %}
+ {% if matched > limit %} + + {% endif %} {% else %}
-

Кандидаты пока не найдены

-

По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.

+

{% if q %}Сотрудники не найдены{% else %}Публикаций пока нет{% endif %}

+

{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}В доступных источниках нет публикаций, которые можно однозначно связать с сотрудниками.{% endif %}

+ {% if q %}Сбросить поиск{% endif %}
{% endif %} {% endif %} diff --git a/app/version.py b/app/version.py index b5d6a81..a0a4892 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.10.6" -FRONTEND_VERSION = "0.10.6" -BACKEND_VERSION = "0.10.6" +APP_VERSION = "0.11.0" +FRONTEND_VERSION = "0.11.0" +BACKEND_VERSION = "0.11.0" diff --git a/pyproject.toml b/pyproject.toml index e454c9f..3073864 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.10.6" +version = "0.11.0" 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 new file mode 100644 index 0000000..b4defee --- /dev/null +++ b/tests/browser_council.py @@ -0,0 +1,104 @@ +"""Offline public council UX check: python tests/browser_council.py. + +Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser. +Synthetic employees and intercepted requests; no production access. +""" +import os +from 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.db import Base, get_db +from app.main import app +from app.models import Employee, EmployeePublication +from app.services import council + + +def main(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + with factory() as db: + for number in range(28): + person = Employee(profile_key=f"test:{number}", full_name=f"Сотрудник {number:02d}", + canonical_url="https://www.hse.ru/staff/test", + current_data={"sections": [{"title": "Учёные степени", + "items": ["Кандидат технических наук"]}]}) + db.add(person) + db.flush() + db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах", + year=2024, publication_type="ARTICLE", publication_id=f"{number}", + url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}")) + db.commit() + + def database(): + with factory() as db: + yield db + + original = council._articles_by_author + council._articles_by_author = lambda: {} + app.dependency_overrides[get_db] = database + client = TestClient(app) + output = Path(".impeccable/review") + output.mkdir(parents=True, exist_ok=True) + + def serve(route): + url = urlsplit(route.request.url) + response = client.get(url.path + ("?" + url.query if url.query else "")) + route.fulfill(status=response.status_code, body=response.content, + content_type=response.headers.get("content-type", "text/plain")) + + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(executable_path=os.getenv("BROWSER_EXECUTABLE")) + page = browser.new_page(viewport={"width": 1440, "height": 1000}) + page.route("**/*", serve) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto("http://miem.test/admin/council") + expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28") + expect(page.get_by_role("link", name="Вход в админку")).to_be_visible() + page.get_by_role("link", name="Далее").click() + expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28") + page.get_by_role("link", name="Назад").click() + summary = page.locator(".council__candidate-summary").first + summary.focus() + page.keyboard.press("Enter") + expect(page.get_by_text("БД miem-workers · сайт ВШЭ").first).to_be_visible() + expect(page.get_by_text("Q1/Q2, К1 и RSCI не подтверждены доступными данными").first).to_be_visible() + assert page.locator(".council__candidate").first.evaluate("e => e.open") + page.screenshot(path=str(output / "desktop.png"), full_page=True) + for width in (1024, 768, 700, 390, 320): + page.set_viewport_size({"width": width, "height": 844}) + assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width + assert summary.evaluate("e => e.scrollWidth <= e.clientWidth"), width + page.set_viewport_size({"width": 390, "height": 844}) + page.screenshot(path=str(output / "mobile.png"), full_page=True) + page.get_by_label("Поиск по ФИО").fill("Сотрудник 27") + page.get_by_role("button", name="Найти", exact=True).click() + expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28") + page.get_by_label("Поиск по ФИО").fill("Нет совпадений") + page.get_by_role("button", name="Найти", exact=True).click() + expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible() + page.get_by_role("link", name="Сбросить поиск").first.click() + expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28") + assert not errors, errors + browser.close() + finally: + council._articles_by_author = original + app.dependency_overrides.clear() + client.close() + engine.dispose() + print(f"Council browser checks passed; screenshots: {output}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_admin_templates.py b/tests/test_admin_templates.py index c5c1672..fad421d 100644 --- a/tests/test_admin_templates.py +++ b/tests/test_admin_templates.py @@ -1,8 +1,13 @@ -from pathlib import Path +from pathlib import Path +from types import SimpleNamespace + +from jinja2 import Environment, FileSystemLoader def test_base_navigation_is_russian_and_has_no_legacy_employees_link(): - template = Path("app/templates/base.html").read_text(encoding="utf-8") + template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render( + request=SimpleNamespace(url=SimpleNamespace(path="/admin")) + ) assert "Обзор" in template assert "Сотрудники" in template diff --git a/tests/test_api.py b/tests/test_api.py index 7a3f977..42b16d7 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.10.6" + assert response.json()["backend_version"] == "0.11.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index a2bf816..9839dc3 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -1,76 +1,182 @@ import csv from datetime import date +import pytest from fastapi.testclient import TestClient +from sqlalchemy.exc import OperationalError -from app.config import Settings, get_settings from app.db import get_db from app.main import app -from app.models import Employee -from app.security import SESSION_COOKIE, sign_session +from app.models import Employee, EmployeePublication from app.services import council -def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path): - fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", - "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"] - export = tmp_path / "articles.csv" - with export.open("w", encoding="utf-8-sig", newline="") as output: - writer = csv.DictWriter(output, fieldnames=fields, delimiter=";") - writer.writeheader() - for number in range(10): - writer.writerow({"Название публикации": f"Article {number}", "Год": 2024, - "DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}", - "Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович", - "Квартиль scopus": "Q1"}) - writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0", - "UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович", - "Квартиль wos": "Q2"}) - monkeypatch.setattr(council, "EXPORT", export) +@pytest.fixture() +def export(monkeypatch, tmp_path): + path = tmp_path / "articles.csv" + def write(rows): + fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)", + "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"] + with path.open("w", encoding="utf-8-sig", newline="") as output: + writer = csv.DictWriter(output, fieldnames=fields, delimiter=";") + writer.writeheader() + writer.writerows(rows) + council._articles_by_author.cache_clear() + monkeypatch.setattr(council, "EXPORT", path) + write([]) + yield write council._articles_by_author.cache_clear() - db_session.add_all([ - Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович", - status="active", current_data={"sections": [{"title": "Учёные степени", - "items": ["Доктор технических наук"]}]}), - Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович", - status="active", current_data={"sections": [{"title": "Учёные степени", - "items": ["Кандидат технических наук"]}]}), - ]) + + +def employee(db, name="Иванов Иван Иванович", degree=None, status="active", key="doctor"): + person = Employee(profile_key=key, canonical_url="https://www.hse.ru/staff/test", full_name=name, + status=status, current_data={"contacts": {"emails": ["private@example.org"]}, + "sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]}) + db.add(person) + db.flush() + return person + + +def publication(db, person, title="Shared work", doi=None, year=2024, kind=None, **extra): + item = EmployeePublication(employee_id=person.id, title=title, year=year, doi_url=doi, + publication_type=kind, source_hash=f"hash-{len(db.new)}-{title}-{doi}", **extra) + db.add(item) + return item + + +def row(title="Shared work", doi="10.1234/shared", year="2024", author="Иванов Иван Иванович", **extra): + return {"Название публикации": title, "Год": year, "DOI": doi, "Авторы НИУ ВШЭ": author, + "Квартиль scopus": "Q1", **extra} + + +def test_council_union_counts_provenance_and_types(db_session, export): + export([row(), row(**{"Квартиль scopus": "", "Квартиль wos": "Q2"}), + row(title="CSV only", doi="", year="2022"), + row(title="Not eligible", doi="10.1234/old", year="2019")]) + person = employee(db_session, degree="Доктор технических наук") + publication(db_session, person, doi="https://dx.doi.org/10.1234/SHARED", kind="ARTICLE", + publication_id="100", url="https://publications.hse.ru/view/100") + publication(db_session, person, title=" shared WORK ", kind="ARTICLE") + publication(db_session, person, title="Book only", kind="BOOK", year=2020) + publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}}) db_session.commit() result = council.council_candidates(db_session, date(2026, 9, 30)) assert result["total"] == 1 - assert result["candidates"][0]["count"] == 10 - assert len(result["candidates"][0]["articles"][0]["sources"]) == 2 + candidate = result["candidates"][0] + assert (candidate["csv_count"], candidate["db_count"], candidate["count"]) == (2, 3, 5) + shared = next(a for a in candidate["articles"] if a["doi"] == "10.1234/shared") + assert shared["type_label"] == "Статья" + assert shared["quartiles"] == ["Scopus Q1", "Web of Science Q2"] + assert shared["rows"] == [2, 3] + assert "Файл CSV" in shared["origins"] and "БД miem-workers · сайт ВШЭ" in shared["origins"] + imported = next(a for a in candidate["articles"] if a["title"] == "Imported") + assert imported["origins"] == ["БД miem-workers · импорт CSV"] + assert not imported["csv_qualifies"] + assert "Тип не указан" in imported["type_label"] assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28) - settings = Settings(_env_file=None, session_secret="test-session-secret") - monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30))) - app.dependency_overrides[get_db] = lambda: db_session - app.dependency_overrides[get_settings] = lambda: settings - try: - client = TestClient(app) - client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings)) - response = client.get("/admin/council") - assert response.status_code == 200 - assert "Иванов Иван Иванович" in response.text - assert "Article 0" in response.text - assert "публикаций по выгрузке" in response.text - finally: - app.dependency_overrides.clear() - council._articles_by_author.cache_clear() -def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch): - monkeypatch.setattr(council, "_articles_by_author", lambda: {}) - settings = Settings(_env_file=None, session_secret="test-session-secret") +def test_every_employee_with_a_publication_no_degree_or_count_threshold(db_session, export): + export([row(author="Кандидат", title="Single qualifying"), + row(author="Без степени", title="Not Q1/Q2", **{"Квартиль scopus": "Q3"}), + row(author="Уволен", title="Old", year="2010")]) + employee(db_session, "Кандидат", "Кандидат технических наук", key="candidate") + employee(db_session, "Без степени", key="none") + employee(db_session, "Уволен", status="dismissed", key="dismissed") + only_db = employee(db_session, "Только БД", key="database") + publication(db_session, only_db, kind="PATENT") + employee(db_session, "Без публикаций", key="empty") + db_session.commit() + items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"] + assert {c["name"] for c in items} == {"Кандидат", "Без степени", "Уволен", "Только БД"} + assert next(c for c in items if c["name"] == "Без степени")["csv_count"] == 0 + assert next(c for c in items if c["name"] == "Уволен")["dismissed"] + + +def test_no_false_merge_when_title_has_conflicting_dois(db_session, export): + export([row(doi="10.1234/one"), row(doi="10.1234/two"), row(doi="")]) + person = employee(db_session) + publication(db_session, person, title="Renamed work", doi="https://doi.org/10.1234/one") + db_session.commit() + item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0] + assert item["count"] == 3 + assert item["csv_count"] == 3 and item["db_count"] == 1 + + +def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export): + export([row()]) + for key in ["one", "two"]: + person = employee(db_session, key=key) + publication(db_session, person, title=key) + db_session.commit() + items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"] + assert len(items) == 2 + assert all(c["ambiguous"] and c["csv_count"] == 0 and c["count"] == 1 for c in items) + + +def test_missing_database_year_merges_only_unambiguous_title_and_non_article_excluded(db_session, export): + export([row(title="Known book"), row(title="Article", doi="10.1234/article")]) + person = employee(db_session) + publication(db_session, person, title="Known book", year=None, kind="BOOK") + publication(db_session, person, title="Article", year=None, kind="ARTICLE") + db_session.commit() + candidate = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0] + assert (candidate["count"], candidate["csv_count"], candidate["db_count"]) == (2, 1, 2) + book = next(a for a in candidate["articles"] if a["type_label"] == "Книга") + assert not book["csv_qualifies"] + assert "не включён в счётчик CSV" in book["criteria"][-1] + + +def test_search_and_pagination_include_all_workers(db_session, export): + for number in range(28): + person = employee(db_session, f"Петров {number:02d}", key=f"person-{number}") + publication(db_session, person, title=f"Work {number}") + db_session.commit() + first = council.council_candidates(db_session) + last = council.council_candidates(db_session, offset=25) + assert first["total"] == 28 and len(first["candidates"]) == 25 + assert len(last["candidates"]) == 3 + assert {c["name"] for c in first["candidates"]}.isdisjoint(c["name"] for c in last["candidates"]) + assert council.council_candidates(db_session, q="петров 27")["matched"] == 1 + assert council.council_candidates(db_session, offset=-1)["offset"] == 0 + assert council.council_candidates(db_session, offset=999)["offset"] == 25 + + +def test_public_page_data_minimization_and_safe_links(db_session, export): + export([row(title='', doi="javascript:bad")]) + person = employee(db_session, degree="Кандидат технических наук") + person.canonical_url = "javascript:bad" + publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK") + db_session.commit() app.dependency_overrides[get_db] = lambda: db_session - app.dependency_overrides[get_settings] = lambda: settings try: client = TestClient(app) - assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403) - client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings)) - response = client.get("/admin/council") + response = client.get("/admin/council", follow_redirects=False) assert response.status_code == 200 - assert "Предварительный список" in response.text - assert "К1 или RSCI могут отсутствовать" in response.text + assert "Иванов Иван Иванович" in response.text and "Кандидат технических наук" in response.text + assert "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" in response.text + assert "Книга" in response.text and "Тип не указан" in response.text + assert "<script>" in response.text and "', doi="javascript:bad")]) - person = employee(db_session, degree="Кандидат технических наук") + person = employee(db_session) person.canonical_url = "javascript:bad" publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK") db_session.commit() @@ -153,13 +161,14 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): client = TestClient(app) response = client.get("/admin/council", follow_redirects=False) assert response.status_code == 200 - assert "Иванов Иван Иванович" in response.text and "Кандидат технических наук" in response.text + assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text + assert "Минимальный порог количества работ не применяется" in response.text assert "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" in response.text assert "Книга" in response.text and "Тип не указан" in response.text assert "<script>" in response.text and "