Feature: серверная часть, ФИО и результаты теста в БД; правка ответа эскалация 80%

Made-with: Cursor
This commit is contained in:
2026-03-16 07:46:37 +00:00
parent 2de0d2cfbd
commit 05388b2ba6
14 changed files with 1971 additions and 48 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# LMS IT OMS — backend

27
backend/app/config.py Normal file
View File

@@ -0,0 +1,27 @@
"""Конфигурация приложения."""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Настройки из переменных окружения."""
postgres_host: str = "db"
postgres_port: int = 5432
postgres_db: str = "lms_it_oms"
postgres_user: str = "lms"
postgres_password: str = ""
content_path: str = "" # Путь к каталогу content (в Docker: /content)
@property
def database_url(self) -> str:
return (
f"postgresql://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
class Config:
env_file = ".env"
env_prefix = ""
settings = Settings()

33
backend/app/database.py Normal file
View File

@@ -0,0 +1,33 @@
"""Подключение к БД и сессия."""
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
logger = logging.getLogger(__name__)
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
echo=False,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Генератор сессии БД для FastAPI."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Создание таблиц при старте."""
from app import models # noqa: F401
Base.metadata.create_all(bind=engine)
logger.info("Таблицы БД созданы или уже существуют")

109
backend/app/main.py Normal file
View File

@@ -0,0 +1,109 @@
"""FastAPI-приложение LMS «Управление ИТ (ОМС)»."""
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db, init_db
from app.models import Participant
from app.schemas import (
StartRequest,
StartResponse,
CompleteRequest,
CompleteResponse,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Инициализация при старте, создание таблиц."""
init_db()
yield
# shutdown при необходимости
app = FastAPI(
title="LMS Управление ИТ (ОМС)",
description="API для фиксации прохождения обучения и результатов тестирования",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Статика: курс (HTML) — из CONTENT_PATH в Docker или content/ рядом с backend
static_path = Path(settings.content_path) if settings.content_path else (Path(__file__).resolve().parent.parent.parent / "content")
if static_path.exists():
app.mount("/content", StaticFiles(directory=str(static_path), html=True), name="content")
@app.get("/")
async def root():
"""Редирект на страницу курса."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/content/it_course_v2.html", status_code=302)
@app.get("/api/health")
async def health():
"""Проверка доступности API."""
return {"status": "ok"}
@app.post("/api/start", response_model=StartResponse)
def api_start(body: StartRequest, db: Session = Depends(get_db)):
"""
Регистрация начала обучения.
Принимает ФИО, создаёт запись в БД, возвращает participant_id для последующей отправки результата.
"""
fio = body.fio.strip()
if not fio:
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
participant = Participant(fio=fio)
db.add(participant)
db.commit()
db.refresh(participant)
logger.info("Обучение начато: participant_id=%s, fio=%s", participant.id, participant.fio)
return StartResponse(participant_id=participant.id, fio=participant.fio)
@app.post("/api/complete", response_model=CompleteResponse)
def api_complete(body: CompleteRequest, db: Session = Depends(get_db)):
"""
Фиксация результата тестирования.
Обновляет запись участника по participant_id: score, percent, passed, completed_at.
"""
participant = db.query(Participant).filter(Participant.id == body.participant_id).first()
if not participant:
raise HTTPException(status_code=404, detail="Участник не найден")
participant.completed_at = datetime.now(timezone.utc)
participant.score = body.score
participant.total_questions = body.total_questions
participant.percent = body.percent
participant.passed = body.passed
db.commit()
logger.info(
"Тест завершён: participant_id=%s, score=%s/%s, percent=%s, passed=%s",
participant.id,
body.score,
body.total_questions,
body.percent,
body.passed,
)
return CompleteResponse(participant_id=participant.id, saved=True)

30
backend/app/models.py Normal file
View File

@@ -0,0 +1,30 @@
"""Модели БД."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, Integer, Boolean, DateTime, Numeric
from app.database import Base
def gen_uuid():
return str(uuid.uuid4())
def utc_now():
return datetime.now(timezone.utc)
class Participant(Base):
"""Участник обучения: ФИО, старт, завершение теста, результат."""
__tablename__ = "participants"
id = Column(String(36), primary_key=True, default=gen_uuid)
fio = Column(String(500), nullable=False, index=True)
started_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)
completed_at = Column(DateTime(timezone=True), nullable=True)
score = Column(Integer, nullable=True)
total_questions = Column(Integer, nullable=True)
percent = Column(Numeric(5, 2), nullable=True)
passed = Column(Boolean, nullable=True)

32
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,32 @@
"""Pydantic-схемы для API."""
from pydantic import BaseModel, Field
class StartRequest(BaseModel):
"""Запрос на начало обучения."""
fio: str = Field(..., min_length=1, max_length=500, description="ФИО сотрудника")
class StartResponse(BaseModel):
"""Ответ после регистрации начала обучения."""
participant_id: str
fio: str
class CompleteRequest(BaseModel):
"""Запрос на фиксацию результата теста."""
participant_id: str = Field(..., min_length=1)
score: int = Field(..., ge=0)
total_questions: int = Field(..., gt=0)
percent: float = Field(..., ge=0, le=100)
passed: bool
class CompleteResponse(BaseModel):
"""Ответ после сохранения результата."""
participant_id: str
saved: bool = True