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

View File

@@ -1,12 +1,12 @@
# Управление ИТ (ОМС) — система дистанционного обучения
# Скопировать в .env и при необходимости изменить значения
# Скопировать в .env и задать пароль БД
# База данных (будет использоваться серверной частью)
# POSTGRES_HOST=db
# POSTGRES_PORT=5432
# POSTGRES_DB=lms_it_oms
# POSTGRES_USER=lms
# POSTGRES_PASSWORD=задать_пароль
# База данных
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=lms_it_oms
POSTGRES_USER=lms
POSTGRES_PASSWORD=задать_надёжный_пароль
# Приложение
# SECRET_KEY=задать_секретный_ключ
# Путь к контенту в контейнере (не менять при стандартном запуске)
# CONTENT_PATH=/content

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
venv/

View File

@@ -1,5 +1,26 @@
# История изменений — Управление ИТ (ОМС)
## 2025-03-16 14:00 UTC Серверная часть и связка с фронтом
**Проблема:** нужна фиксация ФИО при старте обучения и сохранение результатов тестирования в БД.
**Решение:** реализован backend на FastAPI, PostgreSQL, экран ввода ФИО и вызовы API из курса.
**Изменения:**
- **Тест:** исправлен правильный ответ на вопрос про эскалацию (80% вместо 100%).
- **Backend:** FastAPI, SQLAlchemy, модель `Participant` (id, fio, started_at, completed_at, score, total_questions, percent, passed). API: `POST /api/start`, `POST /api/complete`, `GET /api/health`. Статика курса из каталога `content/`.
- **Фронт:** экран приветствия с формой ФИО; после ввода — запрос `/api/start`, сохранение `participant_id` в sessionStorage, отображение «Вы вошли как: ФИО»; после прохождения теста — отправка результатов через `/api/complete`.
- **Docker:** образ backend (Python 3.12), PostgreSQL 15, volume для БД, монтирование `content`. Volume добавлен в `include-volumes.txt`.
- Обновлены README.md, .env.example, HISTORY.md.
**Проверка:**
```bash
cd /opt/lms-it-oms && docker compose up -d
# Открыть http://localhost:8000/ — ввод ФИО, прохождение курса и теста, результат сохраняется в БД.
```
---
## 2025-03-16 12:00 UTC Создание проекта
**Проблема:** нужна система дистанционного обучения «Управление ИТ (ОМСс HTML-контентом и последующей серверной частью для учёта сотрудников и результатов тестов.

View File

@@ -5,22 +5,46 @@
## Назначение
- Раздача контента обучения и тестирования (HTML).
- Фиксация ФИО сотрудника при старте обучения и при прохождении теста.
- Сохранение результатов тестирования в базе данных.
- Фиксация ФИО сотрудника при старте обучения (экран входа).
- Сохранение результатов тестирования в PostgreSQL после прохождения теста.
## Требования и зависимости
- Docker, Docker Compose
- (Планируется) PostgreSQL для хранения записей о прохождении и результатах тестов
- PostgreSQL 15 (запускается в контейнере)
## Установка и настройка
1. Скопировать `.env.example` в `.env` и при необходимости задать переменные.
2. Запуск: `docker compose up -d` (после добавления серверной части).
1. Скопировать `.env.example` в `.env` и задать пароль БД:
```bash
cp .env.example .env
# Отредактировать .env: POSTGRES_PASSWORD=...
```
2. Запуск:
```bash
cd /opt/lms-it-oms
docker compose up -d
```
3. Курс доступен по адресу: **http://localhost:8000/** (редирект на `/content/it_course_v2.html`).
## Конфигурация
См. `.env.example`. Переменные задаются в `.env` (файл не коммитится).
Переменные в `.env` (файл не коммитится):
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| POSTGRES_HOST | Хост БД | db |
| POSTGRES_PORT | Порт БД | 5432 |
| POSTGRES_DB | Имя БД | lms_it_oms |
| POSTGRES_USER | Пользователь БД | lms |
| POSTGRES_PASSWORD | Пароль БД | **обязательно задать** |
| CONTENT_PATH | Путь к каталогу content в контейнере | /content |
## API
- `POST /api/start` — регистрация начала обучения. Тело: `{"fio": "ФИО"}`. Ответ: `{"participant_id": "...", "fio": "..."}`.
- `POST /api/complete` — фиксация результата теста. Тело: `{"participant_id": "...", "score": 20, "total_questions": 25, "percent": 80, "passed": true}`.
- `GET /api/health` — проверка доступности API.
## Структура проекта
@@ -30,27 +54,37 @@
├── HISTORY.md
├── .env.example
├── docker-compose.yml
├── content/ # HTML-файлы обучения и тестирования (положить сюда для анализа)
└── (backend/ и static/ — будут добавлены при реализации серверной части)
├── content/ # HTML курса
│ └── it_course_v2.html
└── backend/
├── Dockerfile
├── requirements.txt
└── app/
├── main.py # FastAPI, статика, API
├── config.py
├── database.py
├── models.py
└── schemas.py
```
### Куда положить HTML для анализа
## Docker Compose
**Положите ваши HTML-файлы обучения и тестирования в каталог:**
```
/opt/lms-it-oms/content/
```
После размещения файлов можно запросить анализ и корректировку контента, а затем — добавление серверной части для учёта ФИО и результатов тестов.
- **app** — FastAPI на порту 8000, раздаёт статику из `./content` и обрабатывает `/api/*`.
- **db** — PostgreSQL 15, volume `lms_it_oms_data` для данных.
## Логирование
(Будет описано после внедрения серверной части.)
- Логи приложения: `docker compose logs -f app`
- Логи БД: `docker compose logs -f db`
- Ротация логов app: json-file, max-size 10m, max-file 3.
## Резервное копирование
Volumes проекта будут добавлены в `/opt/gen7x/backup/include-volumes.txt`.
Volume `lms-it-oms_lms_it_oms_data` добавлен в `/opt/gen7x/backup/include-volumes.txt`. Бэкапить данные PostgreSQL по общему регламенту платформы.
## Восстановление из бэкапа
Восстановить volume БД по инструкции платформы. После восстановления перезапустить: `docker compose up -d`.
## История изменений

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

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

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.9
pydantic-settings>=2.0.0

1586
content/it_course_v2.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,46 @@
# Управление ИТ (ОМС) — система дистанционного обучения
# Серверная часть и БД будут добавлены после размещения HTML-контента
version: "3.9"
# Сервисы: app (FastAPI + статика), db (PostgreSQL)
services:
# Заглушка: после добавления backend и БД здесь появятся app и db
# app:
# build: .
# ports:
# - "8000:8000"
# env_file: .env
# depends_on:
# - db
# db:
# image: postgres:15-alpine
# environment:
# POSTGRES_DB: ${POSTGRES_DB}
# POSTGRES_USER: ${POSTGRES_USER}
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# volumes:
# - lms_it_oms_data:/var/lib/postgresql/data
app:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-lms_it_oms}
POSTGRES_USER: ${POSTGRES_USER:-lms}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
CONTENT_PATH: /content
volumes:
- ./content:/content:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# volumes:
# lms_it_oms_data:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-lms_it_oms}
POSTGRES_USER: ${POSTGRES_USER:-lms}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- lms_it_oms_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms} -d ${POSTGRES_DB:-lms_it_oms}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
lms_it_oms_data: