Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29775b238 | ||
|
|
8e3ea6eeb2 | ||
|
|
5128fac6e5 | ||
|
|
378d7ee96e | ||
|
|
9d788b0663 | ||
|
|
3359ea2f86 | ||
|
|
ccebaddfda | ||
|
|
96987e90c9 | ||
|
|
a82f63d6cb | ||
|
|
03ac2f5ee8 |
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.
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
- run: python -m pytest -q
|
||||
- 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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ cp .env.example .env
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
@@ -53,6 +53,24 @@ uvicorn app.main:app --reload
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Пользователи и личный кабинет
|
||||
|
||||
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
|
||||
|
||||
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
|
||||
|
||||
При первом успешном входе и после сброса требуется задать свой пароль (15–128 символов, можно фразу), указав текущий пароль. Попап нельзя закрыть Escape или нажатием на фон; можно сменить пароль либо выйти. Без JavaScript доступна обычная форма, а серверные операции до смены пароля заблокированы. Начальный администратор также меняет пароль при первом входе после обновления.
|
||||
|
||||
`/admin/account` показывает первый/последний вход, смену пароля и историю действий. Каждый видит только свои действия и события, относящиеся к его учётной записи; администратор на странице пользователей видит общую историю или фильтр по учётной записи. Журнал фиксирует успешные входы, смену/сброс пароля, изменения ролей/доступа и реальные правки карточек: сотрудник, статья, год, прежние/новые значения названия, журнала, квартиля и тематической связи. Сохранение правки и запись истории — одна транзакция; отклонённые и неизменённые сохранения не создают запись о редактировании. История разбита на страницы по 30 записей.
|
||||
|
||||
Сессия ограничена 12 часами. Смена/сброс пароля, изменение роли/доступа и выход отзывают прежние сессии. Собственную роль/доступ и пароль через административный сброс менять нельзя: свой пароль меняется в личном кабинете. Изменения доступа сериализуются, чтобы параллельные администраторы не лишили систему всех администраторов. После пяти неверных входов учётная запись блокирует новые входы на 15 минут; административный сброс снимает это ограничение.
|
||||
|
||||
Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.
|
||||
|
||||
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
|
||||
|
||||
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
@@ -100,7 +118,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
|
||||
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора.
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.
|
||||
|
||||
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
|
||||
|
||||
@@ -127,7 +145,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
@@ -267,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. При ошибке сохранения подсказки остаются доступными для повторного завершения.
|
||||
+79
-30
@@ -9,8 +9,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin, read_session
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction
|
||||
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user,
|
||||
optional_user, sign_session, authenticate, check_csrf)
|
||||
import secrets
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
format_admin_datetime,
|
||||
@@ -25,13 +27,14 @@ from app.services.council import CouncilInputError, council_candidates, _journal
|
||||
from app.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.filters["admin_datetime"] = format_admin_datetime
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
counts = stats_payload(db)
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
@@ -42,25 +45,48 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
response = _render(request, "login.html", {"error": None, "csrf_token": csrf})
|
||||
response.set_cookie(LOGIN_CSRF_COOKIE, csrf, httponly=True, samesite="strict", max_age=600, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
username: str = Form(..., max_length=64),
|
||||
password: str = Form(..., max_length=1024),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
try:
|
||||
user = authenticate(db, username, password, settings)
|
||||
if user is None:
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль, либо вход временно недоступен",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=401)
|
||||
db.add(UserAction(user_id=user.id, action="login"))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "login.html", {"error": "Вход временно недоступен. Попробуйте ещё раз",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=503)
|
||||
redirect = RedirectResponse("/admin/account" if user.must_change_password else "/admin", status_code=303)
|
||||
redirect.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')
|
||||
redirect.delete_cookie(LOGIN_CSRF_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
def logout(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = optional_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if user is not None:
|
||||
try:
|
||||
user.session_version += 1
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось завершить сессии. Попробуйте ещё раз")
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
@@ -71,9 +97,10 @@ def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@@ -93,7 +120,7 @@ def directory(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
@@ -139,20 +166,22 @@ def directory(
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
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):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
||||
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
|
||||
if not result["candidates"]:
|
||||
@@ -163,7 +192,7 @@ def council_employee_articles(employee_id: int, request: Request, specialty: str
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
|
||||
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
|
||||
"can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
|
||||
"can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
class CouncilArticleEdit(BaseModel):
|
||||
@@ -181,7 +210,7 @@ class CouncilArticleEdit(BaseModel):
|
||||
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
|
||||
"can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username}
|
||||
"can_edit": optional_user(request, settings, db) is not None}
|
||||
try:
|
||||
result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc")
|
||||
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
|
||||
@@ -202,9 +231,8 @@ def council_publications(request: Request, employee: str = "", title: str = "",
|
||||
@router.post("/council/publications")
|
||||
def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if username != settings.admin_username:
|
||||
raise HTTPException(401, "Для сохранения войдите в админку")
|
||||
actor = require_user(request, settings, db, lock=True)
|
||||
username = actor.username
|
||||
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
|
||||
if request.headers.get("X-Council-Edit") != "1":
|
||||
raise HTTPException(403, "Недопустимый запрос на изменение")
|
||||
@@ -244,6 +272,22 @@ def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
else:
|
||||
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
|
||||
changes = {}
|
||||
for field, before, after in (("title", article["title"], change.title),
|
||||
("journal", " / ".join(article["journals"]), change.journal),
|
||||
("quartile", article["edit_quartile"], change.quartile)):
|
||||
if before != after:
|
||||
changes[field] = {"before": before, "after": after}
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
before = {"manual": article["manual_specialties"], "codes": article["edit_specialty_codes"] if article["manual_specialties"] else None}
|
||||
after = {"manual": change.specialty_codes is not None,
|
||||
"codes": sorted(set(change.specialty_codes or [])) if change.specialty_codes is not None else None}
|
||||
if before != after:
|
||||
changes["specialty_codes"] = {"before": before, "after": after}
|
||||
if changes:
|
||||
candidate = next(c for c in result["candidates"] if c["employee_id"] == change.employee_id)
|
||||
db.add(UserAction(user_id=actor.id, action="card_edit", employee_id=change.employee_id,
|
||||
employee_name=candidate["name"], publication_year=article["year"], publication_key=change.edit_key, article_title=change.title, changes=changes))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
@@ -259,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)
|
||||
@@ -277,7 +321,7 @@ def employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
@@ -308,7 +352,7 @@ def refresh_employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
@@ -319,7 +363,7 @@ def refresh_employee_detail(
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||
items = [run_payload(run) for run in run_models]
|
||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||
@@ -333,7 +377,7 @@ def run_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
@@ -347,7 +391,7 @@ def trigger_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
@@ -366,7 +410,7 @@ def crawl_now(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -385,7 +429,7 @@ def refresh_dismissed(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -400,11 +444,16 @@ def refresh_dismissed(
|
||||
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
|
||||
payload = {
|
||||
"request": request,
|
||||
"current_user": getattr(request.state, "user", None),
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
response = templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
if getattr(request.state, "user", None) is not None or template == "login.html":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
|
||||
+9
-9
@@ -7,12 +7,12 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlRun, Employee
|
||||
from app.security import require_admin
|
||||
from app.security import require_admin, require_user, check_csrf
|
||||
from app.services.admin_data import employee_display_payload, list_employees_page, run_detail_payload, run_payload, stats_payload
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -36,7 +36,7 @@ def list_employees(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
@@ -59,7 +59,7 @@ def get_employee(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return {"error": "not_found"}
|
||||
@@ -73,7 +73,7 @@ def list_crawl_runs(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all()
|
||||
return {"items": [run_payload(run) for run in runs]}
|
||||
|
||||
@@ -84,7 +84,7 @@ def latest_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
return {"running": run_payload(running), "latest": run_payload(latest)}
|
||||
@@ -97,7 +97,7 @@ def get_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return {"error": "not_found"}
|
||||
@@ -111,7 +111,7 @@ def trigger_crawl(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
if running:
|
||||
return {"status": "already_running", "run": run_payload(running)}
|
||||
@@ -130,7 +130,7 @@ def stats(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return stats_payload(db)
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -26,6 +26,9 @@ def init_db() -> None:
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_runtime_schema()
|
||||
from app.security import ensure_bootstrap_admin
|
||||
with SessionLocal() as db:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
|
||||
|
||||
def _ensure_runtime_schema() -> None:
|
||||
|
||||
@@ -3,12 +3,14 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
from app.users import router as users_router
|
||||
from app.db import init_db
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy import CheckConstraint, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -254,3 +254,42 @@ class CouncilCorrections(Base):
|
||||
data: Mapped[dict] = mapped_column(json_type, nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
class SystemUser(Base):
|
||||
__tablename__ = "system_users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
session_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
first_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class UserAction(Base):
|
||||
__tablename__ = "user_actions"
|
||||
__table_args__ = (Index("ix_user_actions_user_created", "user_id", "created_at"),
|
||||
Index("ix_user_actions_target_created", "target_user_id", "created_at"))
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("system_users.id"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("system_users.id"))
|
||||
employee_id: Mapped[int | None] = mapped_column(Integer)
|
||||
employee_name: Mapped[str | None] = mapped_column(Text)
|
||||
publication_year: Mapped[int | None] = mapped_column(Integer)
|
||||
publication_key: Mapped[str | None] = mapped_column(String(64))
|
||||
article_title: Mapped[str | None] = mapped_column(Text)
|
||||
changes: Mapped[dict] = mapped_column(json_type, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
+181
-25
@@ -2,45 +2,201 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
SESSION_SECONDS = 12 * 60 * 60
|
||||
PASSWORD_ITERATIONS = 600_000
|
||||
|
||||
|
||||
def verify_admin(username: str, password: str, settings: Settings) -> bool:
|
||||
return hmac.compare_digest(username, settings.admin_username) and hmac.compare_digest(
|
||||
password, settings.admin_password
|
||||
)
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": username, "iat": int(time.time())}, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
signature = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}.{signature}"
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt, expected = encoded.split('$')
|
||||
if scheme != 'pbkdf2_sha256' or int(iterations) != PASSWORD_ITERATIONS:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), PASSWORD_ITERATIONS)
|
||||
return hmac.compare_digest(actual, bytes.fromhex(expected))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY_HASH = hash_password('dummy password for unknown account timing')
|
||||
|
||||
|
||||
def temporary_password() -> str:
|
||||
groups = ('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnpqrstuvwxyz', '23456789', '!@#$%+-_')
|
||||
characters = [secrets.choice(group) for group in groups]
|
||||
characters.extend(secrets.choice(''.join(groups)) for _ in range(4))
|
||||
secrets.SystemRandom().shuffle(characters)
|
||||
return ''.join(characters)
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(db: Session, settings: Settings) -> SystemUser:
|
||||
username = settings.admin_username.strip().casefold()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
if user is not None:
|
||||
return user
|
||||
user = SystemUser(username=username, role='admin', password_hash=hash_password(settings.admin_password),
|
||||
must_change_password=False)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
return user
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings, version: int = 0) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({'sub': username, 'iat': int(time.time()),
|
||||
'v': version, 'csrf': secrets.token_urlsafe(24)}, separators=(',', ':')).encode()).decode().rstrip('=')
|
||||
signature = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return f'{payload}.{signature}'
|
||||
|
||||
|
||||
def _session_data(token: str | None, settings: Settings) -> dict | None:
|
||||
if not token or len(token) > 2048 or '.' not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit('.', 1)
|
||||
expected = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode((payload + '=' * (-len(payload) % 4)).encode()))
|
||||
now = int(time.time())
|
||||
if (not isinstance(data, dict) or not isinstance(data.get('sub'), str)
|
||||
or type(data.get('iat')) is not int or not now - SESSION_SECONDS <= data['iat'] <= now + 60
|
||||
or type(data.get('v')) is not int or not isinstance(data.get('csrf'), str)):
|
||||
return None
|
||||
return data
|
||||
except (ValueError, TypeError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_session(token: str | None, settings: Settings) -> str | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit(".", 1)
|
||||
expected = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
data = _session_data(token, settings)
|
||||
return data['sub'] if data else None
|
||||
|
||||
|
||||
def session_csrf(request: Request, settings: Settings) -> str:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
return data['csrf'] if data else ''
|
||||
|
||||
|
||||
async def check_csrf(request: Request, settings: Settings = Depends(get_settings)):
|
||||
if request.method != 'POST':
|
||||
return
|
||||
origin = request.headers.get('origin')
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
raise HTTPException(403, 'Недопустимый источник запроса')
|
||||
if parsed.scheme not in {'http', 'https'} or parsed.netloc.casefold() != request.url.netloc.casefold():
|
||||
raise HTTPException(403, 'Запрос должен исходить со страницы этой системы')
|
||||
# JSON with a custom header cannot be submitted by a cross-site HTML form; CORS stays disabled.
|
||||
if request.headers.get('X-Council-Edit') == '1' and request.headers.get('content-type', '').startswith('application/json'):
|
||||
return
|
||||
expected = (request.cookies.get(LOGIN_CSRF_COOKIE, '') if request.url.path == '/admin/login'
|
||||
else session_csrf(request, settings))
|
||||
if request.url.path == '/admin/logout' and not expected:
|
||||
return
|
||||
supplied = request.headers.get('X-CSRF-Token', '')
|
||||
if not supplied:
|
||||
form = await request.form()
|
||||
supplied = form.get('csrf_token', '')
|
||||
if not expected or not isinstance(supplied, str) or not hmac.compare_digest(supplied.encode(), expected.encode()):
|
||||
raise HTTPException(403, 'Обновите страницу и повторите действие')
|
||||
|
||||
|
||||
def optional_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser | None:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not data:
|
||||
return None
|
||||
query = select(SystemUser).where(SystemUser.username == data['sub'].casefold())
|
||||
if lock:
|
||||
query = query.with_for_update().execution_options(populate_existing=True)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
except Exception:
|
||||
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, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
if user is None or not user.is_active or user.session_version != data['v']:
|
||||
return None
|
||||
return data.get("sub")
|
||||
request.state.user = user
|
||||
request.state.csrf_token = data['csrf']
|
||||
if user.must_change_password and not allow_password_change:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(403, 'Сначала смените временный пароль в личном кабинете')
|
||||
raise HTTPException(303, headers={'Location': '/admin/account'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings) -> str:
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
|
||||
return username
|
||||
def require_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser:
|
||||
user = optional_user(request, settings, db, allow_password_change=allow_password_change, lock=lock)
|
||||
if user is None:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(401, 'Для этого действия войдите в систему')
|
||||
raise HTTPException(303, headers={'Location': '/admin/login'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
|
||||
user = require_user(request, settings, db)
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(403, 'Это действие доступно только администратору')
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str, settings: Settings) -> SystemUser | None:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username.strip().casefold()).with_for_update())
|
||||
valid = verify_password(password, user.password_hash if user else _DUMMY_HASH)
|
||||
if user is None:
|
||||
return None
|
||||
now = utcnow()
|
||||
locked_until = user.locked_until
|
||||
if locked_until and locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if not user.is_active or (locked_until and locked_until > now):
|
||||
return None
|
||||
if not valid:
|
||||
if locked_until:
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts = 0
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= 5:
|
||||
user.locked_until = now + timedelta(minutes=15)
|
||||
db.commit()
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
if user.first_login_at is None:
|
||||
user.first_login_at = now
|
||||
user.must_change_password = True
|
||||
user.last_login_at = now
|
||||
return user
|
||||
+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)
|
||||
|
||||
@@ -946,3 +946,36 @@
|
||||
.council__tooltip[hidden] { display: none; }
|
||||
.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } }
|
||||
|
||||
.users__table-wrap { overflow-x: auto; margin-block: 20px; }
|
||||
.users__table-wrap .table { min-width: 720px; }
|
||||
.users__table-wrap .table__cell { vertical-align: top; overflow-wrap: anywhere; max-width: 420px; }
|
||||
.users__controls { display: grid; gap: 8px; min-width: 160px; }
|
||||
.users__controls label { display: grid; gap: 4px; }
|
||||
.users__create { margin-block: 24px; }
|
||||
.users__create summary { cursor: pointer; padding-block: 10px; font-weight: 600; }
|
||||
.users__create-form, .users__password-form { max-width: 520px; }
|
||||
.users__hint { color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.users__error { color: #991b1b; line-height: 1.5; }
|
||||
.users__temporary { padding: 16px; background: #e7f0eb; border: 1px solid #8fa8a5; border-radius: 8px; }
|
||||
.users__temporary .form__input { max-width: 240px; font-family: monospace; font-size: 20px; }
|
||||
.users__account-meta { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.users__account-meta dt { color: #4c5b64; font-size: 14px; }
|
||||
.users__account-meta dd { margin: 4px 0 0; }
|
||||
.users__change { margin-top: 6px; }
|
||||
@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();
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const required = document.querySelector('[data-required-password]');
|
||||
if (required) {
|
||||
const form = document.querySelector('[data-password-form]');
|
||||
required.querySelector('[data-password-dialog-body]').append(form);
|
||||
required.addEventListener('cancel', event => event.preventDefault());
|
||||
required.showModal();
|
||||
(form.querySelector('[aria-invalid="true"]') || form.querySelector('input[type="password"]')).focus();
|
||||
}
|
||||
document.querySelectorAll('[data-password-reset]').forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!window.confirm('Сбросить пароль? Текущие сессии пользователя завершатся. При следующем входе потребуется сменить временный пароль.')) event.preventDefault();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Личный кабинет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Личный кабинет</h2>
|
||||
<p>{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}</p>
|
||||
<dl class="users__account-meta"><div><dt>Первый вход</dt><dd>{{ account_user.first_login_at|admin_datetime }}</dd></div><div><dt>Последний вход</dt><dd>{{ account_user.last_login_at|admin_datetime }}</dd></div><div><dt>Пароль изменён</dt><dd>{{ account_user.password_changed_at|admin_datetime }}</dd></div></dl>
|
||||
{% if password_changed %}<p role="status">Пароль изменён. Другие сессии завершены.</p>{% endif %}
|
||||
</section>
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Сменить пароль</h2>
|
||||
<p class="users__hint">Смена пароля или выход из системы завершает все ваши сессии.</p>
|
||||
<form class="form users__password-form" method="post" action="/admin/account/password" data-password-form>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if error %}<p class="users__error" id="password-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<label class="form__label">Текущий пароль<input class="form__input" name="current_password" type="password" required maxlength="1024" autocomplete="current-password"{% if error_field == 'current_password' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<label class="form__label">Новый пароль<input class="form__input" name="new_password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" aria-describedby="password-hint{% if error_field == 'new_password' %} password-error{% endif %}"{% if error_field == 'new_password' %} aria-invalid="true"{% endif %}></label>
|
||||
<label class="form__label">Повторите новый пароль<input class="form__input" name="confirmation" type="password" required minlength="15" maxlength="128" autocomplete="new-password"{% if error_field == 'confirmation' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<p class="users__hint" id="password-hint">От 15 до 128 символов. Можно использовать фразу. Новый пароль должен отличаться от текущего.</p>
|
||||
<button class="button" type="submit">Сохранить новый пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">Последние действия</h2>{% include 'user_activity.html' %}</section>
|
||||
{% if account_user.must_change_password %}
|
||||
<dialog class="columns-modal" data-required-password aria-labelledby="required-password-title" aria-describedby="required-password-note">
|
||||
<div class="columns-modal__panel">
|
||||
<h2 class="columns-modal__title" id="required-password-title">Смените временный пароль</h2>
|
||||
<p id="required-password-note">Прежде чем пользоваться учётной записью, задайте свой пароль. Закрыть это окно без смены пароля нельзя.</p>
|
||||
<div data-password-dialog-body></div>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Выйти из системы</button></form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+11
-3
@@ -11,16 +11,20 @@
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/admin' }}">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
{% if public_page %}
|
||||
{% if public_page and not current_user %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в админку</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в систему</a>
|
||||
{% else %}
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
|
||||
<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">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -31,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>
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
</form>
|
||||
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
|
||||
{% else %}
|
||||
<p>Для редактирования статей и квартилей войдите в админку.</p>
|
||||
<a class="button" href="/admin/login">Войти в админку</a>
|
||||
<p>Для редактирования статей и квартилей войдите в систему.</p>
|
||||
<a class="button" href="/admin/login">Войти в систему</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
|
||||
{% else %}
|
||||
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
|
||||
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в админку</a>.</p>{% endif %}
|
||||
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в систему</a>.</p>{% endif %}
|
||||
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
|
||||
{% for article in articles %}
|
||||
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
</form>{% endif %}
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/dismissed/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||
</div>
|
||||
<div class="employee-card__actions">
|
||||
<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--compact" type="submit">Обновить данные</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -9,13 +9,15 @@
|
||||
<body class="admin">
|
||||
<main class="admin__main">
|
||||
<section class="panel">
|
||||
<h1 class="panel__title">Вход в админку</h1>
|
||||
<h1 class="panel__title">Вход в систему</h1>
|
||||
{% if error %}<p>{{ error }}</p>{% endif %}
|
||||
<form class="form" method="post" action="/admin/login">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" required maxlength="64" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" required maxlength="1024" autocomplete="current-password"></label>
|
||||
<button class="button" type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Если забыли пароль, обратитесь к администратору системы.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<section class="panel">
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Запуски парсинга</h2>
|
||||
<form method="post" action="/admin/runs"><button class="button" type="submit">Запустить парсинг</button></form>
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/runs">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button" type="submit">Запустить парсинг</button></form>{% endif %}
|
||||
</div>
|
||||
{% set run = runs[0] if runs else none %}
|
||||
{% if run %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% macro change_value(field, value) %}
|
||||
{% if field == 'specialty_codes' %}{% if value is none or not value.manual %}по справочникам{% else %}{{ value.codes|join(', ') or 'связь исключена' }}{% endif %}
|
||||
{% elif field == 'role' %}{{ {'admin': 'Администратор', 'user': 'Пользователь'}.get(value, 'не задано') }}
|
||||
{% 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': 'Изменение роли или доступа', '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">
|
||||
<thead><tr><th class="table__head" scope="col">Когда</th><th class="table__head" scope="col">Кто</th><th class="table__head" scope="col">Действие</th><th class="table__head" scope="col">Что изменено</th></tr></thead>
|
||||
<tbody>{% for action in actions %}<tr class="table__row">
|
||||
<td class="table__cell">{{ action.created_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{{ user_names.get(action.user_id, '—') }}</td>
|
||||
<td class="table__cell">{{ action_labels.get(action.action, action.action) }}{% if action.target_user_id %}<br>{{ user_names.get(action.target_user_id, '') }}{% endif %}</td>
|
||||
<td class="table__cell">{% if action.employee_name %}<a class="admin__link" href="/admin/council?q={{ action.employee_name|urlencode }}">{{ action.employee_name }}</a><br>{{ action.article_title }}{% if action.publication_year %} · {{ action.publication_year }}{% endif %}{% endif %}
|
||||
{% for field, change in action.changes.items() %}<div class="users__change"><strong>{{ field_labels.get(field, field) }}:</strong> {{ change_value(field, change.before) }} → {{ change_value(field, change.after) }}</div>{% endfor %}
|
||||
{% if not action.changes and not action.employee_name %}—{% endif %}
|
||||
</td>
|
||||
</tr>{% else %}<tr><td class="table__cell" colspan="4">Действий пока нет. Здесь появятся входы, смена пароля и редактирование карточек.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if history_total > history_limit %}<nav class="directory__pagination" aria-label="История действий">
|
||||
{% if history_offset > 0 %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset - history_limit }}">Назад</a>{% endif %}
|
||||
<span>{{ history_offset // history_limit + 1 }} / {{ (history_total + history_limit - 1) // history_limit }}</span>
|
||||
{% if history_offset + history_limit < history_total %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset + history_limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Управление пользователями</h2>
|
||||
<p>Пользователь может просматривать данные и редактировать карточки. Запуск парсинга и управление пользователями доступны администратору.</p>
|
||||
{% if error %}<p class="users__error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if temporary %}<div class="users__temporary" role="status">
|
||||
<p>Временный пароль для <strong>{{ temporary.username }}</strong>. Показывается только сейчас — передайте его пользователю. При входе потребуется смена пароля.</p>
|
||||
<label class="form__label">Временный пароль<input class="form__input" type="text" value="{{ temporary.password }}" readonly autocomplete="off" data-temporary-password></label>
|
||||
</div>{% endif %}
|
||||
<details class="users__create" open>
|
||||
<summary>Создать пользователя</summary>
|
||||
<form class="form users__create-form" method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин<input class="form__input" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" autocomplete="off"></label>
|
||||
<label class="form__label">Имя<input class="form__input" name="full_name" maxlength="255" autocomplete="off"></label>
|
||||
<label class="form__label">Роль<select class="form__input" name="role"><option value="user">Пользователь</option><option value="admin">Администратор</option></select></label>
|
||||
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
|
||||
<button class="button" type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="users__table-wrap"><table class="table">
|
||||
<thead><tr>{% for title in ['Пользователь', 'Первый вход', 'Последний вход', 'Пароль изменён', 'Временный пароль выдан', 'Роль и доступ', 'Действия'] %}<th class="table__head" scope="col">{{ title }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>{% for user in users %}<tr class="table__row">
|
||||
<td class="table__cell"><a class="admin__link" href="?user_id={{ user.id }}">{{ user.username }}</a>{% if user.full_name %}<br>{{ user.full_name }}{% endif %}{% if user.must_change_password %}<br>Требуется смена пароля{% endif %}{% if user.locked_until %}<br>Ограничение входа до {{ user.locked_until|admin_datetime }}{% endif %}</td>
|
||||
<td class="table__cell">{{ user.first_login_at|admin_datetime if user.first_login_at else 'Ещё не входил' }}</td><td class="table__cell">{{ user.last_login_at|admin_datetime if user.last_login_at else 'Ещё не входил' }}</td>
|
||||
<td class="table__cell">{{ user.password_changed_at|admin_datetime }}</td><td class="table__cell">{{ user.password_reset_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{% if user.id == current_user.id %}Администратор · ваша учётная запись{% else %}
|
||||
<form class="users__controls" method="post" action="/admin/users/{{ user.id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Роль {{ user.username }}<select class="form__input" name="role"><option value="user"{% if user.role == 'user' %} selected{% endif %}>Пользователь</option><option value="admin"{% if user.role == 'admin' %} selected{% endif %}>Администратор</option></select></label>
|
||||
<label>Доступ {{ user.username }}<select class="form__input" name="is_active"><option value="true"{% if user.is_active %} selected{% endif %}>Включён</option><option value="false"{% if not user.is_active %} selected{% endif %}>Отключён</option></select></label>
|
||||
<button class="button button--secondary" type="submit">Сохранить доступ</button>
|
||||
</form>{% endif %}
|
||||
</td>
|
||||
<td class="table__cell">{% if user.id != current_user.id %}<form method="post" action="/admin/users/{{ user.id }}/reset-password" data-password-reset>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--secondary" type="submit">Сбросить пароль {{ user.username }}</button>
|
||||
</form>{% else %}<a class="admin__link" href="/admin/account">Сменить свой пароль</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">{% if selected_user %}Действия {{ selected_user.username }}{% else %}Последние действия пользователей{% endif %}</h2>
|
||||
{% if selected_user %}<a class="admin__link" href="/admin/users">Показать всех пользователей</a>{% endif %}
|
||||
{% include 'user_activity.html' %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin import _render
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
from app.security import (SESSION_COOKIE, SESSION_SECONDS, check_csrf, hash_password,
|
||||
require_admin, require_user, sign_session, temporary_password, verify_password)
|
||||
|
||||
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
def _history(db, user_id=None, offset=0):
|
||||
query = select(UserAction)
|
||||
if user_id is not None:
|
||||
query = query.where(or_(UserAction.user_id == user_id, UserAction.target_user_id == user_id))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
offset = min(max(0, offset), ((total - 1) // 30) * 30) if total else 0
|
||||
items = db.scalars(query.order_by(desc(UserAction.created_at), desc(UserAction.id)).offset(offset).limit(30)).all()
|
||||
return {'actions': items, 'history_total': total, 'history_offset': offset, 'history_limit': 30}
|
||||
|
||||
|
||||
def _users_page(request, db, *, user_id=None, offset=0, error=None, temporary=None, status=200):
|
||||
users = db.scalars(select(SystemUser).order_by(SystemUser.username)).all()
|
||||
selected = next((u for u in users if u.id == user_id), None)
|
||||
if user_id is not None and selected is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
response = _render(request, 'users.html', {'users': users, 'selected_user': selected,
|
||||
'user_names': {u.id: u.username for u in users}, 'error': error, 'temporary': temporary,
|
||||
**_history(db, user_id, offset)}, status_code=status)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
def _lock_admin(request, db, settings):
|
||||
# Serialize role/availability changes so concurrent admins cannot disable each other.
|
||||
db.scalars(select(SystemUser).order_by(SystemUser.id).with_for_update()
|
||||
.execution_options(populate_existing=True)).all()
|
||||
return require_admin(request, settings, db)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def users(request: Request, user_id: int | None = None, offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings, db)
|
||||
return _users_page(request, db, user_id=user_id, offset=offset)
|
||||
|
||||
|
||||
@router.post('/users')
|
||||
def create_user(request: Request, username: str = Form(..., max_length=64), full_name: str = Form('', max_length=255),
|
||||
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
username = username.strip().casefold()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in {'admin', 'user'}:
|
||||
return _users_page(request, db, error='Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Выберите одну из двух ролей', status=422)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
password = temporary_password()
|
||||
user = SystemUser(username=username, full_name=full_name.strip(), role=role,
|
||||
password_hash=hash_password(password), password_reset_at=utcnow())
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(UserAction(user_id=actor.id, action='user_create', target_user_id=user.id,
|
||||
changes={'role': {'before': None, 'after': role}}))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Этот логин уже занят. Выберите другой', status=409)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось создать пользователя. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}/reset-password')
|
||||
def reset_password(user_id: int, request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Свой пароль измените в личном кабинете')
|
||||
password = temporary_password()
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = True
|
||||
user.password_reset_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=actor.id, action='password_reset', target_user_id=user.id))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось сбросить пароль. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}')
|
||||
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
if role not in {'admin', 'user'} or is_active not in {'true', 'false'}:
|
||||
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Собственную роль и доступ изменить нельзя')
|
||||
changes = {}
|
||||
for field, value in (('role', role), ('is_active', is_active == 'true')):
|
||||
old = getattr(user, field)
|
||||
if old != value:
|
||||
changes[field] = {'before': old, 'after': value}
|
||||
setattr(user, field, value)
|
||||
if changes:
|
||||
user.session_version += 1
|
||||
db.add(UserAction(user_id=actor.id, action='user_update', target_user_id=user.id, changes=changes))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось изменить доступ. Попробуйте ещё раз', status=503)
|
||||
return RedirectResponse('/admin/users', status_code=303)
|
||||
|
||||
|
||||
@router.get('/account')
|
||||
def account(request: Request, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True)
|
||||
return _account_page(request, db, user, offset=offset)
|
||||
|
||||
|
||||
def _account_page(request, db, user, *, offset=0, error=None, error_field=None, status=200):
|
||||
history = _history(db, user.id, offset)
|
||||
ids = {a.user_id for a in history['actions']} | {a.target_user_id for a in history['actions'] if a.target_user_id is not None}
|
||||
names = dict(db.execute(select(SystemUser.id, SystemUser.username).where(SystemUser.id.in_(ids))).all()) if ids else {}
|
||||
return _render(request, 'account.html', {'account_user': user, 'user_names': names, 'error': error,
|
||||
'error_field': error_field, 'password_changed': request.query_params.get('password_changed') == '1', **history}, status_code=status)
|
||||
|
||||
|
||||
@router.post('/account/password')
|
||||
def change_password(request: Request, current_password: str = Form(..., max_length=1024),
|
||||
new_password: str = Form(..., max_length=128), confirmation: str = Form(..., max_length=128),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return _account_page(request, db, user, error='Текущий пароль неверен. Введите пароль, с которым вошли', error_field='current_password', status=422)
|
||||
if len(new_password) < 15 or new_password == current_password:
|
||||
return _account_page(request, db, user, error='Выберите новый пароль от 15 до 128 символов, отличающийся от текущего. Можно использовать фразу', error_field='new_password', status=422)
|
||||
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()
|
||||
user.session_version += 1
|
||||
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()
|
||||
return _account_page(request, db, user, error='Не удалось сохранить пароль. Попробуйте ещё раз', status=503)
|
||||
response = RedirectResponse('/admin/account?password_changed=1', status_code=303)
|
||||
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.1.1"
|
||||
FRONTEND_VERSION = "1.1.1"
|
||||
BACKEND_VERSION = "1.1.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 форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS system_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user' CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
first_login_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
password_reset_at TIMESTAMPTZ,
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES system_users(id),
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id INTEGER REFERENCES system_users(id),
|
||||
employee_id INTEGER,
|
||||
employee_name TEXT,
|
||||
publication_year INTEGER,
|
||||
publication_key VARCHAR(64),
|
||||
article_title TEXT,
|
||||
changes JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_user_created ON user_actions(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_target_created ON user_actions(target_user_id, created_at);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.1.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,8 +92,23 @@ 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()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 26–28 из 28")
|
||||
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
|
||||
|
||||
@@ -73,7 +73,7 @@ def main():
|
||||
expect(pencil).to_be_visible()
|
||||
pencil.click()
|
||||
expect(page.get_by_role('dialog')).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Войти в админку', exact=True)).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Войти в систему', exact=True)).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(pencil).to_be_focused()
|
||||
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Offline users/roles/password/audit browser scenario; synthetic SQLite data only."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uvicorn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication, utcnow
|
||||
from app.security import ensure_bootstrap_admin
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='Browser initial admin', session_secret='browser-users-secret')
|
||||
with factory() as db:
|
||||
admin = ensure_bootstrap_admin(db, settings)
|
||||
admin.first_login_at = utcnow()
|
||||
employee = Employee(profile_key='browser-users', full_name='Сотрудник для проверки', canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=employee.id, title='Статья для проверки', year=2024, publication_type='ARTICLE',
|
||||
source_hash='browser-users', raw_data={'journal': 'Журнал для проверки'}))
|
||||
db.commit()
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
output = Path('.impeccable/review/users')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
listener = socket.socket()
|
||||
listener.bind(('127.0.0.1', 0))
|
||||
server = uvicorn.Server(uvicorn.Config(app, lifespan='off', access_log=False, log_level='critical'))
|
||||
thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(1000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(.01)
|
||||
assert server.started
|
||||
base_url = f'http://127.0.0.1:{listener.getsockname()[1]}'
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width':1440,'height':1000})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_be_visible()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
creation = page.locator('form[action="/admin/users"]')
|
||||
creation.get_by_label('Логин', exact=True).fill('reader')
|
||||
creation.get_by_label('Имя', exact=True).fill('Тестовый читатель')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
temporary = page.locator('[data-temporary-password]')
|
||||
expect(temporary).to_be_visible()
|
||||
password = temporary.input_value()
|
||||
page.screenshot(path=str(output/'users-desktop.png'), mask=[temporary], full_page=True)
|
||||
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')
|
||||
page.get_by_label('Пароль', exact=True).fill(password)
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
modal = page.get_by_role('dialog', name='Смените временный пароль')
|
||||
expect(modal).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.get_by_label('Текущий пароль', exact=True)).to_be_focused()
|
||||
assert modal.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
page.screenshot(path=str(output/'required-mobile.png'))
|
||||
page.set_viewport_size({'width':1440,'height':1000})
|
||||
page.screenshot(path=str(output/'required-desktop.png'))
|
||||
modal.get_by_label('Текущий пароль', exact=True).fill(password)
|
||||
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='Как пользоваться сайтом')
|
||||
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()
|
||||
expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Диссертационный совет', exact=True).click()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
article = page.locator('[data-council-article]')
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
page.keyboard.press('Enter')
|
||||
editor = page.get_by_role('dialog', name='Редактирование статьи')
|
||||
editor.get_by_label('Название статьи', exact=True).fill('Статья изменена читателем')
|
||||
editor.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Статья изменена читателем')
|
||||
page.get_by_role('link', name='Личный кабинет', exact=True).click()
|
||||
expect(page.get_by_text('Редактирование статьи', exact=True)).to_be_visible()
|
||||
expect(page.get_by_text('Статья изменена читателем', exact=False)).to_be_visible()
|
||||
page.screenshot(path=str(output/'account-desktop.png'), full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'account-mobile.png'), full_page=True)
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Сбросить пароль reader', exact=True).click()
|
||||
expect(page.locator('[data-temporary-password]')).to_be_visible()
|
||||
page.locator('[data-temporary-password]').evaluate("input => input.value = '[скрыто]'")
|
||||
expect(page.locator('.table__cell').filter(has_text='Требуется смена пароля')).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
listener.close()
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
print(f'Users browser scenario passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+4
-3
@@ -11,14 +11,14 @@ from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.security import SESSION_COOKIE, sign_session, _session_data
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "1.1.1"
|
||||
assert response.json()["backend_version"] == "1.3.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
@@ -127,7 +127,8 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)["csrf"]
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", headers={"X-CSRF-Token": csrf}, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
|
||||
@@ -223,8 +223,8 @@ 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.1.1 · Frontend 1.1.1" in response.text
|
||||
assert "Вход в админку" 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
|
||||
finally:
|
||||
@@ -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()
|
||||
@@ -0,0 +1,397 @@
|
||||
import re
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.db import Base
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import SystemUser, UserAction, Employee, EmployeePublication, CouncilCorrections, utcnow
|
||||
from app.security import (SESSION_COOKIE, _session_data, ensure_bootstrap_admin, hash_password,
|
||||
sign_session, temporary_password, verify_password)
|
||||
from app.services import council
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def accounts(db_session, monkeypatch):
|
||||
test_engine = None
|
||||
native_url = os.getenv('USERS_TEST_DATABASE_URL')
|
||||
if native_url:
|
||||
assert make_url(native_url).database == 'miem_users_test', 'Use a disposable miem_users_test database only'
|
||||
test_engine = create_engine(native_url)
|
||||
Base.metadata.create_all(test_engine)
|
||||
db_session = sessionmaker(bind=test_engine)()
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='initial-admin-test', session_secret='users-test-secret')
|
||||
admin = ensure_bootstrap_admin(db_session, settings)
|
||||
user = SystemUser(username='reader', full_name='Тестовый пользователь', role='user',
|
||||
password_hash=hash_password('Initial user password'), must_change_password=False,
|
||||
first_login_at=utcnow())
|
||||
db_session.add(user)
|
||||
employee = Employee(profile_key='users:employee', canonical_url='https://www.hse.ru/staff/test', full_name='Тестовый сотрудник',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db_session.add(employee)
|
||||
db_session.flush()
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Исходная статья', year=2024,
|
||||
publication_type='ARTICLE', source_hash='users-publication', raw_data={'journal': 'Журнал'}))
|
||||
db_session.commit()
|
||||
monkeypatch.setattr(council, '_articles_by_author', lambda: {})
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
try:
|
||||
yield client, db_session, settings, admin, user, employee
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
if test_engine is not None:
|
||||
db_session.close()
|
||||
Base.metadata.drop_all(test_engine)
|
||||
test_engine.dispose()
|
||||
|
||||
|
||||
def act_as(client, user, settings):
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(user.username, settings, user.session_version))
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
return {'X-CSRF-Token': csrf}
|
||||
|
||||
|
||||
def login(client, username, password):
|
||||
page = client.get('/admin/login')
|
||||
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[name=csrf_token]')['value']
|
||||
return client.post('/admin/login', data={'username': username, 'password': password, 'csrf_token': csrf}, follow_redirects=False)
|
||||
|
||||
|
||||
def test_password_hash_and_eight_character_generator():
|
||||
for _ in range(100):
|
||||
password = temporary_password()
|
||||
assert len(password) == 8
|
||||
assert all(re.search(pattern, password) for pattern in (r'[A-Z]', r'[a-z]', r'[0-9]', r'[!@#$%+_\-]'))
|
||||
encoded = hash_password('Секретная фраза с пробелами')
|
||||
assert verify_password('Секретная фраза с пробелами', encoded)
|
||||
assert not verify_password('Другой пароль', encoded)
|
||||
assert not verify_password('anything', 'broken hash')
|
||||
assert hash_password('same password') != hash_password('same password')
|
||||
|
||||
|
||||
def test_create_user_first_login_forces_password_change(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
response = client.post('/admin/users', data={'username': 'new.reader', 'full_name': 'Новый пользователь', 'role': 'user'}, headers=headers)
|
||||
assert response.status_code == 200 and response.headers['cache-control'] == 'no-store'
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == 'new.reader'))
|
||||
assert verify_password(password, user.password_hash) and user.must_change_password
|
||||
assert password not in str(db.scalars(select(UserAction.changes)).all())
|
||||
client.cookies.clear()
|
||||
signed_in = login(client, user.username, password)
|
||||
assert signed_in.status_code == 303 and signed_in.headers['location'] == '/admin/account'
|
||||
db.refresh(user)
|
||||
assert user.first_login_at and user.last_login_at and user.password_changed_at is None
|
||||
first_login = user.first_login_at
|
||||
assert 'data-required-password' in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 403
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
csrf = _session_data(old_token, settings)['csrf']
|
||||
changed = client.post('/admin/account/password', data={'current_password': password,
|
||||
'new_password': 'A new personal passphrase', 'confirmation': 'A new personal passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False)
|
||||
assert changed.status_code == 303
|
||||
db.refresh(user)
|
||||
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()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['/admin/crawl-now', '/admin/runs', '/admin/dismissed/refresh', '/admin/employees/1/refresh', '/api/crawl-runs'])
|
||||
def test_regular_user_cannot_start_scraping(accounts, path):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
response = client.post(path, headers=act_as(client, user, settings), follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
assert 'Запустить парсинг' not in client.get('/admin').text
|
||||
assert 'Обновить данные' not in client.get('/admin/employees/1').text
|
||||
assert client.get('/api/stats').status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
|
||||
client, db, settings, admin, user, employee = accounts
|
||||
act_as(client, user, settings)
|
||||
candidate = council.council_candidates(db)['candidates'][0]
|
||||
article = candidate['articles'][0]
|
||||
data = {'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0,
|
||||
'title': 'Изменённая статья', 'journal': 'Новый журнал', 'quartile': 'Q2', 'specialty_codes': ['1.2.2']}
|
||||
response = client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'})
|
||||
assert response.status_code == 200
|
||||
action = db.scalar(select(UserAction).where(UserAction.action == 'card_edit'))
|
||||
assert action.user_id == user.id and action.employee_id == employee.id and action.publication_year == 2024
|
||||
assert action.changes['title'] == {'before': 'Исходная статья', 'after': 'Изменённая статья'}
|
||||
assert action.changes['quartile'] == {'before': '', 'after': 'Q2'}
|
||||
assert action.changes['specialty_codes']['after']['codes'] == ['1.2.2']
|
||||
db.add(UserAction(user_id=admin.id, action='card_edit', article_title='Чужое действие', changes={}))
|
||||
db.commit()
|
||||
profile = client.get(f'/admin/account?user_id={admin.id}')
|
||||
assert 'Изменённая статья' in profile.text and 'Исходная статья' in profile.text
|
||||
assert 'Чужое действие' not in profile.text
|
||||
assert client.get(f'/admin/users?user_id={admin.id}').status_code == 403
|
||||
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 409
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
|
||||
|
||||
|
||||
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
user.password_changed_at = utcnow()
|
||||
db.commit()
|
||||
previous_changed = user.password_changed_at
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
response = client.post(f'/admin/users/{user.id}/reset-password', headers=act_as(client, admin, settings))
|
||||
assert response.status_code == 200
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
db.refresh(user)
|
||||
assert user.must_change_password and user.password_reset_at and verify_password(password, user.password_hash)
|
||||
assert user.password_changed_at == previous_changed
|
||||
assert password not in client.get('/admin/users').text
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
assert login(client, user.username, password).headers['location'] == '/admin/account'
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_reset')).target_user_id == user.id
|
||||
|
||||
|
||||
def test_role_changes_disable_and_protection_against_locking_out_self(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{admin.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{admin.id}/reset-password', headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'admin', 'is_active': 'true'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.role == 'admin'
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/admin/users', follow_redirects=False).status_code == 303
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
client.cookies.clear()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
assert not user.is_active
|
||||
|
||||
|
||||
def test_csrf_duplicate_user_validation_and_login_throttling(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'unprotected'}).status_code == 403
|
||||
assert client.post('/admin/account/password', data={'current_password': 'anything'}).status_code == 403
|
||||
assert client.post('/admin/login', data={'username': user.username, 'password': 'anything'}).status_code == 403
|
||||
for username, role, code in [('reader', 'user', 409), ('bad login', 'user', 422), ('newuser', 'superuser', 422)]:
|
||||
assert client.post('/admin/users', data={'username': username, 'role': role}, headers=headers).status_code == code
|
||||
client.cookies.clear()
|
||||
for _ in range(5):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.locked_until and user.failed_login_attempts == 5
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
for attempt in range(1, 6):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == attempt
|
||||
assert bool(user.locked_until) == (attempt == 5)
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 5
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 0 and user.locked_until is None
|
||||
|
||||
|
||||
def test_bootstrap_admin_first_login_and_changed_password_no_env_fallback(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
assert login(client, admin.username, 'initial-admin-test').headers['location'] == '/admin/account'
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
assert client.post('/admin/account/password', data={'current_password': 'initial-admin-test',
|
||||
'new_password': 'New administrator passphrase', 'confirmation': 'New administrator passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False).status_code == 303
|
||||
client.cookies.clear()
|
||||
assert login(client, admin.username, 'initial-admin-test').status_code == 401
|
||||
assert login(client, admin.username, 'New administrator passphrase').status_code == 303
|
||||
|
||||
|
||||
def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
|
||||
import time
|
||||
from app import security
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'https://evil.example'}).status_code == 403
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'http://['}).status_code == 403
|
||||
assert db.scalar(select(SystemUser).where(SystemUser.username == 'attacker')) is None
|
||||
token = sign_session(user.username, settings, user.session_version)
|
||||
now = int(time.time())
|
||||
monkeypatch.setattr(security.time, 'time', lambda: now + security.SESSION_SECONDS + 1)
|
||||
assert security.read_session(token, settings) is None
|
||||
|
||||
|
||||
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
|
||||
client, db, settings, _, user, employee = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
|
||||
('Initial user password', 'short', 'short'),
|
||||
('Initial user password', 'New valid personal password', 'Different valid password')]:
|
||||
response = client.post('/admin/account/password', headers=headers,
|
||||
data={'current_password': current, 'new_password': new, 'confirmation': confirmation})
|
||||
assert response.status_code == 422 and user.password_hash == before
|
||||
article = council.council_candidates(db)['candidates'][0]['articles'][0]
|
||||
response = client.post('/admin/council/publications', headers={'X-Council-Edit': '1'}, json={
|
||||
'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0, 'title': article['title'],
|
||||
'journal': article['edit_journal'], 'quartile': article['edit_quartile'], 'specialty_codes': None})
|
||||
assert response.status_code == 200
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == []
|
||||
|
||||
|
||||
def test_logout_revokes_replayed_cookie(accounts):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
assert client.post('/admin/logout', headers=headers, follow_redirects=False).status_code == 303
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
def unavailable():
|
||||
raise OperationalError('UPDATE', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/account/password', data={'current_password': 'Initial user password',
|
||||
'new_password': 'A different personal passphrase', 'confirmation': 'A different personal passphrase'}, headers=headers)
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
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):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
if db.get_bind().dialect.name != 'postgresql':
|
||||
pytest.skip('Requires the disposable PostgreSQL test database')
|
||||
second = SystemUser(username='admin-two', role='admin', password_hash=hash_password('Second administrator passphrase'),
|
||||
must_change_password=False, first_login_at=utcnow())
|
||||
db.add(second)
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind())
|
||||
def sessions():
|
||||
with factory() as session:
|
||||
yield session
|
||||
app.dependency_overrides[get_db] = sessions
|
||||
clients = [TestClient(app), TestClient(app)]
|
||||
actors = [admin, second]
|
||||
targets = [second.id, admin.id]
|
||||
def demote(index):
|
||||
headers = act_as(clients[index], actors[index], settings)
|
||||
response = clients[index].post(f'/admin/users/{targets[index]}',
|
||||
data={'role': 'user', 'is_active': 'true'}, headers=headers, follow_redirects=False)
|
||||
return response.status_code, response.headers.get('location')
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
responses = list(executor.map(demote, [0, 1]))
|
||||
assert sum(location == '/admin/users' for status, location in responses) == 1
|
||||
assert all(status in {303, 403} for status, location in responses)
|
||||
remaining = db.scalars(select(SystemUser).where(SystemUser.role == 'admin', SystemUser.is_active.is_(True))).all()
|
||||
assert len(remaining) == 1
|
||||
finally:
|
||||
for other in clients:
|
||||
other.close()
|
||||
Reference in new issue
Block a user