Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29775b238 | ||
|
|
8e3ea6eeb2 | ||
|
|
5128fac6e5 | ||
|
|
378d7ee96e | ||
|
|
9d788b0663 | ||
|
|
3359ea2f86 | ||
|
|
ccebaddfda |
No files matched your search
@@ -0,0 +1,20 @@
|
||||
## Сделано
|
||||
|
||||
- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.1`.
|
||||
- Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД.
|
||||
- Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении.
|
||||
- Исправлено неблокирующее замечание независимого ревью PR #71: только три новых правила CSS переведены с CRLF на LF, содержимое правил сохранено.
|
||||
- После исправления: `pytest -q` — 412 passed, 11 skipped; `git diff --check main` — passed. Предыдущая запись о passed для полного diff коммита `5128fac` была неточной: ревью подтвердило три trailing whitespace, теперь они устранены.
|
||||
- До этой правки: браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed. Браузерный сценарий повторно не запускался: поведение CSS не менялось.
|
||||
|
||||
## Остановились на
|
||||
|
||||
- Открыт PR #71: https://git.vakanaut.ru/admin/miem_workers/pulls/71. Ревью коммита `5128fac` одобрено с одним неблокирующим замечанием; пользователь поручил исправить его.
|
||||
- Правка готова к коммиту и push в ту же ветку/PR. Доступ к Gitea восстановился; локальный `main` обновлён, основа — `378d7ee`.
|
||||
- PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных.
|
||||
- Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71.
|
||||
- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR.
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
- run: node --check app/static/admin.js
|
||||
- run: node --check app/static/council.js
|
||||
- run: node --check app/static/users.js
|
||||
- run: node --check app/static/guide.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
|
||||
@@ -285,3 +285,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
|
||||
### Подсказки первого входа
|
||||
|
||||
После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения.
|
||||
|
||||
Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+2
-2
@@ -169,7 +169,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
branch=branch)
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
@@ -303,7 +303,7 @@ def export_council(request: Request, q: str = "", sort: str = "total_count", dir
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
branch=branch)
|
||||
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
|
||||
content = council_xlsx(result)
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
|
||||
@@ -9,51 +9,97 @@
|
||||
"specialties": [
|
||||
{
|
||||
"code": "1.2.2",
|
||||
"name": "Математическое моделирование, численные методы и комплексы программ"
|
||||
"name": "Математическое моделирование, численные методы и комплексы программ",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.2",
|
||||
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств"
|
||||
"name": "Электронная компонентная база микро- и наноэлектроники, квантовых устройств",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.6",
|
||||
"name": "Методы и системы защиты информации, информационная безопасность"
|
||||
"name": "Методы и системы защиты информации, информационная безопасность",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.1",
|
||||
"name": "Системный анализ, управление и обработка информации, статистика"
|
||||
"name": "Системный анализ, управление и обработка информации, статистика",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.7",
|
||||
"name": "Компьютерное моделирование и автоматизация проектирования"
|
||||
"name": "Компьютерное моделирование и автоматизация проектирования",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "1.3.8",
|
||||
"name": "Физика конденсированного состояния"
|
||||
"name": "Физика конденсированного состояния",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.15",
|
||||
"name": "Системы, сети и устройства телекоммуникаций"
|
||||
"name": "Системы, сети и устройства телекоммуникаций",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "1.1.2",
|
||||
"name": "Дифференциальные уравнения и математическая физика"
|
||||
"name": "Дифференциальные уравнения и математическая физика",
|
||||
"branches": [
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.13",
|
||||
"name": "Радиотехника, в том числе системы и устройства телевидения"
|
||||
"name": "Радиотехника, в том числе системы и устройства телевидения",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.2",
|
||||
"name": "Вычислительные системы и их элементы"
|
||||
"name": "Вычислительные системы и их элементы",
|
||||
"branches": [
|
||||
"технические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.2.14",
|
||||
"name": "Антенны, СВЧ-устройства и их технологии"
|
||||
"name": "Антенны, СВЧ-устройства и их технологии",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "2.3.5",
|
||||
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей"
|
||||
"name": "Математическое и программное обеспечение вычислительных систем, комплексов и компьютерных сетей",
|
||||
"branches": [
|
||||
"технические",
|
||||
"физико-математические"
|
||||
]
|
||||
}
|
||||
],
|
||||
"journals": [
|
||||
|
||||
+5
-1
@@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, utcnow
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
@@ -138,6 +138,10 @@ def optional_user(request: Request, settings: Settings, db: Session, *, allow_pa
|
||||
user = db.scalar(query)
|
||||
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
|
||||
user = ensure_bootstrap_admin(db, settings)
|
||||
if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password
|
||||
and request.method == 'GET' and request.url.path.startswith('/admin')):
|
||||
request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start'
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
|
||||
+14
-3
@@ -253,7 +253,8 @@ def _unique_articles(articles: list[dict], start: date, end: date, corrections=N
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
|
||||
branch: str = "технические", employee_id: int | None = None) -> dict:
|
||||
branch: str = "технические", employee_id: int | None = None,
|
||||
previous_specialty: str | None = None) -> dict:
|
||||
if branch and branch not in BRANCHES:
|
||||
raise CouncilInputError("Неизвестная отрасль науки")
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
@@ -261,6 +262,15 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
specialties = _k1_specialties()["specialties"]
|
||||
if specialty and specialty not in {s["code"] for s in specialties}:
|
||||
raise CouncilInputError("Неизвестная специальность ВАК")
|
||||
branches = list(dict.fromkeys(name for s in specialties if not specialty or s["code"] == specialty
|
||||
for name in s["branches"]))
|
||||
if branch and branch not in branches:
|
||||
previous_branches = {name for s in specialties if previous_specialty == "" or s["code"] == previous_specialty
|
||||
for name in s["branches"]}
|
||||
if previous_specialty is not None and previous_specialty != specialty and branch in previous_branches:
|
||||
branch = branches[0]
|
||||
else:
|
||||
raise CouncilInputError("Отрасль оценки не относится к выбранным специальностям ВАК")
|
||||
specialty_name = next((s["name"] for s in specialties if s["code"] == specialty), "")
|
||||
degree_sections = Employee.current_data["sections"]
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
@@ -421,13 +431,14 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
criteria_groups = {}
|
||||
for name, (minimum, monographs) in BRANCHES.items():
|
||||
for name in branches:
|
||||
minimum, monographs = BRANCHES[name]
|
||||
criteria_groups.setdefault((minimum, monographs), []).append(name)
|
||||
return {"criteria_groups": [{"names": ", ".join(names), "minimum": minimum, "monographs": monographs}
|
||||
for (minimum, monographs), names in criteria_groups.items()], "journal_names": sorted({name for c in candidates for a in c["articles"] for name in a["journals"]}), "revision": revision, "candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
|
||||
"branch": branch, "branches": list(BRANCHES),
|
||||
"branch": branch, "branches": branches,
|
||||
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
|
||||
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
|
||||
for path in (K1_JOURNALS, K2_JOURNALS)
|
||||
|
||||
@@ -966,3 +966,16 @@
|
||||
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
|
||||
|
||||
.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__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; }
|
||||
@@ -1,4 +1,12 @@
|
||||
(() => {
|
||||
const specialty = document.querySelector('#council-specialty');
|
||||
const branch = document.querySelector('#council-branch');
|
||||
specialty?.addEventListener('change', () => {
|
||||
const names = JSON.parse(specialty.selectedOptions[0].dataset.branches);
|
||||
const previous = branch.value;
|
||||
branch.replaceChildren(...names.map(name => new Option(name[0].toUpperCase() + name.slice(1), name)));
|
||||
if (names.includes(previous)) branch.value = previous;
|
||||
});
|
||||
const dialog = document.querySelector('[data-council-dialog]');
|
||||
if (!dialog) return;
|
||||
const form = dialog.querySelector('[data-edit-form]');
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
(() => {
|
||||
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;
|
||||
|
||||
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});
|
||||
}
|
||||
function open() {
|
||||
index = 0;
|
||||
error.hidden = true;
|
||||
unsaved.hidden = true;
|
||||
dialog.showModal();
|
||||
showStep();
|
||||
}
|
||||
async function finish() {
|
||||
if (saving) return;
|
||||
if (dialog.dataset.guidePending !== 'true') {
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
dialog.setAttribute('aria-busy', 'true');
|
||||
close.disabled = next.disabled = back.disabled = true;
|
||||
error.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),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
dialog.dataset.guidePending = 'false';
|
||||
dialog.close();
|
||||
} catch (_failure) {
|
||||
error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.';
|
||||
error.hidden = false;
|
||||
unsaved.hidden = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
dialog.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(); });
|
||||
next.addEventListener('click', () => {
|
||||
if (index === steps.length - 1) finish();
|
||||
else { index += 1; showStep(); }
|
||||
});
|
||||
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();
|
||||
})();
|
||||
@@ -21,6 +21,7 @@
|
||||
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
{% if current_user and current_user.role == 'admin' %}<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>{% endif %}
|
||||
<a class="admin__link" href="/admin/account"{% if request.url.path == '/admin/account' %} aria-current="page"{% endif %}>Личный кабинет</a>
|
||||
{% if current_user and not current_user.must_change_password %}<a class="admin__link" href="/admin/guide" data-guide-open>Как пользоваться</a>{% endif %}
|
||||
<form method="post" action="/admin/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
@@ -34,6 +35,10 @@
|
||||
<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 %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
<p class="council__intro">Подбор сотрудников в диссертационный совет по техническим и физико-математическим наукам. Публикации за пять календарных лет, включая текущий год, сопоставляются с данными о квартилях и категориях журналов. Для доктора наук минимум зависит от отрасли оценки; для кандидата наук или PhD — 20 работ. Предварительный подсчёт и подтверждённые работы показаны отдельно.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<p>Q1, Q2, К1, К2 и RSCI — предварительные показатели. Квартили определяются по данным ВШЭ, применимым данным каталогов и проверенным основаниям; категории К1/К2 — по сопоставлению журнала со справочниками ВАК. RSCI учитывается при наличии отдельной записи об этом основании; отметка РИНЦ сама по себе его не подтверждает. Сведения из профиля сотрудника дополняются внешними источниками.</p>
|
||||
<p>ВСЕГО — число уникальных работ с основаниями Q1/Q2/К1/RSCI; для доктора наук учитывается также К2. Пересечения между основаниями не увеличивают итог. Для кандидата наук или PhD одной категории К2 недостаточно. Известные другие виды материалов не входят в этот подсчёт без отдельного подтверждения их допустимости.</p>
|
||||
<p>«Подтверждено» — работы с проверенными видом материала, периодом и допустимым основанием. Известная статья автоматически засчитывается по Q1/Q2, если источник связывает её с журналом, квартиль применим к году публикации и нет конфликтов идентичности. Отдельное ручное подтверждение уже известных типа статьи и квартиля не требуется. Точная дата публикации не требуется; текущая категория журнала не подтверждает его категорию в год выхода работы. «Требует проверки» означает, что для вывода не хватает подтверждений: например, степени, года или основания зачёта работы. Это не означает, что у сотрудника нет нужных публикаций. Соответствие публикационному минимуму не означает автоматического включения в совет.</p>
|
||||
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Код специальности журнала сам по себе не меняет отрасль оценки.</p>
|
||||
<p>Порог зависит от отрасли науки, по которой сотрудник включается в совет. Сейчас расчёт выполняется по отрасли «{{ branch }}». Доступные отрасли ограничены выбранной специальностью ВАК.</p>
|
||||
<ul class="council__criteria-list">{% for group in criteria_groups %}<li>Доктор наук · {{ group.names }}: {{ group.minimum }} публикаций{% if group.monographs %}; не менее {{ group.monographs }} монографии{% endif %}.</li>{% endfor %}<li>Кандидат наук или PhD: 20 публикаций для всех отраслей.</li></ul>
|
||||
<p>Связь журнала со специальностью можно изменить в редакторе; она применяется ко всем статьям этого журнала. Это связь тематики журнала, а не заключение о содержании каждой статьи.</p>
|
||||
<p>Фильтр «Специальность ВАК» подбирает сотрудников с хотя бы одной публикацией за пять лет в подходящем журнале К1 либо журнале с ручной тематической привязкой. Связь определяется по журналу из Перечня ВАК на 15.09.2026, а не по содержанию статьи. Квартильные каталоги содержат ограниченные подборки и метрики 2025 года; они не подтверждают другие годы или текущую индексацию. Аналитическая связь журнала со специальностью и отдельное рассмотрение статьи показаны в публикациях.</p>
|
||||
@@ -34,6 +34,7 @@
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<input type="hidden" name="previous_specialty" value="{{ specialty }}">
|
||||
<label class="directory__field" for="council-branch">Отрасль оценки
|
||||
<select class="directory__input" id="council-branch" name="branch">
|
||||
{% for name in branches %}<option value="{{ name }}"{% if branch == name %} selected{% endif %}>{{ name|capitalize }}</option>{% endfor %}
|
||||
@@ -41,9 +42,9 @@
|
||||
</label>
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="">Все специальности</option>
|
||||
<option value="" data-branches='{{ specialties|map(attribute="branches")|sum(start=[])|unique|list|tojson }}'>Все специальности</option>
|
||||
{% for item in specialties %}
|
||||
<option value="{{ item.code }}"{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
<option value="{{ item.code }}" data-branches='{{ item.branches|tojson }}'{% if specialty == item.code %} selected{% endif %}>{{ item.code }}. — {{ item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Как пользоваться · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<article class="panel guide guide--page">
|
||||
<h2 class="panel__title">Как пользоваться сайтом</h2>
|
||||
{% include 'guide_content.html' %}
|
||||
<form method="post" action="/admin/guide/dismiss">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Завершить подсказки</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
|
||||
<p>Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Сотрудники</dt><dd>Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.</dd>
|
||||
<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>
|
||||
<dt>Обзор и Запуски</dt><dd>Количество сотрудников, состояние загрузки данных и история её запусков.</dd>
|
||||
<dt>Личный кабинет</dt><dd>Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.</dd>
|
||||
</dl>
|
||||
{% if current_user.role == 'admin' %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% else %}<p>Вы можете просматривать данные и исправлять сведения о публикациях. Запуск загрузки данных и управление пользователями доступны администратору.</p>{% endif %}
|
||||
<p>Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
|
||||
<p>В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Статус, Email, Учёная степень</dt><dd>Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.</dd>
|
||||
<dt>Период обнаружения: От / До</dt><dd>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</dd>
|
||||
<dt>Сортировка и страница</dt><dd>Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.</dd>
|
||||
<dt>Настроить колонки</dt><dd>Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.</dd>
|
||||
</dl>
|
||||
<p>Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.</p>
|
||||
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Подберите кандидатов в диссовет</h3>
|
||||
<dl class="guide__fields">
|
||||
<dt>Специальность ВАК</dt><dd>Выберите код и название либо «Все специальности». Например, 2.3.2 — вычислительные системы и их элементы.</dd>
|
||||
<dt>Отрасль оценки</dt><dd>Выберите отрасль предполагаемого включения в совет. Для 1.1.2 доступны только физико-математические науки, для 2.3.2 — технические, для остальных специальностей сайта — обе отрасли.</dd>
|
||||
<dt>Поиск по ФИО</dt><dd>Введите фамилию или часть полного имени, чтобы сузить список.</dd>
|
||||
<dt>Сортировать по / Направление</dt><dd>Например, выберите «Подтверждено» и «По убыванию», затем нажмите «Применить». Получится список сотрудников в заданном порядке.</dd>
|
||||
</dl>
|
||||
<p>Фильтр специальности ищет сотрудников с подходящей связью журнала. Он не доказывает тематику каждой статьи и не ограничивает «ВСЕГО» только работами по выбранной специальности.</p>
|
||||
<p>Показатели рассчитаны за пять календарных лет, включая текущий год. Для подготовки ходатайства отдельно проверьте требуемый период и документальные основания. Подробности доступны в блоке «Как рассчитываются показатели».</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Прочитайте показатели сотрудника</h3>
|
||||
<p>В строке сотрудника показаны ФИО, обнаруженные степени, отметка об увольнении при наличии, результат оценки и счётчики. Откройте строку, чтобы загрузить публикации и причины дополнительной проверки.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Q1, Q2, К1</dt><dd>Количество работ с соответствующим основанием. Одна работа может попадать в несколько этих счётчиков.</dd>
|
||||
<dt>ВСЕГО (предв.)</dt><dd>Уникальные работы Q1/Q2/К1/RSCI; для доктора наук также К2. Одна работа учитывается один раз. Для кандидата наук или PhD одной категории К2 недостаточно.</dd>
|
||||
<dt>Подтверждено</dt><dd>Работы с проверенными видом материала, периодом и основанием зачёта. Именно это число сравнивается с минимумом: 9 для доктора по технической отрасли, 11 по физико-математической, 20 для кандидата наук или PhD.</dd>
|
||||
<dt>ЛК сотрудника ВШЭ</dt><dd>Публикации из личного кабинета сотрудника за все годы.</dd>
|
||||
<dt>Все публикации</dt><dd>Работы из личного кабинета и выгрузки ВШЭ, объединённые без дублей за все годы и независимо от категории.</dd>
|
||||
</dl>
|
||||
<p>При раскрытии также показаны К2, RSCI, подтверждение степени, причины статуса и ссылка на профиль ВШЭ. «Требует проверки» означает нехватку подтверждений; выполнение публикационного минимума не гарантирует включение в совет.</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Проверьте строку публикации</h3>
|
||||
<dl class="guide__fields">
|
||||
<dt>Название и год</dt><dd>Помогают определить работу и период. Неизвестный или спорный год отмечен подсказкой.</dd>
|
||||
<dt>Источник и тип материала</dt><dd>«ЛК сотрудника ВШЭ» или «Данные ВШЭ» показывают происхождение записи; тип отличает статью от книги и других материалов.</dd>
|
||||
<dt>Журнал и квартиль</dt><dd>Название издания и Q1–Q4 при наличии. Квартиль должен быть применим к году статьи; К1/К2 — отдельные категории ВАК, а не квартили.</dd>
|
||||
<dt>DOI и Публикация на сайте</dt><dd>Ссылки на исходную работу, если они доступны.</dd>
|
||||
<dt>Основания и специальность</dt><dd>В раскрытой карточке сотрудника приведены источники зачёта, годы метрик, данные каталогов, ISSN и ссылки на паспорта при наличии. При выборе специальности показаны сведения о тематическом соответствии.</dd>
|
||||
</dl>
|
||||
<p>Кнопка «Уточнение» рядом с полем объясняет, что требует проверки. Её можно открыть мышью или клавишей Enter. Предупреждение само по себе не означает, что публикации нет.</p>
|
||||
<p>В разделе «Статьи, журналы и квартили» показана сокращённая строка: сотрудник, название, год, тип, журнал, квартиль и специальности журнала. Там можно искать по ФИО, названию статьи и журналу.</p>
|
||||
</section>
|
||||
<section class="guide__step" data-guide-step>
|
||||
<h3 class="guide__heading" tabindex="-1">Исправьте сведения и выгрузите результат</h3>
|
||||
<p>Войдя в систему, выберите строку публикации и откройте «Редактировать статью». С клавиатуры: перейдите на строку, нажмите Enter, затем активируйте кнопку редактирования.</p>
|
||||
<dl class="guide__fields">
|
||||
<dt>Название статьи / Название журнала</dt><dd>Введите проверенные названия. Для журнала можно выбрать предложение из списка.</dd>
|
||||
<dt>Квартиль</dt><dd>Выберите подтверждённый Q1–Q4. Если год неизвестен, поле недоступно. Изменение квартиля затронет все статьи этого журнала за тот же год.</dd>
|
||||
<dt>Специальности журнала</dt><dd>Оставьте связь из справочников или задайте ручную. Ручной выбор заменяет связь для всех статей журнала за все годы; снятие всех специальностей исключает связь.</dd>
|
||||
<dt>Сохранить / Отмена</dt><dd>Сохранение обновит показатели и запишет действие в историю. Ручная правка не заменяет подтверждение источником для зачёта работы.</dd>
|
||||
<dt>Скачать XLSX</dt><dd>В разделе «Диссертационный совет» выгружает параметры оценки, всех найденных сотрудников и их публикации, включая другие страницы списка.</dd>
|
||||
</dl>
|
||||
<p>Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».</p>
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
<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 %}
|
||||
@@ -4,7 +4,7 @@
|
||||
{% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }}
|
||||
{% else %}{{ value if value else 'не указано' }}{% endif %}
|
||||
{% endmacro %}
|
||||
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа'} %}
|
||||
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа', 'guide_start': 'Подсказки первого входа доступны', 'guide_finish': 'Подсказки первого входа закрыты'} %}
|
||||
{% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
|
||||
<div class="users__table-wrap">
|
||||
<table class="table users__history-table">
|
||||
|
||||
+27
-1
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -157,6 +157,7 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
if new_password != confirmation:
|
||||
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
|
||||
try:
|
||||
first_password_change = user.password_changed_at is None
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = utcnow()
|
||||
@@ -164,6 +165,8 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=user.id, action='password_change'))
|
||||
if first_password_change:
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
@@ -172,3 +175,26 @@ def change_password(request: Request, current_password: str = Form(..., max_leng
|
||||
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.get('/guide')
|
||||
def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_user(request, settings, db)
|
||||
return _render(request, 'guide.html', {'guide_page': True})
|
||||
|
||||
|
||||
@router.post('/guide/dismiss')
|
||||
def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, lock=True)
|
||||
try:
|
||||
latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
||||
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1))
|
||||
if latest == 'guide_start':
|
||||
db.add(UserAction(user_id=user.id, action='guide_finish'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз')
|
||||
if request.headers.get('accept') == 'application/json':
|
||||
return Response(status_code=204)
|
||||
return RedirectResponse('/admin', status_code=303)
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "1.2.1"
|
||||
FRONTEND_VERSION = "1.2.1"
|
||||
BACKEND_VERSION = "1.2.1"
|
||||
APP_VERSION = "1.3.1"
|
||||
FRONTEND_VERSION = "1.3.1"
|
||||
BACKEND_VERSION = "1.3.1"
|
||||
@@ -78,3 +78,8 @@
|
||||
Для обновления К2 используйте существующий экстрактор: `python scripts/extract_k1_journals.py "app/data/ВАК категории.pdf" --category К2 --output app/data/k2_journals.json`. Строки, даты, алиасы и SHA-256 сохраняются, К1 не изменяется. После изменения JSON/PDF пересоберите образ и пересоздайте API: справочники и подтверждения кешируются в процессе.
|
||||
|
||||
Ручные тематические связи журнала задаются в редакторе публикаций и хранятся отдельно от парсера, в `council_corrections.data.specialties`. Они заменяют справочную связь для фильтра и XLSX, но не являются рассмотрением каждой статьи по паспорту и не меняют отрасль оценки.
|
||||
|
||||
|
||||
Отрасли оценки ограничены двенадцатью специальностями сайта: 1.1.2 допускает только физико-математические науки, 2.3.2 — только технические; остальные десять допускают обе отрасли. Основание — поле «Наименование отрасли науки, по которой присуждаются ученые степени» в сохранённых PDF-паспортах `app/data/scientific-specialty-passports/pdf/`. Список хранится в `k1_specialties.json` и сохраняется при регенерации справочника. При выборе «Все специальности» доступны обе отрасли; при смене специальности форма сохраняет подходящую отрасль либо выбирает первую допустимую. Сервер отклоняет несовместимые пары для списка, карточек и XLSX.
|
||||
|
||||
Без JavaScript форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.2.1"
|
||||
version = "1.3.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -170,7 +170,11 @@ if __name__ == "__main__":
|
||||
result = {"schema_version": 1, "source": args.source.name,
|
||||
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(), "as_of": args.as_of,
|
||||
"k1_source_sha256": hashlib.sha256(args.k1.read_bytes().replace(b"\r\n", b"\n")).hexdigest(), "k1_source_hash_eol": "LF", "source_rows": len(journals),
|
||||
"specialties": [{"code": code, "name": name} for code, name in SPECIALTIES], "journals": entries}
|
||||
"specialties": [{"code": code, "name": name,
|
||||
"branches": ["физико-математические"] if code == "1.1.2" else
|
||||
["технические"] if code == "2.3.2" else
|
||||
["технические", "физико-математические"]}
|
||||
for code, name in SPECIALTIES], "journals": entries}
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
from collections import Counter
|
||||
print(len(entries), dict(Counter(j['status'] for j in entries)))
|
||||
@@ -92,6 +92,21 @@ def main():
|
||||
assert "Без степени" not in page.content() and "Только звание" not in page.content()
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
branches = page.locator('#council-branch')
|
||||
specialty = page.locator('#council-specialty')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
specialty.select_option('1.1.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
specialty.select_option('2.3.2')
|
||||
expect(branches.locator('option')).to_have_count(1)
|
||||
expect(branches).to_have_value('технические')
|
||||
specialty.select_option('1.2.2')
|
||||
expect(branches.locator('option')).to_have_count(2)
|
||||
branches.select_option('физико-математические')
|
||||
specialty.select_option('')
|
||||
expect(branches).to_have_value('физико-математические')
|
||||
branches.select_option('технические')
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
@@ -217,6 +232,27 @@ def main():
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
page.screenshot(path=str(output / f"catalog-{width}.png"), full_page=True)
|
||||
assert not errors, errors
|
||||
no_js = browser.new_context(java_script_enabled=False)
|
||||
no_js.route("**/*", serve)
|
||||
fallback = no_js.new_page()
|
||||
for previous, specialty, old_branch, new_branch in [
|
||||
("2.3.2", "1.1.2", "технические", "физико-математические"),
|
||||
("1.1.2", "2.3.2", "физико-математические", "технические"),
|
||||
]:
|
||||
url = f"http://miem.test/admin/council?specialty={previous}&branch={old_branch}&q=Тест&sort=full_name&direction=asc"
|
||||
fallback.goto(url)
|
||||
fallback.get_by_label("Специальность ВАК").select_option(specialty)
|
||||
with fallback.expect_download() as pending_download:
|
||||
fallback.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
book = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
|
||||
fallback.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(fallback.get_by_label("Отрасль оценки")).to_have_value(new_branch)
|
||||
expect(fallback.get_by_label("Специальность ВАК")).to_have_value(specialty)
|
||||
expect(fallback.get_by_label("Поиск по ФИО")).to_have_value("Тест")
|
||||
expect(fallback.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(fallback.get_by_label("Направление")).to_have_value("asc")
|
||||
no_js.close()
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
|
||||
+59
-1
@@ -79,6 +79,9 @@ def main():
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
|
||||
creation.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
nojs_password = page.locator('[data-temporary-password]').input_value()
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('reader')
|
||||
@@ -97,7 +100,62 @@ 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()
|
||||
expect(page.get_by_role('dialog')).to_have_count(0)
|
||||
guide = page.get_by_role('dialog', name='Как пользоваться сайтом')
|
||||
expect(guide).to_be_visible()
|
||||
expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused()
|
||||
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()
|
||||
guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.unroute('**/admin/guide/dismiss')
|
||||
page.reload()
|
||||
expect(guide).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(guide).not_to_be_visible()
|
||||
expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused()
|
||||
page.reload()
|
||||
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()
|
||||
guide.get_by_role('button', name='Завершить подсказки', exact=True).click()
|
||||
expect(guide).not_to_be_visible()
|
||||
no_js = browser.new_context(java_script_enabled=False)
|
||||
nojs_page = no_js.new_page()
|
||||
nojs_page.goto(base_url + '/admin/login')
|
||||
nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader')
|
||||
nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_role('button', name='Войти', exact=True).click()
|
||||
nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password)
|
||||
nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123')
|
||||
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_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()
|
||||
|
||||
+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.2.1"
|
||||
assert response.json()["backend_version"] == "1.3.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
@@ -223,7 +223,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.2.1 · Frontend 1.2.1" in response.text
|
||||
assert "Backend 1.3.1 · Frontend 1.3.1" 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
|
||||
@@ -501,7 +501,9 @@ def test_specialty_page_pagination_search_and_export_agree(db_session, export):
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council?specialty=1.2.2&sort=full_name&direction=asc")
|
||||
assert response.status_code == 200
|
||||
assert '<option value="1.2.2" selected>' in response.text
|
||||
from bs4 import BeautifulSoup
|
||||
selected = BeautifulSoup(response.text, "html.parser").select_one('#council-specialty option[selected]')
|
||||
assert selected["value"] == "1.2.2"
|
||||
assert response.text.count('<option value="2.3.5"') == 1
|
||||
assert "Публикаций по выбранной специальности: 1" in client.get("/admin/council/employees/1/articles?specialty=1.2.2").text
|
||||
assert "Не подходит" not in response.text
|
||||
|
||||
@@ -48,9 +48,14 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
|
||||
"bases": [{**SOURCE, "basis": basis, "applicable_year": year}], **proof_fields}
|
||||
|
||||
|
||||
def result(db, branch="технические", **kwargs):
|
||||
def result(db, branch="технические", assessment_branch=None, **kwargs):
|
||||
db.commit()
|
||||
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
value = council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
if assessment_branch:
|
||||
return assessment.assess_candidate(value["degree"].split("; "), value["articles"],
|
||||
assessment.evidence()["employees"]["test"], assessment_branch, "",
|
||||
date(2022, 1, 1), date(2026, 12, 31))
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
|
||||
@@ -61,7 +66,7 @@ def test_each_doctor_threshold_and_monograph_requirement(db_session, proofs, bra
|
||||
employee = person(db_session, [degree])
|
||||
records = [work(db_session, employee, n) for n in range(minimum + delta)]
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
value = result(db_session, branch)
|
||||
value = result(db_session, assessment_branch=branch)
|
||||
assert value["minimum"] == minimum and value["monograph_minimum"] == mono
|
||||
assert value["confirmed_count"] == minimum + delta
|
||||
assert value["assessment_status"].startswith("Соответствует") == (delta >= 0 and not mono)
|
||||
@@ -102,10 +107,10 @@ def test_monograph_explicit_evidence_and_period_policy(db_session, proofs, branc
|
||||
records = [work(db_session, employee, n) for n in range(8)]
|
||||
records.append(work(db_session, employee, 8, kind="BOOK", kind_evidence=SOURCE))
|
||||
proofs([{**SOURCE, "degree": degree}], records)
|
||||
assert result(db_session, branch)["monograph_count"] == 0
|
||||
assert result(db_session, assessment_branch=branch)["monograph_count"] == 0
|
||||
records[-1]["kind"] = "monograph"
|
||||
proofs([{**SOURCE, "degree": degree}], records, {"monograph_period": {**SOURCE, "value": "five_years"}})
|
||||
value = result(db_session, branch)
|
||||
value = result(db_session, assessment_branch=branch)
|
||||
assert value["confirmed_count"] == 8 and value["monograph_count"] == 1
|
||||
assert value["assessment_status"].startswith("Соответствует")
|
||||
|
||||
@@ -175,8 +180,7 @@ def test_http_validation_context_and_export_parity(db_session, proofs, monkeypat
|
||||
assert "council-date" not in page.text and "Монографии" not in page.text
|
||||
assert "Дата ходатайства" not in page.text
|
||||
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
|
||||
legacy_book = load_workbook(BytesIO(legacy.content))
|
||||
assert dict(legacy_book["Параметры"].values)["Отрасль оценки"] == "экономические"
|
||||
assert legacy.status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -492,3 +496,65 @@ def test_warning_display_does_not_turn_unknown_type_into_confirmed_article(db_se
|
||||
assert article['display_type'] == 'Статья' and article['types'] == []
|
||||
assert article['warnings']['type'] and 'не указан тип материала' in article['warnings']['type'][0]
|
||||
assert not article['confirmed'] and candidate['confirmed_count'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("specialty,branches", [
|
||||
("", ["технические", "физико-математические"]),
|
||||
("1.1.2", ["физико-математические"]),
|
||||
("2.3.2", ["технические"]),
|
||||
*[(code, ["технические", "физико-математические"]) for code in
|
||||
["1.2.2", "1.3.8", "2.2.2", "2.2.13", "2.2.14", "2.2.15", "2.3.1", "2.3.5", "2.3.6", "2.3.7"]],
|
||||
])
|
||||
def test_specialty_limits_branch_options_and_rejects_invalid_requests(db_session, proofs, specialty, branches):
|
||||
from bs4 import BeautifulSoup
|
||||
employee = person(db_session, ["Доктор технических наук"])
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for branch in assessment.BRANCHES:
|
||||
params = {"specialty": specialty, "branch": branch}
|
||||
page = client.get("/admin/council", params=params)
|
||||
if branch in branches:
|
||||
assert page.status_code == 200
|
||||
options = BeautifulSoup(page.text, "html.parser").select("#council-branch option")
|
||||
assert [option["value"] for option in options] == branches
|
||||
assert next(option["value"] for option in options if "selected" in option.attrs) == branch
|
||||
else:
|
||||
assert page.status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx", params=params).status_code == 422
|
||||
assert client.get(f"/admin/council/employees/{employee.id}/articles", params=params).status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous,specialty,old_branch,new_branch", [
|
||||
("2.3.2", "1.1.2", "технические", "физико-математические"),
|
||||
("1.1.2", "2.3.2", "физико-математические", "технические"),
|
||||
("", "1.1.2", "технические", "физико-математические"),
|
||||
])
|
||||
def test_specialty_change_without_js_uses_compatible_branch(db_session, proofs, previous, specialty, old_branch, new_branch):
|
||||
from bs4 import BeautifulSoup
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
params = {"previous_specialty": previous, "specialty": specialty, "branch": old_branch,
|
||||
"q": "Тест", "sort": "full_name", "direction": "asc"}
|
||||
page = client.get("/admin/council", params=params)
|
||||
assert page.status_code == 200
|
||||
parsed = BeautifulSoup(page.text, "html.parser")
|
||||
assert parsed.select_one('#council-branch option[selected]')["value"] == new_branch
|
||||
assert parsed.select_one('[name=previous_specialty]')["value"] == specialty
|
||||
assert parsed.select_one('[name=q]')["value"] == "Тест"
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx", params=params).content))
|
||||
assert dict(book["Параметры"].values)["Отрасль оценки"] == new_branch
|
||||
for invalid_previous in [None, specialty, "oops"]:
|
||||
invalid = {**params, "previous_specialty": invalid_previous}
|
||||
if invalid_previous is None:
|
||||
del invalid["previous_specialty"]
|
||||
assert client.get("/admin/council", params=invalid).status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx", params=invalid).status_code == 422
|
||||
invalid = {**params, "branch": "экономические"}
|
||||
assert client.get("/admin/council", params=invalid).status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -108,6 +108,7 @@ def test_create_user_first_login_forces_password_change(accounts):
|
||||
assert not user.must_change_password and user.password_changed_at and user.session_version == 1
|
||||
assert user.first_login_at == first_login
|
||||
assert 'data-required-password' not in client.get('/admin/account').text
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory').status_code == 200
|
||||
assert client.get('/admin/users').status_code == 403
|
||||
client.cookies.clear()
|
||||
@@ -286,6 +287,80 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
|
||||
db.refresh(user)
|
||||
assert user.password_hash == before and user.session_version == 0
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['user', 'admin'])
|
||||
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
user.role = role
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
data = {'current_password': 'Initial user password', 'new_password': 'New onboarding passphrase',
|
||||
'confirmation': 'New onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
page = client.get('/admin/account')
|
||||
assert 'data-guide-pending="true"' in page.text
|
||||
assert 'data-required-password' not in page.text
|
||||
assert page.headers['Cache-Control'] == 'no-store'
|
||||
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 'Прочитайте показатели сотрудника' 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']
|
||||
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):
|
||||
response = client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': csrf, 'Accept': 'application/json'}, follow_redirects=False)
|
||||
assert response.status_code == 204
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_finish')).all()) == 1
|
||||
client.cookies.clear()
|
||||
headers = act_as(client, user, settings)
|
||||
assert 'data-guide-pending="false"' in client.get('/admin').text
|
||||
data = {'current_password': data['new_password'], 'new_password': 'Second onboarding passphrase',
|
||||
'confirmation': 'Second onboarding passphrase'}
|
||||
assert client.post('/admin/account/password', data=data, headers=headers, follow_redirects=False).status_code == 303
|
||||
assert 'data-guide-pending="false"' in client.get('/admin/account').text
|
||||
assert client.get('/admin/guide').status_code == 200
|
||||
assert len(db.scalars(select(UserAction).where(UserAction.user_id == user.id, UserAction.action == 'guide_start')).all()) == 1
|
||||
|
||||
|
||||
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 client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login'
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.post('/admin/guide/dismiss', headers=headers, follow_redirects=False).headers['location'] == '/admin/account'
|
||||
user.must_change_password = False
|
||||
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"]')
|
||||
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
|
||||
|
||||
|
||||
def test_guide_dismissal_failure_does_not_mark_it_finished(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
db.add(UserAction(user_id=user.id, action='guide_start'))
|
||||
db.commit()
|
||||
headers = act_as(client, user, settings)
|
||||
def unavailable():
|
||||
raise OperationalError('INSERT', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/guide/dismiss', headers={**headers, 'Accept': 'application/json'})
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_finish')) is None
|
||||
assert 'data-guide-pending="true"' in client.get('/admin/account').text
|
||||
|
||||
|
||||
def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts):
|
||||
|
||||
Reference in new issue
Block a user