Files
miem_workers/app/admin.py
T
codex 508aae4f8a
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 10m43s
feat: add council user role and restrict crawl runs
2026-11-12 09:35:26 +00:00

491 lines
24 KiB
Python

from copy import deepcopy
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
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
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
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, 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,
format_admin_datetime,
list_employees_page,
run_detail_payload,
run_payload,
stats_payload,
)
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.services.council import CouncilInputError, council_candidates, _journal_key
from app.services.council_export import council_xlsx
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)):
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()
runs = [run_payload(run) for run in run_models]
return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": runs[0] if runs else None})
@router.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
csrf = secrets.token_urlsafe(24)
response = _render(request, "login.html", {"error": None, "csrf_token": csrf})
response.set_cookie(LOGIN_CSRF_COOKIE, csrf, httponly=True, samesite="strict", max_age=600, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
return response
@router.post("/login")
def login(
request: Request,
username: str = Form(..., max_length=64),
password: str = Form(..., max_length=1024),
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
try:
user = authenticate(db, username, password, settings)
if user is None:
return _render(request, "login.html", {"error": "Неверный логин или пароль, либо вход временно недоступен",
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=401)
db.add(UserAction(user_id=user.id, action="login"))
db.commit()
except SQLAlchemyError:
db.rollback()
return _render(request, "login.html", {"error": "Вход временно недоступен. Попробуйте ещё раз",
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=503)
redirect = RedirectResponse("/admin/account" if user.must_change_password else "/admin", status_code=303)
redirect.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
httponly=True, samesite="lax", max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
redirect.delete_cookie(LOGIN_CSRF_COOKIE)
return redirect
@router.post("/logout")
def logout(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
user = optional_user(request, settings, db, allow_password_change=True, lock=True)
if user is not None:
try:
user.session_version += 1
db.commit()
except SQLAlchemyError:
db.rollback()
raise HTTPException(503, "Не удалось завершить сессии. Попробуйте ещё раз")
redirect = RedirectResponse("/admin/login", status_code=303)
redirect.delete_cookie(SESSION_COOKIE)
return redirect
@router.get("/employees", response_class=HTMLResponse)
def employees(
request: Request,
status: str | None = None,
q: str | None = None,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_user(request, settings, db)
return RedirectResponse("/admin/directory", status_code=303)
@router.get("/directory", response_class=HTMLResponse)
def directory(
request: Request,
status: str | None = None,
q: str | None = None,
started_from: str | None = None,
started_to: str | None = None,
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,
offset: int = 0,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_user(request, settings, db)
parsed_started_from = _parse_date(started_from)
parsed_started_to = _parse_date(started_to)
parsed_has_email = None if has_email in (None, "") else has_email == "true"
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
try:
page = list_employees_page(
db,
status=status,
q=q,
started_from=parsed_started_from,
started_to=parsed_started_to,
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,
offset=offset,
)
except SQLAlchemyError:
db.rollback()
return _render(request, "directory_error.html", {}, status_code=503)
return _render(
request,
"directory.html",
{
"page": page,
"filters": {
"status": status or "",
"q": q or "",
"started_from": started_from or "",
"started_to": started_to or "",
"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"],
"offset": page["offset"],
},
},
)
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
authenticated = optional_user(request, settings, db)
try:
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": can_use_council(authenticated), **result})
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
authenticated = optional_user(request, settings, db)
try:
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
if not result["candidates"]:
raise HTTPException(404, "Сотрудник не найден или не соответствует фильтру. Обновите список")
except CouncilInputError as exc:
raise HTTPException(422, str(exc))
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
"can_edit": can_use_council(authenticated), **result})
class CouncilArticleEdit(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=True)
employee_id: int = Field(gt=0)
edit_key: str = Field(pattern=r"^[a-f0-9]{64}$")
revision: int = Field(ge=0)
title: str = Field(min_length=1, max_length=2000)
journal: str = Field(min_length=1, max_length=500)
quartile: str = Field(pattern=r"^(Q[1-4])?$")
specialty_codes: list[str] | None = Field(default=None, max_length=12)
@router.get("/council/publications", response_class=HTMLResponse)
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": 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"]}
for candidate in result["candidates"] for article in candidate["articles"]
if employee.casefold().strip() in candidate["name"].casefold()
and title.casefold().strip() in article["title"].casefold()
and journal.casefold().strip() in " ".join(article["journals"]).casefold()]
total, limit = len(articles), 50
offset = min(max(0, offset), ((total - 1) // limit) * limit) if total else 0
return _render(request, "council_publications.html", {**context, "error": False,
"articles": articles[offset:offset + limit], "matched": total, "offset": offset, "limit": limit,
"revision": result["revision"], "journal_names": result["journal_names"], "specialties": result["specialties"]})
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council_publications.html", {**context, "error": True}, status_code=503)
@router.post("/council/publications")
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":
raise HTTPException(403, "Недопустимый запрос на изменение")
if not _journal_key(change.journal):
raise HTTPException(422, "Укажите название журнала")
try:
result = council_candidates(db, employee_id=change.employee_id, paginate=False)
article = next((a for c in result["candidates"] if c["employee_id"] == change.employee_id
for a in c["articles"] if a["edit_key"] == change.edit_key), None)
if not article:
raise HTTPException(404, "Статья не найдена. Обновите страницу")
if article["year"] is None and change.quartile:
raise HTTPException(422, "Для квартиля необходим однозначный год публикации")
if change.specialty_codes is not None and not set(change.specialty_codes) <= {item["code"] for item in result["specialties"]}:
raise HTTPException(422, "Неизвестная специальность ВАК")
record = db.get(CouncilCorrections, 1)
revision = record.revision if record else 0
if revision != change.revision:
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
data = deepcopy(record.data) if record else {"articles": {}, "journals": {}}
if change.title != article["title"] or change.journal != article["edit_journal"]:
data["articles"][f"{change.employee_id}:{change.edit_key}"] = {"title": change.title, "journal": change.journal}
specialty_data = data.setdefault("specialties", {})
if "specialty_codes" in change.model_fields_set:
if change.specialty_codes is None:
specialty_data.pop(_journal_key(change.journal), None)
else:
specialty_data[_journal_key(change.journal)] = sorted(set(change.specialty_codes))
if article["year"] is not None and (change.quartile != article["edit_quartile"] or change.journal != article["edit_journal"]):
data["journals"][f"{_journal_key(change.journal)}:{article['year']}"] = change.quartile
if record:
count = db.execute(update(CouncilCorrections).where(CouncilCorrections.id == 1,
CouncilCorrections.revision == change.revision).values(data=data, revision=revision + 1,
updated_by=username)).rowcount
if not count:
db.rollback()
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
else:
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
changes = {}
for field, before, after in (("title", article["title"], change.title),
("journal", " / ".join(article["journals"]), change.journal),
("quartile", article["edit_quartile"], change.quartile)):
if before != after:
changes[field] = {"before": before, "after": after}
if "specialty_codes" in change.model_fields_set:
before = {"manual": article["manual_specialties"], "codes": article["edit_specialty_codes"] if article["manual_specialties"] else None}
after = {"manual": change.specialty_codes is not None,
"codes": sorted(set(change.specialty_codes or [])) if change.specialty_codes is not None else None}
if before != after:
changes["specialty_codes"] = {"before": before, "after": after}
if changes:
candidate = next(c for c in result["candidates"] if c["employee_id"] == change.employee_id)
db.add(UserAction(user_id=actor.id, action="card_edit", employee_id=change.employee_id,
employee_name=candidate["name"], publication_year=article["year"], publication_key=change.edit_key, article_title=change.title, changes=changes))
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
raise HTTPException(503, "Не удалось сохранить изменения. Попробуйте ещё раз")
return {"saved": True}
@router.get("/council/export.xlsx")
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
db: Session = Depends(get_db)):
try:
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
branch=branch, previous_specialty=request.query_params.get("previous_specialty"))
branches = list(dict.fromkeys(b for item in result["specialties"] for b in item["branches"]))
selection_results = [
result if b == result["branch"] and not result["q"] and not result["specialty"] else
council_candidates(db, sort=result["sort"], direction=result["direction"], paginate=False, branch=b)
for b in branches]
content = council_xlsx(result, selection_results)
except CouncilInputError as exc:
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
def employee_detail(
employee_id: int,
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_user(request, settings, db)
employee = db.get(Employee, employee_id)
if not employee:
return RedirectResponse("/admin/employees", status_code=303)
snapshots = [
{
"captured_display": format_admin_datetime(snapshot.captured_at),
"checksum": snapshot.checksum,
"parser_version": snapshot.parser_version,
}
for snapshot in sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20]
]
return _render(
request,
"employee_detail.html",
{
"employee": employee,
"employee_view": employee_detail_payload(employee),
"snapshots": snapshots,
"refresh_status": request.query_params.get("refresh_status"),
},
)
@router.post("/employees/{employee_id}/refresh")
def refresh_employee_detail(
employee_id: int,
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_admin(request, settings, db)
employee = db.get(Employee, employee_id)
if not employee:
return RedirectResponse("/admin/directory", status_code=303)
run = refresh_employee(db, employee, settings)
status = "success" if run.status == "completed" else "error"
return RedirectResponse(f"/admin/employees/{employee_id}?refresh_status={status}", status_code=303)
@router.get("/runs", response_class=HTMLResponse)
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
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()
return _render(request, "runs.html", {"runs": items, "errors": errors})
@router.get("/runs/{run_id}", response_class=HTMLResponse)
def run_detail(
run_id: int,
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_admin(request, settings, db)
run = db.get(CrawlRun, run_id)
if not run:
return RedirectResponse("/admin/runs", status_code=303)
return _render(request, "run_detail.html", {"run": run_detail_payload(db, run)})
@router.post("/runs")
def trigger_run(
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_admin(request, settings, db)
if get_running_run(db):
return RedirectResponse("/admin/runs", status_code=303)
def _crawl() -> None:
with SessionLocal() as db:
run_crawl_if_idle(db, settings)
background_tasks.add_task(_crawl)
return RedirectResponse("/admin/runs", status_code=303)
@router.post("/crawl-now")
def crawl_now(
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_admin(request, settings, db)
if get_running_run(db):
return RedirectResponse("/admin", status_code=303)
def _crawl() -> None:
with SessionLocal() as db:
run_crawl_if_idle(db, settings)
background_tasks.add_task(_crawl)
return RedirectResponse("/admin", status_code=303)
@router.post("/dismissed/refresh")
def refresh_dismissed(
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
require_admin(request, settings, db)
if get_running_run(db):
return RedirectResponse("/admin", status_code=303)
def _refresh() -> None:
with SessionLocal() as db:
refresh_dismissed_status(db, settings)
background_tasks.add_task(_refresh)
return RedirectResponse("/admin", status_code=303)
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
user = getattr(request.state, "user", None)
payload = {
"request": request,
"current_user": user,
"council_access": can_use_council(user),
"csrf_token": getattr(request.state, "csrf_token", ""),
"backend_version": BACKEND_VERSION,
"frontend_version": FRONTEND_VERSION,
**context,
}
response = templates.TemplateResponse(request, template, payload, status_code=status_code)
if getattr(request.state, "user", None) is not None or template == "login.html":
response.headers["Cache-Control"] = "no-store"
return response
def _parse_date(value: str | None):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None