Админ-панель (доступ: is_admin + секретный код сессии в /admin/<code>): - разделы: дашборд (здоровье сервисов, статистика), клиенты, работы, база документов, хранилище MinIO, источники парсинга, отстойник работ - backend: модели ParseSource/StagedWork/AdminSession, миграция 003, core/admin (авторизация), роутер api/admin - frontend: AdminLayout + 7 вкладок, adminApi, вход из кабинета - Celery index.run_parser (фоновый парсинг), автосбор проверенных работ в отстойник с ручным одобрением → индексация в базу Исправления: - openalex parser: тип article (не journal-article), убран is_oa-фильтр - winnowing: приведение xxh64 к signed int64 (фикс bigint out of range) - bcrypt пин 4.0.1 (совместимость с passlib 1.7.4) - alembic: prepend_sys_path + asyncpg-драйвер - frontend: контракт Task (public_id вместо id), react-dropzone Инфраструктура: - docker-compose.test.yml: api+frontend+воркеры локально, БД/кэш/хранилище/ брокер/LLM — на удалённых серверах через .env - .gitignore: исправлено ошибочное игнорирование кода services/*/app/models/ - LICENSE: проприетарная Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
572 lines
25 KiB
Python
572 lines
25 KiB
Python
"""Админ-панель: дашборд, клиенты, работы, документы, хранилище, источники, отстойник.
|
||
|
||
Все эндпоинты требуют is_admin (get_admin_user). Эндпоинты под /admin/panel/{code}/*
|
||
дополнительно проверяют секретный код сессии (verify_admin_code).
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from sqlalchemy import delete, func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.core.admin import create_admin_session, get_admin_user
|
||
from app.core.celery_app import celery_app
|
||
from app.core.minio_client import get_minio
|
||
from app.core.redis_client import get_redis
|
||
from app.database import get_db
|
||
from app.models.admin import ParseSource, StagedWork
|
||
from app.models.document import Document, Fingerprint
|
||
from app.models.task import Task
|
||
from app.models.user import User
|
||
from app.schemas.admin import (
|
||
AdminDocumentResponse,
|
||
AdminSessionResponse,
|
||
AdminStats,
|
||
AdminTaskResponse,
|
||
AdminUserResponse,
|
||
AdminUserUpdate,
|
||
ParseSourceCreate,
|
||
ParseSourceResponse,
|
||
ParseSourceUpdate,
|
||
ServiceHealth,
|
||
StagedWorkDetail,
|
||
StagedWorkResponse,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)])
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# СЕССИЯ ДОСТУПА
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.post("/session", response_model=AdminSessionResponse)
|
||
async def open_admin_session(
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> AdminSessionResponse:
|
||
"""Сгенерировать секретный код доступа и ссылку /admin/<code>."""
|
||
code = await create_admin_session(admin, db)
|
||
from app.models.admin import AdminSession
|
||
|
||
result = await db.execute(select(AdminSession).where(AdminSession.code == code))
|
||
sess = result.scalar_one()
|
||
return AdminSessionResponse(
|
||
code=code,
|
||
url=f"/admin/{code}",
|
||
expires_at=sess.expires_at,
|
||
)
|
||
|
||
|
||
@router.get("/session/verify/{code}")
|
||
async def verify_session(
|
||
code: str,
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> dict:
|
||
"""Проверить валидность кода (для фронта при загрузке /admin/<code>)."""
|
||
from app.models.admin import AdminSession
|
||
|
||
result = await db.execute(select(AdminSession).where(AdminSession.code == code))
|
||
sess = result.scalar_one_or_none()
|
||
if sess is None or sess.user_id != admin.id:
|
||
return {"valid": False}
|
||
expires = sess.expires_at
|
||
if expires.tzinfo is not None:
|
||
expires = expires.replace(tzinfo=None)
|
||
return {"valid": expires >= datetime.utcnow()}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ДАШБОРД / СЕРВИСЫ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/health", response_model=list[ServiceHealth])
|
||
async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]:
|
||
"""Статус всех сервисов инфраструктуры."""
|
||
checks: list[ServiceHealth] = []
|
||
|
||
# PostgreSQL
|
||
try:
|
||
await db.execute(select(func.now()))
|
||
checks.append(ServiceHealth(name="PostgreSQL", ok=True))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="PostgreSQL", ok=False, detail=str(e)[:200]))
|
||
|
||
# Redis
|
||
try:
|
||
r = get_redis()
|
||
await r.ping()
|
||
checks.append(ServiceHealth(name="Redis", ok=True))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="Redis", ok=False, detail=str(e)[:200]))
|
||
|
||
# MinIO
|
||
try:
|
||
get_minio().bucket_exists(settings.MINIO_BUCKET_DOCS)
|
||
checks.append(ServiceHealth(name="MinIO", ok=True))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="MinIO", ok=False, detail=str(e)[:200]))
|
||
|
||
# Elasticsearch
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5) as c:
|
||
resp = await c.get(f"{settings.ELASTICSEARCH_URL}/_cluster/health")
|
||
ok = resp.status_code == 200 and resp.json().get("status") != "red"
|
||
checks.append(ServiceHealth(name="Elasticsearch", ok=ok,
|
||
detail=resp.json().get("status")))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="Elasticsearch", ok=False, detail=str(e)[:200]))
|
||
|
||
# Ollama
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5) as c:
|
||
resp = await c.get(f"{settings.OLLAMA_URL}/api/version")
|
||
checks.append(ServiceHealth(name="Ollama", ok=resp.status_code == 200,
|
||
detail=resp.json().get("version")))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="Ollama", ok=False, detail=str(e)[:200]))
|
||
|
||
# RabbitMQ + воркеры через Celery ping
|
||
try:
|
||
insp = celery_app.control.inspect(timeout=3)
|
||
pong = insp.ping() or {}
|
||
workers = list(pong.keys())
|
||
checks.append(ServiceHealth(name="RabbitMQ/Celery", ok=bool(workers),
|
||
detail=f"воркеров онлайн: {len(workers)}"))
|
||
except Exception as e:
|
||
checks.append(ServiceHealth(name="RabbitMQ/Celery", ok=False, detail=str(e)[:200]))
|
||
|
||
return checks
|
||
|
||
|
||
@router.get("/stats", response_model=AdminStats)
|
||
async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
|
||
"""Сводные счётчики для дашборда."""
|
||
users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one()
|
||
|
||
rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all()
|
||
tasks_by_status = {s: c for s, c in rows}
|
||
|
||
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
|
||
rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all()
|
||
docs_by_source = {s: c for s, c in rows}
|
||
|
||
staging_pending = (
|
||
await db.execute(
|
||
select(func.count()).select_from(StagedWork).where(StagedWork.status == "pending")
|
||
)
|
||
).scalar_one()
|
||
|
||
# Хранилище MinIO
|
||
storage: dict = {}
|
||
try:
|
||
client = get_minio()
|
||
for bucket in (settings.MINIO_BUCKET_DOCS, settings.MINIO_BUCKET_BACKUPS, "staging"):
|
||
try:
|
||
if not client.bucket_exists(bucket):
|
||
continue
|
||
total = 0
|
||
count = 0
|
||
for obj in client.list_objects(bucket, recursive=True):
|
||
total += obj.size or 0
|
||
count += 1
|
||
storage[bucket] = {"objects": count, "bytes": total}
|
||
except Exception:
|
||
continue
|
||
except Exception as e:
|
||
storage = {"error": str(e)[:200]}
|
||
|
||
# Длины очередей через RabbitMQ management API (если доступно)
|
||
queues: dict = {}
|
||
try:
|
||
rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0]
|
||
rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0]
|
||
rmq_host = settings.RABBITMQ_URL.split("@")[1].split(":")[0]
|
||
async with httpx.AsyncClient(timeout=5) as c:
|
||
resp = await c.get(
|
||
f"http://{rmq_host}:15672/api/queues",
|
||
auth=(rmq_user, rmq_pass),
|
||
)
|
||
if resp.status_code == 200:
|
||
for q in resp.json():
|
||
name = q.get("name", "")
|
||
if name.startswith("queue."):
|
||
queues[name] = q.get("messages", 0)
|
||
except Exception as e:
|
||
queues = {"error": str(e)[:120]}
|
||
|
||
return AdminStats(
|
||
users_total=users_total,
|
||
tasks_by_status=tasks_by_status,
|
||
documents_total=docs_total,
|
||
documents_by_source=docs_by_source,
|
||
staging_pending=staging_pending,
|
||
storage=storage,
|
||
queues=queues,
|
||
)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# КЛИЕНТЫ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/users", response_model=list[AdminUserResponse])
|
||
async def list_users(
|
||
q: str | None = None,
|
||
limit: int = Query(50, le=200),
|
||
offset: int = 0,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> list[AdminUserResponse]:
|
||
stmt = select(User)
|
||
if q:
|
||
like = f"%{q.lower()}%"
|
||
stmt = stmt.where(func.lower(User.email).like(like) | func.lower(User.name).like(like))
|
||
stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
|
||
users = (await db.execute(stmt)).scalars().all()
|
||
|
||
result = []
|
||
for u in users:
|
||
cnt = (
|
||
await db.execute(select(func.count()).select_from(Task).where(Task.user_id == u.id))
|
||
).scalar_one()
|
||
item = AdminUserResponse.model_validate(u)
|
||
item.tasks_count = cnt
|
||
result.append(item)
|
||
return result
|
||
|
||
|
||
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
||
async def update_user(
|
||
user_id: int,
|
||
data: AdminUserUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> AdminUserResponse:
|
||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
if data.plan is not None:
|
||
user.plan = data.plan
|
||
if data.is_verified is not None:
|
||
user.is_verified = data.is_verified
|
||
if data.is_admin is not None:
|
||
user.is_admin = data.is_admin
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
# Сбросить кэш пользователя
|
||
try:
|
||
await get_redis().delete(f"user:cache:{user_id}")
|
||
except Exception:
|
||
pass
|
||
return AdminUserResponse.model_validate(user)
|
||
|
||
|
||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)) -> None:
|
||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
await db.execute(delete(Task).where(Task.user_id == user_id))
|
||
await db.delete(user)
|
||
await db.commit()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# РАБОТЫ (задачи)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/tasks", response_model=list[AdminTaskResponse])
|
||
async def list_tasks(
|
||
status_filter: str | None = Query(None, alias="status"),
|
||
type_filter: str | None = Query(None, alias="type"),
|
||
user_id: int | None = None,
|
||
limit: int = Query(50, le=200),
|
||
offset: int = 0,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> list[AdminTaskResponse]:
|
||
stmt = select(Task)
|
||
if status_filter:
|
||
stmt = stmt.where(Task.status == status_filter)
|
||
if type_filter:
|
||
stmt = stmt.where(Task.type == type_filter)
|
||
if user_id:
|
||
stmt = stmt.where(Task.user_id == user_id)
|
||
stmt = stmt.order_by(Task.created_at.desc()).limit(limit).offset(offset)
|
||
tasks = (await db.execute(stmt)).scalars().all()
|
||
return [AdminTaskResponse.model_validate(t) for t in tasks]
|
||
|
||
|
||
@router.get("/tasks/{public_id}")
|
||
async def get_task(public_id: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||
task = (
|
||
await db.execute(select(Task).where(Task.public_id == public_id))
|
||
).scalar_one_or_none()
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="Задача не найдена")
|
||
return {
|
||
"public_id": task.public_id,
|
||
"user_id": task.user_id,
|
||
"type": task.type,
|
||
"status": task.status,
|
||
"input_data": task.input_data,
|
||
"result": task.result,
|
||
"error": task.error,
|
||
"created_at": task.created_at,
|
||
}
|
||
|
||
|
||
@router.post("/tasks/{public_id}/requeue")
|
||
async def requeue_task(public_id: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||
task = (
|
||
await db.execute(select(Task).where(Task.public_id == public_id))
|
||
).scalar_one_or_none()
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="Задача не найдена")
|
||
if task.type != "plagiarism":
|
||
raise HTTPException(status_code=400, detail="Перезапуск поддержан только для проверок плагиата")
|
||
minio_key = (task.input_data or {}).get("minio_key")
|
||
filename = (task.input_data or {}).get("filename")
|
||
if not minio_key:
|
||
raise HTTPException(status_code=400, detail="Нет minio_key для перезапуска")
|
||
task.status = "queued"
|
||
task.error = None
|
||
task.result = None
|
||
await db.commit()
|
||
celery_app.send_task(
|
||
"index.extract_and_check",
|
||
args=[task.id, minio_key, filename],
|
||
queue="queue.index",
|
||
)
|
||
return {"status": "requeued", "public_id": public_id}
|
||
|
||
|
||
@router.delete("/tasks/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_task(public_id: str, db: AsyncSession = Depends(get_db)) -> None:
|
||
task = (
|
||
await db.execute(select(Task).where(Task.public_id == public_id))
|
||
).scalar_one_or_none()
|
||
if task is None:
|
||
raise HTTPException(status_code=404, detail="Задача не найдена")
|
||
await db.delete(task)
|
||
await db.commit()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# БАЗА ДОКУМЕНТОВ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/documents", response_model=list[AdminDocumentResponse])
|
||
async def list_documents(
|
||
q: str | None = None,
|
||
source: str | None = None,
|
||
year: int | None = None,
|
||
limit: int = Query(50, le=200),
|
||
offset: int = 0,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> list[AdminDocumentResponse]:
|
||
stmt = select(Document)
|
||
if q:
|
||
stmt = stmt.where(func.lower(Document.title).like(f"%{q.lower()}%"))
|
||
if source:
|
||
stmt = stmt.where(Document.source == source)
|
||
if year:
|
||
stmt = stmt.where(Document.year == year)
|
||
stmt = stmt.order_by(Document.indexed_at.desc()).limit(limit).offset(offset)
|
||
docs = (await db.execute(stmt)).scalars().all()
|
||
return [AdminDocumentResponse.model_validate(d) for d in docs]
|
||
|
||
|
||
@router.delete("/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_document(doc_id: int, db: AsyncSession = Depends(get_db)) -> None:
|
||
doc = (await db.execute(select(Document).where(Document.id == doc_id))).scalar_one_or_none()
|
||
if doc is None:
|
||
raise HTTPException(status_code=404, detail="Документ не найден")
|
||
await db.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id))
|
||
await db.delete(doc)
|
||
await db.commit()
|
||
# Удалить из Elasticsearch (best-effort)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=5) as c:
|
||
await c.delete(f"{settings.ELASTICSEARCH_URL}/documents/_doc/{doc_id}")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ХРАНИЛИЩЕ
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/storage")
|
||
async def storage_info() -> dict:
|
||
client = get_minio()
|
||
result: dict = {"buckets": []}
|
||
for bucket in (settings.MINIO_BUCKET_DOCS, settings.MINIO_BUCKET_BACKUPS, "staging"):
|
||
try:
|
||
if not client.bucket_exists(bucket):
|
||
continue
|
||
total = 0
|
||
objs = []
|
||
for obj in client.list_objects(bucket, recursive=True):
|
||
total += obj.size or 0
|
||
if len(objs) < 100:
|
||
objs.append({"name": obj.object_name, "size": obj.size})
|
||
result["buckets"].append({
|
||
"name": bucket,
|
||
"objects_total": len(objs),
|
||
"bytes": total,
|
||
"sample": objs,
|
||
})
|
||
except Exception as e:
|
||
result["buckets"].append({"name": bucket, "error": str(e)[:120]})
|
||
return result
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ИСТОЧНИКИ ПАРСИНГА
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/sources", response_model=list[ParseSourceResponse])
|
||
async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]:
|
||
rows = (await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))).scalars().all()
|
||
return [ParseSourceResponse.model_validate(s) for s in rows]
|
||
|
||
|
||
@router.post("/sources", response_model=ParseSourceResponse, status_code=201)
|
||
async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse:
|
||
if data.source_type not in ("openalex", "cyberleninka", "arxiv"):
|
||
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
|
||
src = ParseSource(**data.model_dump())
|
||
db.add(src)
|
||
await db.commit()
|
||
await db.refresh(src)
|
||
return ParseSourceResponse.model_validate(src)
|
||
|
||
|
||
@router.patch("/sources/{source_id}", response_model=ParseSourceResponse)
|
||
async def update_source(
|
||
source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db)
|
||
) -> ParseSourceResponse:
|
||
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
|
||
if src is None:
|
||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||
for k, v in data.model_dump(exclude_unset=True).items():
|
||
setattr(src, k, v)
|
||
await db.commit()
|
||
await db.refresh(src)
|
||
return ParseSourceResponse.model_validate(src)
|
||
|
||
|
||
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)) -> None:
|
||
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
|
||
if src is None:
|
||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||
await db.delete(src)
|
||
await db.commit()
|
||
|
||
|
||
@router.post("/sources/{source_id}/run")
|
||
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
|
||
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
|
||
if src is None:
|
||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||
if src.last_status == "running":
|
||
raise HTTPException(status_code=409, detail="Источник уже парсится")
|
||
src.last_status = "running"
|
||
src.last_error = None
|
||
src.last_run_at = datetime.utcnow()
|
||
await db.commit()
|
||
celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index")
|
||
return {"status": "started", "source_id": source_id}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# ОТСТОЙНИК
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
@router.get("/staging", response_model=list[StagedWorkResponse])
|
||
async def list_staging(
|
||
status_filter: str = Query("pending", alias="status"),
|
||
limit: int = Query(50, le=200),
|
||
offset: int = 0,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> list[StagedWorkResponse]:
|
||
stmt = select(StagedWork)
|
||
if status_filter != "all":
|
||
stmt = stmt.where(StagedWork.status == status_filter)
|
||
stmt = stmt.order_by(StagedWork.created_at.desc()).limit(limit).offset(offset)
|
||
rows = (await db.execute(stmt)).scalars().all()
|
||
return [StagedWorkResponse.model_validate(s) for s in rows]
|
||
|
||
|
||
@router.get("/staging/{staged_id}", response_model=StagedWorkDetail)
|
||
async def get_staging(staged_id: int, db: AsyncSession = Depends(get_db)) -> StagedWorkDetail:
|
||
sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none()
|
||
if sw is None:
|
||
raise HTTPException(status_code=404, detail="Работа не найдена")
|
||
detail = StagedWorkDetail.model_validate(sw)
|
||
# Превью текста из MinIO
|
||
if sw.text_key:
|
||
try:
|
||
obj = get_minio().get_object("staging", sw.text_key)
|
||
text = obj.read().decode("utf-8", errors="replace")
|
||
detail.text_preview = text[:5000]
|
||
except Exception as e:
|
||
detail.text_preview = f"(не удалось прочитать текст: {e})"
|
||
return detail
|
||
|
||
|
||
@router.post("/staging/{staged_id}/approve")
|
||
async def approve_staging(
|
||
staged_id: int,
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> dict:
|
||
sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none()
|
||
if sw is None:
|
||
raise HTTPException(status_code=404, detail="Работа не найдена")
|
||
if sw.status != "pending":
|
||
raise HTTPException(status_code=409, detail=f"Работа уже {sw.status}")
|
||
|
||
# Прочитать извлечённый текст
|
||
full_text = ""
|
||
if sw.text_key:
|
||
try:
|
||
obj = get_minio().get_object("staging", sw.text_key)
|
||
full_text = obj.read().decode("utf-8", errors="replace")
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}")
|
||
|
||
# Добавить в базу документов через существующую задачу индексатора
|
||
doc_data = {
|
||
"source": "user_submission",
|
||
"ext_id": f"staged:{sw.id}",
|
||
"title": sw.title or sw.filename or f"Работа #{sw.id}",
|
||
"authors": sw.authors or [],
|
||
"year": sw.year,
|
||
"lang": sw.lang,
|
||
"abstract": full_text[:2000],
|
||
"full_text": full_text,
|
||
}
|
||
celery_app.send_task("index.add_document", args=[doc_data], queue="queue.index")
|
||
|
||
sw.status = "approved"
|
||
sw.reviewed_by = admin.id
|
||
sw.reviewed_at = datetime.utcnow()
|
||
await db.commit()
|
||
return {"status": "approved", "id": staged_id}
|
||
|
||
|
||
@router.post("/staging/{staged_id}/reject")
|
||
async def reject_staging(
|
||
staged_id: int,
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> dict:
|
||
sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none()
|
||
if sw is None:
|
||
raise HTTPException(status_code=404, detail="Работа не найдена")
|
||
sw.status = "rejected"
|
||
sw.reviewed_by = admin.id
|
||
sw.reviewed_at = datetime.utcnow()
|
||
await db.commit()
|
||
return {"status": "rejected", "id": staged_id}
|