Фильтр по повышению квалификации в справочнике сотрудников #76
No files matched your search
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.3
|
||||
|
||||
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
|
||||
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
|
||||
|
||||
## 1.7.2
|
||||
|
||||
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
|
||||
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
|
||||
|
||||
## 1.6.2
|
||||
|
||||
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
|
||||
|
||||
+8
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user