Feature: серверная часть, ФИО и результаты теста в БД; правка ответа эскалация 80%
Made-with: Cursor
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# LMS IT OMS — backend
|
||||
27
backend/app/config.py
Normal file
27
backend/app/config.py
Normal 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
33
backend/app/database.py
Normal 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
109
backend/app/main.py
Normal 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
30
backend/app/models.py
Normal 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
32
backend/app/schemas.py
Normal 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
|
||||
Reference in New Issue
Block a user