diff --git a/CHANGELOG.md b/CHANGELOG.md index 8892619..ca7ee15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.6.2 + +- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки. +- Обучение сохраняет текущий шаг при работе с фильтрами и доступно повторно из меню «Как пользоваться». + ## 1.5.1 - В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию. diff --git a/README.md b/README.md index 80c56cd..e2534b8 100644 --- a/README.md +++ b/README.md @@ -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. При ошибке сохранения подсказки остаются доступными для повторного завершения. diff --git a/app/static/admin.css b/app/static/admin.css index db1d93f..92b2092 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -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; } diff --git a/app/static/guide.js b/app/static/guide.js index 8311e91..a560db7 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -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); })(); diff --git a/app/templates/base.html b/app/templates/base.html index 27ae68e..1d74b3a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,13 +30,15 @@
+ {% if current_user and not current_user.must_change_password and not guide_page|default(false) %} + {% include 'guide_walkthrough.html' %} + {% endif %} {% block content %}{% endblock %}
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %} - {% include 'guide_dialog.html' %} {% endif %} {% block scripts %}{% endblock %} diff --git a/app/templates/council.html b/app/templates/council.html index 0484638..a2b4de9 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -81,12 +81,12 @@ {{ candidate.name }}{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }} - {{ candidate.q1_count }}Q1 + {{ candidate.q1_count }}Q1 {{ candidate.q2_count }}Q2 {{ candidate.k1_count }}К1 - {{ candidate.total_count }}ВСЕГО (предв.) - {{ candidate.db_count }}ЛК сотрудника ВШЭ - {{ candidate.count }}Все публикации + {{ candidate.total_count }}ВСЕГО (предв.) + {{ candidate.db_count }}ЛК сотрудника ВШЭ + {{ candidate.count }}Все публикации diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html index fec208b..c89f878 100644 --- a/app/templates/council_publications.html +++ b/app/templates/council_publications.html @@ -26,8 +26,8 @@

{{ article.employee_name }}

{{ article.title }}{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}

{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}

-

Журнал: {{ 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' %}

-

Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}

+

Журнал: {{ 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' %}

+

Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}

{% else %}
  • Статьи не найдены. Попробуйте изменить фильтры.
  • {% endfor %} diff --git a/app/templates/guide.html b/app/templates/guide.html index 61fb31c..f16e094 100644 --- a/app/templates/guide.html +++ b/app/templates/guide.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}Как пользоваться · MIEM Employees{% endblock %} {% block content %} -
    +

    Как пользоваться сайтом

    {% include 'guide_content.html' %}
    diff --git a/app/templates/guide_dialog.html b/app/templates/guide_dialog.html deleted file mode 100644 index 78897f5..0000000 --- a/app/templates/guide_dialog.html +++ /dev/null @@ -1,24 +0,0 @@ - -
    -
    -

    Как пользоваться сайтом

    - -
    -

    - {% include 'guide_content.html' %} - - -
    - - -
    -
    -
    -{% if request.state.guide_pending|default(false) %} -
    -{% endif %} diff --git a/app/templates/guide_walkthrough.html b/app/templates/guide_walkthrough.html new file mode 100644 index 0000000..e5f41eb --- /dev/null +++ b/app/templates/guide_walkthrough.html @@ -0,0 +1,25 @@ + +{% if request.state.guide_pending|default(false) %} + +{% endif %} diff --git a/app/version.py b/app/version.py index 6a65aac..ff9070f 100644 --- a/app/version.py +++ b/app/version.py @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 4d7c7ee..f049e28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/browser_users.py b/tests/browser_users.py index e46db73..9a26f6e 100644 --- a/tests/browser_users.py +++ b/tests/browser_users.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index f97c474..81a7c80 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "1.5.1" + assert response.json()["backend_version"] == "1.6.2" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 4d4b2c8..b3959d1 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -230,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.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 diff --git a/tests/test_users.py b/tests/test_users.py index 3f23185..4788611 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -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