Merge pull request 'Ограничить отрасли оценки специальностями ВАК' (#70) from fix/council-specialty-branches into main
CI / test (push) Successful in 10m17s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped

Reviewed-on: #70
Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #70.
This commit is contained in:
admin committed 2026-10-11 22:51:43 +00:00
commit 378d7ee96e
13 files changed
+215 -36

No files matched your search

+2 -2
View File
@@ -169,7 +169,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
authenticated = optional_user(request, settings, db)
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
branch=branch)
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
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):
@@ -303,7 +303,7 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
branch=branch)
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
content = council_xlsx(result)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
+58 -12
View File
@@ -9,51 +9,97 @@
"specialties": [
{
"code": "1.2.2",
"name": "Математическое моделирование, численные методы и комплексы программ"
"name": "Математическое моделирование, численные методы и комплексы программ",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.2.2",
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.6",
"name": "Методы и системы защиты информации, информационная безопасность"
"name": "Методы и системы защиты информации, информационная безопасность",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.1",
"name": "Системный анализ, управление и обработка информации, статистика"
"name": "Системный анализ, управление и обработка информации, статистика",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.7",
"name": "Компьютерное моделирование и автоматизация проектирования"
"name": "Компьютерное моделирование и автоматизация проектирования",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "1.3.8",
"name": "Физика конденсированного состояния"
"name": "Физика конденсированного состояния",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.2.15",
"name": "Системы, сети и устройства телекоммуникаций"
"name": "Системы, сети и устройства телекоммуникаций",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "1.1.2",
"name": "Дифференциальные уравнения и математическая физика"
"name": "Дифференциальные уравнения и математическая физика",
"branches": [
"физико-математические"
]
},
{
"code": "2.2.13",
"name": "Радиотехника, в том числе системы и устройства телевидения"
"name": "Радиотехника, в том числе системы и устройства телевидения",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.2",
"name": "Вычислительные системы и их элементы"
"name": "Вычислительные системы и их элементы",
"branches": [
"технические"
]
},
{
"code": "2.2.14",
"name": "Антенны, СВЧ-устройства и их технологии"
"name": "Антенны, СВЧ-устройства и их технологии",
"branches": [
"технические",
"физико-математические"
]
},
{
"code": "2.3.5",
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей",
"branches": [
"технические",
"физико-математические"
]
}
],
"journals": [
+14 -3
View File
@@ -253,7 +253,8 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
branch: str = "технические", employee_id: int | None = None) -> dict:
branch: str = "технические", employee_id: int | None = None,
previous_specialty: str | None = None) -> dict:
if branch and branch not in BRANCHES:
raise CouncilInputError("Неизвестная отрасль науки")
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
@@ -261,6 +262,15 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
specialties = _k1_specialties()["specialties"]
if specialty and specialty not in {s["code"] for s in specialties}:
raise CouncilInputError("Неизвестная специальность ВАК")
branches = list(dict.fromkeys(name for s in specialties if not specialty or s["code"] == specialty
for name in s["branches"]))
if branch and branch not in branches:
previous_branches = {name for s in specialties if previous_specialty == "" or s["code"] == previous_specialty
for name in s["branches"]}
if previous_specialty is not None and previous_specialty != specialty and branch in previous_branches:
branch = branches[0]
else:
raise CouncilInputError("Отрасль оценки не относится к выбранным специальностям ВАК")
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
degree_sections = Employee.current_data["sections"]
if db.get_bind().dialect.name == "postgresql":
@@ -421,13 +431,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
limit = 25
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
criteria_groups = {}
for name, (minimum, monographs) in BRANCHES.items():
for name in branches:
minimum, monographs = BRANCHES[name]
criteria_groups.setdefault((minimum, monographs), []).append(name)
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
"start": start, "end": end, "total": total,
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
"branch": branch, "branches": list(BRANCHES),
"branch": branch, "branches": branches,
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
for path in (K1_JOURNALS, K2_JOURNALS)
+8
View File
@@ -1,4 +1,12 @@
(() => {
const specialty = document.querySelector('#council-specialty');
const branch = document.querySelector('#council-branch');
specialty?.addEventListener('change', () => {
const names = JSON.parse(specialty.selectedOptions[0].dataset.branches);
const previous = branch.value;
branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name)));
if (names.includes(previous)) branch.value = previous;
});
const dialog = document.querySelector('[data-council-dialog]');
if (!dialog) return;
const form = dialog.querySelector('[data-edit-form]');
+5 -4
View File
@@ -5,7 +5,7 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
</div>
</header>
@@ -26,7 +26,7 @@
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.</p>
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.</p>
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
@@ -34,6 +34,7 @@
</details>
<form class="council__search" method="get" action="/admin/council" role="search">
<input type="hidden" name="previous_specialty" value="{{ specialty }}">
<label class="directory__field" for="council-branch">Отрасль оценки
<select class="directory__input" id="council-branch" name="branch">
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
@@ -41,9 +42,9 @@
</label>
<label class="directory__field" for="council-specialty">Специальность ВАК
<select class="directory__input" id="council-specialty" name="specialty">
<option value="">Все специальности</option>
<option value="" data-branches='{{ specialties|map(attribute="branches")|sum(start=[])|unique|list|tojson }}'>Все специальности</option>
{% for item in specialties %}
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
<option value="{{ item.code }}" data-branches='{{ item.branches|tojson }}'{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
{% endfor %}
</select>
</label>
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.2.1"
FRONTEND_VERSION = "1.2.1"
BACKEND_VERSION = "1.2.1"
APP_VERSION = "1.2.3"
FRONTEND_VERSION = "1.2.3"
BACKEND_VERSION = "1.2.3"
+5
View File
@@ -78,3 +78,8 @@
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX.
Без JavaScript форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "1.2.1"
version = "1.2.3"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+5 -1
View File
@@ -170,7 +170,11 @@ if __name__ == "__main__":
result = {"schema_version": 1, "source": args.source.name,
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
"specialties": [{"code": code, "name": name,
"branches": ["физико-математические"] if code == "1.1.2" else
["технические"] if code == "2.3.2" else
["технические", "физико-математические"]}
for code, name in SPECIALTIES], "journals": entries}
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
from collections import Counter
print(len(entries), dict(Counter(j['status'] for j in entries)))
+36
View File
@@ -92,6 +92,21 @@ def main():
assert "Без степени" not in page.content() and "Только звание" not in page.content()
expect(page.locator("#council-date")).to_have_count(0)
assert "Монографии" not in page.content()
branches = page.locator('#council-branch')
specialty = page.locator('#council-specialty')
expect(branches.locator('option')).to_have_count(2)
specialty.select_option('1.1.2')
expect(branches.locator('option')).to_have_count(1)
expect(branches).to_have_value('физико-математические')
specialty.select_option('2.3.2')
expect(branches.locator('option')).to_have_count(1)
expect(branches).to_have_value('технические')
specialty.select_option('1.2.2')
expect(branches.locator('option')).to_have_count(2)
branches.select_option('физико-математические')
specialty.select_option('')
expect(branches).to_have_value('физико-математические')
branches.select_option('технические')
page.get_by_role("button", name="Применить", exact=True).click()
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
page.get_by_role("link", name="Далее").click()
@@ -217,6 +232,27 @@ def main():
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
page.screenshot(path=str(output / f"catalog-{width}.png"), full_page=True)
assert not errors, errors
no_js = browser.new_context(java_script_enabled=False)
no_js.route("**/*", serve)
fallback = no_js.new_page()
for previous, specialty, old_branch, new_branch in [
("2.3.2", "1.1.2", "технические", "физико-математические"),
("1.1.2", "2.3.2", "физико-математические", "технические"),
]:
url = f"http://miem.test/admin/council?specialty={previous}&branch={old_branch}&q=Тест&sort=full_name&direction=asc"
fallback.goto(url)
fallback.get_by_label("Специальность ВАК").select_option(specialty)
with fallback.expect_download() as pending_download:
fallback.get_by_role("button", name="Скачать XLSX", exact=True).click()
book = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
fallback.get_by_role("button", name="Применить", exact=True).click()
expect(fallback.get_by_label("Отрасль оценки")).to_have_value(new_branch)
expect(fallback.get_by_label("Специальность ВАК")).to_have_value(specialty)
expect(fallback.get_by_label("Поиск по ФИО")).to_have_value("Тест")
expect(fallback.get_by_label("Сортировать по")).to_have_value("full_name")
expect(fallback.get_by_label("Направление")).to_have_value("asc")
no_js.close()
browser.close()
finally:
council._articles_by_author = original
+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"] == "1.2.1"
assert response.json()["backend_version"] == "1.2.3"
def test_api_employees_and_stats_require_admin_session():
+4 -2
View File
@@ -223,7 +223,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.2.1 · Frontend 1.2.1" in response.text
assert "Backend 1.2.3 · Frontend 1.2.3" 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
@@ -501,7 +501,9 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
client = TestClient(app)
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
assert response.status_code == 200
assert '<option value="1.2.2" selected>' in response.text
from bs4 import BeautifulSoup
selected = BeautifulSoup(response.text, "html.parser").select_one('#council-specialty option[selected]')
assert selected["value"] == "1.2.2"
assert response.text.count('<option value="2.3.5"') == 1
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
assert "Не подходит" not in response.text
+73 -7
View File
@@ -48,9 +48,14 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
"bases": [{**SOURCE, "basis": basis, "applicable_year": year}], **proof_fields}
def result(db, branch="технические", **kwargs):
def result(db, branch="технические", assessment_branch=None, **kwargs):
db.commit()
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
value = council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
if assessment_branch:
return assessment.assess_candidate(value["degree"].split("; "), value["articles"],
assessment.evidence()["employees"]["test"], assessment_branch, "",
date(2022, 1, 1), date(2026, 12, 31))
return value
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
@@ -61,7 +66,7 @@ def test_each_doctor_threshold_and_monograph_requirement(db_session, proofs, bra
employee = person(db_session, [degree])
records = [work(db_session, employee, n) for n in range(minimum + delta)]
proofs([{**SOURCE, "degree": degree}], records)
value = result(db_session, branch)
value = result(db_session, assessment_branch=branch)
assert value["minimum"] == minimum and value["monograph_minimum"] == mono
assert value["confirmed_count"] == minimum + delta
assert value["assessment_status"].startswith("Соответствует") == (delta >= 0 and not mono)
@@ -102,10 +107,10 @@ def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branc
records = [work(db_session, employee, n) for n in range(8)]
records.append(work(db_session, employee, 8, kind="BOOK", kind_evidence=SOURCE))
proofs([{**SOURCE, "degree": degree}], records)
assert result(db_session, branch)["monograph_count"] == 0
assert result(db_session, assessment_branch=branch)["monograph_count"] == 0
records[-1]["kind"] = "monograph"
proofs([{**SOURCE, "degree": degree}], records, {"monograph_period": {**SOURCE, "value": "five_years"}})
value = result(db_session, branch)
value = result(db_session, assessment_branch=branch)
assert value["confirmed_count"] == 8 and value["monograph_count"] == 1
assert value["assessment_status"].startswith("Соответствует")
@@ -175,8 +180,7 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
assert "council-date" not in page.text and "Монографии" not in page.text
assert "Дата ходатайства" not in page.text
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
legacy_book = load_workbook(BytesIO(legacy.content))
assert dict(legacy_book["Параметры"].values)["Отрасль оценки"] == "экономические"
assert legacy.status_code == 422
finally:
app.dependency_overrides.clear()
@@ -492,3 +496,65 @@ def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_se
assert article['display_type'] == 'Статья' and article['types'] == []
assert article['warnings']['type'] and 'не указан тип материала' in article['warnings']['type'][0]
assert not article['confirmed'] and candidate['confirmed_count'] == 0
@pytest.mark.parametrize("specialty,branches", [
("", ["технические", "физико-математические"]),
("1.1.2", ["физико-математические"]),
("2.3.2", ["технические"]),
*[(code, ["технические", "физико-математические"]) for code in
["1.2.2", "1.3.8", "2.2.2", "2.2.13", "2.2.14", "2.2.15", "2.3.1", "2.3.5", "2.3.6", "2.3.7"]],
])
def test_specialty_limits_branch_options_and_rejects_invalid_requests(db_session, proofs, specialty, branches):
from bs4 import BeautifulSoup
employee = person(db_session, ["Доктор технических наук"])
db_session.commit()
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
for branch in assessment.BRANCHES:
params = {"specialty": specialty, "branch": branch}
page = client.get("/admin/council", params=params)
if branch in branches:
assert page.status_code == 200
options = BeautifulSoup(page.text, "html.parser").select("#council-branch option")
assert [option["value"] for option in options] == branches
assert next(option["value"] for option in options if "selected" in option.attrs) == branch
else:
assert page.status_code == 422
assert client.get("/admin/council/export.xlsx", params=params).status_code == 422
assert client.get(f"/admin/council/employees/{employee.id}/articles", params=params).status_code == 422
finally:
app.dependency_overrides.clear()
@pytest.mark.parametrize("previous,specialty,old_branch,new_branch", [
("2.3.2", "1.1.2", "технические", "физико-математические"),
("1.1.2", "2.3.2", "физико-математические", "технические"),
("", "1.1.2", "технические", "физико-математические"),
])
def test_specialty_change_without_js_uses_compatible_branch(db_session, proofs, previous, specialty, old_branch, new_branch):
from bs4 import BeautifulSoup
app.dependency_overrides[get_db] = lambda: db_session
try:
client = TestClient(app)
params = {"previous_specialty": previous, "specialty": specialty, "branch": old_branch,
"q": "Тест", "sort": "full_name", "direction": "asc"}
page = client.get("/admin/council", params=params)
assert page.status_code == 200
parsed = BeautifulSoup(page.text, "html.parser")
assert parsed.select_one('#council-branch option[selected]')["value"] == new_branch
assert parsed.select_one('[name=previous_specialty]')["value"] == specialty
assert parsed.select_one('[name=q]')["value"] == "Тест"
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx", params=params).content))
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
for invalid_previous in [None, specialty, "oops"]:
invalid = {**params, "previous_specialty": invalid_previous}
if invalid_previous is None:
del invalid["previous_specialty"]
assert client.get("/admin/council", params=invalid).status_code == 422
assert client.get("/admin/council/export.xlsx", params=invalid).status_code == 422
invalid = {**params, "branch": "экономические"}
assert client.get("/admin/council", params=invalid).status_code == 422
finally:
app.dependency_overrides.clear()