Интерактивный подбор диссовета в XLSX и единый расчёт «Всего» #72
No files matched your search
+13
-12
@@ -1,20 +1,21 @@
|
||||
## Сделано
|
||||
|
||||
- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.1`.
|
||||
- Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД.
|
||||
- Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении.
|
||||
- Исправлено неблокирующее замечание независимого ревью PR #71: только три новых правила CSS переведены с CRLF на LF, содержимое правил сохранено.
|
||||
- После исправления: `pytest -q` — 412 passed, 11 skipped; `git diff --check main` — passed. Предыдущая запись о passed для полного diff коммита `5128fac` была неточной: ревью подтвердило три trailing whitespace, теперь они устранены.
|
||||
- До этой правки: браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed. Браузерный сценарий повторно не запускался: поведение CSS не менялось.
|
||||
- Ветка `feature/council-xlsx-selection`, создана от обновлённого `main` (`7c02b08`) после слияния PR #71. Версия приложения/frontend/backend `1.4.2`.
|
||||
- «Всего» в XLSX использует `candidate.total_count`, как сайт: Q1/Q2/К1/RSCI, у доктора также К2; самостоятельный К2 у кандидата/PhD исключается, пересечения не увеличивают итог.
|
||||
- Первый лист «Подбор диссовета»: специальность ВАК, допустимая отрасль с автоматическим выбором совместимой, поиск, степень, статус, сортировка, счётчики, подтверждённый зачёт, минимум и профиль ВШЭ. Без макросов и новых зависимостей.
|
||||
- Для переключения фильтров выгружаются все сотрудники с учёной степенью и их публикации. Начальные фильтры берутся с сайта; подробные листы сохраняют названия и столбцы. Данные являются снимком; изменения Excel не отправляются на сайт.
|
||||
- Исходные строки остаются текстом; формулы записываются отдельно. Формат и поведение описаны в README и docs/council-evidence.md.
|
||||
- Исправлено замечание P2 ревью SHA `962e936`: общий сервис отклоняет явно пустую отрасль до построения результата. Список, карточка и экспорт возвращают 422; отсутствие параметра сохраняет техническую отрасль. Регрессии охватывают пустую/непустую БД и параметры специальности/предыдущего выбора.
|
||||
- На первом листе добавлена памятка под списком и ссылки к ней/обратно к фильтрам. Памятка поясняет работу с листом, счётчики, К2, подтверждения и пороги, ограничения специальностей, источников, снимка и Excel; правила расчёта и адреса фильтров не менялись.
|
||||
- Проверки: после исправления P2 полный pytest — 418 passed, 11 skipped; синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed; `git diff --check main` — passed.
|
||||
- До исправления P2 в установленном Windows Excel 16.0 проверены 14 сценариев на синтетических данных и пустой файл: результаты, сортировка, поиск (включая буквальный *), специальности, совместимость отраслей, степень, статусы и пороги совпали с сервисом сайта. Дополнительно проверены переходы к памятке и к фильтрам; памятка экспортирована в PDF, проверено наличие разделов и завершающей ссылки. Артефакты: `.impeccable/review/council-xlsx/sample-checked.xlsx`, `memo-preview.pdf`, `cases.json`, `check.ps1` (игнорируются Git).
|
||||
|
||||
## Остановились на
|
||||
|
||||
- Открыт PR #71: https://git.vakanaut.ru/admin/miem_workers/pulls/71. Ревью коммита `5128fac` одобрено с одним неблокирующим замечанием; пользователь поручил исправить его.
|
||||
- Правка готова к коммиту и push в ту же ветку/PR. Доступ к Gitea восстановился; локальный `main` обновлён, основа — `378d7ee`.
|
||||
- PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных.
|
||||
- Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя.
|
||||
- Открыт PR #72: https://git.vakanaut.ru/admin/miem_workers/pulls/72. Ревью SHA `962e936` требует исправить один P2; пользователь поручил исправление. Оно готово к коммиту и push в ту же ветку/PR.
|
||||
- PostgreSQL-тесты пропущены без отдельной тестовой БД; production не изменялся. Тестирование Excel проводилось только на синтетическом наборе.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71.
|
||||
- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
|
||||
- Сохранить исправление коммитом и отправить `git push -u origin feature/council-xlsx-selection` в существующий PR #72.
|
||||
- После публикации остановиться и ждать независимого ревью в отдельной сессии и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
|
||||
@@ -108,7 +108,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
|
||||
|
||||
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422.
|
||||
На странице и в XLSX выбирается отрасль оценки; по умолчанию технические науки. Для доктора порог зависит от отрасли, для кандидата/PhD — 20; самостоятельного К2 недостаточно. Период — пять календарных лет, включая текущий год Москвы. Некорректную специальность или отрасль отклоняет HTTP 422. Явно пустая отрасль (`branch=`) также отклоняется для списка, карточки и XLSX; отсутствие параметра сохраняет значение по умолчанию «технические».
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
@@ -134,9 +134,9 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Подтверждения хранятся отдельно от перезаписываемых парсером данных, в `app/data/council_evidence.json`; по умолчанию файл пустой. Формат, привязка к сотруднику/работе, правила конфликта, монографии и нормативные уточнения: [docs/council-evidence.md](docs/council-evidence.md). Непубличные документы и контакты на страницу не выводятся. РИНЦ или поле `hse_list` не подтверждают RSCI. Неизвестные виды, конфликтующие годы и неподтверждённые основания не создают положительного заключения.
|
||||
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX всех найденных сотрудников. Отрасль и остальные параметры сохраняются при переходах и экспорте. XLSX содержит компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов), а правила и выбранные фильтры вынесены в «Параметры»; внешние ссылки проверяются, строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся отдельным вспомогательным подбором с ограниченным охватом, а не условием соответствия пунктам 5–6.
|
||||
Доступны поиск, сортировка, страницы по 25 сотрудников и XLSX. Первый лист «Подбор диссовета» работает в Excel Microsoft 365 / Excel 2021 и новее без макросов: выбор специальности ВАК, допустимой отрасли, поиск по ФИО, фильтры по степени и статусу, сортировка по ФИО и счётчикам. Начальные фильтры взяты с сайта. На первом листе ссылка «Открыть памятку по расчёту и использованию» ведёт к инструкции под списком: определения счётчиков и статусов, правило К2, ограничения специальностей и источников, различия снимка XLSX и сайта, работа с формулами. Из памятки можно вернуться к фильтрам. При несовместимой отрасли после смены специальности применяется первая допустимая; она показана рядом. Список показывает Q1/Q2/К1/К2/RSCI, «Всего», подтверждённый зачёт, минимум и статус; ссылки ведут на публичные профили ВШЭ. Для переключения фильтров в файл включены все сотрудники с учёной степенью, даже если страница сайта была отфильтрована. Компактные листы «Сотрудники» (12 столбцов) и «Публикации» (14 столбцов) также содержат полный набор данных, а правила и исходные фильтры вынесены в «Параметры». Служебные данные и справочники находятся на скрытых листах. Внешние ссылки проверяются; исходные строки не выполняются как формулы Excel. Фильтр специальности по журналам К1 и ручным тематическим связям остаётся вспомогательным подбором, а не условием соответствия пунктам 5–6. XLSX хранит снимок на момент выгрузки: новые данные и ручные подтверждения требуют повторной выгрузки; изменения фильтров в Excel не отправляются на сайт.
|
||||
|
||||
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» — уникальное объединение Q1 ИЛИ Q2 ИЛИ К1; отдельные К2/RSCI не увеличивают этот столбец. Поэтому экспортный итог может отличаться от общего счётчика сайта. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
В XLSX «Научная степень» — текущая выбранная степень, а «Всего» совпадает с предварительным счётчиком сайта: уникальное объединение Q1/Q2/К1/RSCI, для доктора наук также К2. Для кандидата/PhD самостоятельный К2 исключается; работа с другим подходящим основанием учитывается один раз. «Всего из ЛК» и «Общее количество всех публикаций без дублей» учитывают все годы. Отсутствующий тип материала обозначается «не указан», положительное сообщение о периоде объединяется в «В пределах пяти лет». Версия 1.0.0 меняет набор и порядок столбцов несовместимо с прежними шаблонами обработки XLSX.
|
||||
|
||||
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
|
||||
|
||||
|
||||
+6
-1
@@ -304,7 +304,12 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
content = council_xlsx(result)
|
||||
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
|
||||
selection_results = [
|
||||
result if b == result["branch"] and not result["q"] and not result["specialty"] else
|
||||
council_candidates(db, sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
|
||||
for b in branches]
|
||||
content = council_xlsx(result, selection_results)
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
|
||||
@@ -255,7 +255,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
|
||||
branch: str = "технические", employee_id: int | None = None,
|
||||
previous_specialty: str | None = None) -> dict:
|
||||
if branch and branch not in BRANCHES:
|
||||
if branch not in BRANCHES:
|
||||
raise CouncilInputError("Неизвестная отрасль науки")
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""Compact XLSX report from council data, with an explicit Q1/Q2/K1 total."""
|
||||
"""Offline council selection and detailed XLSX report from the site's assessment."""
|
||||
|
||||
from io import BytesIO
|
||||
from textwrap import fill
|
||||
|
||||
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
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.hyperlink import Hyperlink
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
from app.services.council_source import _name
|
||||
|
||||
SORT_LABELS = {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1",
|
||||
"k2_count": "К2", "rsci_count": "RSCI", "total_count": "Всего",
|
||||
"confirmed_count": "Подтверждено", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
@@ -13,9 +24,13 @@ def _append(sheet, 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 column, value in enumerate(values, 1):
|
||||
if isinstance(value, str):
|
||||
# Source text is never executable; formulas are written separately.
|
||||
sheet.cell(sheet._current_row, column).data_type = "s"
|
||||
|
||||
|
||||
def council_xlsx(result: dict) -> bytes:
|
||||
def council_xlsx(result: dict, selection_results: list[dict]) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
@@ -30,9 +45,10 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Всего из ЛК", "Общее количество всех публикаций без дублей", "Специальности ВАК", "Профиль ВШЭ"])
|
||||
_append(publications, ["№", "ФИО", "Название", "Год", "Тип материала", "Критерии",
|
||||
"Q1", "Q2", "К1", "К2", "RSCI", "Специальность ВАК", "DOI", "Ссылка на сайт"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
all_candidates = next(data["candidates"] for data in selection_results if data["branch"] == result["branch"])
|
||||
for number, candidate in enumerate(all_candidates, 1):
|
||||
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
total = sum(article["q1"] or article["q2"] or article["k1"] for article in candidate["articles"])
|
||||
total = candidate["total_count"]
|
||||
_append(employees, [number, candidate["name"], candidate["selected_degree"],
|
||||
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2")], total,
|
||||
candidate["db_count"], candidate["count"], specialties_text(specialty_codes), candidate["profile_url"]])
|
||||
@@ -56,7 +72,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
["Сортировать по", {"full_name": "ФИО", "q1_count": "Q1", "q2_count": "Q2", "k1_count": "К1", "k2_count": "К2", "rsci_count": "RSCI", "confirmed_count": "Подтверждено",
|
||||
"total_count": "ВСЕГО на сайте", "db_count": "ЛК сотрудника ВШЭ", "count": "Все публикации"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Сотрудников в выгрузке", len(all_candidates)], ["Найдено с исходными фильтрами", result["matched"]],
|
||||
["Всего сотрудников в списке", result["total"]],
|
||||
["Научная степень", "Текущая выбранная степень сотрудника: доктор выше кандидата/PhD. История степеней не перечисляется. Отсутствие публикаций не исключает сотрудника."],
|
||||
["Счётчики Q1/Q2/К1", "Уникальные публикации в пятилетнем периоде по году. Q1/Q2 — отметки Scopus или Web of Science. "
|
||||
"День и месяц публикации не учитываются; для неизвестного типа нужно подтвердить, что это статья. "
|
||||
@@ -65,7 +82,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Без названия журнала К1 не подтверждён."],
|
||||
["К2 / RSCI", "К2 — применимое основание из текущего справочника ВАК либо проверенной записи. RSCI учитывается по отдельному источнику, отметка РИНЦ его не заменяет. Оба основания показаны в Публикациях; К2 также вынесен в Сотрудники."],
|
||||
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — справочные специальности журнала К1 либо ручная тематическая связь за пять календарных лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
|
||||
["Всего", "Число уникальных работ с хотя бы одним основанием Q1 ИЛИ Q2 ИЛИ К1. Пересечения учитываются один раз. К2 и RSCI самостоятельно не увеличивают этот столбец; итог может отличаться от общего счётчика на сайте."],
|
||||
["Всего", "Как на сайте: уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Для кандидата/PhD одного К2 недостаточно. Пересечения учитываются один раз. Это предварительный счётчик; подтверждённый зачёт показан отдельно."],
|
||||
["Интерактивный подбор", "Первый лист содержит всех сотрудников с учёной степенью на момент выгрузки. Специальность, отрасль, поиск, степень, статус и сортировка меняются в Excel без макросов. Начальные фильтры взяты с сайта; подробные листы содержат полный набор сотрудников и публикаций."],
|
||||
["Всего из ЛК", "Число уникальных публикаций из личного кабинета сотрудника ВШЭ за все годы, независимо от категорий."],
|
||||
["Общее количество всех публикаций без дублей", "Все годы и категории; объединение личного кабинета сотрудника и выгрузки из базы ВШЭ без дублей. Дедупликация — по DOI либо согласованным названию и году."],
|
||||
["Лист Публикации", "Все доступные уникальные работы найденных сотрудников за все годы. № соответствует номеру сотрудника на листе Сотрудники. Отсутствующий тип — не указан; сообщение В пределах пяти лет выводится один раз."],
|
||||
@@ -93,13 +111,175 @@ def council_xlsx(result: dict) -> bytes:
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
if isinstance(cell.value, str):
|
||||
# CSV/profile content is text, never an Excel formula or error code.
|
||||
cell.data_type = "s"
|
||||
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")
|
||||
_selection(workbook, result, selection_results)
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _selection(workbook, result, selection_results):
|
||||
sheet = workbook.create_sheet("Подбор диссовета", 0)
|
||||
source = workbook.create_sheet("_Данные подбора")
|
||||
options = workbook.create_sheet("_Справочники подбора")
|
||||
specialties = result["specialties"]
|
||||
labels = ["Все специальности", *[f"{s['code']} — {s['name']}" for s in specialties]]
|
||||
branches = [data["branch"] for data in selection_results]
|
||||
_append(options, ["Специальность", "Список отраслей", "", "Отрасль 1", "Отрасль 2"])
|
||||
for row, label in enumerate(labels, 2):
|
||||
allowed = branches if row == 2 else [b for b in specialties[row - 3]["branches"] if b in branches]
|
||||
name = f"SelectionBranches{row}"
|
||||
_append(options, [label, name, "", *allowed])
|
||||
workbook.defined_names.add(DefinedName(name, attr_text=f"'{options.title}'!$D${row}:${get_column_letter(3 + len(allowed))}${row}"))
|
||||
workbook.defined_names.add(DefinedName("SelectionSpecialties", attr_text=f"'{options.title}'!$A$2:$A${len(labels) + 1}"))
|
||||
workbook.defined_names.add(DefinedName("SelectionOptions", attr_text=f"'{options.title}'!$A$2:$B${len(labels) + 1}"))
|
||||
|
||||
headers = ["№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "RSCI", "Всего", "Подтверждено",
|
||||
"Минимум", "Статус", "Степень подтверждена", "ЛК сотрудника ВШЭ", "Все публикации", "Специальности ВАК", "Профиль ВШЭ"]
|
||||
flag_end = get_column_letter(18 + len(specialties))
|
||||
score, rank, matches, level, search_name = [get_column_letter(19 + len(specialties) + i) for i in range(5)]
|
||||
_append(source, [*headers, "Отрасль", *[s["code"] for s in specialties], "Сортировка", "Порядок", "Подходит", "Уровень степени", "ФИО для поиска"])
|
||||
numbers = {c["employee_id"]: n for n, c in enumerate(selection_results[0]["candidates"], 1)}
|
||||
for data in selection_results:
|
||||
for candidate in sorted(data["candidates"], key=lambda c: _name(c["name"])):
|
||||
codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
|
||||
_append(source, [numbers[candidate["employee_id"]], candidate["name"], candidate["selected_degree"],
|
||||
*[candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "k2", "rsci")], candidate["total_count"],
|
||||
candidate["confirmed_count"], candidate["minimum"], candidate["assessment_status"],
|
||||
"Да" if candidate["degree_confirmed"] else "Нет", candidate["db_count"], candidate["count"],
|
||||
"\n".join(label for s, label in zip(specialties, labels[1:]) if s["code"] in codes) or "Не подтверждены",
|
||||
candidate["profile_url"] or "", data["branch"], *[int(s["code"] in codes) for s in specialties],
|
||||
None, None, None, candidate["degree_level"], _name(candidate["name"])])
|
||||
|
||||
for address, value in {"A1": "Подбор кандидатов в диссертационный совет", "A3": "Специальность ВАК",
|
||||
"A4": "Отрасль оценки", "E4": "Применяется", "A5": "Поиск по ФИО", "A6": "Учёная степень",
|
||||
"J3": "Сортировать по", "J4": "Направление", "J5": "Статус", "J6": "Найдено",
|
||||
"C3": next((label for s, label in zip(specialties, labels[1:]) if s["code"] == result["specialty"]), labels[0]),
|
||||
"C4": result["branch"], "C5": result["q"], "C6": "Все степени", "K3": SORT_LABELS[result["sort"]],
|
||||
"K4": "По убыванию" if result["direction"] == "desc" else "По возрастанию", "K5": "Все статусы",
|
||||
"A2": f"Данные на момент выгрузки · период {result['start'].year}–{result['end'].year} · Backend {BACKEND_VERSION} · Frontend {FRONTEND_VERSION}",
|
||||
"A7": "Всего: Q1/Q2/К1/RSCI; у доктора также К2. Статус и подтверждённый зачёт оцениваются отдельно.",
|
||||
"A8": "Подбор по связи журнала со специальностью не доказывает тематику каждой статьи или включение в совет. Подробности — на листах Сотрудники и Публикации."}.items():
|
||||
sheet[address] = value
|
||||
sheet[address].data_type = "s"
|
||||
for area in ("A1:Q1", "A2:Q2", "A3:B3", "A4:B4", "A5:B5", "A6:B6", "C3:I3", "C4:D4", "F4:I4", "C5:I5", "C6:I6", "K3:Q3", "K4:Q4", "K5:Q5", "A7:Q7", "A8:Q8"):
|
||||
sheet.merge_cells(area)
|
||||
allowed = 'INDIRECT(VLOOKUP($C$3,SelectionOptions,2,FALSE))'
|
||||
sheet["F4"] = f'=IFERROR(IF(COUNTIF({allowed},$C$4)>0,$C$4,INDEX({allowed},1)),"Недопустимая специальность")'
|
||||
for address, formula in {"C3": "=SelectionSpecialties", "C4": f"={allowed}",
|
||||
"C6": '"Все степени,Доктор наук,Кандидат / PhD"', "K3": '"' + ','.join(SORT_LABELS.values()) + '"',
|
||||
"K4": '"По возрастанию,По убыванию"', "K5": '"Все статусы,Соответствует критериям,Требует проверки"'}.items():
|
||||
validation = DataValidation(type="list", formula1=formula, allow_blank=False, showErrorMessage=True,
|
||||
errorStyle="stop", errorTitle="Выберите значение из списка", error="Используйте доступные варианты.")
|
||||
sheet.add_data_validation(validation)
|
||||
validation.add(sheet[address])
|
||||
|
||||
last = max(2, source._current_row)
|
||||
sheet["K6"] = f'=COUNTIF(\'{source.title}\'!${matches}$2:${matches}${last},1)'
|
||||
controls = "'Подбор диссовета'!"
|
||||
search = f'SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(TRIM(LOWER({controls}$C$5)),"ё","е"),"~","~~"),"*","~*"),"?","~?")'
|
||||
for row in range(2, source._current_row + 1):
|
||||
source[f"{score}{row}"] = f'=INDEX($A{row}:$Q{row},1,MATCH({controls}$K$3,{controls}$A$9:$Q$9,0))'
|
||||
comparison = f'IF({controls}$K$4="По убыванию",">","<")'
|
||||
scores, counts = f'${score}$2:${score}${last}', f'$O$2:$O${last}'
|
||||
source[f"{rank}{row}"] = (f'=IF({controls}$K$3="ФИО",IF({controls}$K$4="По убыванию",{last + 1}-ROW(),ROW()-1),'
|
||||
f'COUNTIF({scores},{comparison}&{score}{row})+COUNTIFS({scores},{score}{row},{counts},{comparison}&O{row})'
|
||||
f'+COUNTIFS(${score}$2:${score}{row},{score}{row},$O$2:$O{row},O{row}))')
|
||||
source[f"{matches}{row}"] = (f'=--AND($R{row}={controls}$F$4,'
|
||||
f'OR({controls}$C$3="Все специальности",IFERROR(INDEX($S{row}:${flag_end}{row},1,MATCH({controls}$C$3,'
|
||||
f"'{options.title}'!$A$3:$A${len(labels) + 1},0)),0)=1),"
|
||||
f'ISNUMBER(SEARCH({search},${search_name}{row})),'
|
||||
f'OR({controls}$K$5="Все статусы",IF({controls}$K$5="Соответствует критериям",LEFT($L{row},13)="Соответствует",$L{row}="Требует проверки")),'
|
||||
f'OR({controls}$C$6="Все степени",${level}{row}=IF({controls}$C$6="Доктор наук",2,1)))')
|
||||
|
||||
for column, header in enumerate(headers, 1):
|
||||
sheet.cell(9, column, header)
|
||||
ranks = f"'{source.title}'!${rank}$2:${rank}${last}"
|
||||
match_range = f"'{source.title}'!${matches}$2:${matches}${last}"
|
||||
for row in range(10, 10 + max(1, len(numbers))):
|
||||
sheet[f"S{row}"] = f'=IFERROR(MATCH(_xlfn.AGGREGATE(15,6,{ranks}/({match_range}=1),ROWS($S$10:S{row})),{ranks},0),"")'
|
||||
for column in range(1, 18):
|
||||
value = f"INDEX('{source.title}'!$A$2:$Q${last},$S{row},{column})"
|
||||
if column == 17:
|
||||
value = f'IF({value}="","",HYPERLINK({value},"Профиль ВШЭ"))'
|
||||
sheet.cell(row, column, f'=IF($S{row}="","",{value})')
|
||||
sheet.column_dimensions["S"].hidden = True
|
||||
widths = [9, 34, 35, 9, 9, 9, 9, 9, 12, 16, 12, 42, 20, 22, 22, 65, 22]
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet.iter_rows(max_col=17):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
cell.font = Font(name="Calibri", size=11, color="263640")
|
||||
for row in (1, 9):
|
||||
for cell in sheet[row][:17]:
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
cell.font = Font(name="Calibri", size=15 if row == 1 else 11, bold=True, color="FFFFFF")
|
||||
for address in ("C3", "C4", "C5", "C6", "K3", "K4", "K5"):
|
||||
sheet[address].fill = PatternFill("solid", fgColor="E7F0EB")
|
||||
for row in range(1, 10):
|
||||
sheet.row_dimensions[row].height = 32
|
||||
sheet.freeze_panes = "D10"
|
||||
sheet.sheet_view.zoomScale = 85
|
||||
workbook.active = 0
|
||||
workbook.calculation.fullCalcOnLoad = True
|
||||
workbook.calculation.forceFullCalc = True
|
||||
workbook.calculation.calcMode = "auto"
|
||||
source.sheet_state = options.sheet_state = "hidden"
|
||||
|
||||
memo_row = 12 + max(1, len(numbers))
|
||||
sheet["A7"] = "Открыть памятку по расчёту и использованию"
|
||||
sheet["A7"].hyperlink = Hyperlink(ref="A7", location=f"'Подбор диссовета'!A{memo_row}")
|
||||
sheet["A7"].font = Font(name="Calibri", size=11, color="0563C1", underline="single")
|
||||
memo = [
|
||||
("Памятка: как пользоваться подбором",
|
||||
"1. Меняйте зелёные поля: специальность ВАК и отрасль, поиск по ФИО, степень, статус, сортировку и направление. Список обновляется автоматически.\n"
|
||||
"2. После смены специальности посмотрите поле «Применяется»: если прежняя отрасль несовместима, используется первая допустимая.\n"
|
||||
"3. Для просмотра всех сотрудников выберите «Все специальности», «Все степени» и «Все статусы», очистите поиск. Чтобы искать потенциальных кандидатов, оставьте «Все статусы»: сведения могут требовать проверки.\n"
|
||||
"4. «Найдено» показывает число строк текущего подбора. «Профиль ВШЭ» открывает внешний сайт и требует подключения к Интернету."),
|
||||
("Какие данные включены",
|
||||
"Файл содержит всех сотрудников с обнаруженной или документально подтверждённой степенью доктора, кандидата наук или PhD на момент выгрузки. Отсутствие публикаций не исключает сотрудника. Из истории степеней выбирается высшая текущая: доктор выше кандидата/PhD. Уволенные сотрудники также могут присутствовать; актуальность работы и возможность участия проверяйте на сайте и по документам."),
|
||||
("Период и виды работ",
|
||||
f"Оценка охватывает пять календарных лет: {result['start'].year}–{result['end'].year}. День и месяц не учитываются. Границы зафиксированы при выгрузке и не сдвигаются при открытии файла в следующем году. Известные другие виды материалов по умолчанию исключаются из счётчиков; для их допуска нужно отдельное проверенное основание. Неизвестный вид может попадать в предварительный счётчик, но для подтверждённого зачёта нужно установить вид работы."),
|
||||
("Q1, Q2, К1, К2 и RSCI",
|
||||
"Каждый счётчик показывает уникальные работы с соответствующим основанием в пятилетнем периоде. Q1/Q2 — применимые сведения о квартиле из источников и каталогов; К1/К2 — сопоставление журнала со справочником ВАК либо проверенная запись. RSCI требует отдельного основания: отметка РИНЦ его не заменяет. Одна работа может одновременно входить в несколько счётчиков; складывать их для получения итога нельзя."),
|
||||
("Как считается «Всего»",
|
||||
"Предварительный итог, как на сайте: уникальные работы Q1 ИЛИ Q2 ИЛИ К1 ИЛИ RSCI; для доктора наук также К2. Для кандидата/PhD работа с одним К2 не входит в итог. Если у неё есть другое допустимое основание, она учитывается по нему. Пересечения учитываются один раз. Само попадание в «Всего» не подтверждает выполнение требований к составу совета."),
|
||||
("«Подтверждено» и подтверждение степени",
|
||||
"«Подтверждено» — работы с допустимым видом, согласованным годом в периоде и проверенным применимым основанием без существенных конфликтов источников. Известная статья с журналом и Q1/Q2 за её год может засчитываться автоматически. Для К1/К2/RSCI требуется отдельная запись проверки работы. Ручная правка журнала или квартиля сама по себе не подтверждает зачёт. «Степень подтверждена» проверяется отдельно; обнаруженная степень в профиле не заменяет документальное подтверждение."),
|
||||
("«Минимум» и «Статус»",
|
||||
"Для доктора минимум: 9 подтверждённых публикаций по технической отрасли, 11 по физико-математической. Для кандидата/PhD — 20. Положительный статус требует достаточного подтверждённого зачёта и подтверждения высшей текущей степени. «Требует проверки» может означать нехватку подтверждений или найденных работ; это не доказательство отсутствия публикаций. Статус оценивает публикационные критерии и не является решением о включении в совет."),
|
||||
("«ЛК сотрудника ВШЭ» и «Все публикации»",
|
||||
"Эти счётчики охватывают все годы и категории. «ЛК сотрудника ВШЭ» — уникальные работы из личного кабинета. «Все публикации» — объединение личного кабинета и выгрузки ВШЭ без дублей. Они не равны пятилетнему «Всего» и не сравниваются с публикационным минимумом."),
|
||||
("Специальности ВАК и ручная связь",
|
||||
f"Подбор ограничен {len(specialties)} специальностями сайта. Сотрудник отбирается, если хотя бы одна подходящая работа за пять лет имеет справочную связь журнала К1 или ручную связь с выбранной специальностью. Числовые счётчики при этом включают все работы сотрудника по своим правилам, а не только работы этой специальности. «Не подтверждены» означает отсутствие кодов по этим правилам, а не отсутствие научной специальности у сотрудника. Ручная связь задаётся на сайте для журнала и применяется ко всем его статьям; она не подтверждает содержание каждой статьи по паспорту специальности."),
|
||||
("Что не проверяется автоматически",
|
||||
"Полнота публикаций, содержание каждой статьи по паспорту, актуальность работы сотрудника и все остальные условия включения в диссовет требуют отдельной проверки. Подробные причины и источники смотрите на сайте. Текущая категория ВАК не доказывает категорию в год статьи. Каталоги имеют ограниченный охват и годы метрик; они не подтверждают текущую индексацию или отсутствие журнала в других базах."),
|
||||
("Почему цифры могут расходиться",
|
||||
"Сравнивайте одинаковые период, фильтры и снимок данных. Неполные профили, совпадающие ФИО, отсутствие DOI, разные названия журналов и конфликты годов могут мешать присвоению или объединению работ. При одинаковых ФИО данные выгрузки ВШЭ не присваиваются автоматически. Необычные символы и пробелы поиска в Excel могут обрабатываться иначе, чем на сайте. «Сотрудники» и «Публикации» содержат полный набор и не фильтруются вместе с первым листом; «Параметры» фиксируют исходные настройки выгрузки."),
|
||||
("Обновление данных и работа в Excel",
|
||||
"XLSX — снимок: обновления сайта и ручные подтверждения попадут в него только при новой выгрузке. Изменения в Excel не отправляются на сайт и не создают подтверждений. Используйте Excel Microsoft 365 / Excel 2021 или новее с автоматическим пересчётом формул; макросы для подбора не нужны. Если список не обновился, включите автоматический расчёт в настройках формул. Меняйте зелёные поля, сохраняйте формулы таблицы и скрытые служебные листы, иначе подбор может перестать работать."),
|
||||
]
|
||||
for title, text in memo:
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
cell = sheet.cell(memo_row, 1, title)
|
||||
cell.font = Font(name="Calibri", size=12, bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
sheet.row_dimensions[memo_row].height = 28
|
||||
memo_row += 1
|
||||
text = "\n".join(fill(paragraph, width=90, break_on_hyphens=False) for paragraph in text.splitlines())
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
cell = sheet.cell(memo_row, 1, text)
|
||||
cell.font = Font(name="Calibri", size=11, color="263640")
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
sheet.row_dimensions[memo_row].height = (text.count("\n") + 1) * 15 + 10
|
||||
memo_row += 3
|
||||
cell = sheet.cell(memo_row, 1, "Вернуться к фильтрам")
|
||||
cell.hyperlink = Hyperlink(ref=cell.coordinate, location="'Подбор диссовета'!A1")
|
||||
cell.font = Font(name="Calibri", size=11, color="0563C1", underline="single")
|
||||
sheet.merge_cells(start_row=memo_row, start_column=1, end_row=memo_row, end_column=6)
|
||||
sheet.row_dimensions[memo_row].height = 28
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "1.3.1"
|
||||
FRONTEND_VERSION = "1.3.1"
|
||||
BACKEND_VERSION = "1.3.1"
|
||||
APP_VERSION = "1.4.2"
|
||||
FRONTEND_VERSION = "1.4.2"
|
||||
BACKEND_VERSION = "1.4.2"
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Хранение и привязка
|
||||
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. Компактный XLSX содержит только выбранные столбцы; подробные подтверждения и причины проверки в него не включаются. Экспортный «Всего» считает уникальное объединение Q1/Q2/К1 без самостоятельных К2/RSCI.
|
||||
Файл предназначен для проверенных администратором записей и не обслуживается как публичный файл. Он отделён от `current_data` и `raw_data`, которые перезаписывает парсер; миграция БД не требуется. Перед заменой заполненного файла сохраните его резервную копию. Не помещайте в репозиторий сканы дипломов, контакты, секреты и текст непубличных документов. В `source` храните короткую ссылку на запись проверки или публичный источник. Подтверждения степени целиком и их внутренние ссылки не выводятся публично; основания публикаций и ссылки `source_url` видны на странице. XLSX содержит первый лист интерактивного подбора и компактные подробные листы. Для переключения специальности и отрасли файл включает полный набор сотрудников, а исходные фильтры сайта задают начальный подбор. Для обеих доступных отраслей используются результаты того же сервиса оценки: показаны подтверждённый зачёт, минимум и статус. Подробные записи подтверждений и непубличные ссылки в файл не включаются. Экспортный «Всего» совпадает с сайтом: уникальное объединение Q1/Q2/К1/RSCI, для доктора также К2; одного К2 у кандидата/PhD недостаточно. XLSX — снимок на момент выгрузки; ручное подтверждение на сайте требует новой выгрузки.
|
||||
|
||||
Ключ сотрудника — точный `employees.profile_key`, не ФИО. Публикация привязывается внутри сотрудника по нормализованному DOI (`10.…`, без URL, в нижнем регистре) либо точному `employee_publications.source_hash`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.3.1"
|
||||
version = "1.4.2"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "1.3.1"
|
||||
assert response.json()["backend_version"] == "1.4.2"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+83
-19
@@ -13,7 +13,14 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
from app.services.council_export import _append
|
||||
from app.services.council_export import _append, council_xlsx
|
||||
|
||||
|
||||
def selection_results(db, result):
|
||||
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
|
||||
return [result if b == result["branch"] and not result["q"] and not result["specialty"] else
|
||||
council.council_candidates(db, result["end"], sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
|
||||
for b in branches]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -223,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 1.3.1 · Frontend 1.3.1" in response.text
|
||||
assert "Backend 1.4.2 · Frontend 1.4.2" 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
|
||||
@@ -264,7 +271,7 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
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 == ["Сотрудники", "Публикации", "Параметры"]
|
||||
assert book.sheetnames == ["Подбор диссовета", "Сотрудники", "Публикации", "Параметры", "_Данные подбора", "_Справочники подбора"]
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
assert [cell.value for cell in people[1]] == [
|
||||
"№", "ФИО", "Научная степень", "Q1", "Q2", "К1", "К2", "Всего", "Всего из ЛК",
|
||||
@@ -287,9 +294,12 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
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
|
||||
focused = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))
|
||||
assert focused["Сотрудники"].max_row == 29
|
||||
assert focused["Подбор диссовета"]["C5"].value == "Петров 27"
|
||||
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
|
||||
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
|
||||
assert empty["Сотрудники"].max_row == 29 and empty["Публикации"].max_row == 29
|
||||
assert empty["Подбор диссовета"]["C5"].value == "missing"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -307,10 +317,12 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Сотрудники"]["B2"].value == person.full_name
|
||||
assert book["Сотрудники"]["B2"].data_type == "s"
|
||||
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/"))
|
||||
assert all(b"<f>" not in archive.read(f"xl/worksheets/sheet{n}.xml") for n in (2, 3, 4))
|
||||
person.full_name = "Long " + "x" * 32768
|
||||
db_session.commit()
|
||||
failure = client.get("/admin/council/export.xlsx")
|
||||
@@ -320,6 +332,60 @@ def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_sessi
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_selection_contains_full_data_and_preserves_initial_filters(db_session, export):
|
||||
matching = employee(db_session)
|
||||
publication(db_session, matching, kind="ARTICLE", raw_data={"journal": "Бизнес-информатика"})
|
||||
other = employee(db_session, "Другой сотрудник", key="other")
|
||||
publication(db_session, other, title="Other", kind="ARTICLE")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get('/admin/council/export.xlsx?specialty=1.2.2&q=Иванов&sort=confirmed_count&direction=asc')
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
dashboard, source, options = book.worksheets[0], book['_Данные подбора'], book['_Справочники подбора']
|
||||
assert dashboard.title == 'Подбор диссовета' and book.active == dashboard
|
||||
assert dashboard['C3'].value.startswith('1.2.2 —')
|
||||
assert dashboard['C4'].value == 'технические' and dashboard['C5'].value == 'Иванов'
|
||||
assert dashboard['K3'].value == 'Подтверждено' and dashboard['K4'].value == 'По возрастанию'
|
||||
assert source.max_row == 5 and source.sheet_state == options.sheet_state == 'hidden'
|
||||
assert set(source.cell(row, 2).value for row in range(2, 6)) == {matching.full_name, other.full_name}
|
||||
assert {source.cell(row, 18).value for row in range(2, 6)} == {'технические', 'физико-математические'}
|
||||
assert {source.cell(row, 11).value for row in range(2, 6)} == {9, 11}
|
||||
assert book['Сотрудники'].max_row == book['Публикации'].max_row == 3
|
||||
assert len(dashboard.data_validations.dataValidation) == 6
|
||||
for row in range(3, options.max_row + 1):
|
||||
code = options.cell(row, 1).value.split(' ')[0]
|
||||
expected = ['физико-математические'] if code == '1.1.2' else ['технические'] if code == '2.3.2' else ['технические', 'физико-математические']
|
||||
assert [options.cell(row, column).value for column in (4, 5) if options.cell(row, column).value] == expected
|
||||
assert options.cell(row, 2).value in book.defined_names
|
||||
assert '_xlfn.AGGREGATE' in dashboard['S10'].value
|
||||
assert '$C$3' in dashboard['F4'].value and 'IFERROR' in dashboard['F4'].value
|
||||
memo_address = dashboard['A7'].hyperlink.location.split('!', 1)[1]
|
||||
assert dashboard[memo_address].value == 'Памятка: как пользоваться подбором'
|
||||
assert dashboard[memo_address].row > 11
|
||||
memo_text = ' '.join(str(dashboard.cell(row, 1).value or '').replace('\n', ' ')
|
||||
for row in range(dashboard[memo_address].row, dashboard.max_row + 1))
|
||||
for explanation in ('Для кандидата/PhD', 'Подтверждено', '9 подтверждённых',
|
||||
'11 по физико-математической', '20', 'Не подтверждены', 'все работы сотрудника',
|
||||
'Уволенные', 'автоматический расчёт', 'не отправляются на сайт'):
|
||||
assert explanation in memo_text
|
||||
assert any(cell.hyperlink and cell.hyperlink.location == "'Подбор диссовета'!A1"
|
||||
for row in dashboard for cell in row)
|
||||
assert book.calculation.calcMode == 'auto' and book.calculation.forceFullCalc
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert not any('vbaProject' in name or 'externalLinks' in name for name in archive.namelist())
|
||||
db_session.query(EmployeePublication).delete()
|
||||
db_session.query(Employee).delete()
|
||||
db_session.commit()
|
||||
empty = load_workbook(BytesIO(client.get('/admin/council/export.xlsx').content))
|
||||
assert empty['Сотрудники'].max_row == 1 and empty['Публикации'].max_row == 1
|
||||
assert empty['_Данные подбора'].max_row == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
def fail_on_scan(sheet):
|
||||
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
|
||||
@@ -392,8 +458,7 @@ def test_renamed_k1_journal_matches_current_and_documented_previous_name(db_sess
|
||||
assert [candidate[f"{key}_count"] for key in ("q1", "q2", "k1", "total")] == [0, 0, 1, 1]
|
||||
canonical = "Ученые записки Казанской академии ветеринарной медицины им. Н.Э. Баумана"
|
||||
assert f"К1 · {canonical} (справочник ВАК)" in candidate["articles"][0]["criteria"]
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
assert [book["Сотрудники"].cell(2, column).value for column in (6, 8)] == [1, 1]
|
||||
|
||||
|
||||
@@ -453,8 +518,7 @@ def test_sort_category_counters_and_xlsx(db_session, export, sort, direction):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), sort=sort, direction=direction)
|
||||
expected = ["Борис", "Анна"] if direction == "asc" else ["Анна", "Борис"]
|
||||
assert [candidate["name"] for candidate in result["candidates"]] == expected
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
assert headers[3:8] == ["Q1", "Q2", "К1", "К2", "Всего"]
|
||||
assert "CSV по критериям" not in headers
|
||||
@@ -515,10 +579,11 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
filtered = client.get("/admin/council?specialty=1.2.2&q=27")
|
||||
assert "Показаны 1–1 из 1" in filtered.text
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?specialty=1.2.2&sort=full_name&direction=asc").content))
|
||||
assert book["Сотрудники"].max_row == 29
|
||||
assert book["Сотрудники"].max_row == 30
|
||||
assert book["Подбор диссовета"]["C3"].value.startswith("1.2.2 —")
|
||||
params = dict(book["Параметры"].values)
|
||||
assert params["Специальность ВАК"].startswith("1.2.2.")
|
||||
assert "По специальности 1.2.2" in book["Публикации"]["F2"].value
|
||||
assert "Перечень ВАК" in book["Публикации"]["F2"].value or "справочник ВАК" in book["Публикации"]["F2"].value
|
||||
empty = client.get("/admin/council?specialty=2.2.14")
|
||||
assert "Сотрудники не найдены" in empty.text
|
||||
finally:
|
||||
@@ -558,10 +623,9 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
|
||||
other = employee(db_session, name='Без соответствия', key='other')
|
||||
publication(db_session, other, title='Unknown', kind='ARTICLE')
|
||||
db_session.commit()
|
||||
from app.services.council_export import council_xlsx
|
||||
for specialty in ('', '1.2.2'):
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8), specialty=specialty, paginate=False)
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
staff = list(book['Сотрудники'].values)
|
||||
works = list(book['Публикации'].values)
|
||||
candidate = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == person.full_name)
|
||||
@@ -603,16 +667,16 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
|
||||
result = council.council_candidates(db_session, date(2026, 10, 8))
|
||||
candidate = result['candidates'][0]
|
||||
other = next(a for a in candidate['articles'] if a['title'] == 'Other publication')
|
||||
# Isolate the export rule: other qualifying council bases do not add to its total.
|
||||
# Export the same total as the site, including doctor-only K2 and RSCI.
|
||||
other.update(k2=True, rsci=True, qualifies=True)
|
||||
candidate.update(k2_count=1, rsci_count=1, total_count=2)
|
||||
shared = next(a for a in candidate['articles'] if a['title'] == 'Shared work')
|
||||
shared['criteria'].extend(['В пределах пяти лет', 'В пределах пяти лет по году; точную дату нужно проверить'])
|
||||
from app.services.council_export import council_xlsx
|
||||
book = load_workbook(BytesIO(council_xlsx(result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(result, selection_results(db_session, result))))
|
||||
staff = dict(zip([c.value for c in book['Сотрудники'][1]], [c.value for c in book['Сотрудники'][2]]))
|
||||
assert staff['Научная степень'] == 'Доктор технических наук'
|
||||
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == staff['Всего'] == 1
|
||||
assert staff['Q1'] == staff['Q2'] == staff['К1'] == staff['К2'] == 1
|
||||
assert staff['Всего'] == candidate['total_count'] == 2
|
||||
assert staff['Всего из ЛК'] == staff['Общее количество всех публикаций без дублей'] == 2
|
||||
rows = {values[2]: dict(zip(next(book['Публикации'].values), values)) for values in list(book['Публикации'].values)[1:]}
|
||||
assert rows['Other publication']['Тип материала'] == 'не указан'
|
||||
@@ -620,7 +684,7 @@ def test_xlsx_compact_total_current_degree_and_normalized_criteria(db_session, e
|
||||
assert rows['Shared work']['Критерии'].count('В пределах пяти лет') == 1
|
||||
assert 'точную дату' not in rows['Shared work']['Критерии']
|
||||
params = dict(book['Параметры'].values)
|
||||
assert 'Q1 ИЛИ Q2 ИЛИ К1' in params['Всего']
|
||||
assert 'Q1/Q2/К1/RSCI' in params['Всего'] and 'для доктора наук также К2' in params['Всего']
|
||||
assert 'источники указаны на листе' not in ' '.join(str(v) for v in params.values())
|
||||
assert not any('Работ / По выбранной специальности' == key for key in params)
|
||||
|
||||
|
||||
@@ -84,6 +84,24 @@ def test_candidate_phd_20_and_k2_exclusion(db_session, proofs, degree, count, ba
|
||||
assert value["assessment_status"].startswith("Соответствует") == (basis != "К2" and count >= 20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("degree,total", [("Доктор технических наук", 3), ("Кандидат технических наук", 2), ("Ph.D.", 2)])
|
||||
def test_xlsx_total_matches_site_for_k2_rsci_and_overlapping_bases(db_session, proofs, degree, total):
|
||||
employee = person(db_session, [degree])
|
||||
records = [work(db_session, employee, number, basis=basis) for number, basis in enumerate(("К2", "RSCI", "К1"))]
|
||||
records[-1]["bases"].append({**SOURCE, "basis": "К2", "applicable_year": 2024})
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
db_session.commit()
|
||||
selections = [council.council_candidates(db_session, date(2026, 10, 8), branch=branch, paginate=False)
|
||||
for branch in ("технические", "физико-математические")]
|
||||
candidate = selections[0]["candidates"][0]
|
||||
assert candidate["k2_count"] == 2 and candidate["rsci_count"] == 1
|
||||
assert candidate["total_count"] == total
|
||||
book = load_workbook(BytesIO(council_xlsx(selections[0], selections)))
|
||||
assert book["Сотрудники"]["H2"].value == total
|
||||
assert book["_Данные подбора"]["I2"].value == total
|
||||
assert book["_Данные подбора"]["I3"].value == total
|
||||
|
||||
|
||||
@pytest.mark.parametrize("degrees", list(itertools.permutations(["Кандидат технических наук", "PhD", "Доктор технических наук"])))
|
||||
def test_history_always_chooses_doctor_without_fallback(db_session, proofs, degrees):
|
||||
person(db_session, list(degrees) + ["Профессор", "Неизвестная квалификация"])
|
||||
@@ -185,6 +203,33 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("populated", [False, True])
|
||||
def test_empty_branch_is_rejected_before_export_and_omitted_branch_keeps_default(db_session, proofs, populated):
|
||||
if populated:
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
work(db_session, employee)
|
||||
db_session.commit()
|
||||
with pytest.raises(council.CouncilInputError, match="Неизвестная отрасль науки"):
|
||||
council.council_candidates(db_session, branch="")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for query in ("branch=", "branch=&specialty=1.1.2", "branch=&specialty=1.1.2&previous_specialty="):
|
||||
for path in ("/admin/council", "/admin/council/export.xlsx", "/admin/council/employees/1/articles"):
|
||||
response = client.get(path + "?" + query)
|
||||
assert response.status_code == 422
|
||||
assert "Неизвестная отрасль науки" in response.text
|
||||
assert "content-disposition" not in response.headers
|
||||
assert client.get("/admin/council").status_code == 200
|
||||
response = client.get("/admin/council/export.xlsx")
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Подбор диссовета"]["C4"].value == "технические"
|
||||
assert book["Сотрудники"].max_row == (2 if populated else 1)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_unknown_kind_conflicts_and_safe_source_url(db_session, proofs):
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
record = work(db_session, employee)
|
||||
@@ -323,10 +368,10 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
|
||||
# The policy permits a type; it does not itself confirm a quartile.
|
||||
assert candidate["confirmed_count"] == (int(admitted) if basis not in {"Q1", "Q2"} else 0)
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
headers = [cell.value for cell in book["Сотрудники"][1]]
|
||||
exported = dict(zip(headers, [cell.value for cell in book["Сотрудники"][2]]))
|
||||
assert exported["Всего"] == (int(admitted) if basis in {"Q1", "Q2", "К1"} else 0)
|
||||
assert exported["Всего"] == candidate["total_count"] == int(admitted)
|
||||
publication = dict(zip([cell.value for cell in book["Публикации"][1]], [cell.value for cell in book["Публикации"][2]]))
|
||||
assert publication[basis] == ("Да" if admitted else "Нет")
|
||||
if basis != "RSCI":
|
||||
@@ -370,7 +415,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
|
||||
assert candidate["confirmed_count"] == 1
|
||||
assert full_result["catalog_issues"] and not article["catalog_issues"]
|
||||
assert {r["catalog"] for r in article["catalog_records"]} == {valid_path.parent.name}
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result)))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
assert book["Сотрудники"].cell(2, 5 if flag == "q2" else 4).value == 1
|
||||
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
@@ -400,7 +445,8 @@ def test_automatic_source_quartile_calendar_years(db_session, proofs, year, kind
|
||||
if expected:
|
||||
assert article['confirmed_bases'][0]['basis'] == 'Q2'
|
||||
assert not any('Вид материала не подтверждён' in m for m in article['messages'])
|
||||
book = load_workbook(BytesIO(council_xlsx(council.council_candidates(db_session, today=date(2026, 10, 8)))))
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8))
|
||||
book = load_workbook(BytesIO(council_xlsx(full_result, [full_result, council.council_candidates(db_session, full_result["end"], branch="физико-математические", paginate=False)])))
|
||||
headers = [cell.value for cell in book['Сотрудники'][1]]
|
||||
exported = dict(zip(headers, [cell.value for cell in book['Сотрудники'][2]]))
|
||||
assert exported['Q2'] == candidate['q2_count']
|
||||
|
||||
Reference in new issue
Block a user