Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9cf9708da | ||
|
|
0f2d0db3c7 | ||
|
|
d9420db50c | ||
|
|
c92523f235 | ||
|
|
39894a6712 |
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 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. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+63
-7
@@ -990,15 +990,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; }
|
||||
+233
-42
@@ -1,69 +1,260 @@
|
||||
(() => {
|
||||
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__filters', tips: [
|
||||
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
|
||||
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
|
||||
['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
|
||||
]},
|
||||
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filters', tips: [
|
||||
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
|
||||
['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
|
||||
['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
|
||||
]},
|
||||
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filters', 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) 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.6.2"
|
||||
FRONTEND_VERSION = "1.6.2"
|
||||
BACKEND_VERSION = "1.6.2"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.5.1"
|
||||
version = "1.6.2"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+91
-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,87 @@ 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(5):
|
||||
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')
|
||||
@@ -156,7 +231,6 @@ def main():
|
||||
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.6.2"
|
||||
|
||||
|
||||
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.6.2 · Frontend 1.6.2" in response.text
|
||||
assert "Вход в систему" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
|
||||
+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