Compare commits

..
Author SHA1 Message Date
agent d1854ed5e2 Merge pull request 'Добавить роль пользователя диссовета и ограничить доступ к запускам' (#77) from feature/council-user-role into main
CI / test (push) Successful in 10m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-12 11:35:13 +00:00
codex c7940c690b docs: record Workers 2.0.1 release changes
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-12 09:58:18 +00:00
codex 3fb174899d fix: hide council editing controls from read-only viewers
CI / test (pull_request) Successful in 10m44s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-12 09:45:12 +00:00
codex 508aae4f8a feat: add council user role and restrict crawl runs
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m43s
2026-11-12 09:35:26 +00:00
agent 59dcacbaff Merge pull request 'Фильтр по повышению квалификации в справочнике сотрудников' (#76) from feature/employee-training-filter into main
CI / test (push) Successful in 10m36s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 17:01:29 +00:00
codex 43d609b353 docs: record training guidance and workspace alignment
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:29:34 +03:00
codex 9d618bda81 fix: align the workspace and explain training filters
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 19:17:58 +03:00
codex ae0fc32172 docs: record employee training filters in changelog
CI / test (pull_request) Successful in 10m38s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:47:59 +03:00
codex 83431d8f2a fix: distinguish courses from training provider names
CI / test (pull_request) Successful in 10m37s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 18:32:58 +03:00
codex 0aa472412e fix: recognize training courses listed in profile text
CI / test (pull_request) Canceled after 0s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
2026-11-11 18:30:14 +03:00
codex d10690e28b feat: filter employees by professional development courses
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m40s
2026-11-11 18:25:13 +03:00
agent 744abd0063 Merge pull request 'Контекстные подсказки для новых пользователей Workers' (#75) from feature/contextual-onboarding into main
CI / test (push) Successful in 10m36s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
2026-11-11 12:56:05 +00:00
37 changed files with 533 additions and 109 deletions

No files matched your search

+17
View File
@@ -1,5 +1,22 @@
# Changelog
## 2.0.1
- Роль «Пользователь диссовета» добавляет раздел «Диссовет» к меню обычного пользователя и разрешает редактировать публикации. Без входа доступен только просмотр диссовета, включая публикации и выгрузку XLSX.
- Страницы запусков, их история и прогресс в API доступны только администраторам.
- Поля фильтров справочника и диссовета выделяются при фокусе контуром толщиной 2 px без отступа.
- При обновлении существующей базы с 1.x применяется миграция 014. Она добавляет новую роль и сохраняет назначения существующих пользователей.
## 1.7.3
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
- Основная область занимает доступную ширину окна с теми же боковыми отступами, что и шапка. Пункты меню переносятся, когда не помещаются в строку.
## 1.7.2
- В справочнике сотрудников можно показать профили с курсами повышения квалификации или без них. Начальный год включается в период. Учитываются курсы из сохранённых записей профилей ВШЭ. В API доступны параметры `has_training` и `training_since_year`.
- Панель фильтров свёрнута по умолчанию. Применённые условия сохраняются при переходе между страницами.
## 1.6.2
- Обучение после первой смены пароля открывает нужные разделы Workers и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
+9 -3
View File
@@ -55,7 +55,13 @@ uvicorn app.main:app --reload
## Пользователи и личный кабинет
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
`/admin/users` доступен только администратору. Здесь можно создавать учётные записи, назначать роль, включать и отключать доступ, сбрасывать пароль и смотреть историю входов.
- **Пользователь** — обзор, сотрудники, личный кабинет и подсказки по справочнику.
- **Пользователь диссовета** — те же разделы плюс «Диссертационный совет», подсказки по нему и редактирование публикаций.
- **Администратор** — все разделы, включая «Запуски» и управление пользователями. Список, подробности и текущий прогресс запусков ограничены этой ролью в HTML и API. Остальные роли получают в `/api/stats` сводку по сотрудникам.
«Диссертационный совет», просмотр его публикаций и XLSX доступны публично. Открытая интерактивная документация API отключена. Для мониторинга доступен `/api/health`.
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
@@ -67,7 +73,7 @@ uvicorn app.main:app --reload
Все 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`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. При переходе с 1.x на 2.x сделайте штатную резервную копию существующей PostgreSQL-БД и примените `migrations/014_system_user_council_role.sql` владельцем таблицы. Миграция расширяет ограничение роли и сохраняет все существующие назначения. Для существующей SQLite-БД остановите приложение, сделайте копию файла БД и примените `sqlite3 -bail <файл БД> < migrations/sqlite/014_system_user_council_role.sql` из POSIX-оболочки. В новой БД ограничение создаётся вместе с таблицей. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
@@ -118,7 +124,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 или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
+26 -11
View File
@@ -1,6 +1,7 @@
from copy import deepcopy
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Query, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select, update
@@ -10,8 +11,8 @@ 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, UserAction
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user,
optional_user, sign_session, authenticate, check_csrf)
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, USER_ROLES, require_admin, require_user,
optional_user, sign_session, authenticate, can_use_council, check_csrf)
import secrets
from app.services.admin_data import (
employee_detail_payload,
@@ -30,12 +31,16 @@ from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
templates = Jinja2Templates(directory="app/templates")
templates.env.filters["admin_datetime"] = format_admin_datetime
templates.env.globals["user_roles"] = USER_ROLES
@router.get("", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_user(request, settings, db)
counts = stats_payload(db)
user = require_user(request, settings, db)
is_admin = user.role == "admin"
counts = stats_payload(db, include_runs=is_admin)
if not is_admin:
return _render(request, "dashboard.html", {"counts": counts})
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all()
@@ -114,6 +119,8 @@ def directory(
has_email: str | None = None,
has_academic_degree: str | None = None,
pps: bool = False,
has_training: Literal["", "true", "false"] = "",
training_since_year: str = Query(default="", pattern=r"^([1-9][0-9]{3})?$"),
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -136,6 +143,8 @@ def directory(
has_email=parsed_has_email,
has_academic_degree=parsed_has_academic_degree,
pps=pps,
has_training=has_training == "true" if has_training else None,
training_since_year=int(training_since_year) if training_since_year else None,
sort=sort,
direction=direction,
limit=limit,
@@ -157,6 +166,8 @@ def directory(
"has_email": has_email or "",
"has_academic_degree": has_academic_degree or "",
"pps": pps,
"has_training": has_training,
"training_since_year": training_since_year,
"sort": sort,
"direction": direction,
"limit": page["limit"],
@@ -178,7 +189,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
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": authenticated is not None, **result})
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": can_use_council(authenticated), **result})
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
@@ -195,7 +206,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": authenticated is not None, **result})
"can_edit": can_use_council(authenticated), **result})
class CouncilArticleEdit(BaseModel):
@@ -213,7 +224,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": optional_user(request, settings, db) is not None}
"can_edit": can_use_council(optional_user(request, settings, db))}
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"]}
@@ -235,6 +246,8 @@ def council_publications(request: Request, employee: str = "", title: str = "",
def edit_council_publication(request: Request, change: CouncilArticleEdit,
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
actor = require_user(request, settings, db, lock=True)
if not can_use_council(actor):
raise HTTPException(403, "Редактирование доступно пользователям диссовета и администраторам")
username = actor.username
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
if request.headers.get("X-Council-Edit") != "1":
@@ -371,7 +384,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_user(request, settings, db)
require_admin(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()
@@ -385,7 +398,7 @@ def run_detail(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_user(request, settings, db)
require_admin(request, settings, db)
run = db.get(CrawlRun, run_id)
if not run:
return RedirectResponse("/admin/runs", status_code=303)
@@ -450,9 +463,11 @@ def refresh_dismissed(
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
user = getattr(request.state, "user", None)
payload = {
"request": request,
"current_user": getattr(request.state, "user", None),
"current_user": user,
"council_access": can_use_council(user),
"csrf_token": getattr(request.state, "csrf_token", ""),
"backend_version": BACKEND_VERSION,
"frontend_version": FRONTEND_VERSION,
+10 -6
View File
@@ -1,6 +1,6 @@
from datetime import date
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
@@ -30,6 +30,8 @@ def list_employees(
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
has_training: bool | None = None,
training_since_year: int | None = Query(default=None, ge=1000, le=9999),
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -47,6 +49,8 @@ def list_employees(
has_email=has_email,
has_academic_degree=has_academic_degree,
pps=pps,
has_training=has_training,
training_since_year=training_since_year,
sort=sort,
direction=direction,
limit=limit,
@@ -75,7 +79,7 @@ def list_crawl_runs(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(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]}
@@ -86,7 +90,7 @@ def latest_crawl_run(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(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)}
@@ -99,7 +103,7 @@ def get_crawl_run(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
require_admin(request, settings, db)
run = db.get(CrawlRun, run_id)
if not run:
return {"error": "not_found"}
@@ -132,8 +136,8 @@ def stats(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> dict:
require_user(request, settings, db)
return stats_payload(db)
user = require_user(request, settings, db)
return stats_payload(db, include_runs=user.role == "admin")
def _employee_summary(employee: Employee) -> dict:
+1 -1
View File
@@ -7,7 +7,7 @@ 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 = FastAPI(title="MIEM Employees", version=BACKEND_VERSION, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(api_router)
app.include_router(users_router)
+1 -1
View File
@@ -258,7 +258,7 @@ class CouncilCorrections(Base):
class SystemUser(Base):
__tablename__ = "system_users"
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),)
__table_args__ = (CheckConstraint("role IN ('admin', 'user', 'council')", 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)
+11
View File
@@ -165,6 +165,17 @@ def require_user(request: Request, settings: Settings, db: Session, *, allow_pas
return user
USER_ROLES = {
'user': 'Пользователь',
'council': 'Пользователь диссовета',
'admin': 'Администратор',
}
def can_use_council(user: SystemUser | None) -> bool:
return user is not None and user.role in {'admin', 'council'}
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
user = require_user(request, settings, db)
if user.role != 'admin':
+69 -14
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import date, datetime, time
from math import ceil
from typing import Any
@@ -20,6 +21,44 @@ EMPLOYEE_SORTS = {
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
}
_TRAINING_LABEL = "повышение квалификации"
_TRAINING_KIND = re.compile(r",\s*повышение квалификации\s*(?:,|$)", re.IGNORECASE)
_TRAINING_MENTION = re.compile(
r"\b(?:повышение квалификации|(?:курс|программ)[а-яё]* (?:повышения|по повышению) квалификации)\b",
re.IGNORECASE,
)
_QUOTED_TEXT = re.compile(r'«[^»]*»|"[^"]*"')
_TRAINING_YEAR = re.compile(r"^(\d{4})(?!\d)|(?<!\d)(\d{4})\s*г(?:\.|од[ау]?\b)", re.IGNORECASE)
def _has_training(data: dict[str, Any], since_year: int | None) -> bool:
for value in _as_list(data.get("sections")):
section = _as_dict(value)
title = str(section.get("title") or "").casefold().strip().rstrip(":")
dedicated = title in {_TRAINING_LABEL, "курсы повышения квалификации"}
if not dedicated and _TRAINING_LABEL not in (part.strip() for part in title.split("/")):
continue
for value in _as_list(section.get("year_entries")):
entry = _as_dict(value)
year = entry.get("year")
text = str(entry.get("text") or "").strip()
if since_year is not None and (type(year) is not int or year < since_year):
continue
if text and text != str(year) and (dedicated or _TRAINING_KIND.search(_QUOTED_TEXT.sub("", text))):
return True
# Profiles may also list completed courses in manually entered paragraphs or lists.
for text in _clean_list(section.get("paragraphs")) + _clean_list(section.get("items")):
description = _QUOTED_TEXT.sub("", text).strip()
if not dedicated and not _TRAINING_MENTION.search(description):
continue
if since_year is None:
return True
match = _TRAINING_YEAR.search(description)
if match and int(match.group(1) or match.group(2)) >= since_year:
return True
return False
def employee_display_payload(employee: Employee) -> dict[str, Any]:
data = _as_dict(employee.current_data)
contacts = _as_dict(data.get("contacts"))
@@ -117,6 +156,8 @@ def list_employees_page(
has_email: bool | None = None,
has_academic_degree: bool | None = None,
pps: bool = False,
has_training: bool | None = None,
training_since_year: int | None = None,
sort: str = "full_name",
direction: str = "asc",
limit: int = 50,
@@ -135,15 +176,28 @@ def list_employees_page(
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
order = desc(sort_column) if direction == "desc" else sort_column
ordered_stmt = base_stmt.order_by(order)
if pps:
# Match decoded position strings consistently in PostgreSQL and SQLite.
employees = [employee for employee in db.scalars(ordered_stmt).all()
if any("кафедра" in position.casefold() or "департамент" in position.casefold()
for position in _clean_list(_as_dict(employee.current_data).get("positions")))]
total = len(employees)
if has_training is None and training_since_year is not None:
has_training = True
if pps or has_training is not None:
# Apply profile filters to decoded data before pagination on both databases.
employees = []
first_page = []
total = 0
for employee in db.scalars(ordered_stmt.execution_options(yield_per=100)):
data = _as_dict(employee.current_data)
if pps and not any("кафедра" in position.casefold() or "департамент" in position.casefold()
for position in _clean_list(data.get("positions"))):
continue
if has_training is not None and _has_training(data, training_since_year) != has_training:
continue
if offset and total < limit:
first_page.append(employee)
if offset <= total < offset + limit:
employees.append(employee)
total += 1
if offset >= total:
employees = first_page
offset = 0
employees = employees[offset:offset + limit]
else:
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
if offset >= total:
@@ -159,11 +213,9 @@ def list_employees_page(
}
def stats_payload(db: Session) -> dict[str, Any]:
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
def stats_payload(db: Session, *, include_runs: bool = True) -> dict[str, Any]:
latest_added = db.scalar(select(Employee).order_by(desc(Employee.first_seen_at)).limit(1))
return {
counts = {
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
"verification_required": db.scalar(
@@ -171,11 +223,14 @@ def stats_payload(db: Session) -> dict[str, Any]:
)
or 0,
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
"new_in_last_run": latest_run.new_count if latest_run else 0,
"latest_added": employee_display_payload(latest_added) if latest_added else None,
"latest_run": run_payload(latest_run) if latest_run else None,
"current_running_run": run_payload(running_run) if running_run else None,
}
if include_runs:
latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1))
counts.update(new_in_last_run=latest_run.new_count if latest_run else 0,
latest_run=run_payload(latest_run), current_running_run=run_payload(running_run))
return counts
def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
+34 -8
View File
@@ -1,4 +1,5 @@
.admin {
--admin-gutter: 32px;
margin: 0;
min-height: 100vh;
display: flex;
@@ -27,7 +28,7 @@
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 32px;
padding: 18px var(--admin-gutter);
background: #ffffff;
border-bottom: 1px solid #d9dee7;
flex-wrap: wrap;
@@ -46,6 +47,7 @@
.admin__nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
}
@@ -65,10 +67,14 @@
outline-offset: 2px;
}
:is(.directory__filters, .council__search) :is(input, select):focus {
outline: 2px solid #0f766e;
outline-offset: 0;
}
.admin__main {
flex: 1;
width: min(1180px, calc(100% - 32px));
margin: 28px auto;
margin: 28px var(--admin-gutter);
}
.admin__footer {
@@ -558,9 +564,17 @@
color: #6b7280;
}
.directory__filter-panel > summary {
width: fit-content;
min-height: 44px;
align-content: center;
cursor: pointer;
font-weight: 600;
}
.directory__filters {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 2fr) minmax(0, 1.25fr) minmax(0, 2fr);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
padding: 16px;
background: #ffffff;
@@ -600,6 +614,10 @@
font-weight: 700;
}
.directory__filter-hint {
color: #4b5563;
}
.directory__input {
box-sizing: border-box;
width: 100%;
@@ -783,6 +801,12 @@
font-weight: 700;
}
@media (max-width: 1100px) {
.directory__filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.directory__filters {
grid-template-columns: 1fr 1fr;
@@ -802,6 +826,10 @@
}
@media (max-width: 620px) {
.admin {
--admin-gutter: 16px;
}
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
@@ -812,17 +840,15 @@
}
.admin__header {
align-items: flex-start;
padding: 16px;
padding-block: 16px;
}
.admin__nav {
width: 100%;
flex-wrap: wrap;
}
.admin__main {
width: min(100% - 24px, 1180px);
margin: 20px auto;
margin-block: 20px;
}
.directory__filters {
+2 -2
View File
@@ -8,8 +8,7 @@
if (names.includes(previous)) branch.value = previous;
});
const dialog = document.querySelector('[data-council-dialog]');
if (!dialog) return;
const form = dialog.querySelector('[data-edit-form]');
const form = dialog?.querySelector('[data-edit-form]');
const autoSpecialties = form?.querySelector('[data-specialty-auto]');
const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : [];
autoSpecialties?.addEventListener('change', () => {
@@ -149,6 +148,7 @@
}, true);
window.addEventListener('resize', repositionTooltips);
if (!dialog) return;
dialog.querySelectorAll('[data-edit-close]').forEach(button => {
button.addEventListener('click', () => { if (!saving) dialog.close(); });
});
+12 -5
View File
@@ -5,20 +5,26 @@
const directory = '/admin/directory';
const council = '/admin/council';
const publications = '/admin/council/publications';
const councilAccess = guide.dataset.guideCouncil === 'true';
const steps = [
{id: 'welcome', title: 'Познакомьтесь с Workers',
intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'},
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filters', tips: [
intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. '
: 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'},
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
]},
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filters', tips: [
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filter-panel', tips: [
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
]},
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filters', tips: [
{id: 'employee-training', path: directory, title: 'Проверьте повышение квалификации', region: '.directory__filter-panel', tips: [
['[name="has_training"]', 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'],
['[name="training_since_year"]', 'Например, 2024 включает 2024 год и все последующие.'],
]},
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [
['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'],
['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
@@ -66,7 +72,7 @@
['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
]},
];
].filter(step => councilAccess || (step.path !== council && step.path !== publications));
const main = document.querySelector('#main-content');
const trigger = document.querySelector('[data-guide-open]');
@@ -130,6 +136,7 @@
paused = Boolean(step.path && step.path !== location.pathname);
clearTips();
const region = !paused && step.region && main.querySelector(step.region);
if (region instanceof HTMLDetailsElement) region.open = true;
if (region) region.before(guide);
else main.prepend(guide);
heading.textContent = paused ? 'Продолжите обучение' : step.title;
+1 -1
View File
@@ -3,7 +3,7 @@
{% 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>
<p>{{ account_user.full_name or account_user.username }} · {{ user_roles[account_user.role] }}</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>
+5 -2
View File
@@ -1,3 +1,4 @@
{% set is_admin = current_user and current_user.role == 'admin' %}
<!doctype html>
<html lang="ru">
<head>
@@ -17,9 +18,11 @@
{% 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>
{% if council_access %}<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>{% endif %}
{% if is_admin %}
<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/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">
@@ -7,9 +7,11 @@
{% if candidate.ambiguous %}<p class="council__hint">Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.</p>{% endif %}
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
{% if can_edit %}
{% set employee_id = candidate.employee_id %}
{% include "council_edit_button.html" %}
{% endif %}
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span>
+2 -5
View File
@@ -1,10 +1,10 @@
{% if can_edit %}
<dialog class="columns-modal" data-council-dialog aria-labelledby="article-edit-title">
<div class="columns-modal__panel">
<div class="columns-modal__header">
<h3 class="columns-modal__title" id="article-edit-title">Редактирование статьи</h3>
<button class="button button--ghost" type="button" data-edit-close aria-label="Закрыть редактирование">✕</button>
</div>
{% if can_edit %}
<form class="council-editor" data-edit-form>
<label class="directory__field" for="article-edit-name">Название статьи
<textarea class="directory__input" id="article-edit-name" name="title" required maxlength="2000" rows="3"></textarea>
@@ -37,9 +37,6 @@
</div>
</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>
{% endif %}
</div>
</dialog>
{% endif %}
+2 -3
View File
@@ -18,11 +18,10 @@
<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 %}
<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 }}. Нажмите, чтобы показать редактирование">
{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}
<li class="council__article"{% if can_edit %} tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование"{% endif %}>
{% if can_edit %}{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}{% endif %}
<p class="council__hint">{{ article.employee_name }}</p>
<div class="council__article-heading"><strong>{{ article.title }}</strong><span>{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}</span></div>
<p class="council__hint">{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}</p>
+9 -5
View File
@@ -5,7 +5,7 @@
<a class="metric metric--link" href="/admin/directory"><span class="metric__label">Всего в базе</span><span class="metric__value">{{ counts.total }}</span></a>
<a class="metric metric--link" href="/admin/directory?status=active"><span class="metric__label">Работают</span><span class="metric__value">{{ counts.active }}</span></a>
<a class="metric metric--link" href="/admin/directory?status=verification_required"><span class="metric__label">Требуют проверки</span><span class="metric__value">{{ counts.verification_required }}</span></a>
<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>
{% if is_admin %}<a class="metric metric--link" href="{% if latest_run %}/admin/runs/{{ latest_run.id }}#new-employees{% else %}/admin/runs{% endif %}"><span class="metric__label">Новые за запуск</span><span class="metric__value">{{ counts.new_in_last_run }}</span></a>{% endif %}
<a class="metric metric--link" href="/admin/directory?status=dismissed"><span class="metric__label">Уволены</span><span class="metric__value">{{ counts.dismissed }}</span></a>
</section>
<section class="stats-strip">
@@ -17,6 +17,7 @@
<span class="stats-strip__value">Сотрудников пока нет</span>
{% endif %}
</div>
{% if is_admin %}
<a class="stats-strip__item stats-strip__item--link" href="/admin/runs">
<span class="stats-strip__label">Запуски</span>
<span class="stats-strip__value">{{ counts.runs }}</span>
@@ -25,19 +26,21 @@
<span class="stats-strip__label">Ошибки</span>
<span class="stats-strip__value">{{ counts.errors }}</span>
</div>
{% endif %}
</section>
{% if is_admin %}
<section class="panel progress-panel" data-progress-panel>
<div class="progress-panel__header">
<h2 class="panel__title">Прогресс парсинга</h2>
<div class="progress-panel__actions">
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
<form method="post" action="/admin/crawl-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button" type="submit">Запустить парсинг</button>
</form>{% endif %}
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/dismissed/refresh">
</form>
<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>{% endif %}
</form>
</div>
</div>
{% set run = counts.current_running_run or latest_run %}
@@ -66,6 +69,7 @@
</tbody>
</table>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="/static/admin.js"></script>
+17 -1
View File
@@ -12,6 +12,9 @@
<button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
</div>
{% set has_filters = filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to or filters.has_training or filters.training_since_year %}
<details class="directory__filter-panel">
<summary>Фильтры{% if has_filters %} · применены{% endif %}</summary>
<form class="directory__filters" method="get" action="/admin/directory">
<fieldset class="directory__filter-group directory__filter-group--search">
<legend class="directory__filter-legend">Поиск</legend>
@@ -37,6 +40,18 @@
</select></label>
<label class="directory__checkbox"><input type="checkbox" name="pps" value="true" {% if filters.pps %}checked{% endif %}> ППС</label>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Повышение квалификации</legend>
<label class="directory__field">Курсы в профиле<select class="directory__input" name="has_training">
<option value="" {% if not filters.has_training %}selected{% endif %}>Любое</option>
<option value="true" {% if filters.has_training == "true" %}selected{% endif %}>Есть курсы</option>
<option value="false" {% if filters.has_training == "false" %}selected{% endif %}>Нет курсов</option>
</select></label>
<label class="directory__field">С какого года включительно
<input class="directory__input" name="training_since_year" inputmode="numeric" pattern="[1-9][0-9]{3}" maxlength="4" value="{{ filters.training_since_year }}" placeholder="Например, 2024" aria-describedby="training-year-hint">
</label>
<small class="directory__filter-hint" id="training-year-hint">Если указан только год, будут показаны сотрудники с курсами.</small>
</fieldset>
<fieldset class="directory__filter-group">
<legend class="directory__filter-legend">Период обнаружения</legend>
<label class="directory__field">От<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}"></label>
@@ -64,6 +79,7 @@
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
</div>
</form>
</details>
<details class="directory__help">
<summary>Что означают даты и статусы</summary>
@@ -113,7 +129,7 @@
{% else %}
<tr><td class="directory-table__empty" colspan="15">
<strong>Сотрудники не найдены</strong>
{% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to %}
{% if has_filters %}
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
{% else %}
+9 -5
View File
@@ -1,20 +1,22 @@
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Выберите задачу</h3>
<p>Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.</p>
<p>Сайт помогает найти сотрудника и изучить его профиль{% if council_access %}, а также подобрать кандидатов в диссертационный совет{% endif %}.</p>
<dl class="guide__fields">
<dt>Сотрудники</dt><dd>Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.</dd>
<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>
<dt>Обзор и Запуски</dt><dd>Количество сотрудников, состояние загрузки данных и история её запусков.</dd>
{% if council_access %}<dt>Диссертационный совет</dt><dd>Подбор по специальности ВАК, отрасли оценки и публикационным показателям.</dd>{% endif %}
<dt>Обзор</dt><dd>Количество сотрудников и их статус.</dd>
{% if is_admin %}<dt>Запуски</dt><dd>Состояние загрузки данных и история запусков.</dd>{% endif %}
<dt>Личный кабинет</dt><dd>Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.</dd>
</dl>
{% if current_user.role == 'admin' %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% else %}<p>Вы можете просматривать данные и исправлять сведения о публикациях. Запуск загрузки данных и управление пользователями доступны администратору.</p>{% endif %}
{% if is_admin %}<p>Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».</p>{% endif %}
<p>Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».</p>
</section>
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Найдите сотрудника и откройте профиль</h3>
<p>В разделе «Сотрудники» введите фамилию, часть ФИО или ссылку на профиль ВШЭ в поле «ФИО или ссылка». Например: Иванов.</p>
<p>В разделе «Сотрудники» раскройте панель «Фильтры». В поле «ФИО или ссылка» введите фамилию, часть ФИО или ссылку на профиль ВШЭ. Например, Иванов.</p>
<dl class="guide__fields">
<dt>Статус, Email, Учёная степень</dt><dd>Ограничивают список по состоянию сотрудника и наличию сведений в профиле. Звание профессора или доцента не заменяет учёную степень.</dd>
<dt>Повышение квалификации</dt><dd>Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ. Например, «Нет курсов» и 2024 покажут сотрудников без записей о курсах за 2024 год и позже. Если указать только год, отобразятся сотрудники с курсами за этот период.</dd>
<dt>Период обнаружения: От / До</dt><dd>Введите даты первого обнаружения профиля системой. Это не период приёма на работу.</dd>
<dt>Сортировка и страница</dt><dd>Выберите порядок и число строк. «Применить фильтры» обновит список, «Сбросить» вернёт исходные параметры.</dd>
<dt>Настроить колонки</dt><dd>Позволяет выбрать видимые сведения: должности, контакты, степень, число публикаций, курсов и новостей, даты обнаружения.</dd>
@@ -22,6 +24,7 @@
<p>Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.</p>
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.</p>
</section>
{% if council_access %}
<section class="guide__step" data-guide-step>
<h3 class="guide__heading" tabindex="-1">Подберите кандидатов в диссовет</h3>
<dl class="guide__fields">
@@ -69,3 +72,4 @@
</dl>
<p>Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».</p>
</section>
{% endif %}
+2 -2
View File
@@ -1,4 +1,4 @@
<section class="guide" data-guide data-guide-user="{{ current_user.id }}" data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title" hidden>
<section class="guide" data-guide data-guide-user="{{ current_user.id }}" data-guide-pending="{{ 'true' if request.state.guide_pending|default(false) else 'false' }}" data-guide-council="{{ 'true' if council_access else 'false' }}" data-guide-csrf="{{ csrf_token }}" aria-labelledby="guide-title" hidden>
<div class="guide__header">
<div>
<p class="guide__progress" data-guide-progress></p>
@@ -18,7 +18,7 @@
{% if request.state.guide_pending|default(false) %}
<aside class="panel guide--page" aria-label="Подсказки первого входа" data-guide-fallback>
<h2 class="panel__title">Как пользоваться сайтом</h2>
<p>Откройте справку о сотрудниках, диссовете и публикациях.</p>
<p>{{ 'Откройте справку о сотрудниках, диссовете и публикациях.' if council_access else 'Откройте справку о поиске сотрудников и работе со справочником.' }}</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>
+1 -1
View File
@@ -1,6 +1,6 @@
{% 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 == 'role' %}{{ user_roles.get(value, 'не задано') }}
{% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }}
{% else %}{{ value if value else 'не указано' }}{% endif %}
{% endmacro %}
+2 -2
View File
@@ -15,7 +15,7 @@
<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>
<label class="form__label">Роль<select class="form__input" name="role">{% for role, label in user_roles.items() %}<option value="{{ role }}">{{ label }}</option>{% endfor %}</select></label>
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
<button class="button" type="submit">Создать пользователя</button>
</form>
@@ -29,7 +29,7 @@
<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="role">{% for role, label in user_roles.items() %}<option value="{{ role }}"{% if user.role == role %} selected{% endif %}>{{ label }}</option>{% endfor %}</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 %}
+4 -4
View File
@@ -9,7 +9,7 @@ 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,
from app.security import (SESSION_COOKIE, SESSION_SECONDS, USER_ROLES, check_csrf, hash_password,
require_admin, require_user, sign_session, temporary_password, verify_password)
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
@@ -56,8 +56,8 @@ def create_user(request: Request, username: str = Form(..., max_length=64), full
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)
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in USER_ROLES:
return _users_page(request, db, error='Логин должен содержать от 3 до 64 латинских букв, цифр, точек, дефисов или подчёркиваний. Выберите роль из списка', status=422)
try:
actor = _lock_admin(request, db, settings)
password = temporary_password()
@@ -106,7 +106,7 @@ def reset_password(user_id: int, request: Request, db: Session = Depends(get_db)
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'}:
if role not in USER_ROLES or is_active not in {'true', 'false'}:
raise HTTPException(422, 'Выберите роль и состояние доступа')
try:
actor = _lock_admin(request, db, settings)
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "1.6.2"
FRONTEND_VERSION = "1.6.2"
BACKEND_VERSION = "1.6.2"
APP_VERSION = "2.0.1"
FRONTEND_VERSION = "2.0.1"
BACKEND_VERSION = "2.0.1"
@@ -0,0 +1,6 @@
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE public.system_users
DROP CONSTRAINT ck_system_users_role,
ADD CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council'));
COMMIT;
@@ -0,0 +1,31 @@
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE system_users_new (
id INTEGER PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user', 'council')),
is_active BOOLEAN NOT NULL,
password_hash TEXT NOT NULL,
must_change_password BOOLEAN NOT NULL,
session_version INTEGER NOT NULL,
first_login_at DATETIME,
last_login_at DATETIME,
password_changed_at DATETIME,
password_reset_at DATETIME,
failed_login_attempts INTEGER NOT NULL,
locked_until DATETIME,
created_at DATETIME NOT NULL
);
INSERT INTO system_users_new
SELECT id, username, full_name, role, is_active, password_hash, must_change_password, session_version,
first_login_at, last_login_at, password_changed_at, password_reset_at, failed_login_attempts,
locked_until, created_at
FROM system_users;
DROP TABLE system_users;
ALTER TABLE system_users_new RENAME TO system_users;
CREATE TEMP TABLE check_user_action_references (violations INTEGER CHECK (violations = 0));
INSERT INTO check_user_action_references SELECT count(*) FROM pragma_foreign_key_check('user_actions');
DROP TABLE check_user_action_references;
COMMIT;
PRAGMA foreign_keys = ON;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "1.6.2"
version = "2.0.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+25 -1
View File
@@ -75,8 +75,30 @@ def main():
page.keyboard.press("Tab")
assert skip_link.evaluate("e => getComputedStyle(e).left === '-999px'")
for width in (1440, 1024, 921, 920, 768, 621, 620, 390, 320):
filters = page.locator(".directory__filter-panel")
expect(filters).not_to_have_attribute("open", "")
expect(page.get_by_label("ФИО или ссылка")).to_be_hidden()
filters.locator("summary").focus()
page.keyboard.press("Enter")
expect(page.get_by_label("ФИО или ссылка")).to_be_visible()
assert page.locator('.directory__filters :is(input, select)').evaluate_all("""controls =>
controls.every(control => {
control.focus();
const style = getComputedStyle(control);
return style.outlineWidth === '2px' && style.outlineOffset === '0px';
})
""")
for width in (1920, 1440, 1180, 1024, 921, 920, 768, 621, 620, 390, 320):
page.set_viewport_size({"width": width, "height": 844})
assert page.locator("main").evaluate("""main => {
const header = document.querySelector('.admin__header');
const style = getComputedStyle(header);
const headerBox = header.getBoundingClientRect();
const mainBox = main.getBoundingClientRect();
return Math.abs(mainBox.left - headerBox.left - parseFloat(style.paddingLeft)) < 1
&& Math.abs(headerBox.right - mainBox.right - parseFloat(style.paddingRight)) < 1
&& document.documentElement.scrollWidth <= document.documentElement.clientWidth;
}""")
assert page.locator(".directory__filters").evaluate("e => e.scrollWidth <= e.clientWidth")
assert page.locator(".directory__input").evaluate_all("""controls => controls.every(control => {
const field = control.closest('.directory__field').getBoundingClientRect();
@@ -88,6 +110,7 @@ def main():
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1")
assert "offset=" not in page.url
expect(filters).not_to_have_attribute("open", "")
trigger = page.get_by_role("button", name="Настроить колонки")
trigger.focus()
page.keyboard.press("Enter")
@@ -116,6 +139,7 @@ def main():
assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth")
page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True)
page.keyboard.press("Escape")
filters.locator("summary").click()
page.get_by_label("ФИО или ссылка").fill("Нет совпадений")
page.get_by_role("button", name="Применить фильтры").click()
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
+3 -8
View File
@@ -66,16 +66,11 @@ def main():
page.on('pageerror', lambda error: errors.append(str(error)))
page.goto('http://miem.test/admin/council')
page.locator('.council__candidate-summary').click()
expect(page.locator('.council__article strong')).to_have_text('Исследование передачи данных')
expect(page.locator('[data-council-edit]')).to_have_count(0)
expect(page.locator('[data-council-dialog]')).to_have_count(0)
article = page.locator('[data-council-article]')
pencil = article.locator('[data-council-edit]')
expect(pencil).to_be_hidden()
article.locator('strong').click()
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()
page.keyboard.press('Escape')
expect(pencil).to_be_focused()
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
page.reload()
page.locator('.council__candidate-summary').click()
+24 -3
View File
@@ -71,6 +71,7 @@ def main():
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('combobox', name='Роль', exact=True).select_option('council')
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
temporary = page.locator('[data-temporary-password]')
expect(temporary).to_be_visible()
@@ -80,6 +81,7 @@ def main():
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('combobox', name='Роль', exact=True).select_option('user')
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()
@@ -152,7 +154,23 @@ def main():
expect(tips).to_have_count(3)
go_back.click()
expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible()
for _ in range(5):
for _ in range(3):
go_next.click()
expect(guide.get_by_role('heading', name='Проверьте повышение квалификации')).to_be_visible()
expect(tips).to_have_count(2)
expect(page.get_by_role('combobox', name='Курсы в профиле', exact=True)).to_have_accessible_description(
'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'
)
expect(page.get_by_role('textbox', name='С какого года включительно', exact=True)).to_have_accessible_description(
'Если указан только год, будут показаны сотрудники с курсами. '
'Например, 2024 включает 2024 год и все последующие.'
)
for width in (1440, 320):
page.set_viewport_size({'width': width, 'height': 900})
assert tips.evaluate_all('tips => tips.every(tip => tip.scrollWidth <= tip.clientWidth)')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.set_viewport_size({'width': 1440, 'height': 900})
for _ in range(3):
go_next.click()
assert page.url.endswith('/admin/council')
expect(tips).to_have_count(3)
@@ -226,12 +244,15 @@ def main():
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()
expect(nojs_page.locator('[data-guide-step]')).to_have_count(2)
expect(nojs_page.get_by_text('Повышение квалификации', exact=True)).to_be_visible()
expect(nojs_page.get_by_role('link', name='Диссертационный совет', exact=True)).to_have_count(0)
expect(nojs_page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0)
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_role('link', name='Пользователи', exact=True)).to_have_count(0)
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()
+3 -1
View File
@@ -6,7 +6,9 @@ from jinja2 import Environment, FileSystemLoader
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
request=SimpleNamespace(url=SimpleNamespace(path="/admin")),
current_user=SimpleNamespace(role="admin", must_change_password=True),
council_access=True,
)
assert "Обзор" in template
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "1.6.2"
assert response.json()["backend_version"] == "2.0.1"
def test_api_employees_and_stats_require_admin_session():
+1 -1
View File
@@ -230,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text
assert "javascript:" not in response.text and "private@example.org" not in response.text
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
assert "Backend 1.6.2 · Frontend 1.6.2" in response.text
assert "Backend 2.0.1 · Frontend 2.0.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
+8 -1
View File
@@ -82,8 +82,15 @@ def test_editor_permissions_csrf_validation_and_missing_article(editor, db_sessi
assert db_session.get(CouncilCorrections, 1) is None
client.cookies.clear()
assert save(client, payload).status_code == 401
original_title = db_session.scalar(select(EmployeePublication.title).where(
EmployeePublication.employee_id == payload['employee_id']))
response = client.get('/admin/council/publications')
assert response.status_code == 200 and 'Для редактирования' in response.text
assert response.status_code == 200
assert original_title in response.text
assert 'data-council-edit' not in response.text and 'data-council-dialog' not in response.text
fragment = client.get(f"/admin/council/employees/{payload['employee_id']}/articles")
assert fragment.status_code == 200 and original_title in fragment.text
assert 'data-council-article' not in fragment.text and 'data-council-edit' not in fragment.text
def test_editor_journal_reassignment_year_scope_and_unknown_year(editor, db_session):
+27
View File
@@ -1,6 +1,12 @@
from pathlib import Path
import sqlite3
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.dialects.sqlite import dialect
from sqlalchemy.schema import CreateTable
from app.db import _ensure_runtime_schema
from app.models import SystemUser
def test_runtime_schema_adds_skipped_count_to_existing_crawl_runs_table(monkeypatch):
@@ -142,3 +148,24 @@ def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
columns = {column["name"] for column in inspector.get_columns("employees")}
assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
assert "employee_profile_urls" in inspector.get_table_names()
def test_sqlite_council_role_migration_preserves_users_and_history():
original = str(CreateTable(SystemUser.__table__).compile(dialect=dialect())).replace(", 'council'", "")
with sqlite3.connect(":memory:") as connection:
connection.execute(original)
connection.execute("CREATE TABLE user_actions (user_id INTEGER REFERENCES system_users(id))")
connection.execute("""
INSERT INTO system_users
(id, username, full_name, role, is_active, password_hash, must_change_password,
session_version, failed_login_attempts, created_at)
VALUES (17, 'reader', '', 'user', 1, 'fixture', 0, 4, 0, '2026-11-12')
""")
connection.execute("INSERT INTO user_actions VALUES (17)")
before = connection.execute("SELECT * FROM system_users").fetchall()
migration = Path("migrations/sqlite/014_system_user_council_role.sql").read_text(encoding="utf-8")
connection.executescript(migration)
assert connection.execute("SELECT * FROM system_users").fetchall() == before
assert connection.execute("SELECT * FROM user_actions").fetchall() == [(17,)]
connection.execute("UPDATE system_users SET role = 'council' WHERE id = 17")
assert connection.execute("SELECT role FROM system_users").fetchone() == ("council",)
+113
View File
@@ -0,0 +1,113 @@
from bs4 import BeautifulSoup
from fastapi.testclient import TestClient
import pytest
from app.config import Settings, get_settings
from app.db import get_db
from app.main import app
from app.models import Employee
from app.parser.profile import extract_sections
from app.security import SESSION_COOKIE, sign_session
from app.services.admin_data import list_employees_page
@pytest.mark.parametrize(
("html", "has_course", "has_recent_course"),
[
('<p>2024 (11 декабря), НИУ ВШЭ. Программа повышения квалификации «ИИ».</p>', True, True),
('<ul><li>Программа повышения квалификации «ДПО», октябрь 2024 года.</li></ul>', True, True),
('<p>Курс повышения квалификации «ДПО», 7 сентября 2024 год.</p>', True, True),
('<p>Курсы повышения квалификации: «Технологии 2025 г.», 2023г.</p>', True, False),
('<p>Программа повышения квалификации «Технологии 2025 г.»</p>', True, False),
('<p>2025. Стажировка «Технологии».</p>', False, False),
('<p>2025. Стажировка «Повышение квалификации педагогов».</p>', False, False),
('<p>2025. Стажировка в Институте повышения квалификации.</p>', False, False),
],
)
def test_training_filter_reads_courses_from_profile_paragraphs_and_lists(
db_session, html, has_course, has_recent_course,
):
url = "https://example.test/employee"
soup = BeautifulSoup(
f"<h2>Дополнительное образование / Повышение квалификации / Стажировки</h2><div>{html}</div>",
"html.parser",
)
db_session.add(Employee(
profile_key="training:text", canonical_url=url,
current_data={"sections": extract_sections(soup, url)},
))
db_session.commit()
assert list_employees_page(db_session, has_training=True)["total"] == int(has_course)
assert list_employees_page(db_session, training_since_year=2024)["total"] == int(has_recent_course)
assert list_employees_page(db_session, training_since_year=2024, has_training=False)["total"] == int(
not has_recent_course
)
def test_training_filter_uses_course_year_and_kind_before_pagination(db_session):
title = "Дополнительное образование / Повышение квалификации / Стажировки"
profiles = [
(title, "2023", "«Курс 2024», повышение квалификации"),
(title, "2024", "«Курс», повышение квалификации, НИУ ВШЭ"),
(title, "2025", "«Курс», Повышение квалификации"),
(title, "2025", "«Стажировка», стажировка"),
("Конференции", "2025", "«Доклад», повышение квалификации"),
(title, "", "«Курс без года», повышение квалификации"),
]
for index, (section_title, year, text) in enumerate(profiles):
soup = BeautifulSoup(
f'<h2>{section_title}</h2><div><div class="with-indent">'
f'<div class="person-list-hangover">{year}</div><span>{text}</span></div></div>',
"html.parser",
)
url = f"https://example.test/{index}"
db_session.add(Employee(
profile_key=f"training:{index}", canonical_url=url, full_name=f"Person {index}",
current_data={
"sections": extract_sections(soup, url),
"positions": ["Доцент: кафедра"] if index == 1 else ["Инженер"],
},
))
db_session.add(Employee(profile_key="training:6", canonical_url="https://example.test/6", full_name="Person 6"))
db_session.commit()
def names(**filters):
return [item["full_name"] for item in list_employees_page(db_session, **filters)["employees"]]
assert names(has_training=True) == ["Person 0", "Person 1", "Person 2", "Person 5"]
assert names(has_training=False) == ["Person 3", "Person 4", "Person 6"]
assert names(training_since_year=2024) == ["Person 1", "Person 2"]
assert names(training_since_year=2024, has_training=False) == [
"Person 0", "Person 3", "Person 4", "Person 5", "Person 6",
]
assert names(training_since_year=2024, pps=True) == ["Person 1"]
assert names(training_since_year=2024, offset=1) == ["Person 2"]
assert names(training_since_year=2024, offset=25) == ["Person 1", "Person 2"]
settings = Settings(_env_file=None, admin_username="admin", admin_password="password", session_secret="test-training")
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
with TestClient(app) as client:
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
response = client.get("/api/employees?has_training=false&training_since_year=2024")
assert response.status_code == 200
assert response.json()["total"] == 5
response = client.get("/admin/directory?has_training=true&training_since_year=2024&offset=1")
assert response.status_code == 200
page = BeautifulSoup(response.text, "html.parser")
panel = page.select_one(".directory__filter-panel")
assert not panel.has_attr("open")
assert panel.summary.get_text(strip=True) == "Фильтры · применены"
assert panel.select_one('select[name="has_training"] option[selected]')["value"] == "true"
assert panel.select_one('input[name="training_since_year"]')["value"] == "2024"
assert "Person 1" not in response.text
assert "Person 2" in response.text
previous = page.select_one(".directory__pagination a")["href"]
assert "has_training=true" in previous
assert "training_since_year=2024" in previous
assert client.get("/admin/directory?has_training=&training_since_year=").status_code == 200
assert client.get("/admin/directory?training_since_year=bad").status_code == 422
finally:
app.dependency_overrides.clear()
+38 -6
View File
@@ -126,13 +126,19 @@ def test_regular_user_cannot_start_scraping(accounts, path):
assert client.get('/api/stats').status_code == 200
def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
def test_council_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']}
act_as(client, user, settings)
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 403
assert client.post(f'/admin/users/{user.id}', data={'role': 'council', 'is_active': 'true'},
headers=act_as(client, admin, settings), follow_redirects=False).status_code == 303
db.refresh(user)
assert user.role == 'council' and user.session_version == 1
act_as(client, user, settings)
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'))
@@ -150,6 +156,29 @@ def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
def test_navigation_and_run_access_match_role(accounts, role):
client, db, settings, _, user, _ = accounts
user.role = role
db.commit()
act_as(client, user, settings)
dashboard = BeautifulSoup(client.get('/admin').text, 'html.parser')
links = {link['href'] for link in dashboard.select('nav a[href]')}
assert {'/admin', '/admin/directory', '/admin/account', '/admin/guide'} <= links
assert ('/admin/council' in links) == (role != 'user')
assert ('/admin/runs' in links) == (role == 'admin')
assert ('/admin/users' in links) == (role == 'admin')
assert bool(dashboard.select('[data-progress-panel]')) == (role == 'admin')
assert ('latest_run' in client.get('/api/stats').json()) == (role == 'admin')
for path in ('/admin/runs', '/api/crawl-runs', '/api/crawl-runs/latest', '/api/crawl-runs/1'):
assert client.get(path).status_code == (200 if role == 'admin' else 403)
assert client.get('/admin/runs/1', follow_redirects=False).status_code == (303 if role == 'admin' else 403)
client.cookies.clear()
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/login'
assert client.get('/admin/council').status_code == 200
assert client.get('/docs').status_code == 404
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
client, db, settings, admin, user, _ = accounts
user.password_changed_at = utcnow()
@@ -248,6 +277,8 @@ def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
client, db, settings, _, user, employee = accounts
user.role = 'council'
db.commit()
headers = act_as(client, user, settings)
before = user.password_hash
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
@@ -290,7 +321,7 @@ def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
assert db.scalar(select(UserAction).where(UserAction.action == 'guide_start')) is None
@pytest.mark.parametrize('role', ['user', 'admin'])
@pytest.mark.parametrize('role', ['user', 'council', 'admin'])
def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
client, db, settings, _, user, _ = accounts
user.role = role
@@ -306,11 +337,12 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role):
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 len(parsed.select('[data-guide-step]')) == (2 if role == 'user' else 6)
assert not parsed.select('[data-guide]')
assert 'Прочитайте показатели сотрудника' in guide.text and 'Проверьте строку публикации' in guide.text
assert ('Прочитайте показатели сотрудника' in guide.text) == (role != 'user')
assert ('Проверьте строку публикации' in guide.text) == (role != 'user')
assert ('управлять доступом' in guide.text) == (role == 'admin')
assert 'не ограничивает «ВСЕГО»' in guide.text
assert ('не ограничивает «ВСЕГО»' in guide.text) == (role != 'user')
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide]')['data-guide-csrf']
assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403
assert 'data-guide-pending="true"' in client.get('/admin/directory').text