Compare commits

..
Author SHA1 Message Date
codex e29775b238 fix: normalize guide CSS line endings
CI / test (pull_request) Successful in 10m21s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 15:56:32 +03:00
codex 8e3ea6eeb2 docs: record guide handoff and Gitea connection blocker 2026-10-12 11:08:17 +03:00
codex 5128fac6e5 feat: add first-login usage guide
CI / test (pull_request) Successful in 10m22s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 11:05:04 +03:00
admin 378d7ee96e Merge pull request 'Ограничить отрасли оценки специальностями ВАК' (#70) from fix/council-specialty-branches into main
CI / test (push) Successful in 10m17s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #70
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-11 22:51:43 +00:00
codex 9d788b0663 fix: handle council specialty changes without JavaScript
CI / test (pull_request) Successful in 10m18s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 01:48:34 +03:00
codex 3359ea2f86 fix: restrict council assessment branches by VAK specialty
CI / test (pull_request) Successful in 10m18s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-12 01:39:44 +03:00
admin ccebaddfda Merge pull request 'Пользователи системы: роли, временные пароли, личный кабинет и история правок' (#69) from feature/user-management into main
CI / test (push) Successful in 10m12s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #69
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-10 18:11:56 +00:00
codex 96987e90c9 fix: restore login throttling after lock expiry
CI / test (pull_request) Successful in 10m10s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-10 20:26:28 +03:00
codex a82f63d6cb feat: add system users, password management and activity history
CI / test (pull_request) Successful in 10m8s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped
2026-10-10 18:55:05 +03:00
admin 03ac2f5ee8 Merge pull request 'Ускорение диссовета: подгрузка публикаций по сотруднику и понятные подсказки' (#68) from fix/council-loading into main
CI / test (push) Successful in 9m33s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #68
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-10 14:33:17 +00:00
42 changed files with 1779 additions and 123 deletions

No files matched your search

+20
View File
@@ -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.
+2
View File
@@ -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
+27 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+58 -12
View File
@@ -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": [
+3
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+33
View File
@@ -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; }
+8
View File
@@ -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]');
+69
View File
@@ -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();
})();
+15
View File
@@ -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();
});
});
})();
+35
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 }}. Нажмите, чтобы показать редактирование">
+6 -4
View File
@@ -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 %}
+3 -2
View File
@@ -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>
+12
View File
@@ -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 %}
+71
View File
@@ -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>
+24
View File
@@ -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 %}
+5 -3
View File
@@ -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">
+2 -1
View File
@@ -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 %}
+27
View File
@@ -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 }}&amp;{% 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 }}&amp;{% endif %}offset={{ history_offset + history_limit }}">Далее</a>{% endif %}
</nav>{% endif %}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+5
View File
@@ -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 форма передаёт предыдущую специальность. Если специальность изменена, а отправленная отрасль допустима только для предыдущего выбора, сервер выбирает первую допустимую отрасль новой специальности. Прямые несовместимые пары без контекста смены специальности отклоняются.
+32
View File
@@ -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
View File
@@ -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 = [
+5 -1
View File
@@ -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)))
+37 -1
View File
@@ -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
+1 -1
View File
@@ -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))
+204
View File
@@ -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
View File
@@ -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"
+5 -3
View File
@@ -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
+73 -7
View File
@@ -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()
+397
View File
@@ -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()