feat: export council employees and publication evidence to XLSX
CI / test (pull_request) Successful in 9m11s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped

This commit is contained in:
codex committed 2026-10-08 10:33:59 +03:00
1 parent 6bd1f31f3a
commit 4aeb26abe9
11 files changed
+186 -15

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
## Кандидаты в диссертационный совет
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. Кнопка «Скачать XLSX» выгружает всех найденных сотрудников с выбранными поиском и сортировкой, включая записи на других страницах. Файл содержит листы «Сотрудники», «Публикации» и «Параметры» с источниками, критериями и условиями отбора; экспорт доступен без авторизации по `/admin/council/export.xlsx`. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
+15 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select
from sqlalchemy.exc import SQLAlchemyError
@@ -20,6 +20,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 council_candidates
from app.services.council_export import council_xlsx
from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin")
@@ -144,6 +145,19 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_cou
return _render(request, "council.html", {"error": False, "public_page": True, **result})
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "csv_count", direction: str = "desc",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False)
content = council_xlsx(result)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
def employee_detail(
employee_id: int,
+3 -2
View File
@@ -150,7 +150,7 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "csv_count", direction: str = "desc") -> dict:
sort: str = "csv_count", direction: str = "desc", paginate: bool = True) -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
@@ -187,5 +187,6 @@ 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], "start": start, "end": end, "total": total,
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
+71
View File
@@ -0,0 +1,71 @@
"""XLSX report containing the same public evidence as the council page."""
from io import BytesIO
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
def _append(sheet, values):
values = [ILLEGAL_CHARACTERS_RE.sub("", value) if isinstance(value, str) else value for value in values]
if any(isinstance(value, str) and len(value) > 32767 for value in values):
raise ValueError("Text exceeds Excel cell limit")
sheet.append(values)
for cell in sheet[sheet.max_row]:
if isinstance(cell.value, str):
# CSV/profile content is text, never an Excel formula or error code.
cell.data_type = "s"
def council_xlsx(result: dict) -> bytes:
workbook = Workbook()
workbook.properties.creator = "MIEM Employees"
employees = workbook.active
employees.title = "Сотрудники"
publications = workbook.create_sheet("Публикации")
parameters = workbook.create_sheet("Параметры")
_append(employees, ["№", "ФИО", "Учёная степень", "CSV по критериям", "В БД", "Всего без дублей",
"Статус", "Профиль ВШЭ", "Примечание"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "В счётчике CSV", "В БД", "DOI", "Ссылка на сайте", "Строки CSV"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
_append(employees, [number, candidate["name"], candidate["degree"], candidate["csv_count"],
candidate["db_count"], candidate["count"], "Уволен" if candidate["dismissed"] else "Не отмечен как уволенный",
candidate["profile_url"], note])
for article in candidate["articles"]:
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
"; ".join(article["origins"]), "; ".join(article["criteria"]),
"Да" if article["csv_qualifies"] else "Нет", "Да" if article["in_db"] else "Нет",
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
["Сортировать по", {"full_name": "ФИО", "csv_count": "CSV по критериям", "db_count": "В БД",
"count": "Всего без дублей"}[result["sort"]]],
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
["Отбор по степени", "Доктор наук в профиле; достаточно хотя бы одной публикации из CSV или БД"],
["Счётчик CSV", "Предварительный: Q1/Q2 Scopus или Web of Science в пятилетнем периоде по году. "
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
"Материалы с известным другим типом исключены. К1/RSCI не подтверждены доступными данными."],
]:
_append(parameters, values)
for sheet, widths in [(employees, [7, 34, 40, 22, 12, 22, 30, 45, 55]),
(publications, [16, 34, 65, 12, 40, 45, 65, 22, 12, 45, 45, 22]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for column, width in enumerate(widths, 1):
sheet.column_dimensions[get_column_letter(column)].width = width
for row in sheet:
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="263640")
output = BytesIO()
workbook.save(output)
return output.getvalue()
+5 -4
View File
@@ -11,8 +11,8 @@
{% if error %}
<div class="council__message" role="alert">
<h3>Не удалось загрузить публикации</h3>
<p>Данные временно недоступны. Попробуйте обновить страницу позже.</p>
<h3>{% if export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
</div>
{% else %}
@@ -28,7 +28,7 @@
</label>
<label class="directory__field">Сортировать по
<select class="directory__input" name="sort">
{% for value, label in [('full_name', 'ФИО'), ('csv_count', 'Публикациям CSV по критериям'), ('db_count', 'Публикациям в БД'), ('count', 'Всего публикаций без дублей')] %}
{% for value, label in [('full_name', 'ФИО'), ('csv_count', 'CSV по критериям'), ('db_count', 'Публикации в БД'), ('count', 'Всего без дублей')] %}
<option value="{{ value }}"{% if sort == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
@@ -40,6 +40,7 @@
</select>
</label>
<button class="button" type="submit">Применить</button>
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
{% if q %}<a class="admin__link" href="?sort={{ sort }}&amp;direction={{ direction }}">Сбросить поиск</a>{% endif %}
</form>
<div class="council__list-heading">
@@ -47,7 +48,7 @@
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q %} · Всего в списке: {{ total }}{% endif %}</p>
</div>
{% if candidates %}
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания.</p>
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
<div class="council__list">
{% for candidate in candidates %}
<details class="council__candidate">
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.12.0"
FRONTEND_VERSION = "0.12.0"
BACKEND_VERSION = "0.12.0"
APP_VERSION = "0.13.0"
FRONTEND_VERSION = "0.13.0"
BACKEND_VERSION = "0.13.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.12.0"
version = "0.13.0"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
@@ -12,6 +12,7 @@ dependencies = [
"lxml>=5.2.0",
"psycopg[binary]>=3.2.0",
"pydantic-settings>=2.4.0",
"openpyxl>=3.1.5,<4",
"python-multipart>=0.0.9",
"requests>=2.32.0",
"sqlalchemy>=2.0.32",
+1
View File
@@ -6,6 +6,7 @@ jinja2>=3.1.4
lxml>=5.2.0
psycopg[binary]>=3.2.0
pydantic-settings>=2.4.0
openpyxl>=3.1.5,<4
python-multipart>=0.0.9
requests>=2.32.0
sqlalchemy>=2.0.32
+16 -1
View File
@@ -4,6 +4,7 @@ Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser
Synthetic employees and intercepted requests; no production access.
"""
import os
from io import BytesIO
from pathlib import Path
import sys
from urllib.parse import urlsplit
@@ -11,6 +12,7 @@ from urllib.parse import urlsplit
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi.testclient import TestClient
from openpyxl import load_workbook
from playwright.sync_api import expect, sync_playwright
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@@ -66,7 +68,8 @@ def main():
url = urlsplit(route.request.url)
response = client.get(url.path + ("?" + url.query if url.query else ""))
route.fulfill(status=response.status_code, body=response.content,
content_type=response.headers.get("content-type", "text/plain"))
headers={key: value for key, value in response.headers.items()
if key in {"content-type", "content-disposition"}})
try:
with sync_playwright() as playwright:
@@ -113,11 +116,23 @@ def main():
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
expect(page.get_by_label("Направление")).to_have_value("desc")
expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02")
with page.expect_download() as pending_download:
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
download = pending_download.value
assert download.suggested_filename == "council.xlsx"
download.save_as(str(output / "council.xlsx"))
workbook = load_workbook(output / "council.xlsx")
assert workbook["Сотрудники"].max_row == 29
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.get_by_label("Сортировать по")).to_have_value("full_name")
expect(page.get_by_label("Направление")).to_have_value("desc")
with page.expect_download() as pending_download:
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
assert filtered["Сотрудники"].max_row == 2
page.get_by_label("Поиск по ФИО").fill("Нет совпадений")
page.get_by_role("button", name="Применить", exact=True).click()
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.12.0"
assert response.json()["backend_version"] == "0.13.0"
def test_api_employees_and_stats_require_admin_session():
+68 -1
View File
@@ -1,8 +1,11 @@
import csv
from datetime import date
from io import BytesIO
from zipfile import ZipFile
import pytest
from fastapi.testclient import TestClient
from openpyxl import load_workbook
from sqlalchemy.exc import OperationalError
from app.db import get_db
@@ -206,7 +209,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.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 0.12.0 · Frontend 0.12.0" in response.text
assert "Backend 0.13.0 · Frontend 0.13.0" 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
@@ -227,3 +230,67 @@ def test_public_error_state_missing_export_and_database_failure(db_session, expo
assert response.status_code == 503 and "private error" not in response.text
finally:
app.dependency_overrides.clear()
def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db_session, export):
export([row(author="Петров 27"), row(author="Петров 27", **{"Квартиль wos": "Q2"})])
for number in range(28):
person = employee(db_session, f"Петров {number:02d}", key=f"xlsx-{number}")
publication(db_session, person, title="Shared work" if number == 27 else f"Work {number}",
doi="https://doi.org/10.1234/shared" if number == 27 else None,
kind="ARTICLE", publication_id=str(number), url=f"https://publications.hse.ru/view/{number}")
excluded = employee(db_session, "Без степени", degree=None, key="excluded")
publication(db_session, excluded)
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council/export.xlsx?q=Петров&sort=full_name&direction=desc&offset=25")
assert response.status_code == 200
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
book = load_workbook(BytesIO(response.content))
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
people, works = book["Сотрудники"], book["Публикации"]
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
assert [people.cell(2, n).value for n in [4, 5, 6]] == [1, 1, 1]
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
assert "Файл CSV" in works["F2"].value and "сайт ВШЭ" in works["F2"].value
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
assert works["J2"].value == "https://doi.org/10.1234/shared" and works["L2"].value == "2, 3"
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
assert works.auto_filter.ref == works.dimensions
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
assert "private@example.org" not in text and "Без степени" not in text
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
finally:
app.dependency_overrides.clear()
def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_session, export):
person = employee(db_session, '=HYPERLINK("https://example.org","name")')
publication(db_session, person, title="=1+1\x01")
publication(db_session, person, title="#N/A")
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
response = client.get("/admin/council/export.xlsx")
assert response.status_code == 200
book = load_workbook(BytesIO(response.content))
assert book["Сотрудники"]["B2"].value == person.full_name
assert book["Сотрудники"]["B2"].data_type == "s"
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
assert titles == {"=1+1": "s", "#N/A": "s"}
with ZipFile(BytesIO(response.content)) as archive:
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
person.full_name = "Long " + "x" * 32768
db_session.commit()
failure = client.get("/admin/council/export.xlsx")
assert failure.status_code == 503 and "Не удалось создать XLSX" in failure.text
assert "content-disposition" not in failure.headers
finally:
app.dependency_overrides.clear()