From d10690e28bc9dcebb5ef7d4649c4860548531423 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 18:25:13 +0300 Subject: [PATCH 1/6] feat: filter employees by professional development courses --- app/admin.py | 9 +++- app/api.py | 6 ++- app/services/admin_data.py | 51 ++++++++++++++++++---- app/static/admin.css | 14 ++++++- app/static/guide.js | 7 ++-- app/templates/directory.html | 18 +++++++- app/version.py | 6 +-- pyproject.toml | 2 +- tests/browser_admin.py | 8 ++++ tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_training_filter.py | 79 +++++++++++++++++++++++++++++++++++ 12 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 tests/test_training_filter.py 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..c167eb9 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,27 @@ EMPLOYEE_SORTS = { "hse_start_year": Employee.current_data["hse_start_year"].as_integer(), } +_TRAINING_LABEL = "повышение квалификации" +_TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", 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() + if _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 (title == _TRAINING_LABEL or _TRAINING_KIND.search(text)): + 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 +139,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 +159,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..3c9e0f5 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -558,9 +558,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 +608,10 @@ font-weight: 700; } +.directory__filter-hint { + color: #4b5563; +} + .directory__input { box-sizing: border-box; width: 100%; diff --git a/app/static/guide.js b/app/static/guide.js index a560db7..1054972 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -8,17 +8,17 @@ 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-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [ ['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'], ['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'], ['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'], @@ -130,6 +130,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/version.py b/app/version.py index ff9070f..14ac4fa 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.0" +FRONTEND_VERSION = "1.7.0" +BACKEND_VERSION = "1.7.0" diff --git a/pyproject.toml b/pyproject.toml index f049e28..b33a1a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.6.2" +version = "1.7.0" 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..425489c 100644 --- a/tests/browser_admin.py +++ b/tests/browser_admin.py @@ -75,6 +75,12 @@ def main(): page.keyboard.press("Tab") assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'") + 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 (1440, 1024, 921, 920, 768, 621, 620, 390, 320): page.set_viewport_size({"width": width, "height": 844}) assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth") @@ -88,6 +94,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 +123,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/test_api.py b/tests/test_api.py index 81a7c80..d9c17df 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.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index b3959d1..f62d04a 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.0 · Frontend 1.7.0" 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..a193bf6 --- /dev/null +++ b/tests/test_training_filter.py @@ -0,0 +1,79 @@ +from bs4 import BeautifulSoup +from fastapi.testclient import TestClient + +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 + + +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() -- 2.54.0 From 0aa472412e6ac730d867cb633e9d3d53f75816a6 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 18:30:14 +0300 Subject: [PATCH 2/6] fix: recognize training courses listed in profile text --- app/services/admin_data.py | 20 +++++++++++++++++--- app/static/admin.css | 6 ++++++ app/version.py | 6 +++--- pyproject.toml | 2 +- tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_training_filter.py | 32 ++++++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 9 deletions(-) diff --git a/app/services/admin_data.py b/app/services/admin_data.py index c167eb9..f66b98a 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -23,13 +23,17 @@ EMPLOYEE_SORTS = { _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() - if _TRAINING_LABEL not in (part.strip() for part in title.split("/")): + 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) @@ -37,7 +41,17 @@ def _has_training(data: dict[str, Any], since_year: int | None) -> bool: 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 (title == _TRAINING_LABEL or _TRAINING_KIND.search(text)): + 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 diff --git a/app/static/admin.css b/app/static/admin.css index 3c9e0f5..37b33cd 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -795,6 +795,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; diff --git a/app/version.py b/app/version.py index 14ac4fa..4885a71 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.7.0" -FRONTEND_VERSION = "1.7.0" -BACKEND_VERSION = "1.7.0" +APP_VERSION = "1.7.1" +FRONTEND_VERSION = "1.7.1" +BACKEND_VERSION = "1.7.1" diff --git a/pyproject.toml b/pyproject.toml index b33a1a8..a83a7a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.7.0" +version = "1.7.1" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index d9c17df..5f11000 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.7.0" + assert response.json()["backend_version"] == "1.7.1" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index f62d04a..8f63645 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.7.0 · Frontend 1.7.0" in response.text + assert "Backend 1.7.1 · Frontend 1.7.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 diff --git a/tests/test_training_filter.py b/tests/test_training_filter.py index a193bf6..8e0da24 100644 --- a/tests/test_training_filter.py +++ b/tests/test_training_filter.py @@ -1,5 +1,6 @@ from bs4 import BeautifulSoup from fastapi.testclient import TestClient +import pytest from app.config import Settings, get_settings from app.db import get_db @@ -10,6 +11,37 @@ 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), + ('

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

', True, False), + ('

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

', True, 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 = [ -- 2.54.0 From 83431d8f2a9205229ee17d70b5b6995e2345a5f1 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 18:32:58 +0300 Subject: [PATCH 3/6] fix: distinguish courses from training provider names --- app/services/admin_data.py | 7 +++++-- app/version.py | 6 +++--- pyproject.toml | 2 +- tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_training_filter.py | 2 ++ 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/services/admin_data.py b/app/services/admin_data.py index f66b98a..875c8d2 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -23,9 +23,12 @@ EMPLOYEE_SORTS = { _TRAINING_LABEL = "повышение квалификации" _TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", re.IGNORECASE) -_TRAINING_MENTION = re.compile(r"\bповышени[еяю] квалификации\b", 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: diff --git a/app/version.py b/app/version.py index 4885a71..3029be4 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.7.1" -FRONTEND_VERSION = "1.7.1" -BACKEND_VERSION = "1.7.1" +APP_VERSION = "1.7.2" +FRONTEND_VERSION = "1.7.2" +BACKEND_VERSION = "1.7.2" diff --git a/pyproject.toml b/pyproject.toml index a83a7a8..99b3128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.7.1" +version = "1.7.2" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 5f11000..553b046 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.7.1" + assert response.json()["backend_version"] == "1.7.2" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 8f63645..4a9b311 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.7.1 · Frontend 1.7.1" in response.text + assert "Backend 1.7.2 · Frontend 1.7.2" 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 index 8e0da24..0451836 100644 --- a/tests/test_training_filter.py +++ b/tests/test_training_filter.py @@ -16,10 +16,12 @@ from app.services.admin_data import list_employees_page [ ('

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( -- 2.54.0 From ae0fc321729d5c6b75f3a6d9cba4b1d8dc2dfab2 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 18:47:59 +0300 Subject: [PATCH 4/6] docs: record employee training filters in changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7ee15..9d263b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.7.2 + +- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`. +- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами. + ## 1.6.2 - Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки. -- 2.54.0 From 9d618bda8180da458ed904664d3d099730313396 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 19:17:58 +0300 Subject: [PATCH 5/6] fix: align the workspace and explain training filters --- app/static/admin.css | 17 ++++++++++------- app/static/guide.js | 4 ++++ app/templates/guide_content.html | 3 ++- app/version.py | 6 +++--- pyproject.toml | 2 +- tests/browser_admin.py | 11 ++++++++++- tests/browser_users.py | 19 ++++++++++++++++++- tests/test_api.py | 2 +- tests/test_council.py | 2 +- 9 files changed, 50 insertions(+), 16 deletions(-) diff --git a/app/static/admin.css b/app/static/admin.css index 37b33cd..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 { @@ -820,6 +821,10 @@ } @media (max-width: 620px) { + .admin { + --admin-gutter: 16px; + } + .directory-table [data-column="full_name"] { position: sticky; left: 0; @@ -830,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 1054972..3a93d27 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -18,6 +18,10 @@ ['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'], ['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'], ]}, + {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"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'], 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 3029be4..3f1989a 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.7.2" -FRONTEND_VERSION = "1.7.2" -BACKEND_VERSION = "1.7.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 99b3128..b9cb3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.7.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 425489c..ae00811 100644 --- a/tests/browser_admin.py +++ b/tests/browser_admin.py @@ -81,8 +81,17 @@ def main(): filters.locator("summary").focus() page.keyboard.press("Enter") expect(page.get_by_label("ФИО или ссылка")).to_be_visible() - for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320): + 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(); 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 553b046..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.7.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 4a9b311..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.7.2 · Frontend 1.7.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 -- 2.54.0 From 43d609b3534430d9c20bc64bb9f1e4c2e840942f Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 19:29:34 +0300 Subject: [PATCH 6/6] docs: record training guidance and workspace alignment --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d263b3..719fe7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.7.3 + +- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году. +- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку. + ## 1.7.2 - В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`. -- 2.54.0