diff --git a/.env.example b/.env.example index 2cd74b4..e0334eb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a40386e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.venv/ +venv/ diff --git a/HISTORY.md b/HISTORY.md index 06842f1..3ecf55f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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-контентом и последующей серверной частью для учёта сотрудников и результатов тестов. diff --git a/README.md b/README.md index ce13d7d..fe14593 100644 --- a/README.md +++ b/README.md @@ -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`. ## История изменений diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..54c5440 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..abb3206 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# LMS IT OMS — backend diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..b0c10c1 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..f79a4c4 --- /dev/null +++ b/backend/app/database.py @@ -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("Таблицы БД созданы или уже существуют") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..efa19ae --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..e1fe9b0 --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..c25a2bd --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d44651c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/content/it_course_v2.html b/content/it_course_v2.html new file mode 100644 index 0000000..938c95b --- /dev/null +++ b/content/it_course_v2.html @@ -0,0 +1,1586 @@ + + + + + +Управление ИТ — Обучающий курс + + + + +
+
+
ИТ-специалист · Обучение v2
+

Управление ИТ

+

Интерактивный курс по руководству ИТ-сервисами компании · Редакция 03.03.2026

+ +
+ +
+
+

Вход в курс

+

Укажите ваши фамилию, имя и отчество для регистрации прохождения обучения и фиксации результата тестирования.

+
+ + +
+ +
+
+
+ +
+
+ Прогресс курса +
+ 0 / 4 +
+
+
+ +
+
+ +
+

+
+
+
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
📝
+
+

Итоговое тестирование

+

Подтверждение прохождения курса «Управление ИТ»

+
+
+
0
+
правильных
+
+
+
+ Вопрос +
+ 1 / 25 +
+
+
+ +
+
+ +
+
+ 🏆 +

+
+

+
+
Правильно
+
Ошибок
+
Результат
+
+
+
+ + +
+
+
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml index 16f3605..f9f053b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: