Фильтр по повышению квалификации в справочнике сотрудников #76

Merged
agent merged 6 commits from feature/employee-training-filter into main 2026-11-11 17:01:30 +00:00
15 changed files with 295 additions and 30 deletions

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 1.7.3
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
## 1.7.2
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
## 1.6.2
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
+8 -1
View File
@@ -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"],
+5 -1
View File
@@ -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,
+61 -7
View File
@@ -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)|(?<!\d)(\d{4})\s*г(?:\.|од[ау]?\b)", re.IGNORECASE)
def _has_training(data: dict[str, Any], since_year: int | None) -> 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:
+29 -8
View File
@@ -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 {
+8 -3
View File
@@ -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;
+17 -1
View File
@@ -12,6 +12,9 @@
<button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
</div>
{% 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 %}
<details class="directory__filter-panel">
<summary>Фильтры{% if has_filters %} · применены{% endif %}</summary>
<form class="directory__filters" method="get" action="/admin/directory">
<fieldset class="directory__filter-group directory__filter-group--search">
<legend class="directory__filter-legend">Поиск</legend>
@@ -37,6 +40,18 @@
</select></label>
<label class="directory__checkbox"><input type="checkbox" name="pps" value="true" {% if filters.pps %}checked{% endif %}> ППС</label>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Повышение квалификации</legend>
<label class="directory__field">Курсы в профиле<select class="directory__input" name="has_training">
<option value="" {% if not filters.has_training %}selected{% endif %}>Любое</option>
<option value="true" {% if filters.has_training == "true" %}selected{% endif %}>Есть курсы</option>
<option value="false" {% if filters.has_training == "false" %}selected{% endif %}>Нет курсов</option>
</select></label>
<label class="directory__field">С какого года включительно
<input class="directory__input" name="training_since_year" inputmode="numeric" pattern="[1-9][0-9]{3}" maxlength="4" value="{{ filters.training_since_year }}" placeholder="Например, 2024" aria-describedby="training-year-hint">
</label>
<small class="directory__filter-hint" id="training-year-hint">Если указан только год, будут показаны сотрудники с курсами.</small>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Период обнаружения</legend>
<label class="directory__field">От<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}"></label>
@@ -64,6 +79,7 @@
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
</div>
</form>
</details>
<details class="directory__help">
<summary>Что означают даты и статусы</summary>
@@ -113,7 +129,7 @@
{% else %}
<tr><td class="directory-table__empty" colspan="15">
<strong>Сотрудники не найдены</strong>
{% 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 %}
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
{% else %}
+2 -1
View File
@@ -12,9 +12,10 @@
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
<p>В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.</p>
<p>В разделе «Сотрудники» раскройте панель «Фильтры». В поле «ФИО или ссылка» введите фамилию, часть ФИО или ссылку на профиль ВШЭ. Например, Иванов.</p>
<dl class="guide__fields">
<dt>Статус, Email, Учёная степень</dt><dd>Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.</dd>
<dt>Повышение квалификации</dt><dd>Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ. Например, «Нет курсов» и 2024 покажут сотрудников без записей о курсах за 2024 год и позже. Если указать только год, отобразятся сотрудники с курсами за этот период.</dd>
<dt>Период обнаружения: От / До</dt><dd>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</dd>
<dt>Сортировка и страница</dt><dd>Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.</dd>
<dt>Настроить колонки</dt><dd>Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.</dd>
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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 = [
+18 -1
View File
@@ -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()
+18 -1
View File
@@ -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)
+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.6.2"
assert response.json()["backend_version"] == "1.7.3"
def test_api_employees_and_stats_require_admin_session():
+1 -1
View File
@@ -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
+113
View File
@@ -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"),
[
('<p>2024 (11 декабря), НИУ ВШЭ. Программа повышения квалификации «ИИ».</p>', True, True),
('<ul><li>Программа повышения квалификации «ДПО», октябрь 2024 года.</li></ul>', True, True),
('<p>Курс повышения квалификации «ДПО», 7 сентября 2024 год.</p>', True, True),
('<p>Курсы повышения квалификации: «Технологии 2025 г.», 2023г.</p>', True, False),
('<p>Программа повышения квалификации «Технологии 2025 г.»</p>', True, False),
('<p>2025. Стажировка «Технологии».</p>', False, False),
('<p>2025. Стажировка «Повышение квалификации педагогов».</p>', False, False),
('<p>2025. Стажировка в Институте повышения квалификации.</p>', 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"<h2>Дополнительное образование / Повышение квалификации / Стажировки</h2><div>{html}</div>",
"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'<h2>{section_title}</h2><div><div class="with-indent">'
f'<div class="person-list-hangover">{year}</div><span>{text}</span></div></div>',
"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()