Merge pull request 'Ускорение диссовета: подгрузка публикаций по сотруднику и понятные подсказки' (#68) from fix/council-loading into main
CI / test (push) Successful in 9m33s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped

Reviewed-on: #68
Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #68.
This commit is contained in:
admin committed 2026-10-10 14:33:17 +00:00
commit 03ac2f5ee8
22 files changed
+405 -109

No files matched your search

+6
View File
@@ -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 или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
+18 -2
View File
@@ -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:
+60 -20
View File
@@ -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
+26 -11
View File
@@ -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):
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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; } }
+79 -2
View File
@@ -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,71 @@
});
});
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); });
warning.addEventListener('council-tooltip-close', () => { pinned = false; show(false); });
});
}
setupArticles(document);
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
document.querySelectorAll('[data-warning] button[aria-expanded="true"]').forEach(button => {
button.closest('[data-warning]').dispatchEvent(new Event('council-tooltip-close'));
});
});
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(); });
});
+4 -41
View File
@@ -89,47 +89,10 @@
</span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
<p>Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}{% if not reason.startswith("Подтверждено ") %}<li>{{ reason }}</li>{% endif %}{% endfor %}</ul>{% endif %}
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = candidate.employee_id %}
{% include "council_edit_button.html" %}
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year or 'Год не указан' }}</span>
</div>
<div class="council__article-meta">
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
<span>{{ article.type_label }}</span>
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
</div>
<p class="council__hint">Журнал: {{ article.journals|join(" / ") or "не указан" }} · Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}</p>
<ul class="council__evidence" aria-label="Критерии и подтверждения">
<li>Зачёт в подтверждённое число: {{ 'да' if article.confirmed else 'требует проверки' }}{% if article.publication_date %}; точная дата: {{ article.publication_date.strftime('%d.%m.%Y') }}{% endif %}</li>
{% if specialty %}<li>{{ article.thematic_status }}</li>{% endif %}
{% for message in article.messages %}<li>{{ message }}</li>{% endfor %}
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for record in article.catalog_records %}
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
<ul>{% for metric in record.metrics %}<li>{{ 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 %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for text in record.thematic %}<li>{{ text }}</li>{% endfor %}
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ol>
<div class="council__candidate-body" data-council-content
data-articles-url="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&amp;branch={{ branch|urlencode }}">
<p class="council__hint" role="status" data-articles-status>Публикации загрузятся при раскрытии карточки.</p>
<div data-articles-content><noscript><a class="admin__link" href="/admin/council/employees/{{ candidate.employee_id }}/articles?specialty={{ specialty|urlencode }}&amp;branch={{ branch|urlencode }}">Открыть публикации сотрудника</a></noscript></div>
</div>
</details>
{% endfor %}
@@ -0,0 +1,38 @@
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
<p>Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}{% if not reason.startswith("Подтверждено ") %}<li>{{ reason }}</li>{% endif %}{% endfor %}</ul>{% endif %}
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = candidate.employee_id %}
{% include "council_edit_button.html" %}
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span>
</div>
<div class="council__article-meta">
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
<span>{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</span>
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
{% if article.rows %}<span>Строки выгрузки ВШЭ: {{ article.rows|join(', ') }}</span>{% endif %}
</div>
<p class="council__hint">Журнал: {{ article.journals|join(" / ") or "не указан" }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
{% if specialty %}<p class="council__hint">Специальность: {{ specialty }} — {{ specialty_name }}{% set field = 'specialty' %}{% set label = 'специальность статьи' %}{% include 'council_warning.html' %}</p>{% endif %}
<ul class="council__evidence" aria-label="Источники сведений">
{% for basis in article.confirmed_bases %}<li>{{ basis.basis }} · {{ basis.system or 'издание' }} · {{ basis.category or '' }} · {{ basis.metric or '' }} · применимо к {{ basis.applicable_year }} · {{ basis.source }}{% if basis.source_url %} <a class="admin__link" href="{{ basis.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for record in article.catalog_records %}
<li>{{ record.name }} · {{ record.id }} · ISSN {{ record.issn or '—' }} / {{ record.eissn or '—' }} · {{ record.catalog }}
<ul>{% for metric in record.metrics %}<li>{{ 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 %} <a class="admin__link" href="{{ metric.source_url }}" target="_blank" rel="noopener noreferrer">Источник</a>{% endif %}</li>{% endfor %}
{% for url in record.passport_urls %}{% if url %}<li><a class="admin__link" href="{{ url }}" target="_blank" rel="noopener noreferrer">Паспорт специальности</a></li>{% endif %}{% endfor %}</ul>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ol>
+3 -3
View File
@@ -24,10 +24,10 @@
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}
<p class="council__hint">{{ article.employee_name }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}</span></div>
<p class="council__hint">Журнал: {{ article.journals|join(' / ') or 'не указан' }} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span></div>
<p class="council__hint">{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Журнал: {{ 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' %}</p>
<p class="council__hint">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
{% if article.manual_review %}<p class="council__hint">Ручная корректировка · требует подтверждения</p>{% endif %}
</li>
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
</ol>
+10
View File
@@ -0,0 +1,10 @@
{% if article.warnings[field] %}
<span class="council__warning" data-warning>
{% set warning_id = 'warning-' ~ employee_id ~ '-' ~ article.edit_key ~ '-' ~ field %}
<button class="council__warning-button" type="button" aria-label="Уточнение: {{ label }}" aria-expanded="false" aria-describedby="{{ warning_id }}">
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 6v8"/><circle cx="12" cy="18" r=".5" fill="currentColor"/></svg>
</button>
<span class="council__tooltip" role="tooltip" id="{{ warning_id }}" hidden>{{ article.warnings[field]|unique|join('. ') }}</span>
<noscript><span>{{ article.warnings[field]|unique|join('. ') }}</span></noscript>
</span>
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.0.1"
FRONTEND_VERSION = "1.0.1"
BACKEND_VERSION = "1.0.1"
APP_VERSION = "1.1.1"
FRONTEND_VERSION = "1.1.1"
BACKEND_VERSION = "1.1.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "1.0.1"
version = "1.1.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+49 -7
View File
@@ -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,55 @@ 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
summary.focus()
warning.hover()
expect(summary).to_be_focused()
expect(page.get_by_role('tooltip')).to_be_visible()
page.keyboard.press('Escape')
expect(page.get_by_role('tooltip')).to_have_count(0)
expect(warning).to_have_attribute('aria-expanded', 'false')
assert warning.evaluate("button => button.matches(':hover')")
expect(summary).to_be_focused()
page.mouse.move(5, 5)
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 +173,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 +184,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
+4 -4
View File
@@ -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('физико-математические')
+1 -1
View File
@@ -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.1"
def test_api_employees_and_stats_require_admin_session():
+70 -4
View File
@@ -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 "&lt;script&gt;" in response.text and "<script>" not in response.text
assert 'class="council__article"' not in response.text
fragment = client.get(f"/admin/council/employees/{person.id}/articles")
assert fragment.status_code == 200
assert "Книга" in fragment.text and "не указан тип материала" in fragment.text
assert "&lt;script&gt;" in fragment.text and "<script>" not in fragment.text
assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 1.0.1 · Frontend 1.0.1" in response.text
assert "Backend 1.1.1 · Frontend 1.1.1" in response.text
assert "Вход в админку" in response.text
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
@@ -499,7 +503,7 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
assert response.status_code == 200
assert '<option value="1.2.2" selected>' in response.text
assert response.text.count('<option value="2.3.5"') == 1
assert "Публикаций по выбранной специальности: 1" in response.text
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
assert "Не подходит" not in response.text
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, "html.parser")
@@ -617,3 +621,65 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
assert 'Q1 ИЛИ Q2 ИЛИ К1' in params['Всего']
assert 'источники указаны на листе' not in ' '.join(str(v) for v in params.values())
assert not any('Работ / По выбранной специальности' == key for key in params)
def test_council_lazy_loading_selects_only_matching_publications(db_session, export, monkeypatch):
from sqlalchemy import event
first = employee(db_session, name='Первый сотрудник', key='first')
second = employee(db_session, name='Второй сотрудник', key='second')
publication(db_session, first, title='Первая статья', kind='ARTICLE')
publication(db_session, second, title='Вторая статья', kind='ARTICLE')
db_session.commit()
parsed = []
original = council._database_article
def tracked(publication):
parsed.append(publication.employee_id)
return original(publication)
monkeypatch.setattr(council, '_database_article', tracked)
result = council.council_candidates(db_session, q='первый')
assert result['total'] == 2 and result['matched'] == 1
assert parsed == [first.id]
parsed.clear()
statements = []
def sql(connection, cursor, statement, parameters, context, executemany):
statements.append(statement)
event.listen(db_session.bind, 'before_cursor_execute', sql)
try:
result = council.council_candidates(db_session, q='нет совпадений')
assert result['total'] == 2 and result['matched'] == 0
assert not any('employee_publications' in statement for statement in statements)
finally:
event.remove(db_session.bind, 'before_cursor_execute', sql)
assert parsed == []
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
page = client.get('/admin/council')
assert 'data-articles-url' in page.text and 'class="council__article"' not in page.text
detail = client.get(f'/admin/council/employees/{first.id}/articles')
assert detail.status_code == 200
assert 'Первая статья' in detail.text and 'Вторая статья' not in detail.text
assert parsed == [first.id, second.id, first.id]
assert client.get('/admin/council/employees/99999/articles').status_code == 404
assert client.get(f'/admin/council/employees/{first.id}/articles?specialty=wrong').status_code == 422
finally:
app.dependency_overrides.clear()
def test_lazy_loading_preserves_global_duplicate_name_check(db_session, export):
export([row(author='Одно ФИО', title='Не присваивать автоматически')])
first = employee(db_session, name='Одно ФИО', key='first')
employee(db_session, name='Одно ФИО', key='second')
publication(db_session, first, title='Своя статья')
db_session.commit()
result = council.council_candidates(db_session, employee_id=first.id)
assert result['total'] == 2 and result['matched'] == 1
assert result['candidates'][0]['ambiguous']
assert [a['title'] for a in result['candidates'][0]['articles']] == ['Своя статья']
def test_plain_description_fast_path_and_html_entities():
from app.services.council_source import _description_journals
description = {'short': {'ru': ' Journal Name. 2024. № 1. ', 'en': '<b>Journal &amp; Name</b>. 2024.'},
'shortLeft': {'en': 'Journal &amp; Name. 2024.'}}
assert _description_journals(description) == ['Journal Name', 'Journal & Name', 'Journal & Name']
+18 -5
View File
@@ -373,7 +373,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
try:
response = TestClient(app).get("/admin/council")
assert response.status_code == 200 and "целостность каталога" in response.text
assert journal["name"] in response.text
assert journal["name"] in TestClient(app).get(f"/admin/council/employees/{employee.id}/articles").text
finally:
app.dependency_overrides.clear()
finally:
@@ -413,14 +413,16 @@ def test_automatic_quartile_identity_conflict_and_messages(db_session, proofs):
article = result(db_session)['articles'][0]
assert not article['confirmed']
assert len(article['messages']) == len(set(article['messages']))
issue = 'Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка'
issue = article['warnings']['journal'][0]
assert 'Другой журнал' in issue and 'Текущий журнал' in issue
assert article['messages'].count(issue) == 1
app.dependency_overrides[get_db] = lambda: db_session
try:
response = TestClient(app).get('/admin/council')
response = TestClient(app).get(f'/admin/council/employees/{employee.id}/articles')
from bs4 import BeautifulSoup
card = BeautifulSoup(response.text, 'html.parser').select_one('li.council__article')
assert card.get_text().count(issue) == 1
assert sum(tip.get_text().count(issue) for tip in card.select('[role=tooltip]')) == 1
assert 'Зачёт в подтверждённое число' not in card.get_text()
assert 'Специальность для рассмотрения статьи не выбрана' not in card.get_text()
assert 'Пункт:' not in response.text and 'Минимум:' not in response.text
finally:
@@ -474,8 +476,19 @@ def test_automatic_quartile_categories_and_metrics_preserve_conflicts(db_session
assert candidate['q1_count'] == candidate['q2_count'] == candidate['total_count'] == 1
assert candidate['confirmed_count'] == int(expected)
article = candidate['articles'][0]
assert ('Подтверждённые основания источников расходятся' in article['confirmation_reasons']) == (not expected)
assert any(reason.startswith('Подтверждённые основания источников расходятся') for reason in article['confirmation_reasons']) == (not expected)
assert [(basis.get('category'), basis.get('metric'), basis.get('metric_year'), basis['source'], basis['source_url'])
for basis in article['automatic_quartile_bases']] == [
('Analysis', 'CiteScore', 2024, first['source'], first['source_url']),
(category, metric, 2024, second['source'], second['source_url'])]
def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_session, proofs):
employee = person(db_session, ['Доктор технических наук'])
db_session.add(EmployeePublication(employee_id=employee.id, title='Missing kind', year=2024,
source_hash='missing', raw_data={'journal': 'Journal', 'csv_enrichment': {'Название журнала': 'Journal', 'Квартиль scopus': 'Q1'}}))
candidate = result(db_session)
article = candidate['articles'][0]
assert article['display_type'] == 'Статья' and article['types'] == []
assert article['warnings']['type'] and 'не указан тип материала' in article['warnings']['type'][0]
assert not article['confirmed'] and candidate['confirmed_count'] == 0
+3 -1
View File
@@ -61,7 +61,9 @@ def test_editor_shared_quartile_persistence_filter_and_export(editor, db_session
assert payload['title'] in response.text
assert 'Статьи не найдены' in client.get('/admin/council/publications?title=absent').text
main = client.get('/admin/council')
assert 'data-council-edit' in main.text and payload['title'] in main.text
assert 'data-council-edit' not in main.text and payload['title'] not in main.text
fragment = client.get(f"/admin/council/employees/{payload['employee_id']}/articles")
assert 'data-council-edit' in fragment.text and payload['title'] in fragment.text
from io import BytesIO
from openpyxl import load_workbook
workbook = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
+1 -1
View File
@@ -233,7 +233,7 @@ def test_crawler_doi_or_journal_change_excludes_preserved_quartile(new_doi, new_
article = _database_article(publication)
assert publication.quartiles == payload
assert article["quartiles"] == [] and article["quartile_issues"]
assert all("перепроверка" in issue for issue in article["quartile_issues"])
assert all("перепроверка" in issue or "Уточните журнал статьи" in issue for issue in article["quartile_issues"])
def test_updating_lookup_freezes_legacy_binding_instead_of_rebinding_old_entries():