From e08c30848f589a7a7bc64aef9e6393c4f0a41be2 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 9 Oct 2026 16:23:55 +0300 Subject: [PATCH 1/2] perf: load council publications on demand and reduce database payload --- README.md | 6 ++ app/admin.py | 20 ++++- app/services/council.py | 80 ++++++++++++++----- app/services/council_assessment.py | 37 ++++++--- app/services/council_catalog.py | 2 +- app/services/council_source.py | 2 +- app/services/publication_quartiles.py | 2 +- app/static/admin.css | 8 ++ app/static/council.js | 77 +++++++++++++++++- app/templates/council.html | 45 +---------- app/templates/council_candidate_articles.html | 38 +++++++++ app/templates/council_publications.html | 6 +- app/templates/council_warning.html | 10 +++ app/version.py | 6 +- pyproject.toml | 2 +- tests/browser_council.py | 46 +++++++++-- tests/browser_council_editor.py | 8 +- tests/test_api.py | 2 +- tests/test_council.py | 74 ++++++++++++++++- tests/test_council_assessment.py | 23 ++++-- tests/test_council_editor.py | 4 +- tests/test_publication_quartiles.py | 2 +- 22 files changed, 391 insertions(+), 109 deletions(-) create mode 100644 app/templates/council_candidate_articles.html create mode 100644 app/templates/council_warning.html diff --git a/README.md b/README.md index ffcf194..a91ee13 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,12 @@ Compose запускает `api`, `worker` и `postgres`. API привязан Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет. +Начальный список диссовета содержит только сотрудников и счётчики. Публикации загружаются при раскрытии карточки через `/admin/council/employees/{id}/articles` с сохранением отрасли и специальности; повторное раскрытие использует уже загруженные данные текущей страницы. При ошибке доступна кнопка повтора, без JavaScript — ссылка на публикации. Экспорт XLSX продолжает включать все найденные записи. + +До загрузки публикаций применяется поиск по ФИО и выбор конкретного сотрудника. Проверка одинаковых ФИО остаётся глобальной, а общий счётчик сотрудников не зависит от поиска. PostgreSQL возвращает только разделы профиля об образовании/степенях и необходимые поля публикаций; аннотации, авторы и остальной профиль не загружаются. Plain-text описания журналов не проходят через HTML-парсер; HTML и сущности обрабатываются как прежде. + +Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены. + Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора. Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново. diff --git a/app/admin.py b/app/admin.py index 1b9857c..64b8398 100644 --- a/app/admin.py +++ b/app/admin.py @@ -150,6 +150,22 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result}) +@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse) +def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические", + db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + try: + result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch) + if not result["candidates"]: + raise HTTPException(404, "Сотрудник не найден или не соответствует фильтру. Обновите список") + except CouncilInputError as exc: + raise HTTPException(422, str(exc)) + except (SQLAlchemyError, OSError, ValueError, KeyError): + db.rollback() + raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз") + return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0], + "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) @@ -167,7 +183,7 @@ def council_publications(request: Request, employee: str = "", title: str = "", 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") + result = council_candidates(db, q=employee, 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() @@ -195,7 +211,7 @@ def edit_council_publication(request: Request, change: CouncilArticleEdit, if not _journal_key(change.journal): raise HTTPException(422, "Укажите название журнала") try: - result = council_candidates(db, paginate=False) + result = council_candidates(db, employee_id=change.employee_id, 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: diff --git a/app/services/council.py b/app/services/council.py index 27fda30..758aecc 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -10,10 +10,11 @@ from pathlib import Path from urllib.parse import quote, urlsplit from zoneinfo import ZoneInfo -from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy import select, func, case, cast +from sqlalchemy.dialects.postgresql import JSONPATH +from sqlalchemy.orm import Session -from app.models import Employee, CouncilCorrections +from app.models import Employee, EmployeePublication, CouncilCorrections, json_type 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 @@ -101,7 +102,7 @@ def _articles_by_author() -> dict[str, list[dict]]: def _database_article(publication) -> dict: raw = publication.raw_data if isinstance(publication.raw_data, dict) else {} imported = raw.get("csv_enrichment") - from_site = not imported or bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw)) + from_site = not imported or bool(publication.publication_id or publication.url or getattr(publication, "raw_has_metadata", any(key != "csv_enrichment" for key in raw))) origins = ["ЛК сотрудника ВШЭ"] if from_site else [] if imported: origins.append("Данные ВШЭ") @@ -252,7 +253,7 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "", - branch: str = "технические") -> dict: + branch: str = "технические", employee_id: int | None = None) -> dict: if branch and branch not in BRANCHES: raise CouncilInputError("Неизвестная отрасль науки") today = today or datetime.now(ZoneInfo("Europe/Moscow")).date() @@ -261,27 +262,64 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs if specialty and specialty not in {s["code"] for s in specialties}: raise CouncilInputError("Неизвестная специальность ВАК") specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "") - employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all() + degree_sections = Employee.current_data["sections"] + if db.get_bind().dialect.name == "postgresql": + degree_sections = func.jsonb_path_query_array(Employee.current_data, + cast('$.sections[*] ? (@.title like_regex "образован|степен|academic degree|education" flag "i")', JSONPATH), type_=json_type) + employees = db.execute(select(Employee.id, Employee.profile_key, Employee.full_name, Employee.status, + Employee.canonical_url, degree_sections.label("degree_sections"))).all() 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 = [] + eligible = [] + degrees_by_employee = {} for employee in employees: + try: + degrees = academic_degrees({"sections": employee.degree_sections}) + except (AttributeError, TypeError): + degrees = [] + if any(degree_level(degree) for degree in degrees) or verified["employees"].get(employee.profile_key, {}).get("degrees"): + eligible.append(employee) + degrees_by_employee[employee.id] = degrees + total = len(eligible) + query = _name(q) + eligible = [employee for employee in eligible if query in _name(employee.full_name or "ФИО не указано") + and (employee_id is None or employee.id == employee_id)] + publication_fields = [EmployeePublication.employee_id, EmployeePublication.title, EmployeePublication.year, + EmployeePublication.publication_type, EmployeePublication.publication_id, EmployeePublication.url, + EmployeePublication.doi_url, EmployeePublication.source_hash, EmployeePublication.quartiles] + raw_data, description = EmployeePublication.raw_data, EmployeePublication.description + if db.get_bind().dialect.name == "postgresql": + # Only council metadata crosses the DB boundary; abstracts/authors stay in PostgreSQL. + description = case((func.jsonb_typeof(description) == "object", func.jsonb_build_object( + "short", description["short"], "shortLeft", description["shortLeft"], type_=json_type)), + else_=description) + raw = EmployeePublication.raw_data + raw_has_metadata = case((func.jsonb_typeof(raw) == "object", + raw != func.jsonb_build_object("csv_enrichment", raw["csv_enrichment"])), else_=False) + raw_keys = ("csv_enrichment", "journal", "Журнал", "Название журнала", "issn", "eissn", "ISSN", "EISSN") + raw_data = case((func.jsonb_typeof(raw) == "object", func.jsonb_build_object( + *[value for key in raw_keys for value in (key, raw[key])], + "description", case((func.jsonb_typeof(EmployeePublication.description) == "object", None), + else_=raw["description"]), type_=json_type)), else_=None) + publication_fields.append(raw_has_metadata.label("raw_has_metadata")) + publications_by_employee = defaultdict(list) + if eligible: + publications = db.execute(select(*publication_fields, raw_data.label("raw_data"), description.label("description")) + .where(EmployeePublication.employee_id.in_([e.id for e in eligible]))).all() + for publication in publications: + publications_by_employee[publication.employee_id].append(publication) + candidates = [] + for employee in eligible: author = _name(employee.full_name or "") 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 [] - data = employee.current_data if isinstance(employee.current_data, dict) else {} - try: - degrees = academic_degrees(data) - except (AttributeError, TypeError): - degrees = [] + degrees = degrees_by_employee[employee.id] 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, corrections, employee.id) + articles = _unique_articles([*csv_articles, *(_database_article(p) for p in publications_by_employee[employee.id])], start, end, corrections, employee.id) for article in articles: article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {})) @@ -353,13 +391,18 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs 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("Ручная корректировка требует повторного подтверждения источниками") + message = "Журнал или квартиль исправлен вручную. Для зачёта требуется источник, подтверждающий эти сведения за год статьи" + article["confirmation_reasons"].append(message) + article["warnings"]["quartile"].append(message) 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} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})") for article in articles: + if specialty: + article["warnings"]["specialty"].extend(text for record in article["catalog_records"] + for text in record["thematic"] if not text.startswith(("Паспорт ", "Аналитическая связь журнала:"))) 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), @@ -373,10 +416,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc") if sort != "full_name": candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc") - total = len(candidates) - query = _name(q) - candidates = [item for item in candidates if query in _name(item["name"]) - and (not specialty or item["specialty_count"] > 0)] + candidates = [item for item in candidates if not specialty or item["specialty_count"] > 0] matched = len(candidates) limit = 25 offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 diff --git a/app/services/council_assessment.py b/app/services/council_assessment.py index 61c5078..a810b11 100644 --- a/app/services/council_assessment.py +++ b/app/services/council_assessment.py @@ -127,6 +127,10 @@ def publication_proofs(employee_evidence, article): def assess_article(article, proofs, start, end, level, branch, specialty, policy): reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])] + warnings = {"journal": list(dict.fromkeys(reasons)), "year": [], "type": [], "quartile": [], "specialty": []} + def report(field, message): + reasons.append(message) + warnings[field].append(message) years = article["source_years"] dates = set() for proof in proofs: @@ -138,32 +142,39 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy 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("Годы публикации в источниках расходятся") + report("year", "Источники указывают разные годы публикации. Уточните год статьи") exact = next(iter(dates)) if len(dates) == 1 else None 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(f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None + report("year", 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"))} 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("Вид материала не подтверждён или расходится") + if not types and not (article["types"] == ["Статья"] and automatic_bases): + if len(article["types"]) > 1: + report("type", "Источники называют разные виды работы: " + ", ".join(article["types"]) + ". Уточните тип материала") + elif not article["types"]: + report("type", "В источниках не указан тип материала. В карточке показано «Статья», но для зачёта нужно уточнить вид работы") + elif article["types"] == ["Статья"]: + report("quartile", "Тип «Статья» указан в источнике, но для зачёта по К1/К2/RSCI отдельная запись проверки работы не сохранена. Проверьте основание зачёта") + else: + report("type", f"Источник указывает вид «{article['types'][0]}». Для включения такого материала в минимум нужно отдельное подтверждение") 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("Виды материала в источниках расходятся") + report("type", "Источники называют разные виды работы. Уточните, является ли публикация статьёй") eligible_kind = publication_type_allowed(article["types"], kind, policy) if kind and kind != "article" and not eligible_kind: - reasons.append("Зачёт этого вида материала в общий минимум не подтверждён") + report("type", "Это другой вид работы, а не статья. Его включение в публикационный минимум требует отдельного основания") bases = [] basis_year = exact.year if exact else article["year"] for proof in proofs: for basis in proof.get("bases", []): if not isinstance(basis, dict): - reasons.append("Некорректное подтверждение основания") + report("quartile", "Запись об основании зачёта повреждена. Проверьте источник категории журнала") continue category = basis.get("basis") valid = sourced(basis) and type(basis.get("applicable_year")) is int and basis["applicable_year"] == basis_year @@ -174,7 +185,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy if valid: bases.append(basis) else: - reasons.append("Источник или год применимости основания не подтверждены") + report("quartile", "Для категории журнала не подтверждён источник или год применимости. Нельзя переносить категорию автоматически на год статьи") if kind == "article": bases.extend(automatic_bases) # Different categories may legitimately have different quartiles; conflicting @@ -185,19 +196,19 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else ( 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("Подтверждённые основания источников расходятся") + report("quartile", "Подтверждённые основания источников расходятся: для одной системы, категории, метрики и года указаны разные значения") basis_conflict = True claims[key] = basis["basis"] accepted = [b for b in bases if level == 2 or b["basis"] != "К2"] if not accepted: - reasons.append("Нет подтверждённого допустимого основания выбранного пункта") + report("quartile", f"Нет подтверждённого основания за {basis_year or 'год публикации'}: Q1/Q2, К1{'/К2' if level == 2 else ''} или RSCI. Проверьте категорию журнала, год и источник") # Missing optional evidence is not a veto on another sufficient independent # basis. Identity/date/type conflicts, however, invalidate the whole work. 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 - reasons.append("Идентификаторы ручного подтверждения указывают на разные работы") + report("journal", "DOI и идентификатор в ручном подтверждении указывают на разные работы. Проверьте привязку источника к этой статье") conflicts = date_conflict or type_conflict or basis_conflict or identity_conflict article["confirmed"] = bool(in_period and eligible_kind and accepted and not conflicts) mono_policy = policy.get("monograph_period", {}) @@ -205,6 +216,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy and (mono_policy.get("value") == "all_years" or (mono_policy.get("value") == "five_years" and in_period))) article["confirmation_reasons"] = list(dict.fromkeys(reasons)) + article["warnings"] = warnings + article["display_type"] = article["type_label"] if len(article["types"]) == 1 else "Статья" article["publication_date"] = exact article["confirmed_bases"] = [{**b, "source_url": safe_url(b.get("source_url"))} for b in accepted] article["thematic_status"] = "Рассмотрение статьи по паспорту не подтверждено" if specialty else "Специальность для рассмотрения статьи не выбрана" @@ -225,6 +238,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy f"пункты {review['point_numbers']}; {review['checked_on']}") if len({r["result"] for r in valid_reviews}) > 1: article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка" + if specialty and (not valid_reviews or any(r["result"] != "confirmed" for r in valid_reviews)): + warnings["specialty"].append("Связь журнала со специальностью не доказывает соответствие содержания каждой статьи. Рассмотрение этой работы по паспорту специальности не подтверждено") def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end): diff --git a/app/services/council_catalog.py b/app/services/council_catalog.py index 8fe9b98..226f446 100644 --- a/app/services/council_catalog.py +++ b/app/services/council_catalog.py @@ -149,7 +149,7 @@ def publication_catalog(identifiers, names, specialty=""): return [], [*issues, "ISSN указывают на разные журналы — требуется проверка"] records = data["journals"].get(next(iter(matches)), []) if matches else [] if records and names and not any(str(name).casefold().strip() == r["name"].casefold() for name in names for r in records): - issues.append("Название журнала и ISSN требуют подтверждения идентичности") + issues.append("Название журнала в публикации и запись по ISSN расходятся. Проверьте, относятся ли они к одному журналу") output = [] for record in records: link = record["links"].get(specialty) diff --git a/app/services/council_source.py b/app/services/council_source.py index a2b8aef..b56070a 100644 --- a/app/services/council_source.py +++ b/app/services/council_source.py @@ -47,7 +47,7 @@ def _description_journals(description: dict) -> list[str]: values = value.values() if isinstance(value, dict) else [value] for text in values: if isinstance(text, str): - text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) + text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) if "<" in text or "&" in text else text.strip() # HSE short descriptions start with the journal, followed by the year. names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;")) return names diff --git a/app/services/publication_quartiles.py b/app/services/publication_quartiles.py index f9bdbd4..4c69e3b 100644 --- a/app/services/publication_quartiles.py +++ b/app/services/publication_quartiles.py @@ -118,7 +118,7 @@ def binding_issue(item, payload, doi="", journals=(), identifiers=()): source_name = _name(item.get("journal") or "") names_match = bool(source_name and source_name in {_name(name) for name in journals if name}) if source_name and journals and not names_match and not ids_match: - return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка" + return f"Квартиль сохранён для журнала «{item.get('journal')}», а статья отнесена к «{' / '.join(journals)}». Совпадение по ISSN не подтверждено. Уточните журнал статьи и источник квартиля" if not (source_doi and source_doi == current_doi) and not ids_match and not names_match: return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка" return None diff --git a/app/static/admin.css b/app/static/admin.css index 80d279b..b88a673 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -938,3 +938,11 @@ .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; } + +.council__warning { display: inline-block; vertical-align: middle; } +.council__warning-button { display: inline-flex; justify-content: center; align-items: center; min-width: 40px; min-height: 40px; padding: 0; border: 0; border-radius: 4px; color: #991b1b; background: transparent; cursor: pointer; vertical-align: middle; } +.council__warning-button:hover { background: #fee2e2; } +.council__tooltip { position: fixed; z-index: 30; width: min(360px, calc(100vw - 24px)); padding: 12px; box-sizing: border-box; border: 1px solid #d6dddc; border-radius: 8px; color: #1f2937; background: #fff; box-shadow: 0 6px 20px rgba(15, 23, 42, .18); font: 14px/1.5 system-ui, sans-serif; max-height: min(360px, calc(100vh - 24px)); overflow: auto; text-align: start; overflow-wrap: anywhere; } +.council__tooltip[hidden] { display: none; } +.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } } diff --git a/app/static/council.js b/app/static/council.js index 4159bfe..559cdde 100644 --- a/app/static/council.js +++ b/app/static/council.js @@ -27,7 +27,19 @@ article.classList.add('council__article--active'); } - document.querySelectorAll('[data-council-article]').forEach(article => { + function positionTooltip(button, tip) { + const rect = button.getBoundingClientRect(); + tip.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - tip.offsetWidth - 12))}px`; + tip.style.top = `${Math.max(12, rect.bottom + tip.offsetHeight + 8 < innerHeight ? rect.bottom + 8 : rect.top - tip.offsetHeight - 8)}px`; + } + function repositionTooltips() { + document.querySelectorAll('[data-warning]').forEach(warning => { + const tip = warning.querySelector('[role="tooltip"]'); + if (!tip.hidden) positionTooltip(warning.querySelector('button'), tip); + }); + } + function setupArticles(root) { + root.querySelectorAll('[data-council-article]').forEach(article => { article.addEventListener('click', event => { if (!event.target.closest('a, button')) activate(article); }); @@ -40,7 +52,7 @@ }); }); - document.querySelectorAll('[data-council-edit]').forEach(button => { + root.querySelectorAll('[data-council-edit]').forEach(button => { button.addEventListener('click', () => { trigger = button; selected = JSON.parse(button.dataset.article); @@ -64,6 +76,67 @@ }); }); + root.querySelectorAll('[data-warning]').forEach(warning => { + const button = warning.querySelector('button'); + const tip = warning.querySelector('[role="tooltip"]'); + let pinned = false; + function show(visible) { + tip.hidden = !visible; + button.setAttribute('aria-expanded', String(visible)); + if (!visible) return; + positionTooltip(button, tip); + } + warning.addEventListener('mouseenter', () => show(true)); + warning.addEventListener('mouseleave', () => { if (!pinned && document.activeElement !== button) show(false); }); + button.addEventListener('focus', () => show(true)); + button.addEventListener('blur', () => { pinned = false; show(false); }); + button.addEventListener('click', () => { pinned = !pinned; show(pinned); }); + button.addEventListener('keydown', event => { + if (event.key === 'Escape') { pinned = false; show(false); } + }); + }); + } + setupArticles(document); + + async function loadArticles(details) { + if (details.dataset.loaded || details.dataset.loading) return; + const panel = details.querySelector('[data-council-content]'); + const status = panel.querySelector('[data-articles-status]'); + const content = panel.querySelector('[data-articles-content]'); + details.dataset.loading = '1'; + panel.setAttribute('aria-busy', 'true'); + status.textContent = 'Загружаем публикации…'; + try { + const response = await fetch(panel.dataset.articlesUrl, {credentials: 'same-origin', signal: AbortSignal.timeout(15000)}); + if (!response.ok) throw new Error('Не удалось загрузить публикации. Попробуйте ещё раз.'); + content.innerHTML = await response.text(); + setupArticles(content); + details.dataset.loaded = '1'; + status.textContent = 'Публикации загружены'; + status.classList.add('council__load-status--done'); + } catch (_error) { + status.textContent = 'Не удалось загрузить публикации. Проверьте подключение и повторите загрузку.'; + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'button button--secondary'; + retry.textContent = 'Повторить загрузку публикаций'; + retry.addEventListener('click', () => loadArticles(details)); + content.replaceChildren(retry); + } finally { + delete details.dataset.loading; + panel.removeAttribute('aria-busy'); + } + } + document.querySelectorAll('[data-employee-id]').forEach(details => { + details.addEventListener('toggle', () => { if (details.open) loadArticles(details); }); + if (details.open) loadArticles(details); + }); + document.addEventListener('scroll', event => { + if (event.target instanceof Element && event.target.closest('.council__tooltip')) return; + repositionTooltips(); + }, true); + window.addEventListener('resize', repositionTooltips); + dialog.querySelectorAll('[data-edit-close]').forEach(button => { button.addEventListener('click', () => { if (!saving) dialog.close(); }); }); diff --git a/app/templates/council.html b/app/templates/council.html index d14e58f..a2f8e8a 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -89,47 +89,10 @@ -
-

{{ candidate.assessment_status }}. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.

-

Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}

- {% if candidate.assessment_reasons %}{% endif %} - {% if not candidate.articles %}

Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.

{% endif %} - {% if specialty %}

Публикаций по выбранной специальности: {{ candidate.specialty_count }}

{% endif %} - {% if candidate.profile_url %}Профиль на сайте ВШЭ{% endif %} - {% if candidate.ambiguous %}

Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.

{% endif %} -
    - {% for article in candidate.articles %} -
  1. - {% set employee_id = candidate.employee_id %} - {% include "council_edit_button.html" %} -
    - {{ article.title }} - {{ article.year or 'Год не указан' }} -
    - -

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

    -
      -
    • Зачёт в подтверждённое число: {{ 'да' if article.confirmed else 'требует проверки' }}{% if article.publication_date %}; точная дата: {{ article.publication_date.strftime('%d.%m.%Y') }}{% endif %}
    • - {% if specialty %}
    • {{ article.thematic_status }}
    • {% endif %} - {% for message in article.messages %}
    • {{ message }}
    • {% endfor %} - {% for basis in article.confirmed_bases %}
    • {{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} Источник{% endif %}
    • {% endfor %} - {% for record in article.catalog_records %} -
    • {{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }} -
        {% for metric in record.metrics %}
      • {{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} Источник{% endif %}
      • {% endfor %} - {% for text in record.thematic %}
      • {{ text }}
      • {% endfor %} - {% for url in record.passport_urls %}{% if url %}
      • Паспорт специальности
      • {% endif %}{% endfor %}
      -
    • - {% endfor %} -
    -
  2. - {% endfor %} -
+
+

Публикации загрузятся при раскрытии карточки.

+
{% endfor %} diff --git a/app/templates/council_candidate_articles.html b/app/templates/council_candidate_articles.html new file mode 100644 index 0000000..e20699b --- /dev/null +++ b/app/templates/council_candidate_articles.html @@ -0,0 +1,38 @@ +

{{ candidate.assessment_status }}. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.

+

Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}

+ {% if candidate.assessment_reasons %}{% endif %} + {% if not candidate.articles %}

Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.

{% endif %} + {% if specialty %}

Публикаций по выбранной специальности: {{ candidate.specialty_count }}

{% endif %} + {% if candidate.profile_url %}Профиль на сайте ВШЭ{% endif %} + {% if candidate.ambiguous %}

Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.

{% endif %} +
    + {% for article in candidate.articles %} +
  1. + {% set employee_id = candidate.employee_id %} + {% include "council_edit_button.html" %} +
    + {{ article.title }} + {{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %} +
    + +

    Журнал: {{ article.journals|join(" / ") or "не указан" }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %}

    +

    Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}

    + {% if specialty %}

    Специальность: {{ specialty }} — {{ specialty_name }}{% set field = 'specialty' %}{% set label = 'специальность статьи' %}{% include 'council_warning.html' %}

    {% endif %} +
      + {% for basis in article.confirmed_bases %}
    • {{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} Источник{% endif %}
    • {% endfor %} + {% for record in article.catalog_records %} +
    • {{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }} +
        {% for metric in record.metrics %}
      • {{ metric.system }} · {{ metric.metric }} · {{ metric.category }}{% if metric.edition %} ({{ metric.edition }}){% endif %} · {{ metric.quartile }} · год метрики {{ metric.year }}{% if metric.release_year %}, выпуск {{ metric.release_year }}{% endif %} · {{ metric.source }}{% if metric.source_row %}, строка {{ metric.source_row }}{% endif %}{% if metric.source_pdf_page %}, страница {{ metric.source_pdf_page }}{% endif %}{% if metric.source_url %} Источник{% endif %}
      • {% endfor %} + {% for url in record.passport_urls %}{% if url %}
      • Паспорт специальности
      • {% endif %}{% endfor %}
      +
    • + {% endfor %} +
    +
  2. + {% endfor %} +
diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html index 9ca62c6..45cae59 100644 --- a/app/templates/council_publications.html +++ b/app/templates/council_publications.html @@ -24,10 +24,10 @@
  • {% 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.title }}{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}
    +

    {{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}

    +

    Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}

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

    - {% if article.manual_review %}

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

    {% endif %}
  • {% else %}
  • Статьи не найдены. Попробуйте изменить фильтры.
  • {% endfor %} diff --git a/app/templates/council_warning.html b/app/templates/council_warning.html new file mode 100644 index 0000000..efe3fb3 --- /dev/null +++ b/app/templates/council_warning.html @@ -0,0 +1,10 @@ +{% if article.warnings[field] %} + + {% set warning_id = 'warning-' ~ employee_id ~ '-' ~ article.edit_key ~ '-' ~ field %} + + + + +{% endif %} diff --git a/app/version.py b/app/version.py index 02147c1..df34f66 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.0.1" -FRONTEND_VERSION = "1.0.1" -BACKEND_VERSION = "1.0.1" +APP_VERSION = "1.1.0" +FRONTEND_VERSION = "1.1.0" +BACKEND_VERSION = "1.1.0" diff --git a/pyproject.toml b/pyproject.toml index ef316f0..3be382a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.0.1" +version = "1.1.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 index d0b3e1f..ac044e9 100644 --- a/tests/browser_council.py +++ b/tests/browser_council.py @@ -66,8 +66,15 @@ def main(): output = Path(".impeccable/review") output.mkdir(parents=True, exist_ok=True) + requests = {"articles": 0, "fail": False} + def serve(route): url = urlsplit(route.request.url) + if '/council/employees/' in url.path: + requests['articles'] += 1 + if requests['fail']: + route.fulfill(status=503, body='Temporary failure') + return response = client.get(url.path + ("?" + url.query if url.query else "")) route.fulfill(status=response.status_code, body=response.content, headers={key: value for key, value in response.headers.items() @@ -81,20 +88,45 @@ def main(): 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.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28") assert "Без степени" not in page.content() and "Только звание" not in page.content() expect(page.locator("#council-date")).to_have_count(0) assert "Монографии" not in page.content() page.get_by_role("button", name="Применить", exact=True).click() 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") + expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 26–28 из 28") page.get_by_role("link", name="Назад").click() summary = page.locator(".council__candidate-summary").first + assert requests['articles'] == 0 + requests['fail'] = True summary.focus() page.keyboard.press("Enter") + expect(page.get_by_role('button', name='Повторить загрузку публикаций')).to_be_visible() + requests['fail'] = False + page.get_by_role('button', name='Повторить загрузку публикаций').click() expect(page.locator(".council__source").filter(has_text="ЛК сотрудника ВШЭ").first).to_be_visible() - expect(page.get_by_text("Q1/Q2 и К1 не подтверждены доступными данными").first).to_be_visible() + before = requests['articles'] + summary.click() + summary.click() + assert requests['articles'] == before + warning = page.get_by_role('button', name='Уточнение: тип материала').first + warning.hover() + expect(page.get_by_role('tooltip')).to_be_visible() + expect(page.get_by_role('tooltip')).to_contain_text('тип материала') + page.screenshot(path=str(output / 'warning-desktop.png')) + page.set_viewport_size({'width': 320, 'height': 844}) + warning.focus() + page.keyboard.press('Enter') + expect(page.get_by_role('tooltip')).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.screenshot(path=str(output / 'warning-mobile.png')) + page.keyboard.press('Escape') + expect(page.get_by_role('tooltip')).to_have_count(0) + page.set_viewport_size({'width': 1440, 'height': 1000}) + page.get_by_role("button", name="Уточнение: квартиль и основание зачёта").first.click() + expect(page.get_by_role("tooltip").first).to_contain_text("Нет подтверждённого основания") + page.keyboard.press("Escape") assert page.locator(".council__candidate").first.evaluate("e => e.open") page.screenshot(path=str(output / "desktop.png"), full_page=True) page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False) @@ -131,7 +163,7 @@ def main(): assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27" 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") + expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28") expect(page.get_by_label("Сортировать по")).to_have_value("full_name") expect(page.get_by_label("Направление")).to_have_value("desc") with page.expect_download() as pending_download: @@ -142,16 +174,16 @@ def main(): 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") + expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–25 из 28") expect(page.get_by_label("Сортировать по")).to_have_value("full_name") expect(page.get_by_label("Направление")).to_have_value("desc") page.get_by_label("Специальность ВАК").select_option("1.2.2") page.get_by_role("button", name="Применить", exact=True).click() - expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28") + expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28") expect(page.locator(".council__candidate-identity strong")).to_have_text("Сотрудник 27") page.locator(".council__candidate-summary").click() expect(page.get_by_text("Публикаций по выбранной специальности: 1", exact=True)).to_be_visible() - expect(page.get_by_text("По специальности 1.2.2:", exact=False)).to_be_visible() + expect(page.get_by_text("Специальность: 1.2.2", exact=False).first).to_be_visible() for width in (1440, 768, 390, 320): page.set_viewport_size({"width": width, "height": 900}) assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width diff --git a/tests/browser_council_editor.py b/tests/browser_council_editor.py index a5d06a7..90ab32f 100644 --- a/tests/browser_council_editor.py +++ b/tests/browser_council_editor.py @@ -86,7 +86,7 @@ def main(): 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.get_by_role('dialog').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') @@ -112,12 +112,12 @@ def main(): 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.get_by_role('dialog').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('Журнал: Новый журнал · Квартиль: Q2', exact=False)).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() @@ -127,7 +127,7 @@ def main(): page.keyboard.press('Escape') page.reload() page.locator('.council__candidate-summary').click() - expect(page.get_by_text('Журнал: Новый журнал · Квартиль: Q2', exact=True)).to_be_visible() + expect(page.get_by_text('Квартиль: Q2', exact=False)).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('физико-математические') diff --git a/tests/test_api.py b/tests/test_api.py index 707c8f7..4ba6e65 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"] == "1.0.1" + assert response.json()["backend_version"] == "1.1.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index f7b26bc..edfbebb 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -215,11 +215,15 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text assert "Используется высшая обнаруженная степень; её документальное подтверждение проверяется отдельно." in response.text assert "CSV по критериям" not 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 "