fix: include VAK specialties in council XLSX rows

This commit is contained in:
codex committed 2026-10-08 13:10:38 +03:00
1 parent 675a3b5c6d
commit 881aba6b35
7 files changed
+67 -15

No files matched your search

+4 -1
View File
@@ -155,7 +155,7 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.15.0`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.15.1`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
@@ -173,6 +173,9 @@ node --check app/static/admin.js
известным типом, отличным от статьи, не подтверждают специальность. Счётчики Q1/Q2/К1
сохраняют прежний смысл; число работ по выбранной специальности указано внутри
карточки сотрудника. Фильтр сохраняется при поиске, смене страницы и экспорте XLSX.
На листах «Сотрудники» и «Публикации» XLSX отдельная колонка содержит коды и названия
подтверждённых специальностей, включая выгрузку без фильтра. При выбранном фильтре
добавлены число подходящих работ сотрудника и отметка «Да/Нет» для каждой публикации.
`app/data/k1_specialties.json` содержит все 773 журнала из `k1_journals.json`, коды
действующих специальностей, ISSN, страницы и строки исходного перечня, историю
+15 -6
View File
@@ -22,21 +22,28 @@ def council_xlsx(result: dict) -> bytes:
employees.title = "Сотрудники"
publications = workbook.create_sheet("Публикации")
parameters = workbook.create_sheet("Параметры")
specialty_labels = {item["code"]: f"{item['code']}. — {item['name']}" for item in result["specialties"]}
def specialties_text(codes):
return "\n".join(label for code, label in specialty_labels.items() if code in codes) or "Не подтверждены"
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"])
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"])
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV"])
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности"])
for number, candidate in enumerate(result["candidates"], 1):
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
specialty_codes = {code for article in candidate["articles"] for code in article["specialty_codes"]}
_append(employees, [number, candidate["name"], candidate["degree"], candidate["q1_count"], candidate["q2_count"],
candidate["k1_count"], candidate["total_count"],
candidate["db_count"], candidate["count"],
candidate["profile_url"], note])
candidate["profile_url"], note, specialties_text(specialty_codes),
candidate["specialty_count"] if result["specialty"] else None])
for article in candidate["articles"]:
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
"; ".join(article["origins"]), "; ".join(article["criteria"]),
*["Да" if article[key] else "Нет" for key in ("q1", "q2", "k1", "qualifies", "in_db")],
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
article["doi_url"], article["url"], ", ".join(map(str, article["rows"])), specialties_text(article["specialty_codes"]),
("Да" if article["matches_specialty"] else "Нет") if result["specialty"] else None])
_append(parameters, ["Параметр", "Значение"])
for values in [
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
@@ -52,13 +59,15 @@ def council_xlsx(result: dict) -> bytes:
"Материалы с известным другим типом исключены. К1 — совпадение названия журнала с итоговой категорией "
"в справочнике из ВАК категории.pdf; историческая категория не подтверждается. "
"Без названия журнала К1 не подтверждён."],
["Специальности ВАК в строках", "Коды и названия из 12 специальностей, доступных на сайте. Для публикации — специальности релевантного журнала К1, учитываемые за пять лет; для сотрудника — объединение этих специальностей без дублей. Не подтверждены — нет достаточных данных по этим правилам."],
["Работ / По выбранной специальности", "При выбранном фильтре показаны число соответствующих публикаций сотрудника и отметка Да/Нет для каждой работы. Без фильтра ячейки пустые; список специальностей остаётся заполненным."],
["ВСЕГО", "Уникальные публикации, входящие в Q1 ИЛИ Q2 ИЛИ К1; пересечения учитываются один раз."],
["Страница ВШЭ / Все публикации", "Все годы и категории; общий перечень объединяет CSV и БД без дублей. "
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
]:
_append(parameters, values)
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22]),
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28]),
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28]),
(parameters, [32, 95])]:
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.15.0"
FRONTEND_VERSION = "0.15.0"
BACKEND_VERSION = "0.15.0"
APP_VERSION = "0.15.1"
FRONTEND_VERSION = "0.15.1"
BACKEND_VERSION = "0.15.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.15.0"
version = "0.15.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+4
View File
@@ -157,6 +157,10 @@ def main():
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
assert filtered["Сотрудники"].max_row == 2
assert dict(filtered["Параметры"].values)["Специальность ВАК"].startswith("1.2.2.")
headers = {cell.value: cell.column for cell in filtered["Сотрудники"][1]}
assert filtered["Сотрудники"].cell(2, headers["Специальности ВАК"]).value.startswith("1.2.2.")
assert filtered["Сотрудники"].cell(2, headers["Работ по выбранной специальности"]).value == 1
pending_download.value.save_as(str(output / "specialty-export.xlsx"))
assert not errors, errors
browser.close()
finally:
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.15.0"
assert response.json()["backend_version"] == "0.15.1"
def test_api_employees_and_stats_require_admin_session():
+39 -3
View File
@@ -211,7 +211,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "&lt;script&gt;" in response.text and "<script>" not in response.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 0.15.0 · Frontend 0.15.0" in response.text
assert "Backend 0.15.1 · Frontend 0.15.1" 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
@@ -256,11 +256,11 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
people, works = book["Сотрудники"], book["Публикации"]
assert [cell.value for cell in people[1]] == [
"№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
"Профиль ВШЭ", "Примечание"
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности"
]
assert people["J2"].value == "https://www.hse.ru/staff/test"
assert people["K2"].value is None
assert people.max_column == 11
assert people.max_column == 13
assert works["L1"].value == "Страница ВШЭ"
assert people.max_row == 29 and works.max_row == 29
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
@@ -529,3 +529,39 @@ def test_specialty_reference_integrity():
assert journal["status"] == "ambiguous" and not journal["specialty_codes"]
for specialty in data["specialties"]:
assert any(specialty["code"] in j["specialty_codes"] for j in data["journals"])
def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, export):
export([row(title="Old journal", doi="10.1234/conflict", year="2020", **{"Название журнала": "Бизнес-информатика"}),
row(title="Old journal", doi="10.1234/conflict", year="2024")])
person = employee(db_session)
for title in ['Matching', 'Second matching']:
publication(db_session, person, title=title, kind='ARTICLE', raw_data={'journal': 'Бизнес-информатика'})
publication(db_session, person, title='Book', kind='BOOK', raw_data={'journal': 'Бизнес-информатика'})
publication(db_session, person, title='Unknown journal', kind='ARTICLE', raw_data={'journal': 'Other'})
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)))
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)
assert candidate['Специальности ВАК'].count('1.2.2.') == 1
assert 'Математическое моделирование' in candidate['Специальности ВАК']
assert '2.3.5.' in candidate['Специальности ВАК']
assert '5.2.2.' not in candidate['Специальности ВАК'] # Only the site's twelve specialties.
assert candidate['Работ по выбранной специальности'] == (2 if specialty else None)
records = {values[2]: dict(zip(works[0], values)) for values in works[1:]}
assert '1.2.2.' in records['Matching']['Специальности ВАК']
assert records['Matching']['По выбранной специальности'] == ('Да' if specialty else None)
for title in ('Book', 'Unknown journal', 'Old journal'):
assert records[title]['Специальности ВАК'] == 'Не подтверждены'
assert records[title]['По выбранной специальности'] == ('Нет' if specialty else None)
if not specialty:
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
assert book['Сотрудники'].auto_filter.ref.endswith(f'M{len(staff)}')
assert book['Публикации'].auto_filter.ref.endswith(f'Q{len(works)}')