Leadetboard, file downloader
This commit is contained in:
parent
cb49eb2e05
commit
b2927601a9
25
README.md
25
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Alabuga Gamification Platform
|
||||
# «Автостопом по Алабуге»
|
||||
|
||||
Проект реализует прототип геймифицированного модуля для кадровой системы «Алабуги». Мы создаём космический лор, ранги, миссии, журнал событий и магазин артефактов. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript).
|
||||
Проект «Автостопом по Алабуге» — это геймифицированный гид по карьерной Галактике Алабуги. Как и в «Автостопом по Галактике», экипаж полагается на остроумные подсказки, лор и рейтинг пилотов, чтобы не потерять полотенце в бюрократических туманностях. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript).
|
||||
|
||||
## Содержимое репозитория
|
||||
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
- API: http://localhost:8000 (документация Swagger — `/docs`).
|
||||
- Фронтенд: http://localhost:3000.
|
||||
|
||||
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера.
|
||||
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера. Загружаемые кандидатами файлы помещаются в каталог, заданный переменной `ALABUGA_UPLOADS_PATH` (по умолчанию `./data/uploads`).
|
||||
|
||||
## Пользовательские учётные записи (сидированные)
|
||||
|
||||
|
|
@ -50,9 +50,11 @@ Docker Compose автоматически переопределяет `ALABUGA_
|
|||
2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
|
||||
3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
|
||||
4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||
5. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||
6. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
|
||||
7. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
||||
5. **Документы для миссии #1**: на странице `/missions/1` прикрепите паспорт (PDF/изображение), свежую фотографию и резюме (файл или ссылка). После отправки файлы можно скачать из блока «Загружено ранее».
|
||||
6. **Выполнение миссии**: отправьте отчёт и документы, затем переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||
7. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
|
||||
8. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
||||
9. **Лидерборд**: откройте `/leaderboard` (доступно и пилотам, и HR), чтобы увидеть текущие позиции по опыту и уровню компетенций.
|
||||
|
||||
### Подтверждение электронной почты
|
||||
|
||||
|
|
@ -65,6 +67,16 @@ Docker Compose автоматически переопределяет `ALABUGA_
|
|||
|
||||
Демо-учётные записи в сид-данных имеют уже подтверждённый e-mail.
|
||||
|
||||
### Очистка тестовых данных
|
||||
|
||||
Чтобы удалить отправленные миссии, журнал и вложения, выполните:
|
||||
|
||||
```bash
|
||||
python -m scripts.reset_demo_data
|
||||
```
|
||||
|
||||
Скрипт прогонит миграции, очистит таблицы `mission_submissions`, `orders`, `journal_entries`, сбросит опыт/ману пользователей и удалит весь каталог с загруженными документами (`ALABUGA_UPLOADS_PATH`).
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
|
|
@ -84,6 +96,7 @@ pytest
|
|||
- Онбординг с сохранением прогресса и космическим лором.
|
||||
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
||||
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
||||
- Лидерборд пилотов по опыту с отображением ключевых компетенций.
|
||||
|
||||
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ HR-платформа [hr.alabuga.ru]
|
|||
|
||||
- основная платформа для авторизации в экосистеме «Алабуги». На этой
|
||||
платформе расположены бизнес-симуляции, в которые играют кандидаты и
|
||||
|
||||
з
|
||||
сотрудники
|
||||
|
||||
Карьера.100 лидеров [career.alabuga.space]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Alabuga Gamification API Environment Variables
|
||||
|
||||
# Debug mode (enables auto-creation of demo users)
|
||||
# General settings
|
||||
ALABUGA_ENVIRONMENT=local
|
||||
ALABUGA_DEBUG=true
|
||||
|
||||
# Security settings
|
||||
|
|
@ -13,6 +14,7 @@ ALABUGA_REQUIRE_EMAIL_CONFIRMATION=false
|
|||
|
||||
# Database settings
|
||||
ALABUGA_SQLITE_PATH=/data/app.db
|
||||
ALABUGA_UPLOADS_PATH=/data/uploads
|
||||
|
||||
# CORS settings (JSON array format)
|
||||
ALABUGA_BACKEND_CORS_ORIGINS=["http://localhost:3000", "http://frontend:3000", "http://0.0.0.0:3000"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
"""Add submission document fields"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20240927_0005'
|
||||
down_revision = '20240927_0004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('mission_submissions', sa.Column('passport_path', sa.String(length=512), nullable=True))
|
||||
op.add_column('mission_submissions', sa.Column('photo_path', sa.String(length=512), nullable=True))
|
||||
op.add_column('mission_submissions', sa.Column('resume_path', sa.String(length=512), nullable=True))
|
||||
op.add_column('mission_submissions', sa.Column('resume_link', sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('mission_submissions', 'resume_link')
|
||||
op.drop_column('mission_submissions', 'resume_path')
|
||||
op.drop_column('mission_submissions', 'photo_path')
|
||||
op.drop_column('mission_submissions', 'passport_path')
|
||||
|
|
@ -3,26 +3,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||
from app.schemas.mission import (
|
||||
MissionBase,
|
||||
MissionDetail,
|
||||
MissionSubmissionCreate,
|
||||
MissionSubmissionRead,
|
||||
)
|
||||
from app.services.mission import submit_mission
|
||||
from app.services.mission import UNSET, submit_mission
|
||||
from app.services.storage import delete_submission_document, save_submission_document
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
||||
|
||||
# Для миссии #1 требуется обязательное прикрепление документов.
|
||||
REQUIRED_DOCUMENT_MISSIONS = {1}
|
||||
|
||||
|
||||
def _load_user_progress(user: User) -> set[int]:
|
||||
"""Возвращаем идентификаторы успешно завершённых миссий."""
|
||||
|
|
@ -194,8 +198,15 @@ def list_missions(
|
|||
mission_titles=mission_titles,
|
||||
)
|
||||
dto = MissionBase.model_validate(mission)
|
||||
dto.is_available = is_available
|
||||
dto.locked_reasons = reasons
|
||||
dto.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||
if mission.id in completed_missions:
|
||||
dto.is_completed = True
|
||||
dto.is_available = False
|
||||
dto.locked_reasons = ["Миссия уже завершена"]
|
||||
else:
|
||||
dto.is_completed = False
|
||||
dto.is_available = is_available
|
||||
dto.locked_reasons = reasons
|
||||
response.append(dto)
|
||||
|
||||
return response
|
||||
|
|
@ -260,18 +271,28 @@ def get_mission(
|
|||
created_at=mission.created_at,
|
||||
updated_at=mission.updated_at,
|
||||
)
|
||||
data.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||
if mission.id in completed_missions:
|
||||
data.is_completed = True
|
||||
data.is_available = False
|
||||
data.locked_reasons = ["Миссия уже завершена"]
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт")
|
||||
def submit(
|
||||
async def submit(
|
||||
mission_id: int,
|
||||
submission_in: MissionSubmissionCreate,
|
||||
*,
|
||||
comment: str | None = Form(None),
|
||||
proof_url: str | None = Form(None),
|
||||
resume_link: str | None = Form(None),
|
||||
passport: UploadFile | None = File(None),
|
||||
photo: UploadFile | None = File(None),
|
||||
resume_file: UploadFile | None = File(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> MissionSubmissionRead:
|
||||
"""Пилот отправляет доказательство выполнения миссии."""
|
||||
"""Пилот отправляет доказательство выполнения миссии и сопроводительные документы."""
|
||||
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
||||
if not mission:
|
||||
|
|
@ -297,13 +318,88 @@ def submit(
|
|||
)
|
||||
if not is_available:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
|
||||
submission = submit_mission(
|
||||
db=db,
|
||||
user=current_user,
|
||||
mission=mission,
|
||||
comment=submission_in.comment,
|
||||
proof_url=submission_in.proof_url,
|
||||
|
||||
existing_submission = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(MissionSubmission.user_id == current_user.id, MissionSubmission.mission_id == mission.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def _has_upload(upload: UploadFile | None) -> bool:
|
||||
return bool(upload and upload.filename)
|
||||
|
||||
passport_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||
photo_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||
resume_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||
|
||||
if passport_required and not (
|
||||
(existing_submission and existing_submission.passport_path) or _has_upload(passport)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Загрузите скан паспорта кандидата.")
|
||||
|
||||
if photo_required and not (
|
||||
(existing_submission and existing_submission.photo_path) or _has_upload(photo)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Добавьте актуальную фотографию кандидата.")
|
||||
|
||||
existing_resume_sources = bool(
|
||||
existing_submission
|
||||
and (existing_submission.resume_path or existing_submission.resume_link)
|
||||
)
|
||||
resume_link_trimmed = (resume_link or "").strip()
|
||||
resume_file_provided = _has_upload(resume_file)
|
||||
if resume_required and not (existing_resume_sources or resume_link_trimmed or resume_file_provided):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Добавьте ссылку на резюме или загрузите файл с резюме.",
|
||||
)
|
||||
|
||||
new_passport_path = None
|
||||
new_photo_path = None
|
||||
new_resume_path = None
|
||||
|
||||
try:
|
||||
if _has_upload(passport):
|
||||
new_passport_path = save_submission_document(
|
||||
upload=passport,
|
||||
user_id=current_user.id,
|
||||
mission_id=mission.id,
|
||||
kind="passport",
|
||||
)
|
||||
|
||||
if _has_upload(photo):
|
||||
new_photo_path = save_submission_document(
|
||||
upload=photo,
|
||||
user_id=current_user.id,
|
||||
mission_id=mission.id,
|
||||
kind="photo",
|
||||
)
|
||||
|
||||
if resume_file_provided:
|
||||
new_resume_path = save_submission_document(
|
||||
upload=resume_file,
|
||||
user_id=current_user.id,
|
||||
mission_id=mission.id,
|
||||
kind="resume",
|
||||
)
|
||||
|
||||
submission = submit_mission(
|
||||
db=db,
|
||||
user=current_user,
|
||||
mission=mission,
|
||||
comment=(comment or "").strip() or None,
|
||||
proof_url=(proof_url or "").strip() or None,
|
||||
passport_path=new_passport_path if new_passport_path is not None else UNSET,
|
||||
photo_path=new_photo_path if new_photo_path is not None else UNSET,
|
||||
resume_path=new_resume_path if new_resume_path is not None else UNSET,
|
||||
resume_link=(resume_link_trimmed or None) if resume_link is not None else UNSET,
|
||||
)
|
||||
except Exception:
|
||||
delete_submission_document(new_passport_path)
|
||||
delete_submission_document(new_photo_path)
|
||||
delete_submission_document(new_resume_path)
|
||||
raise
|
||||
|
||||
return MissionSubmissionRead.model_validate(submission)
|
||||
|
||||
|
||||
|
|
@ -328,3 +424,45 @@ def get_submission(
|
|||
if not submission:
|
||||
return None
|
||||
return MissionSubmissionRead.model_validate(submission)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/submissions/{submission_id}/files/{document}",
|
||||
summary="Скачиваем загруженные файлы",
|
||||
)
|
||||
def download_submission_file(
|
||||
submission_id: int,
|
||||
document: str,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
"""Возвращаем файл паспорта, фото или резюме."""
|
||||
|
||||
submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена")
|
||||
|
||||
if submission.user_id != current_user.id and current_user.role != UserRole.HR:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Нет доступа к файлу")
|
||||
|
||||
attribute_map = {
|
||||
"passport": submission.passport_path,
|
||||
"photo": submission.photo_path,
|
||||
"resume": submission.resume_path,
|
||||
}
|
||||
|
||||
relative_path = attribute_map.get(document)
|
||||
if not relative_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||
|
||||
file_path = settings.uploads_path / relative_path
|
||||
resolved = file_path.resolve()
|
||||
base = settings.uploads_path.resolve()
|
||||
if not resolved.is_relative_to(base):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||
|
||||
if not resolved.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||
|
||||
return FileResponse(resolved, filename=resolved.name)
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.rank import Rank
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole, UserCompetency
|
||||
from app.models.mission import SubmissionStatus
|
||||
from app.schemas.progress import ProgressSnapshot
|
||||
from app.schemas.rank import RankBase
|
||||
from app.schemas.user import UserProfile
|
||||
from app.schemas.user import LeaderboardEntry, UserCompetencyRead, UserProfile
|
||||
from app.services.rank import build_progress_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
|
@ -52,3 +53,40 @@ def get_progress(
|
|||
_ = current_user.competencies
|
||||
snapshot = build_progress_snapshot(current_user, db)
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.get("/leaderboard", response_model=list[LeaderboardEntry], summary="Лидерборд пилотов")
|
||||
def leaderboard(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> list[LeaderboardEntry]:
|
||||
"""Возвращаем пилотов, отсортированных по опыту с перечислением компетенций."""
|
||||
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(User.role == UserRole.PILOT)
|
||||
.options(
|
||||
selectinload(User.current_rank),
|
||||
selectinload(User.competencies).selectinload(UserCompetency.competency),
|
||||
selectinload(User.submissions),
|
||||
)
|
||||
.order_by(User.xp.desc(), User.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
leaderboard: list[LeaderboardEntry] = []
|
||||
for user in users:
|
||||
completed = sum(1 for submission in user.submissions if submission.status == SubmissionStatus.APPROVED)
|
||||
competencies = [UserCompetencyRead.model_validate(entry) for entry in user.competencies]
|
||||
leaderboard.append(
|
||||
LeaderboardEntry(
|
||||
user_id=user.id,
|
||||
full_name=user.full_name,
|
||||
rank_title=user.current_rank.title if user.current_rank else None,
|
||||
xp=user.xp,
|
||||
mana=user.mana,
|
||||
completed_missions=completed,
|
||||
competencies=competencies,
|
||||
)
|
||||
)
|
||||
|
||||
return leaderboard
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
|||
model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore")
|
||||
|
||||
project_name: str = "Alabuga Gamification API"
|
||||
environment: str = "local"
|
||||
debug: bool = False
|
||||
secret_key: str = "super-secret-key-change-me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
|
|
@ -31,6 +32,7 @@ class Settings(BaseSettings):
|
|||
]
|
||||
|
||||
sqlite_path: Path = Path("/data/app.db")
|
||||
uploads_path: Path = Path("./data/uploads")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
|
|
@ -48,7 +50,11 @@ def get_settings() -> Settings:
|
|||
if not settings.sqlite_path.is_absolute():
|
||||
settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve()
|
||||
|
||||
if not settings.uploads_path.is_absolute():
|
||||
settings.uploads_path = (BASE_DIR / settings.uploads_path).resolve()
|
||||
|
||||
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings.uploads_path.mkdir(parents=True, exist_ok=True)
|
||||
return settings
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,38 +2,94 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401 - важно, чтобы Base знала обо всех моделях
|
||||
from app.api.routes import admin, auth, journal, missions, onboarding, store, users
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User, UserRole
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.rank import Rank
|
||||
# Import all models to ensure they're registered with Base.metadata
|
||||
from app import models # This imports all models through the __init__.py
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
ALEMBIC_CONFIG = Path(__file__).resolve().parents[1] / "alembic.ini"
|
||||
|
||||
app = FastAPI(title=settings.project_name)
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Прогоняем миграции Alembic, поддерживая легаси-базы без alembic_version."""
|
||||
|
||||
config = Config(str(ALEMBIC_CONFIG))
|
||||
config.set_main_option("sqlalchemy.url", str(settings.database_url))
|
||||
script = ScriptDirectory.from_config(config)
|
||||
head_revision = script.get_current_head()
|
||||
|
||||
inspector = inspect(engine)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
current_revision: str | None = None
|
||||
if "alembic_version" in tables:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone()
|
||||
current_revision = row[0] if row else None
|
||||
|
||||
if "alembic_version" not in tables or current_revision is None:
|
||||
if not tables:
|
||||
command.upgrade(config, "head")
|
||||
return
|
||||
|
||||
user_columns = set()
|
||||
if "users" in tables:
|
||||
user_columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
|
||||
submission_columns = set()
|
||||
if "mission_submissions" in tables:
|
||||
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if "preferred_branch" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||
if "motivation" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
|
||||
|
||||
if "passport_path" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
||||
if "photo_path" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN photo_path VARCHAR(512)"))
|
||||
if "resume_path" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_path VARCHAR(512)"))
|
||||
if "resume_link" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
|
||||
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
|
||||
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
def create_demo_users() -> None:
|
||||
"""Create demo users if they don't exist."""
|
||||
"""Создаём демо-пользователей, чтобы упростить проверку сценариев."""
|
||||
|
||||
session: Session = SessionLocal()
|
||||
try:
|
||||
# Check if demo users already exist
|
||||
pilot_exists = session.query(User).filter(User.email == "candidate@alabuga.space").first()
|
||||
hr_exists = session.query(User).filter(User.email == "hr@alabuga.space").first()
|
||||
|
||||
|
||||
if pilot_exists and hr_exists:
|
||||
print("✅ Demo users already exist")
|
||||
return
|
||||
|
||||
# Get base rank (or None if no ranks exist)
|
||||
|
||||
base_rank = session.query(Rank).order_by(Rank.required_xp).first()
|
||||
|
||||
# Create pilot demo user
|
||||
|
||||
if not pilot_exists:
|
||||
pilot = User(
|
||||
email="candidate@alabuga.space",
|
||||
|
|
@ -46,9 +102,7 @@ def create_demo_users() -> None:
|
|||
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
|
||||
)
|
||||
session.add(pilot)
|
||||
print("✅ Created demo pilot user: candidate@alabuga.space / orbita123")
|
||||
|
||||
# Create HR demo user
|
||||
|
||||
if not hr_exists:
|
||||
hr_rank = session.query(Rank).order_by(Rank.required_xp.desc()).first()
|
||||
hr = User(
|
||||
|
|
@ -61,19 +115,12 @@ def create_demo_users() -> None:
|
|||
preferred_branch="Куратор миссий",
|
||||
)
|
||||
session.add(hr)
|
||||
print("✅ Created demo HR user: hr@alabuga.space / orbita123")
|
||||
|
||||
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to create demo users: {e}")
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.project_name)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.backend_cors_origins,
|
||||
|
|
@ -93,9 +140,11 @@ app.include_router(admin.router)
|
|||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Create demo users on startup if in debug mode."""
|
||||
if settings.debug:
|
||||
async def on_startup() -> None:
|
||||
"""При запуске обновляем схему БД и подготавливаем демо-данные."""
|
||||
|
||||
run_migrations()
|
||||
if settings.environment != "production":
|
||||
create_demo_users()
|
||||
|
||||
|
||||
|
|
@ -103,4 +152,4 @@ async def startup_event():
|
|||
def healthcheck() -> dict[str, str]:
|
||||
"""Простой ответ для Docker healthcheck."""
|
||||
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "environment": settings.environment}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ class MissionSubmission(Base, TimestampMixin):
|
|||
)
|
||||
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
proof_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
passport_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
resume_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
resume_link: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
awarded_xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
awarded_mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||
|
||||
|
|
@ -22,6 +22,9 @@ class MissionBase(BaseModel):
|
|||
is_active: bool
|
||||
is_available: bool = True
|
||||
locked_reasons: list[str] = Field(default_factory=list)
|
||||
is_completed: bool = False
|
||||
requires_documents: bool = False
|
||||
is_completed: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -92,6 +95,7 @@ class MissionSubmissionCreate(BaseModel):
|
|||
|
||||
comment: Optional[str] = None
|
||||
proof_url: Optional[str] = None
|
||||
resume_link: Optional[str] = None
|
||||
|
||||
|
||||
class MissionSubmissionRead(BaseModel):
|
||||
|
|
@ -102,9 +106,40 @@ class MissionSubmissionRead(BaseModel):
|
|||
status: SubmissionStatus
|
||||
comment: Optional[str]
|
||||
proof_url: Optional[str]
|
||||
resume_link: Optional[str]
|
||||
awarded_xp: int
|
||||
awarded_mana: int
|
||||
updated_at: datetime
|
||||
passport_path: Optional[str] = Field(default=None, exclude=True)
|
||||
photo_path: Optional[str] = Field(default=None, exclude=True)
|
||||
resume_path: Optional[str] = Field(default=None, exclude=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@computed_field # type: ignore[misc]
|
||||
@property
|
||||
def passport_url(self) -> Optional[str]:
|
||||
"""Ссылка для скачивания файла паспорта."""
|
||||
|
||||
if self.passport_path:
|
||||
return f"/api/missions/submissions/{self.id}/files/passport"
|
||||
return None
|
||||
|
||||
@computed_field # type: ignore[misc]
|
||||
@property
|
||||
def photo_url(self) -> Optional[str]:
|
||||
"""Ссылка на загруженную фотографию."""
|
||||
|
||||
if self.photo_path:
|
||||
return f"/api/missions/submissions/{self.id}/files/photo"
|
||||
return None
|
||||
|
||||
@computed_field # type: ignore[misc]
|
||||
@property
|
||||
def resume_url(self) -> Optional[str]:
|
||||
"""Ссылка на загруженный файл резюме."""
|
||||
|
||||
if self.resume_path:
|
||||
return f"/api/missions/submissions/{self.id}/files/resume"
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -71,6 +71,21 @@ class UserProfile(UserRead):
|
|||
artifacts: list[UserArtifactRead]
|
||||
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
"""Строка лидерборда по опыту и компетенциям."""
|
||||
|
||||
user_id: int
|
||||
full_name: str
|
||||
rank_title: Optional[str]
|
||||
xp: int
|
||||
mana: int
|
||||
completed_missions: int
|
||||
competencies: list[UserCompetencyRead]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Создание пользователя (используется для сидов)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -10,10 +12,23 @@ from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
|||
from app.models.user import User, UserArtifact, UserCompetency
|
||||
from app.services.journal import log_event
|
||||
from app.services.rank import apply_rank_upgrade
|
||||
from app.services.storage import delete_submission_document
|
||||
|
||||
|
||||
UNSET: Any = object()
|
||||
|
||||
|
||||
def submit_mission(
|
||||
*, db: Session, user: User, mission: Mission, comment: str | None, proof_url: str | None
|
||||
*,
|
||||
db: Session,
|
||||
user: User,
|
||||
mission: Mission,
|
||||
comment: str | None,
|
||||
proof_url: str | None,
|
||||
passport_path: Any = UNSET,
|
||||
photo_path: Any = UNSET,
|
||||
resume_path: Any = UNSET,
|
||||
resume_link: Any = UNSET,
|
||||
) -> MissionSubmission:
|
||||
"""Создаём или обновляем отправку."""
|
||||
|
||||
|
|
@ -30,6 +45,25 @@ def submit_mission(
|
|||
|
||||
submission.comment = comment
|
||||
submission.proof_url = proof_url
|
||||
|
||||
if passport_path is not UNSET:
|
||||
if isinstance(passport_path, str) and submission.passport_path and submission.passport_path != passport_path:
|
||||
delete_submission_document(submission.passport_path)
|
||||
submission.passport_path = passport_path if isinstance(passport_path, str) else None
|
||||
|
||||
if photo_path is not UNSET:
|
||||
if isinstance(photo_path, str) and submission.photo_path and submission.photo_path != photo_path:
|
||||
delete_submission_document(submission.photo_path)
|
||||
submission.photo_path = photo_path if isinstance(photo_path, str) else None
|
||||
|
||||
if resume_path is not UNSET:
|
||||
if isinstance(resume_path, str) and submission.resume_path and submission.resume_path != resume_path:
|
||||
delete_submission_document(submission.resume_path)
|
||||
submission.resume_path = resume_path if isinstance(resume_path, str) else None
|
||||
|
||||
if resume_link is not UNSET:
|
||||
submission.resume_link = resume_link if isinstance(resume_link, str) else None
|
||||
|
||||
submission.status = SubmissionStatus.PENDING
|
||||
|
||||
db.add(submission)
|
||||
|
|
|
|||
59
backend/app/services/storage.py
Normal file
59
backend/app/services/storage.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Утилиты для сохранения и удаления загруженных файлов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _ensure_within_base(path: Path) -> None:
|
||||
"""Проверяем, что путь находится внутри каталога загрузок."""
|
||||
|
||||
base = settings.uploads_path.resolve()
|
||||
resolved = path.resolve()
|
||||
if not resolved.is_relative_to(base):
|
||||
raise ValueError("Путь выходит за пределы каталога uploads")
|
||||
|
||||
|
||||
def save_submission_document(
|
||||
*, upload: UploadFile, user_id: int, mission_id: int, kind: str
|
||||
) -> str:
|
||||
"""Сохраняем вложение пользователя и возвращаем относительный путь."""
|
||||
|
||||
extension = Path(upload.filename or "").suffix or ".bin"
|
||||
sanitized_extension = extension[:16]
|
||||
|
||||
target_dir = settings.uploads_path / f"user_{user_id}" / f"mission_{mission_id}"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_path = target_dir / f"{kind}{sanitized_extension}"
|
||||
with target_path.open("wb") as buffer:
|
||||
upload.file.seek(0)
|
||||
shutil.copyfileobj(upload.file, buffer)
|
||||
upload.file.seek(0)
|
||||
|
||||
relative_path = target_path.relative_to(settings.uploads_path).as_posix()
|
||||
return relative_path
|
||||
|
||||
|
||||
def delete_submission_document(relative_path: str | None) -> None:
|
||||
"""Удаляем файл вложения, если он существует."""
|
||||
|
||||
if not relative_path:
|
||||
return
|
||||
|
||||
file_path = settings.uploads_path / relative_path
|
||||
try:
|
||||
_ensure_within_base(file_path)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
parent = file_path.parent
|
||||
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
|
@ -12,6 +12,10 @@ interface Submission {
|
|||
status: string;
|
||||
comment: string | null;
|
||||
proof_url: string | null;
|
||||
resume_link: string | null;
|
||||
passport_url: string | null;
|
||||
photo_url: string | null;
|
||||
resume_url: string | null;
|
||||
awarded_xp: number;
|
||||
awarded_mana: number;
|
||||
updated_at: string;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import '../styles/globals.css';
|
|||
import { getSession } from '../lib/auth/session';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Alabuga Mission Control',
|
||||
description: 'Космический модуль геймификации для пилотов Алабуги'
|
||||
title: 'Автостопом по Алабуге',
|
||||
description: 'Галактогид по миссиям и рангам Алабуги в духе «Автостопом по Галактике»'
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -24,6 +24,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||
} else if (isHr && !viewingAsPilot) {
|
||||
links = [
|
||||
{ href: '/admin', label: 'HR панель' },
|
||||
{ href: '/leaderboard', label: 'Лидерборд' },
|
||||
{ href: '/admin/view-as', label: 'Просмотр от лица пилота' },
|
||||
];
|
||||
} else {
|
||||
|
|
@ -33,6 +34,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||
{ href: '/missions', label: 'Миссии' },
|
||||
{ href: '/journal', label: 'Журнал' },
|
||||
{ href: '/store', label: 'Магазин' },
|
||||
{ href: '/leaderboard', label: 'Лидерборд' },
|
||||
];
|
||||
if (isHr) {
|
||||
// Дополнительный пункт для HR: быстрый выход из режима просмотра.
|
||||
|
|
@ -57,9 +59,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
|
||||
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Автостопом по Алабуге</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
||||
Путь пилота от искателя до командира космической эскадры
|
||||
Всегда держите полотенце под рукой и следуйте подсказкам бортового гидронавигатора
|
||||
</p>
|
||||
</div>
|
||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||
|
|
|
|||
105
frontend/src/app/leaderboard/page.tsx
Normal file
105
frontend/src/app/leaderboard/page.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { requireSession } from '../../lib/auth/session';
|
||||
|
||||
interface CompetencyEntry {
|
||||
competency: {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
};
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface LeaderboardRow {
|
||||
user_id: number;
|
||||
full_name: string;
|
||||
rank_title: string | null;
|
||||
xp: number;
|
||||
mana: number;
|
||||
completed_missions: number;
|
||||
competencies: CompetencyEntry[];
|
||||
}
|
||||
|
||||
async function fetchLeaderboard(token: string) {
|
||||
return apiFetch<LeaderboardRow[]>('/api/leaderboard', { authToken: token });
|
||||
}
|
||||
|
||||
function CompetencyChips({ competencies }: { competencies: CompetencyEntry[] }) {
|
||||
if (!competencies.length) {
|
||||
return <span style={{ color: 'var(--text-muted)' }}>Нет данных</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{competencies.map((entry) => (
|
||||
<span
|
||||
key={entry.competency.id}
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(108, 92, 231, 0.18)',
|
||||
border: '1px solid rgba(108,92,231,0.35)'
|
||||
}}
|
||||
>
|
||||
{entry.competency.name} · {entry.level}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function LeaderboardPage() {
|
||||
const session = await requireSession();
|
||||
const rows = await fetchLeaderboard(session.token);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Лидерборд пилотов</h2>
|
||||
<p style={{ color: 'var(--text-muted)', maxWidth: '720px' }}>
|
||||
Здесь собраны все пилоты программы, отсортированные по опыту. HR может использовать таблицу как быстрый срез
|
||||
прогресса и компетенций, а кандидаты — видеть своё место в космофлоте.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginTop: '1.5rem', overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '720px' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>#</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Пилот</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Ранг</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Опыт</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Мана</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Завершено миссий</th>
|
||||
<th style={{ padding: '0.75rem 1rem' }}>Компетенции</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.user_id} style={{ borderTop: '1px solid rgba(162, 155, 254, 0.15)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem', width: '48px' }}>{index + 1}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<strong>{row.full_name}</strong>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.rank_title ?? '—'}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.xp}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.mana}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.completed_missions}</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<CompetencyChips competencies={row.competencies} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '1rem 1rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Пока нет данных — завершите первую миссию, чтобы попасть в лидерборд.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export default async function LoginPage({
|
|||
<section className={styles.container}>
|
||||
<SpaceBackground />
|
||||
<form className={styles.form} action={authenticate}>
|
||||
<h1>Вход в Mission Control</h1>
|
||||
<h1>Вход в «Автостопом по Алабуге»</h1>
|
||||
{/* Подсказка для демо-режима, чтобы не искать логин/пароль в README. */}
|
||||
<p className={styles.hint}>
|
||||
Используйте демо-учётные записи: <strong>candidate@alabuga.space / orbita123</strong> или
|
||||
|
|
|
|||
|
|
@ -19,11 +19,27 @@ interface MissionDetail {
|
|||
}>;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
is_completed: boolean;
|
||||
requires_documents: boolean;
|
||||
}
|
||||
|
||||
async function fetchMission(id: number, token: string) {
|
||||
const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token });
|
||||
return { mission };
|
||||
const [mission, submission] = await Promise.all([
|
||||
apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token }),
|
||||
apiFetch<MissionSubmission | null>(`/api/missions/${id}/submission`, { authToken: token })
|
||||
]);
|
||||
return { mission, submission };
|
||||
}
|
||||
|
||||
interface MissionSubmission {
|
||||
id: number;
|
||||
comment: string | null;
|
||||
proof_url: string | null;
|
||||
resume_link: string | null;
|
||||
passport_url: string | null;
|
||||
photo_url: string | null;
|
||||
resume_url: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
interface MissionPageProps {
|
||||
|
|
@ -34,7 +50,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
const missionId = Number(params.id);
|
||||
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
||||
const session = await requireSession();
|
||||
const { mission } = await fetchMission(missionId, session.token);
|
||||
const { mission, submission } = await fetchMission(missionId, session.token);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -44,7 +60,22 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
<p style={{ marginTop: '1rem' }}>
|
||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||
</p>
|
||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||
{mission.is_completed && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
border: '1px solid rgba(85, 239, 196, 0.35)',
|
||||
background: 'rgba(85, 239, 196, 0.12)'
|
||||
}}
|
||||
>
|
||||
<strong>Миссия завершена</strong>
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
||||
HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!mission.is_available && !mission.is_completed && mission.locked_reasons.length > 0 && (
|
||||
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
||||
<strong>Миссия заблокирована</strong>
|
||||
<ul style={{ marginTop: '0.5rem' }}>
|
||||
|
|
@ -65,7 +96,14 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<MissionSubmissionForm missionId={mission.id} token={session.token} locked={!mission.is_available} />
|
||||
<MissionSubmissionForm
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
locked={!mission.is_available && !mission.is_completed}
|
||||
completed={mission.is_completed}
|
||||
requiresDocuments={mission.requires_documents}
|
||||
submission={submission ?? undefined}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export interface MissionSummary {
|
|||
is_active: boolean;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
is_completed: boolean;
|
||||
requires_documents: boolean;
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -28,29 +30,49 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
|
||||
return (
|
||||
<div className="grid">
|
||||
{missions.map((mission) => (
|
||||
<Card key={mission.id}>
|
||||
<span className="badge">{mission.difficulty}</span>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||
)}
|
||||
<a
|
||||
className={mission.is_available ? 'primary' : 'secondary'}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '1rem',
|
||||
pointerEvents: mission.is_available ? 'auto' : 'none',
|
||||
opacity: mission.is_available ? 1 : 0.5
|
||||
}}
|
||||
href={mission.is_available ? `/missions/${mission.id}` : '#'}
|
||||
>
|
||||
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
|
||||
</a>
|
||||
</Card>
|
||||
))}
|
||||
{missions.map((mission) => {
|
||||
const completed = mission.is_completed;
|
||||
const locked = !mission.is_available && !completed;
|
||||
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||
const linkDisabled = locked;
|
||||
const actionLabel = completed
|
||||
? 'Миссия выполнена'
|
||||
: mission.is_available
|
||||
? 'Открыть брифинг'
|
||||
: 'Заблокировано';
|
||||
|
||||
return (
|
||||
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="badge">{mission.difficulty}</span>
|
||||
{completed && <span style={{ color: '#55efc4', fontSize: '0.85rem' }}>✓ завершено</span>}
|
||||
</div>
|
||||
{mission.requires_documents && !completed && (
|
||||
<p style={{ marginTop: '0.25rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
🗂 Требуется загрузка документов
|
||||
</p>
|
||||
)}
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||
{locked && mission.locked_reasons.length > 0 && (
|
||||
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||
)}
|
||||
<a
|
||||
className={primaryClass}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '1rem',
|
||||
pointerEvents: linkDisabled ? 'none' : 'auto',
|
||||
opacity: linkDisabled ? 0.5 : 1
|
||||
}}
|
||||
href={linkDisabled ? '#' : `/missions/${mission.id}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</a>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
type ExistingSubmission = {
|
||||
id: number;
|
||||
comment: string | null;
|
||||
proof_url: string | null;
|
||||
resume_link: string | null;
|
||||
passport_url: string | null;
|
||||
photo_url: string | null;
|
||||
resume_url: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
};
|
||||
|
||||
interface MissionSubmissionFormProps {
|
||||
missionId: number;
|
||||
token?: string;
|
||||
locked?: boolean;
|
||||
submission?: ExistingSubmission | null;
|
||||
completed?: boolean;
|
||||
requiresDocuments?: boolean;
|
||||
}
|
||||
|
||||
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [proofUrl, setProofUrl] = useState('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
export function MissionSubmissionForm({ missionId, token, locked = false, submission, completed = false, requiresDocuments = false }: MissionSubmissionFormProps) {
|
||||
const [comment, setComment] = useState(submission?.comment ?? '');
|
||||
const [proofUrl, setProofUrl] = useState(submission?.proof_url ?? '');
|
||||
const [resumeLink, setResumeLink] = useState(submission?.resume_link ?? '');
|
||||
const initialStatus = submission?.status === 'approved' || completed
|
||||
? 'Миссия уже зачтена. Вы можете просматривать прикреплённые документы.'
|
||||
: null;
|
||||
const [status, setStatus] = useState<string | null>(initialStatus);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSubmission, setCurrentSubmission] = useState<ExistingSubmission | null>(submission ?? null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
const passportInputRef = useRef<HTMLInputElement>(null);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const resumeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isApproved = completed || currentSubmission?.status === 'approved';
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
setStatus('Не удалось получить токен демо-пользователя.');
|
||||
|
|
@ -27,20 +52,76 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
|||
return;
|
||||
}
|
||||
|
||||
if (isApproved) {
|
||||
setStatus('Миссия уже зачтена. Дополнительная отправка не требуется.');
|
||||
return;
|
||||
}
|
||||
|
||||
const passportFile = passportInputRef.current?.files?.[0];
|
||||
const photoFile = photoInputRef.current?.files?.[0];
|
||||
const resumeFile = resumeInputRef.current?.files?.[0];
|
||||
const resumeTrimmed = resumeLink.trim();
|
||||
|
||||
if (requiresDocuments) {
|
||||
if (!currentSubmission?.passport_url && !passportFile) {
|
||||
setStatus('Добавьте скан паспорта кандидата.');
|
||||
return;
|
||||
}
|
||||
if (!currentSubmission?.photo_url && !photoFile) {
|
||||
setStatus('Приложите фотографию кандидата.');
|
||||
return;
|
||||
}
|
||||
const hasResumeAttachment = Boolean(currentSubmission?.resume_url || currentSubmission?.resume_link);
|
||||
if (!hasResumeAttachment && !resumeFile && !resumeTrimmed) {
|
||||
setStatus('Укажите ссылку на резюме или загрузите файл.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('comment', comment.trim());
|
||||
formData.append('proof_url', proofUrl.trim());
|
||||
formData.append('resume_link', resumeTrimmed);
|
||||
|
||||
if (passportFile) {
|
||||
formData.append('passport', passportFile);
|
||||
}
|
||||
|
||||
if (photoFile) {
|
||||
formData.append('photo', photoFile);
|
||||
}
|
||||
|
||||
if (resumeFile) {
|
||||
formData.append('resume_file', resumeFile);
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
await apiFetch(`/api/missions/${missionId}/submit`, {
|
||||
const updated = await apiFetch<ExistingSubmission>(`/api/missions/${missionId}/submit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment, proof_url: proofUrl }),
|
||||
body: formData,
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Отчёт отправлен! HR проверит миссию в панели модерации.');
|
||||
setComment('');
|
||||
setProofUrl('');
|
||||
|
||||
setCurrentSubmission(updated);
|
||||
setComment(updated.comment ?? '');
|
||||
setProofUrl(updated.proof_url ?? '');
|
||||
setResumeLink(updated.resume_link ?? '');
|
||||
|
||||
if (passportInputRef.current) passportInputRef.current.value = '';
|
||||
if (photoInputRef.current) photoInputRef.current.value = '';
|
||||
if (resumeInputRef.current) resumeInputRef.current.value = '';
|
||||
|
||||
const nextStatus = updated.status === 'approved'
|
||||
? 'Миссия уже зачтена. Вы можете просматривать прикреплённые документы.'
|
||||
: 'Отчёт и документы отправлены! HR проверит миссию в панели модерации.';
|
||||
setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatus('Не удалось отправить данные. Попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -48,9 +129,14 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
|||
}
|
||||
|
||||
return (
|
||||
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
|
||||
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }} encType="multipart/form-data">
|
||||
<h3>Отправить отчёт</h3>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||
{requiresDocuments && (
|
||||
<p style={{ marginTop: '0.25rem', color: 'var(--text-muted)' }}>
|
||||
Для этой миссии необходимо приложить паспорт, фотографию и резюме. Файлы попадут напрямую в панель HR.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Комментарий
|
||||
<textarea
|
||||
value={comment}
|
||||
|
|
@ -58,24 +144,103 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
|||
rows={4}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="Опишите, что сделали."
|
||||
disabled={locked}
|
||||
disabled={locked || isApproved}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||
Ссылка на доказательство
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Ссылка на доказательство (опционально)
|
||||
<input
|
||||
type="url"
|
||||
value={proofUrl}
|
||||
onChange={(event) => setProofUrl(event.target.value)}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="https://..."
|
||||
disabled={locked}
|
||||
disabled={locked || isApproved}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary" type="submit" disabled={loading || locked}>
|
||||
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||
|
||||
<fieldset style={{ border: '1px solid rgba(162, 155, 254, 0.35)', borderRadius: '16px', padding: '1rem', marginBottom: '1rem' }}>
|
||||
<legend style={{ padding: '0 0.5rem' }}>Документы</legend>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Паспорт (PDF или изображение)
|
||||
<input
|
||||
ref={passportInputRef}
|
||||
type="file"
|
||||
name="passport"
|
||||
accept="application/pdf,image/*"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={locked || isApproved}
|
||||
required={requiresDocuments && !currentSubmission?.passport_url}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Фотография кандидата
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={locked || isApproved}
|
||||
required={requiresDocuments && !currentSubmission?.photo_url}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Резюме (можно приложить файл и/или ссылку)
|
||||
<input
|
||||
ref={resumeInputRef}
|
||||
type="file"
|
||||
name="resume_file"
|
||||
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={locked || isApproved}
|
||||
required={requiresDocuments && !currentSubmission?.resume_url && !currentSubmission?.resume_link}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={resumeLink}
|
||||
onChange={(event) => setResumeLink(event.target.value)}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="https://disk.yandex.ru/..."
|
||||
disabled={locked || isApproved}
|
||||
required={requiresDocuments && !currentSubmission?.resume_url && !currentSubmission?.resume_link}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{currentSubmission && (
|
||||
<div style={{ marginTop: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
<strong>Загружено ранее:</strong>
|
||||
<ul style={{ listStyle: 'none', margin: '0.5rem 0 0', padding: 0 }}>
|
||||
<li>
|
||||
Паспорт: {currentSubmission.passport_url ? <a href={currentSubmission.passport_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||
</li>
|
||||
<li>
|
||||
Фото: {currentSubmission.photo_url ? <a href={currentSubmission.photo_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||
</li>
|
||||
<li>
|
||||
Резюме (файл): {currentSubmission.resume_url ? <a href={currentSubmission.resume_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||
</li>
|
||||
<li>
|
||||
Резюме (ссылка): {currentSubmission.resume_link ? <a href={currentSubmission.resume_link} target="_blank" rel="noreferrer">открыть</a> : 'ссылка не указана'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<button className="primary" type="submit" disabled={loading || locked || isApproved}>
|
||||
{locked ? 'Недоступно' : isApproved ? 'Миссия выполнена' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||
</button>
|
||||
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
||||
{status && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
color: status.includes('зачтена') ? 'var(--accent-light)' : status.startsWith('Отчёт') ? 'var(--accent-light)' : 'var(--error)'
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ type Submission = {
|
|||
status: string;
|
||||
comment: string | null;
|
||||
proof_url: string | null;
|
||||
resume_link: string | null;
|
||||
passport_url: string | null;
|
||||
photo_url: string | null;
|
||||
resume_url: string | null;
|
||||
awarded_xp: number;
|
||||
awarded_mana: number;
|
||||
updated_at: string;
|
||||
|
|
@ -55,6 +59,23 @@ export function AdminSubmissionCard({ submission, token }: Props) {
|
|||
</a>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<strong>Документы кандидата:</strong>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||
<li>
|
||||
Паспорт: {submission.passport_url ? <a href={submission.passport_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||
</li>
|
||||
<li>
|
||||
Фото: {submission.photo_url ? <a href={submission.photo_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||
</li>
|
||||
<li>
|
||||
Резюме (файл): {submission.resume_url ? <a href={submission.resume_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||
</li>
|
||||
<li>
|
||||
Резюме (ссылка): {submission.resume_link ? <a href={submission.resume_link} target="_blank" rel="noreferrer">открыть</a> : 'не указана'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||
</small>
|
||||
|
|
@ -79,4 +100,3 @@ export function AdminSubmissionCard({ submission, token }: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ export interface RequestOptions extends RequestInit {
|
|||
|
||||
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||
|
||||
if (!isFormData && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (options.authToken) {
|
||||
headers.set('Authorization', `Bearer ${options.authToken}`);
|
||||
}
|
||||
|
|
@ -20,8 +24,25 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
const detail = data?.detail ?? data?.message ?? data?.error;
|
||||
if (detail) {
|
||||
throw new Error(String(detail));
|
||||
}
|
||||
throw new Error(`Запрос завершился ошибкой (${response.status}).`);
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof Error) {
|
||||
throw parseError;
|
||||
}
|
||||
throw new Error(`Запрос завершился ошибкой (${response.status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
throw new Error(`API error ${response.status}: ${text}`);
|
||||
throw new Error(text || `Запрос завершился ошибкой (${response.status}).`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
|
|
|
|||
53
scripts/reset_demo_data.py
Normal file
53
scripts/reset_demo_data.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Удаление тестовых данных и вложений."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.append(str(ROOT / 'backend'))
|
||||
|
||||
from app.main import run_migrations
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.mission import MissionSubmission
|
||||
from app.models.store import Order
|
||||
from app.models.journal import JournalEntry
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Очищаем пользовательскую активность и загруженные документы."""
|
||||
|
||||
run_migrations()
|
||||
|
||||
session: Session = SessionLocal()
|
||||
try:
|
||||
session.query(MissionSubmission).delete()
|
||||
session.query(Order).delete()
|
||||
session.query(JournalEntry).delete()
|
||||
|
||||
# Сбрасываем опыт и ману демо-пользователям, чтобы они начинали «с нуля».
|
||||
for user in session.query(User).all():
|
||||
user.xp = 0
|
||||
user.mana = 0
|
||||
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
uploads_dir: Path = settings.uploads_path
|
||||
if uploads_dir.exists():
|
||||
shutil.rmtree(uploads_dir)
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("Demo data cleared. Mission submissions, journal entries, orders и загруженные файлы удалены.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
reset()
|
||||
Loading…
Reference in New Issue
Block a user