diff --git a/README.md b/README.md index 6d36b4c..b0f7ad2 100644 --- a/README.md +++ b/README.md @@ -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 предварительный. Публичная страница не открывает контакты, служебные данные или операции админки. diff --git a/app/admin.py b/app/admin.py index 2692ac9..b346e3b 100644 --- a/app/admin.py +++ b/app/admin.py @@ -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, diff --git a/app/services/council.py b/app/services/council.py index b41ecca..42b5e22 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -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} diff --git a/app/services/council_export.py b/app/services/council_export.py new file mode 100644 index 0000000..9289f2b --- /dev/null +++ b/app/services/council_export.py @@ -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() diff --git a/app/templates/council.html b/app/templates/council.html index da2a614..75e89fe 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -11,8 +11,8 @@ {% if error %}
{% else %} @@ -28,7 +28,7 @@ + {% if q %}Сбросить поиск{% endif %}{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q %} · Всего в списке: {{ total }}{% endif %}
Откройте сотрудника, чтобы увидеть публикации, источники и основания.
+Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.