Compare commits

..
Author SHA1 Message Date
codex 43d609b353 docs: record training guidance and workspace alignment
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:29:34 +03:00
codex 9d618bda81 fix: align the workspace and explain training filters
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:17:58 +03:00
codex ae0fc32172 docs: record employee training filters in changelog
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:47:59 +03:00
codex 83431d8f2a fix: distinguish courses from training provider names
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:32:58 +03:00
codex 0aa472412e fix: recognize training courses listed in profile text
CI / test (pull_request) Canceled after 0s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
2026-11-11 18:30:14 +03:00
codex d10690e28b feat: filter employees by professional development courses
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m40s
2026-11-11 18:25:13 +03:00
agent 744abd0063 Merge pull request 'Контекстные подсказки для новых пользователей Workers' (#75) from feature/contextual-onboarding into main
CI / test (push) Successful in 10m36s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 12:56:05 +00:00
codex f9cf9708da docs: record contextual onboarding changes
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:29:58 +03:00
codex 0f2d0db3c7 fix: keep guide accessible when browser storage is blocked
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:15:04 +03:00
codex d9420db50c fix: preserve guide progress across navigation
CI / test (pull_request) Canceled after 0s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
2026-11-11 14:12:07 +03:00
codex c92523f235 feat: show onboarding tips beside Workers fields
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 14:07:05 +03:00
agent 39894a6712 Merge pull request 'Use the shared MIEM production deployment contract' (#74) from docs/replacement-vps-deployment into main
CI / test (push) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 09:12:32 +00:00
23 changed files with 723 additions and 129 deletions

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## 1.7.3
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
## 1.7.2
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
## 1.6.2
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
- Обучение сохраняет текущий шаг при работе с фильтрами и доступно повторно из меню «Как пользоваться».
## 1.5.1
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
+1 -1
View File
@@ -288,6 +288,6 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
### Подсказки первого входа
После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
+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:
+92 -15
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 {
@@ -990,15 +1011,71 @@
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; }
.guide__panel > :not([data-guide-step]) { flex-shrink: 0; }
.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; }
.guide__heading { margin-block: 20px 12px; font-size: 18px; }
.guide {
margin-block: 16px;
padding: clamp(12px, 2vw, 20px);
border: 1px solid #99d5ce;
border-radius: 12px;
background: #f0fdfa;
color: #164e49;
scroll-margin-block: 16px;
}
.guide__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 20px;
}
.guide__progress { margin: 0 0 4px; color: #35615b; font-size: 13px; font-variant-numeric: tabular-nums; }
.guide__heading { margin: 0; font-size: 18px; }
.guide--page .guide__heading { margin-block: 20px 12px; }
.guide__heading:focus-visible { outline: 2px solid #0f766e; outline-offset: 4px; border-radius: 2px; }
.guide__intro { max-width: 66ch; margin: 8px 0 0; line-height: 1.5; }
.guide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.guide__actions .button { min-height: 44px; }
.guide__field { display: grid; justify-items: start; min-width: 0; }
.guide__tip {
position: relative;
display: block;
box-sizing: border-box;
min-width: 0;
max-width: 44ch;
margin-block: 7px 2px;
padding: 7px 10px;
border-radius: 8px;
background: #e0f5f1;
color: #164e49;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
text-align: start;
overflow-wrap: anywhere;
}
.guide__tip::before {
position: absolute;
inset-block-start: -4px;
inset-inline-start: 13px;
width: 8px;
height: 8px;
background: inherit;
content: "";
transform: rotate(45deg);
}
.council__candidate-count .guide__tip { padding-inline: 6px; }
.council__search:has(.guide__tip) > .directory__field { align-self: start; }
@media (min-width: 701px) {
.council__candidate-summary:has(.guide__tip) {
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) 18px;
}
}
@media (max-width: 420px) {
.council__counts:has(.guide__tip) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.directory__checkbox:has(.guide__tip) { flex-wrap: wrap; }
.directory__checkbox .guide__tip { flex-basis: 100%; }
.guide__fields { margin-block: 16px; }
.guide__fields dt { margin-block-start: 14px; font-weight: 600; }
.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; }
.guide--page { max-width: 75ch; }
.guide--page .guide__step + .guide__step { margin-block-start: 32px; }
.guide .columns-modal__header { flex-wrap: wrap; }
+238 -42
View File
@@ -1,69 +1,265 @@
(() => {
const dialog = document.querySelector('[data-guide-dialog]');
if (!dialog) return;
const trigger = document.querySelector('[data-guide-open]');
const steps = Array.from(dialog.querySelectorAll('[data-guide-step]'));
const back = dialog.querySelector('[data-guide-back]');
const next = dialog.querySelector('[data-guide-next]');
const close = dialog.querySelector('[data-guide-close]');
const error = dialog.querySelector('[data-guide-error]');
const unsaved = dialog.querySelector('[data-guide-unsaved]');
let index = 0;
let saving = false;
const guide = document.querySelector('[data-guide]');
if (!guide) return;
function showStep() {
steps.forEach((step, number) => { step.hidden = number !== index; });
dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`;
back.disabled = index === 0;
next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
steps[index].scrollTop = 0;
steps[index].querySelector('h3').focus({preventScroll: true});
const directory = '/admin/directory';
const council = '/admin/council';
const publications = '/admin/council/publications';
const steps = [
{id: 'welcome', title: 'Познакомьтесь с Workers',
intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'},
{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__filter-panel', tips: [
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
['[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"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
]},
{id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [
['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'],
['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'],
]},
{id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [
['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'],
['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'],
['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'],
]},
{id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [
['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'],
['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'],
]},
{id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading',
intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [
['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'],
['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'],
['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'],
]},
{id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading',
intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [
['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'],
['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'],
]},
{id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [
['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'],
['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'],
]},
{id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [
['#publications-employee', 'Укажите фамилию или часть ФИО автора.'],
['#publications-article', 'Введите несколько слов из названия статьи.'],
['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'],
]},
{id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles',
intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [
['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'],
['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'],
['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'],
], editorTips: [
['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'],
['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
]},
];
const main = document.querySelector('#main-content');
const trigger = document.querySelector('[data-guide-open]');
const fallback = document.querySelector('[data-guide-fallback]');
const heading = guide.querySelector('#guide-title');
const progress = guide.querySelector('[data-guide-progress]');
const intro = guide.querySelector('[data-guide-intro]');
const reference = guide.querySelector('[data-guide-reference]');
const back = guide.querySelector('[data-guide-back]');
const next = guide.querySelector('[data-guide-next]');
const close = guide.querySelector('[data-guide-close]');
const error = guide.querySelector('[data-guide-error]');
const unsaved = guide.querySelector('[data-guide-unsaved]');
const storageKey = 'workers-guide:' + guide.dataset.guideUser;
let index = 0;
let paused = false;
let saving = false;
let removeTips = [];
function addTip([selector, message, placement], id, allowHidden = false) {
const target = main.querySelector(selector);
if (!target || (!allowHidden && !target.getClientRects().length)) return;
const tip = document.createElement('span');
tip.className = 'guide__tip';
tip.id = 'guide-tip-' + id;
tip.textContent = message;
const description = target.getAttribute('aria-describedby');
target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' '));
const label = target.labels?.[0];
let group;
if (label) {
// The description is announced separately from the label text.
tip.setAttribute('aria-hidden', 'true');
label.append(tip);
} else if (placement === 'inside') {
target.append(tip);
} else if (target.matches('button')) {
group = document.createElement('div');
group.className = 'guide__field';
target.before(group);
group.append(target, tip);
} else {
target.after(tip);
}
removeTips.push(() => {
tip.remove();
if (description === null) target.removeAttribute('aria-describedby');
else target.setAttribute('aria-describedby', description);
if (group) group.replaceWith(target);
});
}
function open() {
index = 0;
error.hidden = true;
unsaved.hidden = true;
dialog.showModal();
function clearTips() {
removeTips.forEach(remove => remove());
removeTips = [];
}
function showStep(focus = true) {
const step = steps[index];
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;
progress.textContent = `Шаг ${index + 1} из ${steps.length}`;
intro.textContent = paused ? step.title + '.' : step.intro || '';
intro.hidden = !intro.textContent;
reference.hidden = index !== 0;
error.hidden = unsaved.hidden = true;
back.hidden = paused;
back.disabled = index === 0;
next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
if (!paused) {
step.tips?.forEach((tip, number) => addTip(tip, number));
step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true));
}
guide.hidden = false;
if (focus) {
heading.focus({preventScroll: true});
guide.scrollIntoView({block: 'start'});
}
}
function goTo(number, path = steps[number].path || '/admin/account') {
if (!saveStep(steps[number].id)) {
location.assign(trigger.href);
return;
}
if (path !== location.pathname) {
location.assign(path);
return;
}
index = number;
showStep();
}
function hide(remember = true) {
clearTips();
guide.hidden = true;
saveStep(remember ? 'closed' : null);
trigger?.focus();
}
function saveStep(step) {
try {
if (step === null) sessionStorage.removeItem(storageKey);
else sessionStorage.setItem(storageKey, step);
return true;
} catch {
return false;
}
}
async function finish() {
if (saving) return;
if (dialog.dataset.guidePending !== 'true') {
dialog.close();
if (guide.dataset.guidePending !== 'true') {
hide();
return;
}
saving = true;
dialog.setAttribute('aria-busy', 'true');
guide.setAttribute('aria-busy', 'true');
close.disabled = next.disabled = back.disabled = true;
error.hidden = true;
error.hidden = unsaved.hidden = true;
try {
const response = await fetch('/admin/guide/dismiss', {
method: 'POST', credentials: 'same-origin', redirect: 'error',
headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error();
dialog.dataset.guidePending = 'false';
dialog.close();
guide.dataset.guidePending = 'false';
hide();
} catch (_failure) {
error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.';
error.hidden = false;
unsaved.hidden = false;
error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
error.hidden = unsaved.hidden = false;
unsaved.focus();
} finally {
saving = false;
dialog.removeAttribute('aria-busy');
guide.removeAttribute('aria-busy');
close.disabled = next.disabled = false;
back.disabled = index === 0;
}
}
trigger?.addEventListener('click', event => { event.preventDefault(); open(); });
back.addEventListener('click', () => { index -= 1; showStep(); });
trigger?.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (saving) return;
goTo(0, location.pathname);
});
back.addEventListener('click', () => goTo(index - 1));
next.addEventListener('click', () => {
if (index === steps.length - 1) finish();
else { index += 1; showStep(); }
if (paused) goTo(index);
else if (index === steps.length - 1) finish();
else goTo(index + 1);
});
close.addEventListener('click', finish);
unsaved.addEventListener('click', () => { if (!saving) dialog.close(); });
dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); });
dialog.addEventListener('close', () => trigger?.focus());
if (dialog.dataset.guidePending === 'true') open();
unsaved.addEventListener('click', () => hide(false));
guide.addEventListener('keydown', event => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
finish();
});
function restore(allowAutoStart) {
let active;
try {
active = sessionStorage.getItem(storageKey);
} catch {
return;
}
const requested = steps.findIndex(step => step.id === active);
const autoStart = allowAutoStart && requested < 0 && active !== 'closed' && guide.dataset.guidePending === 'true';
if (autoStart && !saveStep(steps[0].id)) return;
if (fallback) fallback.hidden = true;
if (requested >= 0) {
index = requested;
showStep(!allowAutoStart || index > 0);
} else if (autoStart) {
showStep(false);
} else {
clearTips();
guide.hidden = true;
}
}
window.addEventListener('pageshow', event => {
if (event.persisted) restore(false);
});
restore(true);
})();
+3 -1
View File
@@ -30,13 +30,15 @@
</nav>
</header>
<main class="admin__main" id="main-content">
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
{% include 'guide_walkthrough.html' %}
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="admin__footer">
Backend {{ backend_version }} · Frontend {{ frontend_version }}
</footer>
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %}
{% include 'guide_dialog.html' %}
<script src="/static/guide.js?v={{ frontend_version }}" defer></script>
{% endif %}
{% block scripts %}{% endblock %}
+4 -4
View File
@@ -81,12 +81,12 @@
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small><small>{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }}</small></span>
<span class="council__counts">
<span class="council__candidate-count"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count" data-guide-count="q1"><strong>{{ candidate.q1_count }}</strong><small>Q1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.q2_count }}</strong><small>Q2</small></span>
<span class="council__candidate-count"><strong>{{ candidate.k1_count }}</strong><small>К1</small></span>
<span class="council__candidate-count"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
<span class="council__candidate-count" data-guide-count="total"><strong>{{ candidate.total_count }}</strong><small>ВСЕГО (предв.)</small></span>
<span class="council__candidate-count" data-guide-count="hse"><strong>{{ candidate.db_count }}</strong><small>ЛК сотрудника ВШЭ</small></span>
<span class="council__candidate-count" data-guide-count="all"><strong>{{ candidate.count }}</strong><small>Все публикации</small></span>
</span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
+2 -2
View File
@@ -26,8 +26,8 @@
<p class="council__hint">{{ article.employee_name }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span></div>
<p class="council__hint">{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
<p class="council__hint">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
<p class="council__hint" data-guide-publication="journal">Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}</p>
<p class="council__hint" data-guide-publication="specialties">Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}</p>
</li>
{% else %}<li class="council__message">Статьи не найдены. Попробуйте изменить фильтры.</li>{% endfor %}
</ol>
+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 %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
{% block content %}
<article class="panel guide guide--page">
<article class="panel guide--page">
<h2 class="panel__title">Как пользоваться сайтом</h2>
{% include 'guide_content.html' %}
<form method="post" action="/admin/guide/dismiss">
+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>
-24
View File
@@ -1,24 +0,0 @@
<dialog class="columns-modal guide" data-guide-dialog data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title">
<div class="columns-modal__panel guide__panel">
<div class="columns-modal__header">
<h2 class="columns-modal__title" id="guide-title">Как пользоваться сайтом</h2>
<button class="button button--ghost" type="button" data-guide-close>Закрыть подсказки</button>
</div>
<p class="guide__progress" data-guide-progress role="status"></p>
{% include 'guide_content.html' %}
<p class="users__error" data-guide-error role="alert" hidden></p>
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
<div class="guide__actions">
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
<button class="button" type="button" data-guide-next>Далее</button>
</div>
</div>
</dialog>
{% if request.state.guide_pending|default(false) %}
<noscript><aside class="panel guide" aria-label="Подсказки первого входа">
<h2 class="panel__title">Как пользоваться сайтом</h2>
<p>Пароль изменён. Откройте подсказки о поиске сотрудников, подборе диссовета и проверке публикаций.</p>
<a class="button" href="/admin/guide">Открыть подсказки</a>
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
</aside></noscript>
{% endif %}
+25
View File
@@ -0,0 +1,25 @@
<section class="guide" data-guide data-guide-user="{{ current_user.id }}" data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title" hidden>
<div class="guide__header">
<div>
<p class="guide__progress" data-guide-progress></p>
<h2 class="guide__heading" id="guide-title" tabindex="-1"></h2>
</div>
<div class="guide__actions">
<button class="button button--ghost" type="button" data-guide-close aria-label="Закрыть подсказки">Закрыть</button>
<button class="button button--secondary" type="button" data-guide-back>Назад</button>
<button class="button" type="button" data-guide-next>Далее</button>
</div>
</div>
<p class="guide__intro" data-guide-intro hidden></p>
<a class="admin__link" href="/admin/guide" target="_blank" rel="noopener" data-guide-reference hidden>Подробная справка ↗</a>
<p class="users__error" data-guide-error role="alert" hidden></p>
<button class="button button--ghost" type="button" data-guide-unsaved hidden>Продолжить без сохранения</button>
</section>
{% if request.state.guide_pending|default(false) %}
<aside class="panel guide--page" aria-label="Подсказки первого входа" data-guide-fallback>
<h2 class="panel__title">Как пользоваться сайтом</h2>
<p>Откройте справку о сотрудниках, диссовете и публикациях.</p>
<a class="button" href="/admin/guide">Открыть подсказки</a>
<form method="post" action="/admin/guide/dismiss"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Пропустить подсказки</button></form>
</aside>
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.5.1"
FRONTEND_VERSION = "1.5.1"
BACKEND_VERSION = "1.5.1"
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.5.1"
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()
+108 -17
View File
@@ -100,16 +100,23 @@ def main():
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
guide = page.get_by_role('dialog', name='Как пользоваться сайтом')
guide = page.locator('[data-guide]')
expect(guide).to_be_visible()
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused()
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
restricted = context.new_page()
restricted.add_init_script("""Object.defineProperty(window, 'sessionStorage', {
get() { throw new DOMException('Storage is disabled', 'SecurityError'); },
});""")
restricted.goto(base_url + '/admin/account')
expect(restricted.get_by_role('link', name='Открыть подсказки', exact=True)).to_be_visible()
restricted.get_by_role('link', name='Как пользоваться', exact=True).click()
expect(restricted.get_by_role('heading', name='Как пользоваться сайтом')).to_be_visible()
restricted.close()
for width in (1440, 320):
page.set_viewport_size({'width': width, 'height': 900})
assert guide.evaluate('node => node.scrollWidth <= node.clientWidth')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.screenshot(path=str(output / f'guide-first-{width}.png'))
page.keyboard.press('Tab')
assert page.evaluate('document.querySelector("[data-guide-dialog]").contains(document.activeElement)')
page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure'))
guide.get_by_role('button', name='Закрыть подсказки', exact=True).click()
expect(guide.locator('[data-guide-error]')).to_be_visible()
@@ -119,6 +126,7 @@ def main():
page.unroute('**/admin/guide/dismiss')
page.reload()
expect(guide).to_be_visible()
guide.get_by_role('heading').focus()
page.keyboard.press('Escape')
expect(guide).not_to_be_visible()
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
@@ -126,20 +134,103 @@ def main():
expect(guide).not_to_be_visible()
page.get_by_role('link', name='Как пользоваться', exact=True).click()
expect(guide).to_be_visible()
guide.get_by_role('button', name='Далее', exact=True).click()
expect(guide.get_by_role('heading', name='Найдите сотрудника и откройте профиль')).to_be_visible()
guide.get_by_role('button', name='Назад', exact=True).click()
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_visible()
for number in range(1, 7):
expect(guide.locator('[data-guide-progress]')).to_have_text(f'Шаг {number} из 6')
expect(guide.locator('[data-guide-step]:visible')).to_have_count(1)
if number == 4:
expect(guide).to_contain_text('ВСЕГО (предв.)')
page.screenshot(path=str(output / 'guide-counts-mobile.png'))
if number < 6:
guide.get_by_role('button', name='Далее', exact=True).click()
go_next = guide.get_by_role('button', name='Далее', exact=True)
go_back = guide.get_by_role('button', name='Назад', exact=True)
tips = page.locator('.guide__tip:visible')
go_next.click()
expect(guide.get_by_role('heading', name='Найдите сотрудника', exact=True)).to_be_focused()
assert page.url.endswith('/admin/directory')
expect(tips).to_have_count(3)
search = page.get_by_role('textbox', name='ФИО или ссылка', exact=True)
expect(search).to_have_accessible_description('Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.')
search.fill('Сотрудник')
page.get_by_role('button', name='Применить фильтры', exact=True).click()
expect(tips).to_have_count(3)
expect(search).to_have_value('Сотрудник')
page.get_by_role('link', name='Сбросить', exact=True).click()
expect(search).to_have_value('')
expect(tips).to_have_count(3)
go_back.click()
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
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)
for width in (1440, 320):
page.set_viewport_size({'width': width, 'height': 900})
assert page.evaluate('''() => {
const tips = [...document.querySelectorAll('.guide__tip')];
const boxes = tips.map(tip => tip.getBoundingClientRect());
const fields = [...document.querySelectorAll('.council__search input:not([type=hidden]), .council__search select')];
const overlaps = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
return tips.every(tip => tip.scrollWidth <= tip.clientWidth && tip.scrollHeight <= tip.clientHeight)
&& boxes.every((box, i) => boxes.slice(i + 1).every(other => !overlaps(box, other))
&& fields.every(field => !overlaps(box, field.getBoundingClientRect())))
&& document.documentElement.scrollWidth <= innerWidth;
}''')
guide.evaluate("node => node.scrollIntoView({block: 'start'})")
page.screenshot(path=str(output / f'guide-council-{width}.png'))
go_back.click()
expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible()
profile = page.get_by_role('link', name='Сотрудник для проверки', exact=True)
expect(profile).to_have_accessible_description('Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.')
profile.click()
expect(guide.get_by_role('heading', name='Продолжите обучение')).to_be_visible()
guide.get_by_role('button', name='Продолжить', exact=True).click()
expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible()
go_next.click()
go_next.click()
go_next.click()
expect(tips).to_have_count(3)
expect(page.locator('[data-guide-count="total"] .guide__tip')).to_contain_text('Уникальные работы')
page.screenshot(path=str(output / 'guide-counts-mobile.png'))
go_next.click()
expect(tips).to_have_count(2)
expect(page.locator('[data-guide-count="hse"] .guide__tip')).to_contain_text('личного кабинета')
go_next.click()
expect(tips).to_have_count(2)
go_next.click()
assert page.url.endswith('/admin/council/publications')
go_next.click()
expect(tips).to_have_count(3)
warning = page.locator('[data-warning] button').first
warning.focus()
expect(warning).to_have_attribute('aria-expanded', 'true')
page.keyboard.press('Escape')
expect(warning).to_have_attribute('aria-expanded', 'false')
expect(guide).to_be_visible()
page.locator('[data-council-article]').click()
page.get_by_role('button', name='Редактировать статью: Статья для проверки', exact=True).click()
guide_editor = page.get_by_role('dialog', name='Редактирование статьи')
expect(guide_editor.locator('.guide__tip')).to_have_count(3)
expect(guide_editor.get_by_role('combobox', name='Квартиль', exact=True)).to_have_accessible_description(
'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'
)
page.keyboard.press('Escape')
expect(guide_editor).not_to_be_visible()
expect(guide).to_be_visible()
guide.get_by_role('button', name='Завершить подсказки', exact=True).click()
expect(guide).not_to_be_visible()
expect(tips).to_have_count(0)
page.go_back()
expect(guide).not_to_be_visible()
expect(tips).to_have_count(0)
no_js = browser.new_context(java_script_enabled=False)
nojs_page = no_js.new_page()
nojs_page.goto(base_url + '/admin/login')
@@ -152,11 +243,11 @@ 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)
no_js.close()
expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible()
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0)
page.get_by_role('link', name='Обзор', exact=True).click()
expect(page.get_by_role('button', 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.5.1"
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.5.1 · Frontend 1.5.1" 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()
+4 -4
View File
@@ -307,11 +307,11 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
guide = client.get('/admin/guide')
parsed = BeautifulSoup(guide.text, 'html.parser')
assert len(parsed.select('[data-guide-step]')) == 6
assert not parsed.select('[data-guide-dialog]')
assert not parsed.select('[data-guide]')
assert 'Прочитайте показатели сотрудника' in guide.text and 'Проверьте строку публикации' in guide.text
assert ('управлять доступом' in guide.text) == (role == 'admin')
assert 'не ограничивает «ВСЕГО»' in guide.text
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide-dialog]')['data-guide-csrf']
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide]')['data-guide-csrf']
assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403
assert 'data-guide-pending="true"' in client.get('/admin/directory').text
for _ in range(2):
@@ -331,7 +331,7 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
def test_guide_access_and_no_javascript_completion(accounts):
client, db, settings, _, user, _ = accounts
assert 'data-guide-dialog' not in client.get('/admin/council').text
assert not BeautifulSoup(client.get('/admin/council').text, 'html.parser').select('[data-guide]')
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login'
user.must_change_password = True
db.commit()
@@ -342,7 +342,7 @@ def test_guide_access_and_no_javascript_completion(accounts):
db.add(UserAction(user_id=user.id, action='guide_start'))
db.commit()
page = BeautifulSoup(client.get('/admin').text, 'html.parser')
assert page.select_one('noscript a[href="/admin/guide"]')
assert page.select_one('[data-guide-fallback] a[href="/admin/guide"]')
response = client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False)
assert response.status_code == 303 and response.headers['location'] == '/admin'
assert 'data-guide-pending="false"' in client.get('/admin/account').text