feat: add offline council selection to XLSX and align totals
This commit is contained in:
1 parent
7c02b0825f
commit
2baa2e1aa1
10 files changed
+253
-53
No files matched your search
+11
-12
@@ -1,20 +1,19 @@
|
||||
## Сделано
|
||||
|
||||
- Ветка `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.0`.
|
||||
- «Всего» в XLSX использует `candidate.total_count`, как сайт: Q1/Q2/К1/RSCI, у доктора также К2; самостоятельный К2 у кандидата/PhD исключается, пересечения не увеличивают итог.
|
||||
- Первый лист «Подбор диссовета»: специальность ВАК, допустимая отрасль с автоматическим выбором совместимой, поиск, степень, статус, сортировка, счётчики, подтверждённый зачёт, минимум и профиль ВШЭ. Без макросов и новых зависимостей.
|
||||
- Для переключения фильтров выгружаются все сотрудники с учёной степенью и их публикации. Начальные фильтры берутся с сайта; подробные листы сохраняют названия и столбцы. Данные являются снимком; изменения Excel не отправляются на сайт.
|
||||
- Исходные строки остаются текстом; формулы записываются отдельно. Формат и поведение описаны в README и docs/council-evidence.md.
|
||||
- Проверки: полный pytest — 416 passed, 11 skipped; синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed; `git diff --check main` — passed.
|
||||
- В установленном Windows Excel 16.0 проверены 14 сценариев на синтетических данных и пустой файл: результаты, сортировка, поиск (включая буквальный *), специальности, совместимость отраслей, степень, статусы и пороги совпали с сервисом сайта. Артефакты: `.impeccable/review/council-xlsx/sample-checked.xlsx`, `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 в `main`. Текущая ветка ещё не опубликована.
|
||||
- PostgreSQL-тесты пропущены без отдельной тестовой БД; production не изменялся. Тестирование Excel проводилось только на синтетическом наборе.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71.
|
||||
- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
|
||||
- Коммит, `git push -u origin feature/council-xlsx-selection`, поиск существующего PR и создание единственного PR в `main`, если отсутствует.
|
||||
- После публикации остановиться и ждать независимого ревью в отдельной сессии и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
|
||||
@@ -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 и новее без макросов: выбор специальности ВАК, допустимой отрасли, поиск по ФИО, фильтры по степени и статусу, сортировка по ФИО и счётчикам. Начальные фильтры взяты с сайта. При несовместимой отрасли после смены специальности применяется первая допустимая; она показана рядом. Список показывает 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):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""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
|
||||
|
||||
@@ -6,6 +6,15 @@ 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.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 +22,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 +43,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 +70,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 +80,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 +109,121 @@ 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"
|
||||
+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.0"
|
||||
FRONTEND_VERSION = "1.4.0"
|
||||
BACKEND_VERSION = "1.4.0"
|
||||
@@ -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.0"
|
||||
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.0"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
+72
-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.0 · Frontend 1.4.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
|
||||
@@ -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,49 @@ 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
|
||||
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 +447,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 +507,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 +568,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 +612,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 +656,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 +673,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) + ["Профессор", "Неизвестная квалификация"])
|
||||
@@ -323,10 +341,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 +388,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 +418,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