diff --git a/README.md b/README.md index ffcf194..a91ee13 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,12 @@ Compose запускает `api`, `worker` и `postgres`. API привязан Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет. +Начальный список диссовета содержит только сотрудников и счётчики. Публикации загружаются при раскрытии карточки через `/admin/council/employees/{id}/articles` с сохранением отрасли и специальности; повторное раскрытие использует уже загруженные данные текущей страницы. При ошибке доступна кнопка повтора, без JavaScript — ссылка на публикации. Экспорт XLSX продолжает включать все найденные записи. + +До загрузки публикаций применяется поиск по ФИО и выбор конкретного сотрудника. Проверка одинаковых ФИО остаётся глобальной, а общий счётчик сотрудников не зависит от поиска. PostgreSQL возвращает только разделы профиля об образовании/степенях и необходимые поля публикаций; аннотации, авторы и остальной профиль не загружаются. Plain-text описания журналов не проходят через HTML-парсер; HTML и сущности обрабатываются как прежде. + +Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены. + Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора. Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново. diff --git a/app/admin.py b/app/admin.py index 1b9857c..64b8398 100644 --- a/app/admin.py +++ b/app/admin.py @@ -150,6 +150,22 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result}) +@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse) +def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические", + db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + try: + result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch) + if not result["candidates"]: + raise HTTPException(404, "Сотрудник не найден или не соответствует фильтру. Обновите список") + except CouncilInputError as exc: + raise HTTPException(422, str(exc)) + except (SQLAlchemyError, OSError, ValueError, KeyError): + db.rollback() + raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз") + return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0], + "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result}) + + class CouncilArticleEdit(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True) employee_id: int = Field(gt=0) @@ -167,7 +183,7 @@ def council_publications(request: Request, employee: str = "", title: str = "", context = {"public_page": True, "employee": employee, "title": title, "journal": journal, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username} try: - result = council_candidates(db, paginate=False, sort="full_name", direction="asc") + result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc") articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]} for candidate in result["candidates"] for article in candidate["articles"] if employee.casefold().strip() in candidate["name"].casefold() @@ -195,7 +211,7 @@ def edit_council_publication(request: Request, change: CouncilArticleEdit, if not _journal_key(change.journal): raise HTTPException(422, "Укажите название журнала") try: - result = council_candidates(db, paginate=False) + result = council_candidates(db, employee_id=change.employee_id, paginate=False) article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id for a in c["articles"] if a["edit_key"] == change.edit_key), None) if not article: diff --git a/app/services/council.py b/app/services/council.py index 27fda30..758aecc 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -10,10 +10,11 @@ from pathlib import Path from urllib.parse import quote, urlsplit from zoneinfo import ZoneInfo -from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy import select, func, case, cast +from sqlalchemy.dialects.postgresql import JSONPATH +from sqlalchemy.orm import Session -from app.models import Employee, CouncilCorrections +from app.models import Employee, EmployeePublication, CouncilCorrections, json_type from app.services.academic_degrees import academic_degrees from app.services.council_source import _doi, _journal_names, _name, load_reference, _description_journals from app.services.council_catalog import catalog, publication_catalog @@ -101,7 +102,7 @@ def _articles_by_author() -> dict[str, list[dict]]: def _database_article(publication) -> dict: raw = publication.raw_data if isinstance(publication.raw_data, dict) else {} imported = raw.get("csv_enrichment") - from_site = not imported or bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw)) + from_site = not imported or bool(publication.publication_id or publication.url or getattr(publication, "raw_has_metadata", any(key != "csv_enrichment" for key in raw))) origins = ["ЛК сотрудника ВШЭ"] if from_site else [] if imported: origins.append("Данные ВШЭ") @@ -252,7 +253,7 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "", - branch: str = "технические") -> dict: + branch: str = "технические", employee_id: int | None = None) -> dict: if branch and branch not in BRANCHES: raise CouncilInputError("Неизвестная отрасль науки") today = today or datetime.now(ZoneInfo("Europe/Moscow")).date() @@ -261,27 +262,64 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs if specialty and specialty not in {s["code"] for s in specialties}: raise CouncilInputError("Неизвестная специальность ВАК") specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "") - employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all() + degree_sections = Employee.current_data["sections"] + if db.get_bind().dialect.name == "postgresql": + degree_sections = func.jsonb_path_query_array(Employee.current_data, + cast('$.sections[*] ? (@.title like_regex "образован|степен|academic degree|education" flag "i")', JSONPATH), type_=json_type) + employees = db.execute(select(Employee.id, Employee.profile_key, Employee.full_name, Employee.status, + Employee.canonical_url, degree_sections.label("degree_sections"))).all() name_counts = Counter(_name(employee.full_name or "") for employee in employees) by_author = _articles_by_author() verified = evidence() corrections_record = db.get(CouncilCorrections, 1) corrections = corrections_record.data if corrections_record else {} revision = corrections_record.revision if corrections_record else 0 - candidates = [] + eligible = [] + degrees_by_employee = {} for employee in employees: + try: + degrees = academic_degrees({"sections": employee.degree_sections}) + except (AttributeError, TypeError): + degrees = [] + if any(degree_level(degree) for degree in degrees) or verified["employees"].get(employee.profile_key, {}).get("degrees"): + eligible.append(employee) + degrees_by_employee[employee.id] = degrees + total = len(eligible) + query = _name(q) + eligible = [employee for employee in eligible if query in _name(employee.full_name or "ФИО не указано") + and (employee_id is None or employee.id == employee_id)] + publication_fields = [EmployeePublication.employee_id, EmployeePublication.title, EmployeePublication.year, + EmployeePublication.publication_type, EmployeePublication.publication_id, EmployeePublication.url, + EmployeePublication.doi_url, EmployeePublication.source_hash, EmployeePublication.quartiles] + raw_data, description = EmployeePublication.raw_data, EmployeePublication.description + if db.get_bind().dialect.name == "postgresql": + # Only council metadata crosses the DB boundary; abstracts/authors stay in PostgreSQL. + description = case((func.jsonb_typeof(description) == "object", func.jsonb_build_object( + "short", description["short"], "shortLeft", description["shortLeft"], type_=json_type)), + else_=description) + raw = EmployeePublication.raw_data + raw_has_metadata = case((func.jsonb_typeof(raw) == "object", + raw != func.jsonb_build_object("csv_enrichment", raw["csv_enrichment"])), else_=False) + raw_keys = ("csv_enrichment", "journal", "Журнал", "Название журнала", "issn", "eissn", "ISSN", "EISSN") + raw_data = case((func.jsonb_typeof(raw) == "object", func.jsonb_build_object( + *[value for key in raw_keys for value in (key, raw[key])], + "description", case((func.jsonb_typeof(EmployeePublication.description) == "object", None), + else_=raw["description"]), type_=json_type)), else_=None) + publication_fields.append(raw_has_metadata.label("raw_has_metadata")) + publications_by_employee = defaultdict(list) + if eligible: + publications = db.execute(select(*publication_fields, raw_data.label("raw_data"), description.label("description")) + .where(EmployeePublication.employee_id.in_([e.id for e in eligible]))).all() + for publication in publications: + publications_by_employee[publication.employee_id].append(publication) + candidates = [] + for employee in eligible: author = _name(employee.full_name or "") ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author)) csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else [] - data = employee.current_data if isinstance(employee.current_data, dict) else {} - try: - degrees = academic_degrees(data) - except (AttributeError, TypeError): - degrees = [] + degrees = degrees_by_employee[employee.id] employee_evidence = verified["employees"].get(employee.profile_key, {}) - if not any(degree_level(degree) for degree in degrees) and not employee_evidence.get("degrees"): - continue - articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end, corrections, employee.id) + articles = _unique_articles([*csv_articles, *(_database_article(p) for p in publications_by_employee[employee.id])], start, end, corrections, employee.id) for article in articles: article["manual_review"] = article["journal_corrected"] or (len(article["journals"]) == 1 and f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {})) @@ -353,13 +391,18 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs if not any((a["doi"] and p.get("doi") == a["doi"]) or p.get("source_hash") in a["source_hashes"] for a in changed)]} assessment = assess_candidate(degrees, articles, adjusted_evidence, branch, specialty, start, end) for article in changed: - article["confirmation_reasons"].append("Ручная корректировка требует повторного подтверждения источниками") + message = "Журнал или квартиль исправлен вручную. Для зачёта требуется источник, подтверждающий эти сведения за год статьи" + article["confirmation_reasons"].append(message) + article["warnings"]["quartile"].append(message) specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0 for article in articles: article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"]) if article["matches_specialty"]: article["criteria"].append(f"По специальности {specialty}: {specialty_name} ({'ручная связь журнала' if article['manual_specialties'] else 'журнал К1, Перечень ВАК'})") for article in articles: + if specialty: + article["warnings"]["specialty"].extend(text for record in article["catalog_records"] + for text in record["thematic"] if not text.startswith(("Паспорт ", "Аналитическая связь журнала:"))) article["messages"] = list(dict.fromkeys([*article["criteria"], *article["confirmation_reasons"]])) candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id, "count": len(articles), @@ -373,10 +416,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc") if sort != "full_name": candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc") - total = len(candidates) - query = _name(q) - candidates = [item for item in candidates if query in _name(item["name"]) - and (not specialty or item["specialty_count"] > 0)] + candidates = [item for item in candidates if not specialty or item["specialty_count"] > 0] matched = len(candidates) limit = 25 offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 diff --git a/app/services/council_assessment.py b/app/services/council_assessment.py index 61c5078..a810b11 100644 --- a/app/services/council_assessment.py +++ b/app/services/council_assessment.py @@ -127,6 +127,10 @@ def publication_proofs(employee_evidence, article): def assess_article(article, proofs, start, end, level, branch, specialty, policy): reasons = [*article.get("catalog_issues", []), *article.get("quartile_issues", [])] + warnings = {"journal": list(dict.fromkeys(reasons)), "year": [], "type": [], "quartile": [], "specialty": []} + def report(field, message): + reasons.append(message) + warnings[field].append(message) years = article["source_years"] dates = set() for proof in proofs: @@ -138,32 +142,39 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy date_years = {value.year for value in dates} date_conflict = len(years) > 1 or len(date_years) > 1 or bool(date_years and years and not date_years <= set(years)) if date_conflict: - reasons.append("Годы публикации в источниках расходятся") + report("year", "Источники указывают разные годы публикации. Уточните год статьи") exact = next(iter(dates)) if len(dates) == 1 else None publication_year = article["year"] if article["year"] is not None else next(iter(date_years)) if len(date_years) == 1 else None in_period = publication_year is not None and start.year <= publication_year <= end.year if not in_period: - reasons.append(f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None + report("year", f"Вне пятилетнего периода {start.year}–{end.year}" if publication_year is not None else "Год не указан — период не подтверждён") types = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))} automatic_bases = [b for b in article.get("automatic_quartile_bases", []) if b["applicable_year"] == (exact.year if exact else article["year"]) and not article.get("catalog_issues") and not article.get("quartile_issues")] - if len(types) != 1 and not (not types and article["types"] == ["Статья"] and automatic_bases): - reasons.append("Вид материала не подтверждён или расходится") + if not types and not (article["types"] == ["Статья"] and automatic_bases): + if len(article["types"]) > 1: + report("type", "Источники называют разные виды работы: " + ", ".join(article["types"]) + ". Уточните тип материала") + elif not article["types"]: + report("type", "В источниках не указан тип материала. В карточке показано «Статья», но для зачёта нужно уточнить вид работы") + elif article["types"] == ["Статья"]: + report("quartile", "Тип «Статья» указан в источнике, но для зачёта по К1/К2/RSCI отдельная запись проверки работы не сохранена. Проверьте основание зачёта") + else: + report("type", f"Источник указывает вид «{article['types'][0]}». Для включения такого материала в минимум нужно отдельное подтверждение") kind = next(iter(types)) if len(types) == 1 else "article" if not types and article["types"] == ["Статья"] and automatic_bases else None type_conflict = len(types) > 1 or bool(article["types"] and kind and article["types"] != [TYPE_LABELS.get(kind)]) if type_conflict: - reasons.append("Виды материала в источниках расходятся") + report("type", "Источники называют разные виды работы. Уточните, является ли публикация статьёй") eligible_kind = publication_type_allowed(article["types"], kind, policy) if kind and kind != "article" and not eligible_kind: - reasons.append("Зачёт этого вида материала в общий минимум не подтверждён") + report("type", "Это другой вид работы, а не статья. Его включение в публикационный минимум требует отдельного основания") bases = [] basis_year = exact.year if exact else article["year"] for proof in proofs: for basis in proof.get("bases", []): if not isinstance(basis, dict): - reasons.append("Некорректное подтверждение основания") + report("quartile", "Запись об основании зачёта повреждена. Проверьте источник категории журнала") continue category = basis.get("basis") valid = sourced(basis) and type(basis.get("applicable_year")) is int and basis["applicable_year"] == basis_year @@ -174,7 +185,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy if valid: bases.append(basis) else: - reasons.append("Источник или год применимости основания не подтверждены") + report("quartile", "Для категории журнала не подтверждён источник или год применимости. Нельзя переносить категорию автоматически на год статьи") if kind == "article": bases.extend(automatic_bases) # Different categories may legitimately have different quartiles; conflicting @@ -185,19 +196,19 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy key = ("ВАК", basis_year) if basis["basis"] in {"К1", "К2"} else ( basis.get("system", basis["basis"]), basis.get("category"), basis.get("metric"), basis.get("metric_year"), basis_year) if key in claims and claims[key] != basis["basis"]: - reasons.append("Подтверждённые основания источников расходятся") + report("quartile", "Подтверждённые основания источников расходятся: для одной системы, категории, метрики и года указаны разные значения") basis_conflict = True claims[key] = basis["basis"] accepted = [b for b in bases if level == 2 or b["basis"] != "К2"] if not accepted: - reasons.append("Нет подтверждённого допустимого основания выбранного пункта") + report("quartile", f"Нет подтверждённого основания за {basis_year or 'год публикации'}: Q1/Q2, К1{'/К2' if level == 2 else ''} или RSCI. Проверьте категорию журнала, год и источник") # Missing optional evidence is not a veto on another sufficient independent # basis. Identity/date/type conflicts, however, invalidate the whole work. identity_conflict = bool(article.get("quartile_issues")) or any("расход" in reason or "идентичности" in reason or "разные журналы" in reason for reason in article.get("catalog_issues", [])) if article.get("proof_identity_conflict"): identity_conflict = True - reasons.append("Идентификаторы ручного подтверждения указывают на разные работы") + report("journal", "DOI и идентификатор в ручном подтверждении указывают на разные работы. Проверьте привязку источника к этой статье") conflicts = date_conflict or type_conflict or basis_conflict or identity_conflict article["confirmed"] = bool(in_period and eligible_kind and accepted and not conflicts) mono_policy = policy.get("monograph_period", {}) @@ -205,6 +216,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy and (mono_policy.get("value") == "all_years" or (mono_policy.get("value") == "five_years" and in_period))) article["confirmation_reasons"] = list(dict.fromkeys(reasons)) + article["warnings"] = warnings + article["display_type"] = article["type_label"] if len(article["types"]) == 1 else "Статья" article["publication_date"] = exact article["confirmed_bases"] = [{**b, "source_url": safe_url(b.get("source_url"))} for b in accepted] article["thematic_status"] = "Рассмотрение статьи по паспорту не подтверждено" if specialty else "Специальность для рассмотрения статьи не выбрана" @@ -225,6 +238,8 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy f"пункты {review['point_numbers']}; {review['checked_on']}") if len({r["result"] for r in valid_reviews}) > 1: article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка" + if specialty and (not valid_reviews or any(r["result"] != "confirmed" for r in valid_reviews)): + warnings["specialty"].append("Связь журнала со специальностью не доказывает соответствие содержания каждой статьи. Рассмотрение этой работы по паспорту специальности не подтверждено") def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end): diff --git a/app/services/council_catalog.py b/app/services/council_catalog.py index 8fe9b98..226f446 100644 --- a/app/services/council_catalog.py +++ b/app/services/council_catalog.py @@ -149,7 +149,7 @@ def publication_catalog(identifiers, names, specialty=""): return [], [*issues, "ISSN указывают на разные журналы — требуется проверка"] records = data["journals"].get(next(iter(matches)), []) if matches else [] if records and names and not any(str(name).casefold().strip() == r["name"].casefold() for name in names for r in records): - issues.append("Название журнала и ISSN требуют подтверждения идентичности") + issues.append("Название журнала в публикации и запись по ISSN расходятся. Проверьте, относятся ли они к одному журналу") output = [] for record in records: link = record["links"].get(specialty) diff --git a/app/services/council_source.py b/app/services/council_source.py index a2b8aef..b56070a 100644 --- a/app/services/council_source.py +++ b/app/services/council_source.py @@ -47,7 +47,7 @@ def _description_journals(description: dict) -> list[str]: values = value.values() if isinstance(value, dict) else [value] for text in values: if isinstance(text, str): - text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) + text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True) if "<" in text or "&" in text else text.strip() # HSE short descriptions start with the journal, followed by the year. names.append(re.split(r"\b(?:19|20)\d{2}\b", text, maxsplit=1)[0].strip(" .,:;")) return names diff --git a/app/services/publication_quartiles.py b/app/services/publication_quartiles.py index f9bdbd4..4c69e3b 100644 --- a/app/services/publication_quartiles.py +++ b/app/services/publication_quartiles.py @@ -118,7 +118,7 @@ def binding_issue(item, payload, doi="", journals=(), identifiers=()): source_name = _name(item.get("journal") or "") names_match = bool(source_name and source_name in {_name(name) for name in journals if name}) if source_name and journals and not names_match and not ids_match: - return "Журнал сохранённого квартиля и публикации расходятся — требуется перепроверка" + return f"Квартиль сохранён для журнала «{item.get('journal')}», а статья отнесена к «{' / '.join(journals)}». Совпадение по ISSN не подтверждено. Уточните журнал статьи и источник квартиля" if not (source_doi and source_doi == current_doi) and not ids_match and not names_match: return "Привязка сохранённого квартиля к публикации не подтверждена — требуется перепроверка" return None diff --git a/app/static/admin.css b/app/static/admin.css index 80d279b..b88a673 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -938,3 +938,11 @@ .council-editor__specialties fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid #d6dddc; border-radius: 8px; } .council-editor__specialties .columns-modal__grid { grid-template-columns: 1fr; margin-block: 12px; } .council__criteria-list { line-height: 1.55; } + +.council__warning { display: inline-block; vertical-align: middle; } +.council__warning-button { display: inline-flex; justify-content: center; align-items: center; min-width: 40px; min-height: 40px; padding: 0; border: 0; border-radius: 4px; color: #991b1b; background: transparent; cursor: pointer; vertical-align: middle; } +.council__warning-button:hover { background: #fee2e2; } +.council__tooltip { position: fixed; z-index: 30; width: min(360px, calc(100vw - 24px)); padding: 12px; box-sizing: border-box; border: 1px solid #d6dddc; border-radius: 8px; color: #1f2937; background: #fff; box-shadow: 0 6px 20px rgba(15, 23, 42, .18); font: 14px/1.5 system-ui, sans-serif; max-height: min(360px, calc(100vh - 24px)); overflow: auto; text-align: start; overflow-wrap: anywhere; } +.council__tooltip[hidden] { display: none; } +.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } } diff --git a/app/static/council.js b/app/static/council.js index 4159bfe..880c2aa 100644 --- a/app/static/council.js +++ b/app/static/council.js @@ -27,7 +27,19 @@ article.classList.add('council__article--active'); } - document.querySelectorAll('[data-council-article]').forEach(article => { + function positionTooltip(button, tip) { + const rect = button.getBoundingClientRect(); + tip.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - tip.offsetWidth - 12))}px`; + tip.style.top = `${Math.max(12, rect.bottom + tip.offsetHeight + 8 < innerHeight ? rect.bottom + 8 : rect.top - tip.offsetHeight - 8)}px`; + } + function repositionTooltips() { + document.querySelectorAll('[data-warning]').forEach(warning => { + const tip = warning.querySelector('[role="tooltip"]'); + if (!tip.hidden) positionTooltip(warning.querySelector('button'), tip); + }); + } + function setupArticles(root) { + root.querySelectorAll('[data-council-article]').forEach(article => { article.addEventListener('click', event => { if (!event.target.closest('a, button')) activate(article); }); @@ -40,7 +52,7 @@ }); }); - document.querySelectorAll('[data-council-edit]').forEach(button => { + root.querySelectorAll('[data-council-edit]').forEach(button => { button.addEventListener('click', () => { trigger = button; selected = JSON.parse(button.dataset.article); @@ -64,6 +76,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(); }); }); diff --git a/app/templates/council.html b/app/templates/council.html index d14e58f..a2f8e8a 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -89,47 +89,10 @@ -
{{ candidate.assessment_status }}. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.
-Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}
- {% if candidate.assessment_reasons %}Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.
{% endif %} - {% if specialty %}Публикаций по выбранной специальности: {{ candidate.specialty_count }}
{% endif %} - {% if candidate.profile_url %}Профиль на сайте ВШЭ{% endif %} - {% if candidate.ambiguous %}Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.
{% endif %} -Журнал: {{ article.journals|join(" / ") or "не указан" }} · Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}
-Публикации загрузятся при раскрытии карточки.
+{{ candidate.assessment_status }}. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.
+Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}
+ {% if candidate.assessment_reasons %}Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.
{% endif %} + {% if specialty %}Публикаций по выбранной специальности: {{ candidate.specialty_count }}
{% endif %} + {% if candidate.profile_url %}Профиль на сайте ВШЭ{% endif %} + {% if candidate.ambiguous %}Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.
{% endif %} +Журнал: {{ article.journals|join(" / ") or "не указан" }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %}
+Квартиль: {{ article.edit_quartile or "не указан или неоднозначен" }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}
+ {% if specialty %}Специальность: {{ specialty }} — {{ specialty_name }}{% set field = 'specialty' %}{% set label = 'специальность статьи' %}{% include 'council_warning.html' %}
{% endif %} +{{ article.employee_name }}
-Журнал: {{ article.journals|join(' / ') or 'не указан' }} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}
+{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}
+Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}
Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}
- {% if article.manual_review %}Ручная корректировка · требует подтверждения
{% endif %}