test build
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
jze9
2026-05-18 01:14:40 +05:00
commit 2a14350ee3
46 changed files with 3620 additions and 0 deletions

View File

115
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,115 @@
from fastapi import APIRouter, HTTPException, status, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
import asyncio
import json as _json
router = APIRouter(prefix="/api/admin", tags=["admin"])
SOURCES = {
"wikipedia_ru": "Wikipedia RU (~8 GB)",
"wikipedia_en": "Wikipedia EN (~22 GB)",
}
class JobResponse(BaseModel):
status: str
source: str | None = None
label: str | None = None
progress: int = 0
message: str = ""
started_at: str | None = None
finished_at: str | None = None
docs_done: int = 0
@router.get("/index/sources")
def list_sources():
"""Список доступных источников для индексации."""
return [{"key": k, "label": v} for k, v in SOURCES.items()]
@router.post("/index/download/{source_key}", response_model=JobResponse)
def start_download(source_key: str):
"""Скачать дамп и проиндексировать с нуля."""
if source_key not in SOURCES:
raise HTTPException(status_code=400, detail=f"Unknown source: {source_key}")
from app.tasks.indexing import get_job_status, download_and_index
current = get_job_status()
if current.get("status") == "running":
raise HTTPException(status_code=409, detail="Задание уже выполняется")
download_and_index.delay(source_key)
return JobResponse(status="started", source=source_key, label=SOURCES[source_key], message="Запуск скачивания…")
@router.post("/index/update/{source_key}", response_model=JobResponse)
def start_update(source_key: str):
"""Обновить индекс — только новые документы (дамп не скачивается заново если уже есть)."""
if source_key not in SOURCES:
raise HTTPException(status_code=400, detail=f"Unknown source: {source_key}")
from app.tasks.indexing import get_job_status, update_index
current = get_job_status()
if current.get("status") == "running":
raise HTTPException(status_code=409, detail="Задание уже выполняется")
update_index.delay(source_key)
return JobResponse(status="started", source=source_key, label=SOURCES[source_key], message="Запуск обновления…")
@router.get("/index/status", response_model=JobResponse)
def job_status():
"""Текущий статус задания индексации."""
from app.tasks.indexing import get_job_status
data = get_job_status()
return JobResponse(
status=data.get("status", "idle"),
source=data.get("source"),
label=data.get("label"),
progress=data.get("progress", 0),
message=data.get("message", ""),
started_at=data.get("started_at"),
finished_at=data.get("finished_at"),
docs_done=data.get("docs_done", 0),
)
@router.websocket("/ws/index-progress")
async def ws_index_progress(websocket: WebSocket):
"""Stream indexing job progress via Redis pub/sub."""
await websocket.accept()
import redis.asyncio as aioredis
from app.config import settings
r = aioredis.from_url(settings.REDIS_URL)
pubsub = r.pubsub()
await pubsub.subscribe("index_job_progress")
# Send current state immediately
from app.tasks.indexing import get_job_status
await websocket.send_json(get_job_status())
try:
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
data = _json.loads(message["data"])
except Exception:
continue
await websocket.send_json(data)
if data.get("status") in ("done", "error"):
break
except (WebSocketDisconnect, asyncio.CancelledError):
pass
finally:
try:
await pubsub.unsubscribe("index_job_progress")
except Exception:
pass
try:
await r.aclose()
except Exception:
pass

115
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,115 @@
from datetime import datetime, timedelta
from typing import Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.config import settings
from app.models.db import get_db, User
router = APIRouter(prefix="/api/auth", tags=["auth"])
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
# ── Schemas ────────────────────────────────────────────────────────────────
class RegisterRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: str
email: str
created_at: datetime
class Config:
from_attributes = True
# ── Helpers ────────────────────────────────────────────────────────────────
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_token(user_id) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": str(user_id), "exp": expire},
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> Optional[User]:
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id_str: str = payload.get("sub")
if not user_id_str:
return None
import uuid as _uuid
user_id = _uuid.UUID(user_id_str)
except (JWTError, ValueError):
return None
return db.query(User).filter(User.id == user_id).first()
def require_user(current_user: Optional[User] = Depends(get_current_user)) -> User:
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
# ── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(body: RegisterRequest, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form.username).first()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
return {"access_token": create_token(user.id)}
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(require_user)):
return current_user