diff --git a/.codex-session.md b/.codex-session.md index 945ab62..a91f43a 100644 --- a/.codex-session.md +++ b/.codex-session.md @@ -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. diff --git a/README.md b/README.md index 44e05ca..0527abd 100644 --- a/README.md +++ b/README.md @@ -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: данные включены в образ и кешируются в процессе. diff --git a/app/admin.py b/app/admin.py index 045ca0c..01c1cf8 100644 --- a/app/admin.py +++ b/app/admin.py @@ -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): diff --git a/app/services/council_export.py b/app/services/council_export.py index 2af1815..4335a9d 100644 --- a/app/services/council_export.py +++ b/app/services/council_export.py @@ -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" diff --git a/app/version.py b/app/version.py index 10e3afd..5daaaa3 100644 --- a/app/version.py +++ b/app/version.py @@ -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" diff --git a/docs/council-evidence.md b/docs/council-evidence.md index b569d74..d3e9d94 100644 --- a/docs/council-evidence.md +++ b/docs/council-evidence.md @@ -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`. Если записаны оба идентификатора, они должны обозначать одну работу. После изменения исходных записей проверяйте актуальность привязки; ФИО и нечёткое название не используются для ручных подтверждений. Несколько записей сохраняются как независимые доказательства; существенные конфликты дат, видов, категорий и идентичности требуют проверки. diff --git a/pyproject.toml b/pyproject.toml index 371303f..0121959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_api.py b/tests/test_api.py index 751f8f3..5070de9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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(): diff --git a/tests/test_council.py b/tests/test_council.py index 7d08317..c3662f4 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -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"" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/")) + assert all(b"" 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) diff --git a/tests/test_council_assessment.py b/tests/test_council_assessment.py index cb6c36d..fa90009 100644 --- a/tests/test_council_assessment.py +++ b/tests/test_council_assessment.py @@ -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']