Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d609b353 | ||
|
|
9d618bda81 | ||
|
|
ae0fc32172 | ||
|
|
83431d8f2a | ||
|
|
0aa472412e | ||
|
|
d10690e28b | ||
|
|
744abd0063 | ||
|
|
f9cf9708da | ||
|
|
0f2d0db3c7 | ||
|
|
d9420db50c | ||
|
|
c92523f235 | ||
|
|
39894a6712 |
No files matched your search
@@ -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`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+92
-15
@@ -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
@@ -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);
|
||||
})();
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user