Сотрудники не найдены
- {% 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}',
+ "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()
|