Merge pull request 'Ускорение диссовета: подгрузка публикаций по сотруднику и понятные подсказки' (#68) from fix/council-loading into main
Reviewed-on: #68 Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #68.
This commit is contained in:
commit
03ac2f5ee8
22 files changed
+405
-109
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(); });
|
||||
});
|
||||
|
||||
@@ -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 }}&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 }}&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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "<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 "<script>" 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 & Name</b>. 2024.'},
|
||||
'shortLeft': {'en': 'Journal & Name. 2024.'}}
|
||||
assert _description_journals(description) == ['Journal Name', 'Journal & Name', 'Journal & Name']
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user