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..32b60b1 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`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО и страницы по 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..72f7106 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -1,17 +1,18 @@ -"""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 @@ -20,18 +21,28 @@ 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 +50,137 @@ 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): + degrees = [] + if not any(DOCTOR.fullmatch(degree) for degree in degrees): 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)} + 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..c4cd2fc 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..6ebf289 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.1" +FRONTEND_VERSION = "0.11.1" +BACKEND_VERSION = "0.11.1" diff --git a/pyproject.toml b/pyproject.toml index e454c9f..f081c04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.10.6" +version = "0.11.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 new file mode 100644 index 0000000..a4d7e7c --- /dev/null +++ b/tests/browser_council.py @@ -0,0 +1,111 @@ +"""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}")) + for name, degree in [("Без степени", None), ("Кандидат наук", "Кандидат технических наук")]: + person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test", + current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]}) + db.add(person) + db.flush() + db.add(EmployeePublication(employee_id=person.id, title="Не включать в совет", year=2024, source_hash=name)) + 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") + assert "Без степени" not in page.content() and "Кандидат наук" not in page.content() + 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..551af66 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.1" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index a2bf816..7dd89b3 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -1,76 +1,191 @@ 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="Доктор технических наук", 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_only_doctors_with_a_publication_no_count_threshold(db_session, export): + export([row(author="Доктор", title="Single qualifying"), + row(author="Без степени", title="No degree"), + row(author="Кандидат", title="Candidate work"), + row(author="PhD", title="PhD work"), + row(author="Две степени", title="Mixed degrees", **{"Квартиль scopus": "Q3"}), + row(author="Уволен", title="Old", year="2010")]) + employee(db_session, "Доктор", key="csv-only") + for name, degree in [("Без степени", None), ("Кандидат", "Кандидат технических наук"), ("PhD", "Ph.D.")]: + person = employee(db_session, name, degree, key=name) + publication(db_session, person) + employee(db_session, "Две степени", "Кандидат технических наук; Доктор физико-математических наук", key="mixed") + 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"] == 1 + assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1 + assert next(c for c in items if c["name"] == "Две степени")["csv_count"] == 0 + assert next(c for c in items if c["name"] == "Уволен")["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) + 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 "Минимальный порог количества работ не применяется" 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 "