Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1854ed5e2 | ||
|
|
c7940c690b | ||
|
|
3fb174899d | ||
|
|
508aae4f8a | ||
|
|
59dcacbaff | ||
|
|
43d609b353 | ||
|
|
9d618bda81 | ||
|
|
ae0fc32172 | ||
|
|
83431d8f2a | ||
|
|
0aa472412e | ||
|
|
d10690e28b | ||
|
|
744abd0063 |
No files matched your search
@@ -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 и показывает до трёх коротких подсказок у полей, показателей диссовета и сведений о публикациях. Карточки вписаны в страницу и читаются без внутренней прокрутки.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",)
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user