From b2927601a9e2fc448147308b6a672be399a1a584 Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Sun, 28 Sep 2025 20:29:36 +0300 Subject: [PATCH] Leadetboard, file downloader --- README.md | 25 ++- TZ_gamification_markdown.md | 2 +- backend/.env.example | 4 +- .../20240927_0005_submission_documents.py | 26 +++ backend/app/api/routes/missions.py | 170 +++++++++++++-- backend/app/api/routes/users.py | 44 +++- backend/app/core/config.py | 6 + backend/app/main.py | 103 ++++++--- backend/app/models/mission.py | 4 + backend/app/schemas/mission.py | 37 +++- backend/app/schemas/user.py | 15 ++ backend/app/services/mission.py | 36 ++- backend/app/services/storage.py | 59 +++++ frontend/src/app/admin/page.tsx | 4 + frontend/src/app/layout.tsx | 10 +- frontend/src/app/leaderboard/page.tsx | 105 +++++++++ frontend/src/app/login/page.tsx | 2 +- frontend/src/app/missions/[id]/page.tsx | 48 +++- frontend/src/components/MissionList.tsx | 68 ++++-- .../src/components/MissionSubmissionForm.tsx | 205 ++++++++++++++++-- .../components/admin/AdminSubmissionCard.tsx | 22 +- frontend/src/lib/api.ts | 25 ++- scripts/reset_demo_data.py | 53 +++++ 23 files changed, 961 insertions(+), 112 deletions(-) create mode 100644 backend/alembic/versions/20240927_0005_submission_documents.py create mode 100644 backend/app/services/storage.py create mode 100644 frontend/src/app/leaderboard/page.tsx create mode 100644 scripts/reset_demo_data.py diff --git a/README.md b/README.md index 221ea16..24b0600 100644 --- a/README.md +++ b/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). Здесь можно: diff --git a/TZ_gamification_markdown.md b/TZ_gamification_markdown.md index 872eadd..5042285 100644 --- a/TZ_gamification_markdown.md +++ b/TZ_gamification_markdown.md @@ -223,7 +223,7 @@ HR-платформа [hr.alabuga.ru] - основная платформа для авторизации в экосистеме «Алабуги». На этой платформе расположены бизнес-симуляции, в которые играют кандидаты и - +з сотрудники Карьера.100 лидеров [career.alabuga.space] diff --git a/backend/.env.example b/backend/.env.example index a923e52..5238ff0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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"] diff --git a/backend/alembic/versions/20240927_0005_submission_documents.py b/backend/alembic/versions/20240927_0005_submission_documents.py new file mode 100644 index 0000000..d1323bb --- /dev/null +++ b/backend/alembic/versions/20240927_0005_submission_documents.py @@ -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') diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index cb78e85..e95a010 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -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) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index b8c7c15..98de829 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 050071d..0b56b69 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index d539e8b..25826c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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} diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py index 88a0d42..62bf08c 100644 --- a/backend/app/models/mission.py +++ b/backend/app/models/mission.py @@ -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) diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 1eb7b15..8d2917d 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 44dc306..b073323 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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): """Создание пользователя (используется для сидов).""" diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py index ee4cfa6..99b407b 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -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) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..36b896b --- /dev/null +++ b/backend/app/services/storage.py @@ -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() diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 97039a1..9d81408 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2926876..c0f2443 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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 }} >
-

Mission Control

+

Автостопом по Алабуге

- Путь пилота от искателя до командира космической эскадры + Всегда держите полотенце под рукой и следуйте подсказкам бортового гидронавигатора