diff --git a/CHANGELOG.md b/CHANGELOG.md
index 719fe7a..ee91851 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 2.0.1
+
+- Роль «Пользователь диссовета» добавляет раздел «Диссовет» к меню обычного пользователя и разрешает редактировать публикации. Без входа доступен только просмотр диссовета, включая публикации и выгрузку XLSX.
+- Страницы запусков, их история и прогресс в API доступны только администраторам.
+- Поля фильтров справочника и диссовета выделяются при фокусе контуром толщиной 2 px без отступа.
+- При обновлении существующей базы с 1.x применяется миграция 014. Она добавляет новую роль и сохраняет назначения существующих пользователей.
+
## 1.7.3
- Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году.
diff --git a/README.md b/README.md
index e2534b8..a88d140 100644
--- a/README.md
+++ b/README.md
@@ -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 или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
diff --git a/app/admin.py b/app/admin.py
index 5dc5add..884bd52 100644
--- a/app/admin.py
+++ b/app/admin.py
@@ -11,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,
@@ -31,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()
@@ -185,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)
@@ -202,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):
@@ -220,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"]}
@@ -242,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":
@@ -378,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()
@@ -392,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)
@@ -457,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,
diff --git a/app/api.py b/app/api.py
index 702b6b5..daa20d5 100644
--- a/app/api.py
+++ b/app/api.py
@@ -79,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]}
@@ -90,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)}
@@ -103,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"}
@@ -136,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:
diff --git a/app/main.py b/app/main.py
index 09a839d..c6631c6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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)
diff --git a/app/models.py b/app/models.py
index a3a21f3..ba130d0 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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)
diff --git a/app/security.py b/app/security.py
index 3b84852..fa1a800 100644
--- a/app/security.py
+++ b/app/security.py
@@ -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':
diff --git a/app/services/admin_data.py b/app/services/admin_data.py
index 875c8d2..0021af7 100644
--- a/app/services/admin_data.py
+++ b/app/services/admin_data.py
@@ -213,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(
@@ -225,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:
diff --git a/app/static/admin.css b/app/static/admin.css
index 0f0197a..904cb75 100644
--- a/app/static/admin.css
+++ b/app/static/admin.css
@@ -67,6 +67,11 @@
outline-offset: 2px;
}
+:is(.directory__filters, .council__search) :is(input, select):focus {
+ outline: 2px solid #0f766e;
+ outline-offset: 0;
+}
+
.admin__main {
flex: 1;
margin: 28px var(--admin-gutter);
diff --git a/app/static/council.js b/app/static/council.js
index 06acf4b..f8c18d5 100644
--- a/app/static/council.js
+++ b/app/static/council.js
@@ -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(); });
});
diff --git a/app/static/guide.js b/app/static/guide.js
index 3a93d27..d7b59fc 100644
--- a/app/static/guide.js
+++ b/app/static/guide.js
@@ -5,9 +5,11 @@
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: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'},
+ intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. '
+ : 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'},
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
@@ -70,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]');
diff --git a/app/templates/account.html b/app/templates/account.html
index ab91967..f7fa217 100644
--- a/app/templates/account.html
+++ b/app/templates/account.html
@@ -3,7 +3,7 @@
{% block content %}
{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }} {{ account_user.full_name or account_user.username }} · {{ user_roles[account_user.role] }} Пароль изменён. Другие сессии завершены.Личный кабинет
-