473 lines
23 KiB
Python
473 lines
23 KiB
Python
from copy import deepcopy
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, Form, 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, require_admin, require_user,
|
|
optional_user, sign_session, authenticate, check_csrf)
|
|
import secrets
|
|
from app.services.admin_data import (
|
|
employee_detail_payload,
|
|
format_admin_datetime,
|
|
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
|
|
|
|
|
|
@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)
|
|
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,
|
|
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,
|
|
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 "",
|
|
"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": authenticated is not None, **result})
|
|
|
|
|
|
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
|
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
|
|
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
authenticated = optional_user(request, settings, db)
|
|
try:
|
|
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
|
|
if not result["candidates"]:
|
|
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": authenticated is not None, **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": optional_user(request, settings, db) is not None}
|
|
try:
|
|
result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc")
|
|
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
|
|
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)
|
|
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_user(request, settings, db)
|
|
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
|
items = [run_payload(run) for run in run_models]
|
|
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
|
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_user(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:
|
|
payload = {
|
|
"request": request,
|
|
"current_user": getattr(request.state, "user", None),
|
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
|
"backend_version": BACKEND_VERSION,
|
|
"frontend_version": FRONTEND_VERSION,
|
|
**context,
|
|
}
|
|
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
|