diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7ee15..719fe7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.7.3 + +- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году. +- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку. + +## 1.7.2 + +- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`. +- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами. + ## 1.6.2 - Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки. diff --git a/app/admin.py b/app/admin.py index 3ad1ade..5dc5add 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,6 +1,7 @@ from copy import deepcopy +from typing import Literal from pydantic import BaseModel, Field, ConfigDict -from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, Form, Query, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from sqlalchemy import desc, func, select, update @@ -114,6 +115,8 @@ def directory( has_email: str | None = None, has_academic_degree: str | None = None, pps: bool = False, + has_training: Literal["", "true", "false"] = "", + training_since_year: str = Query(default="", pattern=r"^([1-9][0-9]{3})?$"), sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -136,6 +139,8 @@ def directory( has_email=parsed_has_email, has_academic_degree=parsed_has_academic_degree, pps=pps, + has_training=has_training == "true" if has_training else None, + training_since_year=int(training_since_year) if training_since_year else None, sort=sort, direction=direction, limit=limit, @@ -157,6 +162,8 @@ def directory( "has_email": has_email or "", "has_academic_degree": has_academic_degree or "", "pps": pps, + "has_training": has_training, + "training_since_year": training_since_year, "sort": sort, "direction": direction, "limit": page["limit"], diff --git a/app/api.py b/app/api.py index c5f5b45..702b6b5 100644 --- a/app/api.py +++ b/app/api.py @@ -1,6 +1,6 @@ from datetime import date -from fastapi import APIRouter, BackgroundTasks, Depends, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request from sqlalchemy import desc, select from sqlalchemy.orm import Session @@ -30,6 +30,8 @@ def list_employees( has_email: bool | None = None, has_academic_degree: bool | None = None, pps: bool = False, + has_training: bool | None = None, + training_since_year: int | None = Query(default=None, ge=1000, le=9999), sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -47,6 +49,8 @@ def list_employees( has_email=has_email, has_academic_degree=has_academic_degree, pps=pps, + has_training=has_training, + training_since_year=training_since_year, sort=sort, direction=direction, limit=limit, diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 2cf4d14..875c8d2 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from datetime import date, datetime, time from math import ceil from typing import Any @@ -20,6 +21,44 @@ EMPLOYEE_SORTS = { "hse_start_year": Employee.current_data["hse_start_year"].as_integer(), } +_TRAINING_LABEL = "повышение квалификации" +_TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", re.IGNORECASE) +_TRAINING_MENTION = re.compile( + r"\b(?:повышение квалификации|(?:курс|программ)[а-яё]* (?:повышения|по повышению) квалификации)\b", + re.IGNORECASE, +) +_QUOTED_TEXT = re.compile(r'«[^»]*»|"[^"]*"') +_TRAINING_YEAR = re.compile(r"^(\d{4})(?!\d)|(? bool: + for value in _as_list(data.get("sections")): + section = _as_dict(value) + title = str(section.get("title") or "").casefold().strip().rstrip(":") + dedicated = title in {_TRAINING_LABEL, "курсы повышения квалификации"} + if not dedicated and _TRAINING_LABEL not in (part.strip() for part in title.split("/")): + continue + for value in _as_list(section.get("year_entries")): + entry = _as_dict(value) + year = entry.get("year") + text = str(entry.get("text") or "").strip() + if since_year is not None and (type(year) is not int or year < since_year): + continue + if text and text != str(year) and (dedicated or _TRAINING_KIND.search(_QUOTED_TEXT.sub("", text))): + return True + # Profiles may also list completed courses in manually entered paragraphs or lists. + for text in _clean_list(section.get("paragraphs")) + _clean_list(section.get("items")): + description = _QUOTED_TEXT.sub("", text).strip() + if not dedicated and not _TRAINING_MENTION.search(description): + continue + if since_year is None: + return True + match = _TRAINING_YEAR.search(description) + if match and int(match.group(1) or match.group(2)) >= since_year: + return True + return False + + def employee_display_payload(employee: Employee) -> dict[str, Any]: data = _as_dict(employee.current_data) contacts = _as_dict(data.get("contacts")) @@ -117,6 +156,8 @@ def list_employees_page( has_email: bool | None = None, has_academic_degree: bool | None = None, pps: bool = False, + has_training: bool | None = None, + training_since_year: int | None = None, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -135,15 +176,28 @@ def list_employees_page( sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) order = desc(sort_column) if direction == "desc" else sort_column ordered_stmt = base_stmt.order_by(order) - if pps: - # Match decoded position strings consistently in PostgreSQL and SQLite. - employees = [employee for employee in db.scalars(ordered_stmt).all() - if any("кафедра" in position.casefold() or "департамент" in position.casefold() - for position in _clean_list(_as_dict(employee.current_data).get("positions")))] - total = len(employees) + if has_training is None and training_since_year is not None: + has_training = True + if pps or has_training is not None: + # Apply profile filters to decoded data before pagination on both databases. + employees = [] + first_page = [] + total = 0 + for employee in db.scalars(ordered_stmt.execution_options(yield_per=100)): + data = _as_dict(employee.current_data) + if pps and not any("кафедра" in position.casefold() or "департамент" in position.casefold() + for position in _clean_list(data.get("positions"))): + continue + if has_training is not None and _has_training(data, training_since_year) != has_training: + continue + if offset and total < limit: + first_page.append(employee) + if offset <= total < offset + limit: + employees.append(employee) + total += 1 if offset >= total: + employees = first_page offset = 0 - employees = employees[offset:offset + limit] else: total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 if offset >= total: diff --git a/app/static/admin.css b/app/static/admin.css index 92b2092..0f0197a 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -1,4 +1,5 @@ .admin { + --admin-gutter: 32px; margin: 0; min-height: 100vh; display: flex; @@ -27,7 +28,7 @@ align-items: center; justify-content: space-between; gap: 24px; - padding: 18px 32px; + padding: 18px var(--admin-gutter); background: #ffffff; border-bottom: 1px solid #d9dee7; flex-wrap: wrap; @@ -46,6 +47,7 @@ .admin__nav { display: flex; align-items: center; + flex-wrap: wrap; gap: 14px; } @@ -67,8 +69,7 @@ .admin__main { flex: 1; - width: min(1180px, calc(100% - 32px)); - margin: 28px auto; + margin: 28px var(--admin-gutter); } .admin__footer { @@ -558,9 +559,17 @@ color: #6b7280; } +.directory__filter-panel > summary { + width: fit-content; + min-height: 44px; + align-content: center; + cursor: pointer; + font-weight: 600; +} + .directory__filters { display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; padding: 16px; background: #ffffff; @@ -600,6 +609,10 @@ font-weight: 700; } +.directory__filter-hint { + color: #4b5563; +} + .directory__input { box-sizing: border-box; width: 100%; @@ -783,6 +796,12 @@ font-weight: 700; } +@media (max-width: 1100px) { + .directory__filters { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + @media (max-width: 920px) { .directory__filters { grid-template-columns: 1fr 1fr; @@ -802,6 +821,10 @@ } @media (max-width: 620px) { + .admin { + --admin-gutter: 16px; + } + .directory-table [data-column="full_name"] { position: sticky; left: 0; @@ -812,17 +835,15 @@ } .admin__header { align-items: flex-start; - padding: 16px; + padding-block: 16px; } .admin__nav { width: 100%; - flex-wrap: wrap; } .admin__main { - width: min(100% - 24px, 1180px); - margin: 20px auto; + margin-block: 20px; } .directory__filters { diff --git a/app/static/guide.js b/app/static/guide.js index a560db7..3a93d27 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -8,17 +8,21 @@ const steps = [ {id: 'welcome', title: 'Познакомьтесь с Workers', intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'}, - {id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filters', tips: [ + {id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [ ['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'], ['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'], ['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'], ]}, - {id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filters', tips: [ + {id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filter-panel', tips: [ ['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'], ['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'], ['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'], ]}, - {id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filters', tips: [ + {id: 'employee-training', path: directory, title: 'Проверьте повышение квалификации', region: '.directory__filter-panel', tips: [ + ['[name="has_training"]', 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'], + ['[name="training_since_year"]', 'Например, 2024 включает 2024 год и все последующие.'], + ]}, + {id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [ ['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'], ['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'], ['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'], @@ -130,6 +134,7 @@ paused = Boolean(step.path && step.path !== location.pathname); clearTips(); const region = !paused && step.region && main.querySelector(step.region); + if (region instanceof HTMLDetailsElement) region.open = true; if (region) region.before(guide); else main.prepend(guide); heading.textContent = paused ? 'Продолжите обучение' : step.title; diff --git a/app/templates/directory.html b/app/templates/directory.html index 90a64fb..2838598 100644 --- a/app/templates/directory.html +++ b/app/templates/directory.html @@ -12,6 +12,9 @@ + {% set has_filters = filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to or filters.has_training or filters.training_since_year %} +
+ Фильтры{% if has_filters %} · применены{% endif %}
+
+ Повышение квалификации + + + Если указан только год, будут показаны сотрудники с курсами. +
Период обнаружения @@ -64,6 +79,7 @@ Сбросить +
Что означают даты и статусы @@ -113,7 +129,7 @@ {% else %} Сотрудники не найдены - {% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to %} + {% if has_filters %} Попробуйте изменить запрос или сбросить фильтры. Сбросить фильтры {% else %} diff --git a/app/templates/guide_content.html b/app/templates/guide_content.html index 5c6129d..eac82da 100644 --- a/app/templates/guide_content.html +++ b/app/templates/guide_content.html @@ -12,9 +12,10 @@

Найдите сотрудника и откройте профиль

-

В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.

+

В разделе «Сотрудники» раскройте панель «Фильтры». В поле «ФИО или ссылка» введите фамилию, часть ФИО или ссылку на профиль ВШЭ. Например, Иванов.

Статус, Email, Учёная степень
Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.
+
Повышение квалификации
Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ. Например, «Нет курсов» и 2024 покажут сотрудников без записей о курсах за 2024 год и позже. Если указать только год, отобразятся сотрудники с курсами за этот период.
Период обнаружения: От / До
Введите даты первого обнаружения профиля системой. Это не период приёма на работу.
Сортировка и страница
Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.
Настроить колонки
Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.
diff --git a/app/version.py b/app/version.py index ff9070f..3f1989a 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.6.2" -FRONTEND_VERSION = "1.6.2" -BACKEND_VERSION = "1.6.2" +APP_VERSION = "1.7.3" +FRONTEND_VERSION = "1.7.3" +BACKEND_VERSION = "1.7.3" diff --git a/pyproject.toml b/pyproject.toml index f049e28..b9cb3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.6.2" +version = "1.7.3" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/browser_admin.py b/tests/browser_admin.py index eb7972c..ae00811 100644 --- a/tests/browser_admin.py +++ b/tests/browser_admin.py @@ -75,8 +75,23 @@ def main(): page.keyboard.press("Tab") assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'") - for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320): + filters = page.locator(".directory__filter-panel") + expect(filters).not_to_have_attribute("open", "") + expect(page.get_by_label("ФИО или ссылка")).to_be_hidden() + filters.locator("summary").focus() + page.keyboard.press("Enter") + expect(page.get_by_label("ФИО или ссылка")).to_be_visible() + for width in (1920, 1440, 1180, 1024, 921, 920, 768, 621, 620, 390, 320): page.set_viewport_size({"width": width, "height": 844}) + assert page.locator("main").evaluate("""main => { + const header = document.querySelector('.admin__header'); + const style = getComputedStyle(header); + const headerBox = header.getBoundingClientRect(); + const mainBox = main.getBoundingClientRect(); + return Math.abs(mainBox.left - headerBox.left - parseFloat(style.paddingLeft)) < 1 + && Math.abs(headerBox.right - mainBox.right - parseFloat(style.paddingRight)) < 1 + && document.documentElement.scrollWidth <= document.documentElement.clientWidth; + }""") assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth") assert page.locator(".directory__input").evaluate_all("""controls => controls.every(control => { const field = control.closest('.directory__field').getBoundingClientRect(); @@ -88,6 +103,7 @@ def main(): page.get_by_role("button", name="Применить фильтры").click() expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1") assert "offset=" not in page.url + expect(filters).not_to_have_attribute("open", "") trigger = page.get_by_role("button", name="Настроить колонки") trigger.focus() page.keyboard.press("Enter") @@ -116,6 +132,7 @@ def main(): assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth") page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True) page.keyboard.press("Escape") + filters.locator("summary").click() page.get_by_label("ФИО или ссылка").fill("Нет совпадений") page.get_by_role("button", name="Применить фильтры").click() expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible() diff --git a/tests/browser_users.py b/tests/browser_users.py index 9a26f6e..db8e3b4 100644 --- a/tests/browser_users.py +++ b/tests/browser_users.py @@ -152,7 +152,23 @@ def main(): expect(tips).to_have_count(3) go_back.click() expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible() - for _ in range(5): + for _ in range(3): + go_next.click() + expect(guide.get_by_role('heading', name='Проверьте повышение квалификации')).to_be_visible() + expect(tips).to_have_count(2) + expect(page.get_by_role('combobox', name='Курсы в профиле', exact=True)).to_have_accessible_description( + 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.' + ) + expect(page.get_by_role('textbox', name='С какого года включительно', exact=True)).to_have_accessible_description( + 'Если указан только год, будут показаны сотрудники с курсами. ' + 'Например, 2024 включает 2024 год и все последующие.' + ) + for width in (1440, 320): + page.set_viewport_size({'width': width, 'height': 900}) + assert tips.evaluate_all('tips => tips.every(tip => tip.scrollWidth <= tip.clientWidth)') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.set_viewport_size({'width': 1440, 'height': 900}) + for _ in range(3): go_next.click() assert page.url.endswith('/admin/council') expect(tips).to_have_count(3) @@ -227,6 +243,7 @@ def main(): nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click() nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click() expect(nojs_page.locator('[data-guide-step]')).to_have_count(6) + expect(nojs_page.get_by_text('Повышение квалификации', exact=True)).to_be_visible() expect(nojs_page.get_by_role('heading', name='Проверьте строку публикации')).to_be_visible() nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click() expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0) diff --git a/tests/test_api.py b/tests/test_api.py index 81a7c80..4e54a5c 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.6.2" + assert response.json()["backend_version"] == "1.7.3" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index b3959d1..c64a114 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -230,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.6.2 · Frontend 1.6.2" in response.text + assert "Backend 1.7.3 · Frontend 1.7.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 diff --git a/tests/test_training_filter.py b/tests/test_training_filter.py new file mode 100644 index 0000000..0451836 --- /dev/null +++ b/tests/test_training_filter.py @@ -0,0 +1,113 @@ +from bs4 import BeautifulSoup +from fastapi.testclient import TestClient +import pytest + +from app.config import Settings, get_settings +from app.db import get_db +from app.main import app +from app.models import Employee +from app.parser.profile import extract_sections +from app.security import SESSION_COOKIE, sign_session +from app.services.admin_data import list_employees_page + + +@pytest.mark.parametrize( + ("html", "has_course", "has_recent_course"), + [ + ('

2024 (11 декабря), НИУ ВШЭ. Программа повышения квалификации «ИИ».

', True, True), + ('
  • Программа повышения квалификации «ДПО», октябрь 2024 года.
', True, True), + ('

Курс повышения квалификации «ДПО», 7 сентября 2024 год.

', True, True), + ('

Курсы повышения квалификации: «Технологии 2025 г.», 2023г.

', True, False), + ('

Программа повышения квалификации «Технологии 2025 г.»

', True, False), + ('

2025. Стажировка «Технологии».

', False, False), + ('

2025. Стажировка «Повышение квалификации педагогов».

', False, False), + ('

2025. Стажировка в Институте повышения квалификации.

', False, False), + ], +) +def test_training_filter_reads_courses_from_profile_paragraphs_and_lists( + db_session, html, has_course, has_recent_course, +): + url = "https://example.test/employee" + soup = BeautifulSoup( + f"

Дополнительное образование / Повышение квалификации / Стажировки

{html}
", + "html.parser", + ) + db_session.add(Employee( + profile_key="training:text", canonical_url=url, + current_data={"sections": extract_sections(soup, url)}, + )) + db_session.commit() + assert list_employees_page(db_session, has_training=True)["total"] == int(has_course) + assert list_employees_page(db_session, training_since_year=2024)["total"] == int(has_recent_course) + assert list_employees_page(db_session, training_since_year=2024, has_training=False)["total"] == int( + not has_recent_course + ) + + +def test_training_filter_uses_course_year_and_kind_before_pagination(db_session): + title = "Дополнительное образование / Повышение квалификации / Стажировки" + profiles = [ + (title, "2023", "«Курс 2024», повышение квалификации"), + (title, "2024", "«Курс», повышение квалификации, НИУ ВШЭ"), + (title, "2025", "«Курс», Повышение квалификации"), + (title, "2025", "«Стажировка», стажировка"), + ("Конференции", "2025", "«Доклад», повышение квалификации"), + (title, "", "«Курс без года», повышение квалификации"), + ] + for index, (section_title, year, text) in enumerate(profiles): + soup = BeautifulSoup( + f'

{section_title}

' + f'
{year}
{text}
', + "html.parser", + ) + url = f"https://example.test/{index}" + db_session.add(Employee( + profile_key=f"training:{index}", canonical_url=url, full_name=f"Person {index}", + current_data={ + "sections": extract_sections(soup, url), + "positions": ["Доцент: кафедра"] if index == 1 else ["Инженер"], + }, + )) + db_session.add(Employee(profile_key="training:6", canonical_url="https://example.test/6", full_name="Person 6")) + db_session.commit() + + def names(**filters): + return [item["full_name"] for item in list_employees_page(db_session, **filters)["employees"]] + + assert names(has_training=True) == ["Person 0", "Person 1", "Person 2", "Person 5"] + assert names(has_training=False) == ["Person 3", "Person 4", "Person 6"] + assert names(training_since_year=2024) == ["Person 1", "Person 2"] + assert names(training_since_year=2024, has_training=False) == [ + "Person 0", "Person 3", "Person 4", "Person 5", "Person 6", + ] + assert names(training_since_year=2024, pps=True) == ["Person 1"] + assert names(training_since_year=2024, offset=1) == ["Person 2"] + assert names(training_since_year=2024, offset=25) == ["Person 1", "Person 2"] + + settings = Settings(_env_file=None, admin_username="admin", admin_password="password", session_secret="test-training") + app.dependency_overrides[get_db] = lambda: db_session + app.dependency_overrides[get_settings] = lambda: settings + try: + with TestClient(app) as client: + client.cookies.set(SESSION_COOKIE, sign_session("admin", settings)) + response = client.get("/api/employees?has_training=false&training_since_year=2024") + assert response.status_code == 200 + assert response.json()["total"] == 5 + + response = client.get("/admin/directory?has_training=true&training_since_year=2024&offset=1") + assert response.status_code == 200 + page = BeautifulSoup(response.text, "html.parser") + panel = page.select_one(".directory__filter-panel") + assert not panel.has_attr("open") + assert panel.summary.get_text(strip=True) == "Фильтры · применены" + assert panel.select_one('select[name="has_training"] option[selected]')["value"] == "true" + assert panel.select_one('input[name="training_since_year"]')["value"] == "2024" + assert "Person 1" not in response.text + assert "Person 2" in response.text + previous = page.select_one(".directory__pagination a")["href"] + assert "has_training=true" in previous + assert "training_since_year=2024" in previous + assert client.get("/admin/directory?has_training=&training_since_year=").status_code == 200 + assert client.get("/admin/directory?training_since_year=bad").status_code == 422 + finally: + app.dependency_overrides.clear()