Files
anti-plagiarism/services/api/app/api/admin.py
jze9 9894fa9320 feat: админ-панель, лицензия, тестовый стек на распределённой инфраструктуре
Админ-панель (доступ: 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>
2026-05-30 20:45:53 +05:00

572 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Админ-панель: дашборд, клиенты, работы, документы, хранилище, источники, отстойник.
Все эндпоинты требуют 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}