"""Pydantic-схемы для админ-панели.""" from datetime import datetime from typing import Any from pydantic import BaseModel, Field, computed_field from app.core.progress import run_percent # ─── Сессия доступа ─────────────────────────────────────────────────────────── class AdminSessionResponse(BaseModel): code: str url: str expires_at: datetime # ─── Клиенты ────────────────────────────────────────────────────────────────── class AdminUserResponse(BaseModel): id: int email: str name: str plan: str is_verified: bool is_admin: bool created_at: datetime tasks_count: int = 0 model_config = {"from_attributes": True} class AdminUserUpdate(BaseModel): plan: str | None = None is_verified: bool | None = None is_admin: bool | None = None # ─── Работы ───────────────────────────────────────────────────────────────── class AdminTaskResponse(BaseModel): public_id: str user_id: int type: str status: str error: str | None = None created_at: datetime model_config = {"from_attributes": True} # ─── Документы базы ─────────────────────────────────────────────────────────── class AdminDocumentResponse(BaseModel): id: int source: str ext_id: str title: str authors: list = Field(default_factory=list) year: int | None = None lang: str | None = None doi: str | None = None url: str | None = None indexed_at: datetime model_config = {"from_attributes": True} # ─── Прогоны парсинга ───────────────────────────────────────────────────────── class ParseRunResponse(BaseModel): """Состояние прогона для шкалы загрузки (без журнала — он в ParseRunDetail).""" id: int source_id: int status: str stage: str target: int fetched: int processed: int added: int duplicates: int skipped: int failed: int cancel_requested: bool = False error: str | None = None # started_at — постановка в очередь, run_started_at — реальный старт работы started_at: datetime run_started_at: datetime | None = None heartbeat_at: datetime | None = None finished_at: datetime | None = None model_config = {"from_attributes": True} @computed_field # type: ignore[prop-decorator] @property def percent(self) -> float: return run_percent(self.status, self.stage, self.target, self.fetched, self.processed) @computed_field # type: ignore[prop-decorator] @property def queued_s(self) -> float | None: """Сколько прогон ждал своей очереди, сек.""" if self.run_started_at is None: return None return round((self.run_started_at - self.started_at).total_seconds(), 1) @computed_field # type: ignore[prop-decorator] @property def duration_s(self) -> float | None: """Сколько прогон реально работал, сек (None — ещё идёт или не начинался). Прогоны до появления run_started_at (миграция 006) остаются без длительности: у них известен только момент постановки в очередь. """ if self.run_started_at is None or self.finished_at is None: return None return round((self.finished_at - self.run_started_at).total_seconds(), 1) class ParseRunDetail(ParseRunResponse): log: list[dict[str, Any]] = Field(default_factory=list) # ─── Источники парсинга ─────────────────────────────────────────────────────── class ParseSourceCreate(BaseModel): source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc") name: str = Field(min_length=1, max_length=255) query: str | None = None lang: str | None = None year_from: int | None = None year_to: int | None = None limit: int = Field(default=1000, ge=1, le=100000) enabled: bool = True class ParseSourceUpdate(BaseModel): name: str | None = None query: str | None = None lang: str | None = None year_from: int | None = None year_to: int | None = None limit: int | None = Field(default=None, ge=1, le=100000) enabled: bool | None = None class ParseSourceResponse(BaseModel): id: int source_type: str name: str query: str | None = None lang: str | None = None year_from: int | None = None year_to: int | None = None limit: int enabled: bool last_status: str last_error: str | None = None last_run_at: datetime | None = None docs_added: int created_at: datetime # Последний (или текущий) прогон — источник данных для шкалы в списке last_run: ParseRunResponse | None = None model_config = {"from_attributes": True} class ParseSourceBulkCreate(BaseModel): """Пакетное добавление: один тип/лимит, много запросов (по строке на тему).""" source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc") queries: list[str] = Field(min_length=1, max_length=500) name_prefix: str = "" lang: str | None = None year_from: int | None = None year_to: int | None = None limit: int = Field(default=1000, ge=1, le=100000) run_now: bool = False # ─── Отстойник ──────────────────────────────────────────────────────────────── class StagedWorkResponse(BaseModel): id: int user_id: int | None = None task_id: str | None = None filename: str | None = None title: str | None = None authors: list = Field(default_factory=list) year: int | None = None lang: str | None = None word_count: int | None = None status: str document_id: int | None = None created_at: datetime model_config = {"from_attributes": True} class StagedWorkDetail(StagedWorkResponse): text_preview: str | None = None # ─── Дашборд ────────────────────────────────────────────────────────────────── class ServiceHealth(BaseModel): name: str ok: bool detail: str | None = None class AdminStats(BaseModel): users_total: int tasks_by_status: dict[str, int] documents_total: int documents_by_source: dict[str, int] staging_pending: int storage: dict[str, Any] queues: dict[str, Any]