Leadetboard, file downloader

This commit is contained in:
danilgryaznev 2025-09-28 20:29:36 +03:00
parent cb49eb2e05
commit b2927601a9
23 changed files with 961 additions and 112 deletions

View File

@ -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). Здесь можно:

View File

@ -223,7 +223,7 @@ HR-платформа [hr.alabuga.ru]
- основная платформа для авторизации в экосистеме «Алабуги». На этой
платформе расположены бизнес-симуляции, в которые играют кандидаты и
з
сотрудники
Карьера.100 лидеров [career.alabuga.space]

View File

@ -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"]

View File

@ -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')

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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)

View File

@ -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

View File

@ -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):
"""Создание пользователя (используется для сидов)."""

View File

@ -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)

View 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()

View File

@ -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;

View File

@ -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 }}>

View 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>
);
}

View File

@ -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> или

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View 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()