From f45c66e069a13092dd964d92edf77a95a411e51d Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 9 Oct 2026 12:03:19 +0300 Subject: [PATCH 1/4] feat: add council publication and journal quartile editor --- .gitea/workflows/ci.yml | 1 + README.md | 4 + app/admin.py | 96 ++++++++++++-- app/models.py | 11 ++ app/services/council.py | 60 +++++++-- app/static/admin.css | 19 +++ app/static/council.js | 104 +++++++++++++++ app/templates/council.html | 12 +- app/templates/council_edit_button.html | 3 + app/templates/council_edit_dialog.html | 34 +++++ app/templates/council_publications.html | 43 +++++++ app/version.py | 6 +- migrations/012_council_corrections.sql | 8 ++ pyproject.toml | 2 +- tests/browser_council_editor.py | 141 +++++++++++++++++++++ tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_council_editor.py | 161 ++++++++++++++++++++++++ 18 files changed, 684 insertions(+), 25 deletions(-) create mode 100644 app/static/council.js create mode 100644 app/templates/council_edit_button.html create mode 100644 app/templates/council_edit_dialog.html create mode 100644 app/templates/council_publications.html create mode 100644 migrations/012_council_corrections.sql create mode 100644 tests/browser_council_editor.py create mode 100644 tests/test_council_editor.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e97eba3..4cda449 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: python -m pip install -r requirements.txt - run: python -m pytest -q - run: node --check app/static/admin.js + - run: node --check app/static/council.js - run: sh -n infra/postgres-backup.sh - run: sh -n infra/install-postgres-backup-cron.sh diff --git a/README.md b/README.md index c40a8a7..311f99d 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ Compose запускает `api`, `worker` и `postgres`. API привязан Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет. +Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора. + +Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX, но требуют повторного подтверждения источниками для зачёта. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново. + Сервис читает подготовленный `app/data/council_articles.json` и таблицу `employee_publications`. Справочник преобразуется из CSV скриптом `scripts/prepare_council_articles.py`, проверяет схему и индекс авторов; импорт/парсинг CSV не объявляется доказательством выполнения критериев. Сопоставление данных ВШЭ по полному ФИО разрешено только при единственном совпадении. Дедупликация использует DOI либо нормализованные название и год при отсутствии неоднозначных DOI. Источники с разными годами не переносят категории между периодами. К1/К2 сопоставляются по точным нормализованным названиям журнала и документированным алиасам; название статьи, переводы и нечёткое совпадение не используются. Справочник К1 содержит 773 журнала, К2 — 1539; экстрактор проверяет все 3134 строки, даты и SHA-256 источника. Это текущие категории, а не историческое доказательство. Q1/Q2 читаются также из двух тематических JSON-каталогов (схемы 1.0.0/1.1.0, 229 уникальных журналов): ISSN/eISSN, категории CiteScore/JCR, источники и версии паспортов. Снимок метрик 2025 не распространяется автоматически на другие годы; текущая индексация не проверена. Каталоги не являются белым списком. Аналитические прямые/условные связи журнала показываются отдельно от рассмотрения статьи по паспорту. diff --git a/app/admin.py b/app/admin.py index 66ce9df..ac69fdc 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,14 +1,16 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request +from copy import deepcopy +from pydantic import BaseModel, Field, ConfigDict +from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates -from sqlalchemy import desc, func, select -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import desc, func, select, update +from sqlalchemy.exc import SQLAlchemyError, IntegrityError from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.db import SessionLocal, get_db -from app.models import CrawlError, CrawlRun, Employee -from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin +from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections +from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin, read_session from app.services.admin_data import ( employee_detail_payload, format_admin_datetime, @@ -19,7 +21,7 @@ from app.services.admin_data import ( ) from app.services.crawl_control import get_running_run, run_crawl_if_idle from app.services.crawler import refresh_dismissed_status, refresh_employee -from app.services.council import CouncilInputError, council_candidates +from app.services.council import CouncilInputError, council_candidates, _journal_key from app.services.council_export import council_xlsx from app.version import BACKEND_VERSION, FRONTEND_VERSION @@ -136,7 +138,7 @@ def directory( @router.get("/council", response_class=HTMLResponse) def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", - db: Session = Depends(get_db)): + db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): try: result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty, branch="технические") @@ -145,7 +147,85 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c except (SQLAlchemyError, OSError, ValueError, KeyError): db.rollback() return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503) - return _render(request, "council.html", {"error": False, "public_page": True, **result}) + return _render(request, "council.html", {"error": False, "public_page": True, "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) + edit_key: str = Field(pattern=r"^[a-f0-9]{64}$") + revision: int = Field(ge=0) + title: str = Field(min_length=1, max_length=2000) + journal: str = Field(min_length=1, max_length=500) + quartile: str = Field(pattern=r"^(Q[1-4])?$") + + +@router.get("/council/publications", response_class=HTMLResponse) +def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0, + db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + 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") + 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() + and title.casefold().strip() in article["title"].casefold() + and journal.casefold().strip() in " ".join(article["journals"]).casefold()] + total, limit = len(articles), 50 + offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0 + return _render(request, "council_publications.html", {**context, "error": False, + "articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit, + "revision": result["revision"], "journal_names": result["journal_names"]}) + except (SQLAlchemyError, OSError, ValueError, KeyError): + db.rollback() + return _render(request, "council_publications.html", {**context, "error": True}, status_code=503) + + +@router.post("/council/publications") +def edit_council_publication(request: Request, change: CouncilArticleEdit, + db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): + username = read_session(request.cookies.get(SESSION_COOKIE), settings) + if username != settings.admin_username: + raise HTTPException(401, "Для сохранения войдите в админку") + # A custom header plus JSON prevents cross-site form submissions; CORS is disabled. + if request.headers.get("X-Council-Edit") != "1": + raise HTTPException(403, "Недопустимый запрос на изменение") + if not _journal_key(change.journal): + raise HTTPException(422, "Укажите название журнала") + try: + result = council_candidates(db, 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: + raise HTTPException(404, "Статья не найдена. Обновите страницу") + if article["year"] is None and change.quartile: + raise HTTPException(422, "Для квартиля необходим однозначный год публикации") + record = db.get(CouncilCorrections, 1) + revision = record.revision if record else 0 + if revision != change.revision: + raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением") + data = deepcopy(record.data) if record else {"articles": {}, "journals": {}} + data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal} + if article["year"] is not None: + data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile + if record: + count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1, + CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1, + updated_by=username)).rowcount + if not count: + db.rollback() + raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением") + else: + db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username)) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением") + except (SQLAlchemyError, OSError, ValueError, KeyError): + db.rollback() + raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз") + return {"saved": True} @router.get("/council/export.xlsx") diff --git a/app/models.py b/app/models.py index 0ac7b1c..572f9f1 100644 --- a/app/models.py +++ b/app/models.py @@ -243,3 +243,14 @@ class ParseResourceCache(Base): body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) parser_version: Mapped[str | None] = mapped_column(String(32)) fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + + +class CouncilCorrections(Base): + """Manual council data, outside crawler-owned publications.""" + __tablename__ = "council_corrections" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + data: Mapped[dict] = mapped_column(json_type, nullable=False) + updated_by: Mapped[str] = mapped_column(String(255), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) diff --git a/app/services/council.py b/app/services/council.py index ad1f3cf..e937456 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from app.models import Employee +from app.models import Employee, CouncilCorrections 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 @@ -133,7 +133,7 @@ def _database_article(publication) -> dict: "url": _safe_url(publication.url)} -def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]: +def _unique_articles(articles: list[dict], start: date, end: date, corrections=None, employee_id=None) -> list[dict]: k1_journals = _k1_journals() specialties = _k1_specialties()["by_journal"] title_years = defaultdict(set) @@ -171,8 +171,23 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict] merged["in_csv"] |= article["in_csv"] merged["in_db"] |= article["in_db"] merged["url"] = merged["url"] or article["url"] - for article in unique.values(): + for identity, article in unique.items(): evidence = article.pop("evidence") + article["edit_key"] = hashlib.sha256(json.dumps( + identity, + ensure_ascii=False).encode()).hexdigest() + correction = (corrections or {}).get("articles", {}).get(f"{employee_id}:{article['edit_key']}") + article["manually_corrected"] = bool(correction) + if correction: + journal_changed = article["journals"] != [correction["journal"]] + article["title"] = correction["title"] + article["journals"] = [correction["journal"]] + if journal_changed: + article["issns"] = [] + article["quartiles"] = [] + article["quartile_issues"] = [] + evidence = [{**item, "journals": article["journals"], + "quartiles": [] if journal_changed else item["quartiles"]} for item in evidence] years = sorted({item["year"] for item in evidence if item["year"] is not None}) # Never pick a conflicting source year merely because that row came first. year = article["year"] = years[0] if len(years) == 1 else None @@ -237,6 +252,9 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs 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 = [] for employee in employees: author = _name(employee.full_name or "") @@ -250,9 +268,11 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs 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) + articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end, corrections, employee.id) for article in articles: - proofs = publication_proofs(employee_evidence, article) + article["manual_review"] = article["manually_corrected"] or (len(article["journals"]) == 1 and + f"{_journal_key(article['journals'][0])}:{article['year']}" in corrections.get("journals", {})) + proofs = [] if article["manual_review"] else publication_proofs(employee_evidence, article) kinds = {proof["kind"] for proof in proofs if proof.get("kind") and sourced(proof.get("kind_evidence"))} kind = next(iter(kinds)) if len(kinds) == 1 else None preliminary_type_allowed = len(kinds) <= 1 and publication_type_allowed( @@ -260,7 +280,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs if not preliminary_type_allowed: for flag in ("q1", "q2", "k1", "k2", "rsci"): article[flag] = False - identifiers = [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))] + identifiers = [] if article["manually_corrected"] else [*article["issns"], *(value for proof in proofs for value in proof.get("issns", []))] for name in article["journals"]: canonical = _k1_journals().get(_journal_key(name)) identifiers.extend(_k1_specialties()["journal_issns"].get(canonical, [])) @@ -278,13 +298,35 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis")) if key: article[key] = True - assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end) + journal_key = _journal_key(article["journals"][0]) if len(article["journals"]) == 1 else "" + manual_quartile = corrections.get("journals", {}).get(f"{journal_key}:{article['year']}") + if manual_quartile is not None: + article["quartiles"] = [f"Ручная корректировка {manual_quartile}"] if manual_quartile else [] + article["q1"] = bool(manual_quartile == "Q1" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year) + article["q2"] = bool(manual_quartile == "Q2" and preliminary_type_allowed and article["year"] is not None and start.year <= article["year"] <= end.year) + article["criteria"] = [c for c in article["criteria"] if not c.endswith((" Q1", " Q2", " Q3", " Q4")) and c != "Q1/Q2 и К1 не подтверждены доступными данными"] + article["criteria"].append(f"Квартиль журнала за {article['year']}: {manual_quartile or 'не указан'} · ручная корректировка, требует подтверждения") + article["qualifies"] = article["q1"] or article["q2"] or article["k1"] + labels = {q[-2:] for q in article["quartiles"] if q.endswith((" Q1", " Q2", " Q3", " Q4"))} + if manual_quartile is None: + labels.update(metric["quartile"] for record in article["catalog_records"] for metric in record["metrics"] + if metric["year"] == article["year"] and metric["quartile"] in {"Q1", "Q2", "Q3", "Q4"}) + labels.update(basis["basis"] for proof in proofs for basis in proof.get("bases", []) + if isinstance(basis, dict) and basis.get("applicable_year") == article["year"] and basis.get("basis") in {"Q1", "Q2", "Q3", "Q4"}) + article["edit_quartile"] = manual_quartile if manual_quartile is not None else next(iter(labels)) if len(labels) == 1 else "" + article["edit_journal"] = article["journals"][0] if len(article["journals"]) == 1 else "" + changed = [a for a in articles if a["manual_review"]] + adjusted_evidence = {**employee_evidence, "publications": [p for p in employee_evidence.get("publications", []) + 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("Ручная корректировка требует повторного подтверждения источниками") 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} (журнал К1, Перечень ВАК)") - candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", + candidates.append({**assessment, "specialty_count": specialty_count,"name": employee.full_name or "ФИО не указано", "employee_id": employee.id, "count": len(articles), **{f"{key}_count": sum(a[key] for a in articles) for key in ("q1", "q2", "k1", "k2", "rsci")}, "total_count": sum(a["qualifies"] for a in articles), @@ -303,7 +345,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs matched = len(candidates) limit = 25 offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 - return {"candidates": candidates[offset:offset + limit] if paginate else candidates, + return {"journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates, "start": start, "end": end, "total": total, "specialties": specialties, "specialty": specialty, "specialty_name": specialty_name, "branch": branch, diff --git a/app/static/admin.css b/app/static/admin.css index 4dcccae..94e6cfd 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -913,3 +913,22 @@ .council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; } .council__article-heading { flex-direction: column; gap: 4px; } } + +.council__article[data-council-article] { position: relative; padding-inline-end: 60px; cursor: pointer; } +.council__article[data-council-article]:hover { background: #f3f7f5; } +.council__article--active { background: #e7f0eb; } +.council__article[data-council-article]:focus-visible { outline: 3px solid #0f766e; outline-offset: 2px; } +.council__edit { position: absolute; inset-inline-end: 4px; top: 12px; min-width: 44px; min-height: 44px; visibility: hidden; display: inline-flex; align-items: center; justify-content: center; } +.council__article--active > .council__edit, +.council__article:focus-within > .council__edit { visibility: visible; } +.council__articles--editor { padding: 20px 20px 20px 42px; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; } +.council__articles--editor .council__hint { margin-block: 8px; } +.council-editor { display: grid; gap: 16px; margin-top: 20px; } +.council-editor .directory__field { min-width: 0; } +.council-editor textarea { resize: vertical; min-height: 90px; font: inherit; } +.council-editor__actions { display: flex; flex-wrap: wrap; gap: 12px; } +.council-editor__error { margin: 0; color: #991b1b; line-height: 1.5; } +@media (max-width: 600px) { + .council__articles--editor { padding-inline: 28px 12px; } + .council-editor .directory__input { font-size: 16px; } +} diff --git a/app/static/council.js b/app/static/council.js new file mode 100644 index 0000000..b396b00 --- /dev/null +++ b/app/static/council.js @@ -0,0 +1,104 @@ +(() => { + const dialog = document.querySelector('[data-council-dialog]'); + if (!dialog) return; + const form = dialog.querySelector('[data-edit-form]'); + let selected; + let trigger; + let saving = false; + const openKey = 'miem.council.openCandidates'; + + try { + const opened = JSON.parse(sessionStorage.getItem(openKey) || '[]'); + document.querySelectorAll('[data-employee-id]').forEach(node => { + if (opened.includes(node.dataset.employeeId)) node.open = true; + }); + sessionStorage.removeItem(openKey); + } catch (_error) { /* Storage is optional. */ } + + function activate(article) { + document.querySelectorAll('.council__article--active').forEach(node => { + node.classList.remove('council__article--active'); + }); + article.classList.add('council__article--active'); + } + + document.querySelectorAll('[data-council-article]').forEach(article => { + article.addEventListener('click', event => { + if (!event.target.closest('a, button')) activate(article); + }); + article.addEventListener('keydown', event => { + if (event.target === article && ['Enter', ' '].includes(event.key)) { + event.preventDefault(); + activate(article); + article.querySelector('[data-council-edit]').focus(); + } + }); + }); + + document.querySelectorAll('[data-council-edit]').forEach(button => { + button.addEventListener('click', () => { + trigger = button; + selected = JSON.parse(button.dataset.article); + if (form) { + form.elements.title.value = selected.title; + form.elements.journal.value = selected.journal; + form.elements.quartile.value = selected.quartile; + form.elements.quartile.disabled = selected.year === null; + form.querySelector('[data-edit-error]').hidden = true; + form.querySelector('[data-edit-note]').textContent = selected.year === null + ? 'Год публикации не указан или неоднозначен. Можно изменить название статьи и журнал; квартиль недоступен.' + : `Изменение квартиля применяется ко всем статьям выбранного журнала за ${selected.year} год. Проверьте квартиль при смене журнала.`; + } + dialog.showModal(); + }); + }); + + dialog.querySelectorAll('[data-edit-close]').forEach(button => { + button.addEventListener('click', () => { if (!saving) dialog.close(); }); + }); + dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); }); + dialog.addEventListener('click', event => { if (event.target === dialog && !saving) dialog.close(); }); + dialog.addEventListener('close', () => trigger?.focus()); + + form?.addEventListener('submit', async event => { + event.preventDefault(); + if (saving) return; + const error = form.querySelector('[data-edit-error]'); + const submit = form.querySelector('[type="submit"]'); + const payload = { + employee_id: selected.employee_id, edit_key: selected.edit_key, revision: selected.revision, + title: form.elements.title.value.trim(), journal: form.elements.journal.value.trim(), + quartile: selected.year === null ? '' : form.elements.quartile.value, + }; + saving = true; + submit.disabled = true; + submit.textContent = 'Сохраняем…'; + form.setAttribute('aria-busy', 'true'); + error.hidden = true; + try { + const response = await fetch('/admin/council/publications', { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json', 'X-Council-Edit': '1'}, + body: JSON.stringify(payload), signal: AbortSignal.timeout(15000), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(typeof data.detail === 'string' ? data.detail : 'Проверьте название статьи, журнал и квартиль.'); + } + try { + sessionStorage.setItem(openKey, JSON.stringify(Array.from(document.querySelectorAll('[data-employee-id][open]')).map(node => node.dataset.employeeId))); + } catch (_error) { /* Storage is optional. */ } + window.location.reload(); + } catch (failure) { + error.textContent = failure.name === 'TimeoutError' + ? 'Не получен ответ сервера. Обновите страницу, чтобы проверить сохранение.' + : failure.message === 'Failed to fetch' ? 'Нет связи с сервером. Проверьте подключение и попробуйте ещё раз.' : failure.message; + error.hidden = false; + } finally { + saving = false; + submit.disabled = false; + submit.textContent = 'Сохранить'; + form.removeAttribute('aria-busy'); + } + }); +})(); diff --git a/app/templates/council.html b/app/templates/council.html index 55e36d8..9acf7b3 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -9,6 +9,8 @@ + Статьи, журналы и квартили ↗ + {% if error %}