Add store item images and admin management

This commit is contained in:
Danil Gryaznev 2025-09-30 22:45:05 -06:00
parent 989a413162
commit e546dfc319
30 changed files with 1780 additions and 43 deletions

View File

@ -0,0 +1,55 @@
"""offline missions fields
Revision ID: 20241010_0008
Revises: 3c5430b2cbd3
Create Date: 2024-10-10 00:08:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20241010_0008"
down_revision = "3c5430b2cbd3"
branch_labels = None
depends_on = None
mission_format_enum = sa.Enum("online", "offline", name="missionformat")
def upgrade() -> None:
mission_format_enum.create(op.get_bind(), checkfirst=True)
with op.batch_alter_table("missions", schema=None) as batch_op:
batch_op.add_column(sa.Column("format", mission_format_enum, nullable=False, server_default="online"))
batch_op.add_column(sa.Column("event_location", sa.String(length=160), nullable=True))
batch_op.add_column(sa.Column("event_address", sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column("event_starts_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("event_ends_at", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("registration_deadline", sa.DateTime(timezone=True), nullable=True))
batch_op.add_column(sa.Column("registration_url", sa.String(length=512), nullable=True))
batch_op.add_column(sa.Column("registration_notes", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("capacity", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("contact_person", sa.String(length=120), nullable=True))
batch_op.add_column(sa.Column("contact_phone", sa.String(length=64), nullable=True))
op.execute("UPDATE missions SET format = 'online' WHERE format IS NULL")
with op.batch_alter_table("missions", schema=None) as batch_op:
batch_op.alter_column("format", server_default=None)
def downgrade() -> None:
with op.batch_alter_table("missions", schema=None) as batch_op:
batch_op.drop_column("contact_phone")
batch_op.drop_column("contact_person")
batch_op.drop_column("capacity")
batch_op.drop_column("registration_notes")
batch_op.drop_column("registration_url")
batch_op.drop_column("registration_deadline")
batch_op.drop_column("event_ends_at")
batch_op.drop_column("event_starts_at")
batch_op.drop_column("event_address")
batch_op.drop_column("event_location")
batch_op.drop_column("format")
mission_format_enum.drop(op.get_bind(), checkfirst=True)

View File

@ -0,0 +1,22 @@
"""Добавляем колонку для фото профиля кандидата."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20241012_0009"
down_revision = "20241010_0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("profile_photo_path")

View File

@ -0,0 +1,25 @@
"""Add image url to store items"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20241014_0010"
down_revision = "20241012_0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Добавляем колонку с изображением товара."""
op.add_column("store_items", sa.Column("image_url", sa.String(length=255), nullable=True))
def downgrade() -> None:
"""Удаляем колонку с изображением товара."""
op.drop_column("store_items", "image_url")

View File

@ -19,6 +19,7 @@ from app.models.mission import (
SubmissionStatus,
)
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.store import StoreItem
from app.models.user import Competency, User, UserRole
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
@ -38,7 +39,8 @@ from app.schemas.rank import (
RankUpdate,
)
from app.schemas.user import CompetencyBase
from app.services.mission import approve_submission, reject_submission
from app.schemas.store import StoreItemCreate, StoreItemRead, StoreItemUpdate
from app.services.mission import approve_submission, registration_is_open, reject_submission
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
router = APIRouter(prefix="/api/admin", tags=["admin"])
@ -47,6 +49,11 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
def _mission_to_detail(mission: Mission) -> MissionDetail:
"""Формируем детальную схему миссии."""
participant_count = sum(
1 for submission in mission.submissions if submission.status != SubmissionStatus.REJECTED
)
is_registration_open = registration_is_open(mission, participant_count=participant_count)
return MissionDetail(
id=mission.id,
title=mission.title,
@ -54,6 +61,17 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
format=mission.format,
event_location=mission.event_location,
event_address=mission.event_address,
event_starts_at=mission.event_starts_at,
event_ends_at=mission.event_ends_at,
registration_deadline=mission.registration_deadline,
registration_url=mission.registration_url,
registration_notes=mission.registration_notes,
capacity=mission.capacity,
contact_person=mission.contact_person,
contact_phone=mission.contact_phone,
is_active=mission.is_active,
minimum_rank_id=mission.minimum_rank_id,
artifact_id=mission.artifact_id,
@ -68,6 +86,8 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
],
created_at=mission.created_at,
updated_at=mission.updated_at,
registered_participants=participant_count,
registration_open=is_registration_open,
)
@ -120,6 +140,15 @@ def _branch_to_read(branch: Branch) -> BranchRead:
)
def _sanitize_optional(value: str | None) -> str | None:
"""Обрезаем пробелы и заменяем пустые строки на None."""
if value is None:
return None
stripped = value.strip()
return stripped or None
def _load_rank(db: Session, rank_id: int) -> Rank:
"""Загружаем ранг с зависимостями."""
@ -143,6 +172,7 @@ def _load_mission(db: Session, mission_id: int) -> Mission:
selectinload(Mission.prerequisites),
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
selectinload(Mission.branches),
selectinload(Mission.submissions),
)
.filter(Mission.id == mission_id)
.one()
@ -157,6 +187,99 @@ def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(requir
return [MissionBase.model_validate(mission) for mission in missions]
@router.get("/store/items", response_model=list[StoreItemRead], summary="Товары магазина (HR)")
def admin_store_items(
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
) -> list[StoreItemRead]:
"""Возвращаем товары магазина для панели HR."""
items = db.query(StoreItem).order_by(StoreItem.name).all()
return [StoreItemRead.model_validate(item) for item in items]
@router.post(
"/store/items",
response_model=StoreItemRead,
status_code=status.HTTP_201_CREATED,
summary="Создать товар",
)
def admin_store_create(
item_in: StoreItemCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> StoreItemRead:
"""Создаём новый товар в магазине."""
name = item_in.name.strip()
description = item_in.description.strip()
if not name or not description:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Название и описание не могут быть пустыми",
)
item = StoreItem(
name=name,
description=description,
cost_mana=item_in.cost_mana,
stock=item_in.stock,
image_url=_sanitize_optional(item_in.image_url),
)
db.add(item)
db.commit()
db.refresh(item)
return StoreItemRead.model_validate(item)
@router.patch(
"/store/items/{item_id}",
response_model=StoreItemRead,
summary="Обновить товар",
)
def admin_store_update(
item_id: int,
item_in: StoreItemUpdate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> StoreItemRead:
"""Редактируем существующий товар."""
item = db.query(StoreItem).filter(StoreItem.id == item_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Товар не найден")
update_data = item_in.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
new_name = update_data["name"].strip()
if not new_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Название не может быть пустым",
)
item.name = new_name
if "description" in update_data and update_data["description"] is not None:
new_description = update_data["description"].strip()
if not new_description:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание не может быть пустым",
)
item.description = new_description
if "cost_mana" in update_data and update_data["cost_mana"] is not None:
item.cost_mana = update_data["cost_mana"]
if "stock" in update_data and update_data["stock"] is not None:
item.stock = update_data["stock"]
if "image_url" in update_data:
item.image_url = _sanitize_optional(update_data["image_url"])
db.add(item)
db.commit()
db.refresh(item)
return StoreItemRead.model_validate(item)
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
def admin_mission_detail(
mission_id: int,
@ -172,6 +295,7 @@ def admin_mission_detail(
selectinload(Mission.prerequisites),
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
selectinload(Mission.branches),
selectinload(Mission.submissions),
)
.filter(Mission.id == mission_id)
.first()
@ -414,6 +538,17 @@ def create_mission_endpoint(
xp_reward=mission_in.xp_reward,
mana_reward=mission_in.mana_reward,
difficulty=mission_in.difficulty,
format=mission_in.format,
event_location=mission_in.event_location,
event_address=mission_in.event_address,
event_starts_at=mission_in.event_starts_at,
event_ends_at=mission_in.event_ends_at,
registration_deadline=mission_in.registration_deadline,
registration_url=mission_in.registration_url,
registration_notes=mission_in.registration_notes,
capacity=mission_in.capacity,
contact_person=mission_in.contact_person,
contact_phone=mission_in.contact_phone,
minimum_rank_id=mission_in.minimum_rank_id,
artifact_id=mission_in.artifact_id,
)
@ -475,7 +610,25 @@ def update_mission_endpoint(
payload = mission_in.model_dump(exclude_unset=True)
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
for attr in [
"title",
"description",
"xp_reward",
"mana_reward",
"difficulty",
"is_active",
"format",
"event_location",
"event_address",
"event_starts_at",
"event_ends_at",
"registration_deadline",
"registration_url",
"registration_notes",
"capacity",
"contact_person",
"contact_phone",
]:
if attr in payload:
setattr(mission, attr, payload[attr])

View File

@ -66,7 +66,6 @@ def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | di
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
role=UserRole.PILOT,
preferred_branch=user_in.preferred_branch,
motivation=user_in.motivation,
current_rank_id=base_rank.id if base_rank else None,
is_email_confirmed=not settings.require_email_confirmation,

View File

@ -3,8 +3,11 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user
@ -27,7 +30,7 @@ from app.schemas.coding import (
CodingRunResponse,
)
from app.services.coding import count_completed_challenges, evaluate_challenge
from app.services.mission import UNSET, submit_mission
from app.services.mission import UNSET, registration_is_open, submit_mission
from app.services.storage import delete_submission_document, save_submission_document
from app.core.config import settings
@ -282,12 +285,29 @@ def list_missions(
mission_titles = {mission.id: mission.title for mission in missions}
completed_missions = _load_user_progress(current_user)
submission_status_map = {
submission.mission_id: submission.status for submission in current_user.submissions
}
coding_progress = count_completed_challenges(
db,
mission_ids=[mission.id for mission in missions if mission.coding_challenges],
user=current_user,
)
mission_ids = [mission.id for mission in missions]
registration_counts = {
mission_id: count
for mission_id, count in (
db.query(MissionSubmission.mission_id, func.count(MissionSubmission.id))
.filter(
MissionSubmission.mission_id.in_(mission_ids),
MissionSubmission.status != SubmissionStatus.REJECTED,
)
.group_by(MissionSubmission.mission_id)
.all()
)
}
response: list[MissionBase] = []
for mission in missions:
is_available, reasons = _mission_availability(
@ -310,6 +330,14 @@ def list_missions(
dto.has_coding_challenges = bool(mission.coding_challenges)
dto.coding_challenge_count = len(mission.coding_challenges)
dto.completed_coding_challenges = coding_progress.get(mission.id, 0)
dto.submission_status = submission_status_map.get(mission.id)
participants = registration_counts.get(mission.id, 0)
dto.registered_participants = participants
dto.registration_open = registration_is_open(
mission,
participant_count=participants,
now=datetime.now(timezone.utc),
)
response.append(dto)
return response
@ -370,6 +398,17 @@ def get_mission(
xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
format=mission.format,
event_location=mission.event_location,
event_address=mission.event_address,
event_starts_at=mission.event_starts_at,
event_ends_at=mission.event_ends_at,
registration_deadline=mission.registration_deadline,
registration_url=mission.registration_url,
registration_notes=mission.registration_notes,
capacity=mission.capacity,
contact_person=mission.contact_person,
contact_phone=mission.contact_phone,
is_active=mission.is_active,
is_available=is_available,
locked_reasons=reasons,
@ -384,6 +423,24 @@ def get_mission(
data.has_coding_challenges = bool(mission.coding_challenges)
data.coding_challenge_count = len(mission.coding_challenges)
data.completed_coding_challenges = coding_progress.get(mission.id, 0)
data.submission_status = next(
(submission.status for submission in current_user.submissions if submission.mission_id == mission.id),
None,
)
participant_count = (
db.query(MissionSubmission)
.filter(
MissionSubmission.mission_id == mission.id,
MissionSubmission.status != SubmissionStatus.REJECTED,
)
.count()
)
data.registered_participants = participant_count
data.registration_open = registration_is_open(
mission,
participant_count=participant_count,
now=datetime.now(timezone.utc),
)
if mission.id in completed_missions:
data.is_completed = True
data.is_available = False
@ -555,6 +612,25 @@ async def submit(
.first()
)
participant_count = (
db.query(MissionSubmission)
.filter(
MissionSubmission.mission_id == mission.id,
MissionSubmission.status != SubmissionStatus.REJECTED,
)
.count()
)
registration_open_state = registration_is_open(
mission,
participant_count=participant_count,
now=datetime.now(timezone.utc),
)
if not registration_open_state and not existing_submission:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Регистрация на офлайн-мероприятие закрыта.",
)
def _has_upload(upload: UploadFile | None) -> bool:
return bool(upload and upload.filename)

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user
@ -12,8 +12,18 @@ 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 LeaderboardEntry, UserCompetencyRead, UserProfile
from app.schemas.user import (
LeaderboardEntry,
ProfilePhotoResponse,
UserCompetencyRead,
UserProfile,
)
from app.services.rank import build_progress_snapshot
from app.services.storage import (
build_photo_data_url,
delete_profile_photo,
save_profile_photo,
)
router = APIRouter(prefix="/api", tags=["profile"])
@ -29,7 +39,89 @@ def get_profile(
_ = item.competency
for artifact in current_user.artifacts:
_ = artifact.artifact
return UserProfile.model_validate(current_user)
profile = UserProfile.model_validate(current_user)
profile.profile_photo_uploaded = bool(current_user.profile_photo_path)
profile.profile_photo_updated_at = (
current_user.updated_at if current_user.profile_photo_path else None
)
return profile
@router.get(
"/me/photo",
response_model=ProfilePhotoResponse,
summary="Получаем фото профиля кандидата",
)
def get_profile_photo(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> ProfilePhotoResponse:
"""Читаем сохранённое изображение и возвращаем его в виде data URL."""
db.refresh(current_user)
if not current_user.profile_photo_path:
return ProfilePhotoResponse(photo=None, detail="Фотография не загружена")
try:
photo = build_photo_data_url(current_user.profile_photo_path)
except FileNotFoundError:
# Если файл удалили вручную, сбрасываем ссылку в базе, чтобы не мешать пользователю загрузить новую.
current_user.profile_photo_path = None
db.add(current_user)
db.commit()
return ProfilePhotoResponse(photo=None, detail="Файл не найден")
return ProfilePhotoResponse(photo=photo)
@router.post(
"/me/photo",
response_model=ProfilePhotoResponse,
status_code=status.HTTP_200_OK,
summary="Загружаем фото профиля",
)
def upload_profile_photo(
photo: UploadFile = File(...),
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ProfilePhotoResponse:
"""Сохраняем изображение и возвращаем обновлённый data URL."""
try:
relative_path = save_profile_photo(upload=photo, user_id=current_user.id)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
delete_profile_photo(current_user.profile_photo_path)
current_user.profile_photo_path = relative_path
db.add(current_user)
db.commit()
db.refresh(current_user)
photo_url = build_photo_data_url(relative_path)
return ProfilePhotoResponse(photo=photo_url, detail="Фотография обновлена")
@router.delete(
"/me/photo",
response_model=ProfilePhotoResponse,
summary="Удаляем фото профиля",
)
def delete_profile_photo_endpoint(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> ProfilePhotoResponse:
"""Удаляем сохранённое фото и очищаем ссылку в профиле."""
if not current_user.profile_photo_path:
return ProfilePhotoResponse(photo=None, detail="Фотография уже удалена")
delete_profile_photo(current_user.profile_photo_path)
current_user.profile_photo_path = None
db.add(current_user)
db.commit()
return ProfilePhotoResponse(photo=None, detail="Фотография удалена")
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")

View File

@ -30,6 +30,13 @@ def run_migrations() -> None:
config = Config(str(ALEMBIC_CONFIG))
config.set_main_option("sqlalchemy.url", str(settings.database_url))
# Alembic трактует относительный script_location относительно текущей рабочей
# директории процесса. В тестах и фронтенд-сервере мы запускаем backend из
# корня репозитория, поэтому явно подсказываем абсолютный путь до папки с
# миграциями, чтобы `alembic` не падал с "Path doesn't exist: alembic".
config.set_main_option(
"script_location", str(Path(__file__).resolve().parents[1] / "alembic")
)
script = ScriptDirectory.from_config(config)
head_revision = script.get_current_head()
@ -55,11 +62,17 @@ def run_migrations() -> None:
if "mission_submissions" in tables:
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
mission_columns = set()
if "missions" in tables:
mission_columns = {column["name"] for column in inspector.get_columns("missions")}
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 "profile_photo_path" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN profile_photo_path VARCHAR(512)"))
if "passport_path" not in submission_columns:
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
@ -70,6 +83,39 @@ def run_migrations() -> None:
if "resume_link" not in submission_columns:
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
if "missions" in tables:
# Легаси-базы без alembic_version пропускали миграцию с офлайн-полями,
# поэтому докидываем недостающие колонки вручную, чтобы API /admin не падало.
if "format" not in mission_columns:
conn.execute(
text(
"ALTER TABLE missions ADD COLUMN format VARCHAR(20) NOT NULL DEFAULT 'online'"
)
)
conn.execute(text("UPDATE missions SET format = 'online' WHERE format IS NULL"))
if "event_location" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN event_location VARCHAR(160)"))
if "event_address" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN event_address VARCHAR(255)"))
if "event_starts_at" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN event_starts_at TIMESTAMP"))
if "event_ends_at" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN event_ends_at TIMESTAMP"))
if "registration_deadline" not in mission_columns:
conn.execute(
text("ALTER TABLE missions ADD COLUMN registration_deadline TIMESTAMP")
)
if "registration_url" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_url VARCHAR(512)"))
if "registration_notes" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_notes TEXT"))
if "capacity" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN capacity INTEGER"))
if "contact_person" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_person VARCHAR(120)"))
if "contact_phone" not in mission_columns:
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_phone VARCHAR(64)"))
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})

View File

@ -2,10 +2,20 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import (
Boolean,
DateTime,
Enum as SQLEnum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
@ -22,6 +32,13 @@ class MissionDifficulty(str, Enum):
HARD = "hard"
class MissionFormat(str, Enum):
"""Формат проведения миссии."""
ONLINE = "online"
OFFLINE = "offline"
class Mission(Base, TimestampMixin):
"""Игровая миссия."""
@ -35,6 +52,19 @@ class Mission(Base, TimestampMixin):
difficulty: Mapped[MissionDifficulty] = mapped_column(
SQLEnum(MissionDifficulty), default=MissionDifficulty.MEDIUM, nullable=False
)
format: Mapped[MissionFormat] = mapped_column(
SQLEnum(MissionFormat), default=MissionFormat.ONLINE, nullable=False
)
event_location: Mapped[Optional[str]] = mapped_column(String(160))
event_address: Mapped[Optional[str]] = mapped_column(String(255))
event_starts_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
event_ends_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
registration_deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
registration_url: Mapped[Optional[str]] = mapped_column(String(512))
registration_notes: Mapped[Optional[str]] = mapped_column(Text)
capacity: Mapped[Optional[int]] = mapped_column(Integer)
contact_person: Mapped[Optional[str]] = mapped_column(String(120))
contact_phone: Mapped[Optional[str]] = mapped_column(String(64))
minimum_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"))
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

View File

@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
description: Mapped[str] = mapped_column(Text, nullable=False)
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
image_url: Mapped[Optional[str]] = mapped_column(String(255))
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")

View File

@ -46,6 +46,7 @@ class User(Base, TimestampMixin):
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
profile_photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(

View File

@ -7,7 +7,7 @@ from typing import Optional
from pydantic import BaseModel, Field, computed_field
from app.models.mission import MissionDifficulty, SubmissionStatus
from app.models.mission import MissionDifficulty, MissionFormat, SubmissionStatus
class MissionBase(BaseModel):
@ -19,6 +19,17 @@ class MissionBase(BaseModel):
xp_reward: int
mana_reward: int
difficulty: MissionDifficulty
format: MissionFormat
event_location: Optional[str] = None
event_address: Optional[str] = None
event_starts_at: Optional[datetime] = None
event_ends_at: Optional[datetime] = None
registration_deadline: Optional[datetime] = None
registration_url: Optional[str] = None
registration_notes: Optional[str] = None
capacity: Optional[int] = None
contact_person: Optional[str] = None
contact_phone: Optional[str] = None
is_active: bool
is_available: bool = True
locked_reasons: list[str] = Field(default_factory=list)
@ -27,6 +38,9 @@ class MissionBase(BaseModel):
has_coding_challenges: bool = False
coding_challenge_count: int = 0
completed_coding_challenges: int = 0
submission_status: Optional[SubmissionStatus] = None
registered_participants: int = 0
registration_open: bool = True
class Config:
from_attributes = True
@ -67,6 +81,17 @@ class MissionCreate(BaseModel):
xp_reward: int
mana_reward: int
difficulty: MissionDifficulty = MissionDifficulty.MEDIUM
format: MissionFormat = MissionFormat.ONLINE
event_location: Optional[str] = None
event_address: Optional[str] = None
event_starts_at: Optional[datetime] = None
event_ends_at: Optional[datetime] = None
registration_deadline: Optional[datetime] = None
registration_url: Optional[str] = None
registration_notes: Optional[str] = None
capacity: Optional[int] = None
contact_person: Optional[str] = None
contact_phone: Optional[str] = None
minimum_rank_id: Optional[int] = None
artifact_id: Optional[int] = None
prerequisite_ids: list[int] = []
@ -83,6 +108,17 @@ class MissionUpdate(BaseModel):
xp_reward: Optional[int] = None
mana_reward: Optional[int] = None
difficulty: Optional[MissionDifficulty] = None
format: Optional[MissionFormat] = None
event_location: Optional[str | None] = None
event_address: Optional[str | None] = None
event_starts_at: Optional[datetime | None] = None
event_ends_at: Optional[datetime | None] = None
registration_deadline: Optional[datetime | None] = None
registration_url: Optional[str | None] = None
registration_notes: Optional[str | None] = None
capacity: Optional[int | None] = None
contact_person: Optional[str | None] = None
contact_phone: Optional[str | None] = None
minimum_rank_id: Optional[int | None] = None
artifact_id: Optional[int | None] = None
prerequisite_ids: Optional[list[int]] = None

View File

@ -10,19 +10,41 @@ from pydantic import BaseModel
from app.models.store import OrderStatus
class StoreItemRead(BaseModel):
"""Товар магазина."""
class StoreItemBase(BaseModel):
"""Базовые поля товара магазина."""
id: int
name: str
description: str
cost_mana: int
stock: int
image_url: Optional[str] = None
class StoreItemRead(StoreItemBase):
"""Товар магазина для чтения."""
id: int
class Config:
from_attributes = True
class StoreItemCreate(StoreItemBase):
"""Запрос на создание товара."""
pass
class StoreItemUpdate(BaseModel):
"""Запрос на обновление товара."""
name: Optional[str] = None
description: Optional[str] = None
cost_mana: Optional[int] = None
stock: Optional[int] = None
image_url: Optional[str] = None
class OrderRead(BaseModel):
"""Информация о заказе."""

View File

@ -69,6 +69,8 @@ class UserProfile(UserRead):
competencies: list[UserCompetencyRead]
artifacts: list[UserArtifactRead]
profile_photo_uploaded: bool = False
profile_photo_updated_at: Optional[datetime] = None
class LeaderboardEntry(BaseModel):
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
email: EmailStr
full_name: str
password: str
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
preferred_branch: Optional[str] = None
motivation: Optional[str] = None
class ProfilePhotoResponse(BaseModel):
"""Ответ с данными загруженной фотографии."""
photo: Optional[str] = None
detail: Optional[str] = None

View File

@ -2,13 +2,14 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.journal import JournalEventType
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.mission import Mission, MissionFormat, 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
@ -171,3 +172,29 @@ def reject_submission(db: Session, submission: MissionSubmission, comment: str |
)
return submission
def registration_is_open(
mission: Mission,
*,
participant_count: int,
now: datetime | None = None,
) -> bool:
"""Проверяем, доступна ли запись на офлайн-мероприятие."""
if mission.format != MissionFormat.OFFLINE:
return True
current_time = now or datetime.now(timezone.utc)
deadline = mission.registration_deadline
if deadline and deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
if deadline and deadline < current_time:
return False
if mission.capacity is not None and participant_count >= mission.capacity:
return False
return mission.is_active

View File

@ -2,8 +2,10 @@
from __future__ import annotations
from pathlib import Path
import base64
import mimetypes
import shutil
from pathlib import Path
from fastapi import UploadFile
@ -40,8 +42,34 @@ def save_submission_document(
return relative_path
def delete_submission_document(relative_path: str | None) -> None:
"""Удаляем файл вложения, если он существует."""
def save_profile_photo(*, upload: UploadFile, user_id: int) -> str:
"""Сохраняем фото профиля кандидата."""
allowed_types = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
content_type = upload.content_type or mimetypes.guess_type(upload.filename or "")[0]
if content_type not in allowed_types:
raise ValueError("Допустимы только изображения JPG, PNG или WEBP")
extension = allowed_types[content_type]
target_dir = settings.uploads_path / f"user_{user_id}" / "profile"
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / f"photo{extension}"
with target_path.open("wb") as buffer:
upload.file.seek(0)
shutil.copyfileobj(upload.file, buffer)
upload.file.seek(0)
return target_path.relative_to(settings.uploads_path).as_posix()
def _delete_relative_file(relative_path: str | None) -> None:
"""Удаляем файл и очищаем пустые каталоги."""
if not relative_path:
return
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
parent = file_path.parent
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
def delete_submission_document(relative_path: str | None) -> None:
"""Удаляем файл вложения, если он существует."""
_delete_relative_file(relative_path)
def delete_profile_photo(relative_path: str | None) -> None:
"""Удаляем сохранённую фотографию профиля."""
_delete_relative_file(relative_path)
def build_photo_data_url(relative_path: str) -> str:
"""Формируем data URL для изображения, чтобы отдать его фронту."""
file_path = settings.uploads_path / relative_path
_ensure_within_base(file_path)
if not file_path.exists():
raise FileNotFoundError("Файл не найден")
mime_type = mimetypes.guess_type(file_path.name)[0] or "image/jpeg"
with file_path.open("rb") as fh:
encoded = base64.b64encode(fh.read()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"

View File

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="shirtTitle shirtDesc">
<title id="shirtTitle">Футболка экипажа Алабуги</title>
<desc id="shirtDesc">Минималистичная иллюстрация футболки с надписью Алабуга и эмблемой миссии.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#1e293b" />
</linearGradient>
<linearGradient id="shirt" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5c6bc0" />
<stop offset="100%" stop-color="#283593" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#bg)" />
<g transform="translate(160 40)">
<path
d="M60 20 L120 0 L180 20 L220 20 C240 20 248 38 244 60 L226 150 C220 200 200 240 160 260 L120 280 L80 260 C40 240 20 200 14 150 L-4 60 C-8 38 0 20 20 20 Z"
fill="url(#shirt)"
stroke="#c5cae9"
stroke-width="6"
stroke-linejoin="round"
/>
<rect x="72" y="92" width="136" height="46" rx="8" fill="#1e88e5" opacity="0.85" />
<text x="140" y="122" text-anchor="middle" font-size="32" font-weight="600" fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
АЛАБУГА
</text>
<circle cx="140" cy="190" r="42" fill="#0d47a1" stroke="#bbdefb" stroke-width="6" />
<text x="140" y="200" text-anchor="middle" font-size="26" font-weight="500" fill="#bbdefb" font-family="'Montserrat', 'Arial', sans-serif">
⚡42
</text>
</g>
<text x="60" y="320" fill="#e8eaf6" font-family="'Montserrat', 'Arial', sans-serif" font-size="32" font-weight="600">
Футболка экипажа
</text>
<text x="60" y="348" fill="#c5cae9" font-family="'Montserrat', 'Arial', sans-serif" font-size="20">
Лимитированная серия для выпускников миссии
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="title desc">
<title id="title">Экскурсия по Алабуге</title>
<desc id="desc">Стилизованный рисунок кампуса Алабуги с отмеченным маршрутом экскурсии.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3f51b5" />
<stop offset="100%" stop-color="#1a237e" />
</linearGradient>
<linearGradient id="sun" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffeb3b" />
<stop offset="100%" stop-color="#ff9800" />
</linearGradient>
<linearGradient id="path" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff8a80" />
<stop offset="100%" stop-color="#ff5252" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#sky)" />
<circle cx="520" cy="90" r="46" fill="url(#sun)" opacity="0.9" />
<g fill="none" stroke="#90caf9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.75">
<path d="M80 260 L160 210 L220 240 L320 200 L420 220 L520 190" />
<path d="M100 180 L180 150 L260 170 L340 140 L420 160" />
</g>
<g fill="#e3f2fd" opacity="0.9">
<rect x="70" y="190" width="120" height="90" rx="12" />
<rect x="210" y="210" width="120" height="70" rx="10" />
<rect x="360" y="200" width="140" height="80" rx="14" />
</g>
<path d="M100 300 C220 280, 360 300, 520 250" stroke="url(#path)" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round" />
<g fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
<text x="60" y="330" font-size="36" font-weight="600">Экскурсия по Алабуге</text>
<text x="60" y="360" font-size="20" opacity="0.85">Тур по производственным цехам и кампусу</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -3,6 +3,7 @@ import { AdminMissionManager } from '../../components/admin/AdminMissionManager'
import { AdminRankManager } from '../../components/admin/AdminRankManager';
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
import { apiFetch } from '../../lib/api';
import { requireRole } from '../../lib/auth/session';
@ -61,6 +62,15 @@ interface ArtifactSummary {
image_url?: string | null;
}
interface StoreItemSummary {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
}
interface SubmissionStats {
pending: number;
approved: number;
@ -85,14 +95,24 @@ export default async function AdminPage() {
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
const session = await requireRole('hr');
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
const [
submissions,
missions,
branches,
ranks,
competencies,
artifacts,
stats,
storeItems,
] = await Promise.all([
apiFetch<Submission[]>('/api/admin/submissions', { authToken: session.token }),
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: session.token }),
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token })
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token }),
apiFetch<StoreItemSummary[]>('/api/admin/store/items', { authToken: session.token }),
]);
return (
@ -167,6 +187,7 @@ export default async function AdminPage() {
/>
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
<AdminArtifactManager token={session.token} artifacts={artifacts} />
<AdminStoreManager token={session.token} items={storeItems} />
</div>
</section>
);

View File

@ -2,6 +2,7 @@ import { apiFetch } from '../../../lib/api';
import { requireSession } from '../../../lib/auth/session';
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
import { OfflineMissionRegistration } from '../../../components/OfflineMissionRegistration';
interface MissionDetail {
id: number;
@ -10,6 +11,7 @@ interface MissionDetail {
xp_reward: number;
mana_reward: number;
difficulty: string;
format: 'online' | 'offline';
minimum_rank_id: number | null;
artifact_id: number | null;
prerequisites: number[];
@ -25,6 +27,19 @@ interface MissionDetail {
has_coding_challenges: boolean;
coding_challenge_count: number;
completed_coding_challenges: number;
event_location?: string | null;
event_address?: string | null;
event_starts_at?: string | null;
event_ends_at?: string | null;
registration_deadline?: string | null;
registration_url?: string | null;
registration_notes?: string | null;
capacity?: number | null;
contact_person?: string | null;
contact_phone?: string | null;
submission_status?: 'pending' | 'approved' | 'rejected' | null;
registered_participants: number;
registration_open: boolean;
}
async function fetchMission(id: number, token: string) {
@ -90,6 +105,40 @@ export default async function MissionPage({ params }: MissionPageProps) {
<h2>{mission.title}</h2>
<span className="badge">{mission.difficulty}</span>
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>{mission.description}</p>
{mission.format === 'offline' && (
<div className="card" style={{ marginTop: '1rem', background: 'rgba(162, 155, 254, 0.08)' }}>
<h3 style={{ marginBottom: '0.5rem' }}>Офлайн событие</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, color: 'var(--text-muted)' }}>
{mission.event_location && <li>📍 {mission.event_location}</li>}
{mission.event_address && <li>🧭 {mission.event_address}</li>}
{mission.event_starts_at && (
<li>
🗓 Старт: {new Date(mission.event_starts_at).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</li>
)}
{mission.event_ends_at && (
<li>
🕘 Завершение: {new Date(mission.event_ends_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</li>
)}
<li>
👥 Зарегистрировано: {mission.registered_participants}
{mission.capacity ? ` из ${mission.capacity}` : ''}
</li>
{mission.registration_deadline && (
<li>
Запись до: {new Date(mission.registration_deadline).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</li>
)}
{mission.registration_notes && <li> {mission.registration_notes}</li>}
{mission.registration_open ? (
<li style={{ color: 'var(--accent-light)' }}>Регистрация открыта</li>
) : (
<li style={{ color: 'var(--error)' }}>Регистрация закрыта</li>
)}
</ul>
</div>
)}
<p style={{ marginTop: '1rem' }}>
Награда: {mission.xp_reward} XP · {mission.mana_reward}
</p>
@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) {
initialState={codingState}
initialCompleted={mission.is_completed}
/>
) : mission.format === 'offline' ? (
<OfflineMissionRegistration
missionId={mission.id}
token={session.token}
locked={!mission.is_available && !mission.is_completed}
registrationOpen={mission.registration_open}
registeredCount={mission.registered_participants}
capacity={mission.capacity}
submission={submission ? { id: submission.id, comment: submission.comment, status: submission.status } : null}
eventLocation={mission.event_location}
eventAddress={mission.event_address}
eventStartsAt={mission.event_starts_at}
eventEndsAt={mission.event_ends_at}
registrationDeadline={mission.registration_deadline}
registrationUrl={mission.registration_url}
registrationNotes={mission.registration_notes}
contactPerson={mission.contact_person}
contactPhone={mission.contact_phone}
/>
) : (
<MissionSubmissionForm
missionId={mission.id}

View File

@ -15,6 +15,7 @@ interface ProfileResponse {
name: string;
rarity: string;
}>;
profile_photo_uploaded: boolean;
}
interface ProgressResponse {
@ -64,6 +65,8 @@ export default async function DashboardPage() {
mana={profile.mana}
competencies={profile.competencies}
artifacts={profile.artifacts}
token={session.token}
profilePhotoUploaded={profile.profile_photo_uploaded}
progress={progress}
/>
</div>

View File

@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '').trim();
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
if (!fullName || !email || !password) {
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
try {
// 2. Собираем payload в формате, который ожидает FastAPI.
const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation };
const payload = { full_name: fullName, email, password, motivation };
const response = await apiFetch<any>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
@ -80,17 +79,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: { e
Пароль
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
</label>
<label className={styles.field}>
Интересующая ветка (необязательно)
<select className={styles.input} name="preferredBranch" defaultValue="">
<option value="">Выберите ветку</option>
<option value="Получение оффера">Получение оффера</option>
<option value="Рекрутинг">Рекрутинг</option>
<option value="Квесты">Квесты</option>
<option value="Симулятор">Симулятор</option>
<option value="Лекторий">Лекторий</option>
</select>
</label>
<label className={styles.field}>
Что хотите добиться?
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />

View File

@ -8,6 +8,7 @@ interface StoreItem {
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
}
async function fetchStore(token: string) {

View File

@ -9,6 +9,17 @@ export interface MissionSummary {
xp_reward: number;
mana_reward: number;
difficulty: string;
format: 'online' | 'offline';
event_location?: string | null;
event_address?: string | null;
event_starts_at?: string | null;
event_ends_at?: string | null;
registration_deadline?: string | null;
registration_url?: string | null;
registration_notes?: string | null;
capacity?: number | null;
contact_person?: string | null;
contact_phone?: string | null;
is_active: boolean;
is_available: boolean;
locked_reasons: string[];
@ -17,6 +28,9 @@ export interface MissionSummary {
has_coding_challenges: boolean;
coding_challenge_count: number;
completed_coding_challenges: number;
submission_status?: 'pending' | 'approved' | 'rejected' | null;
registered_participants: number;
registration_open: boolean;
}
const Card = styled.div`
@ -38,13 +52,32 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
const locked = !mission.is_available && !completed;
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
const linkDisabled = locked;
const actionLabel = completed
? 'Миссия выполнена'
: mission.is_available
? mission.has_coding_challenges
? 'Решать задачи'
: 'Открыть брифинг'
: 'Заблокировано';
let actionLabel = 'Открыть брифинг';
if (completed) {
actionLabel = mission.format === 'offline' ? 'Регистрация подтверждена' : 'Миссия выполнена';
} else if (!mission.is_available) {
actionLabel = 'Заблокировано';
} else if (mission.format === 'offline') {
if (mission.submission_status === 'pending') {
actionLabel = 'Заявка отправлена';
} else if (mission.submission_status === 'approved') {
actionLabel = 'Регистрация подтверждена';
} else if (!mission.registration_open) {
actionLabel = 'Регистрация закрыта';
} else {
actionLabel = 'Записаться';
}
} else if (mission.has_coding_challenges) {
actionLabel = 'Решать задачи';
}
const offlineDetails = mission.format === 'offline'
? {
date: mission.event_starts_at ? new Date(mission.event_starts_at) : null,
end: mission.event_ends_at ? new Date(mission.event_ends_at) : null,
deadline: mission.registration_deadline ? new Date(mission.registration_deadline) : null
}
: null;
return (
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
🗂 Требуется загрузка документов
</p>
)}
{mission.format === 'offline' && offlineDetails?.date && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--accent-light)' }}>
<div>📍 {mission.event_location ?? 'Офлайн мероприятие'}</div>
<div>
🗓 {offlineDetails.date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} ·
{' '}
{offlineDetails.date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{offlineDetails.end &&
` ${offlineDetails.end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
</div>
<div>
👥 {mission.registered_participants}
{mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано
</div>
{!mission.registration_open && mission.submission_status !== 'approved' && (
<div style={{ color: 'var(--error)' }}>Регистрация завершена</div>
)}
{mission.registration_open && offlineDetails.deadline && (
<div>
Запись до{' '}
{offlineDetails.deadline.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long'
})}{' '}
{offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</div>
)}
</div>
)}
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
{mission.has_coding_challenges && (

View File

@ -0,0 +1,205 @@
'use client';
import { useState } from 'react';
import { apiFetch } from '../lib/api';
type SubmissionStatus = 'pending' | 'approved' | 'rejected';
interface ExistingSubmission {
id: number;
comment: string | null;
status: SubmissionStatus;
}
interface OfflineMissionRegistrationProps {
missionId: number;
token?: string;
locked?: boolean;
registrationOpen: boolean;
registeredCount: number;
capacity?: number | null;
submission?: ExistingSubmission | null;
eventLocation?: string | null;
eventAddress?: string | null;
eventStartsAt?: string | null;
eventEndsAt?: string | null;
registrationDeadline?: string | null;
registrationUrl?: string | null;
registrationNotes?: string | null;
contactPerson?: string | null;
contactPhone?: string | null;
}
function formatDateTime(value?: string | null) {
if (!value) return null;
const date = new Date(value);
const formattedDate = date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
});
const formattedTime = date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
});
return `${formattedDate} · ${formattedTime}`;
}
export function OfflineMissionRegistration({
missionId,
token,
locked = false,
registrationOpen,
registeredCount,
capacity,
submission,
eventLocation,
eventAddress,
eventStartsAt,
eventEndsAt,
registrationDeadline,
registrationUrl,
registrationNotes,
contactPerson,
contactPhone,
}: OfflineMissionRegistrationProps) {
const [comment, setComment] = useState(submission?.comment ?? '');
const initialStatus = (() => {
if (submission?.status === 'approved') {
return 'Регистрация подтверждена HR. Встретимся офлайн!';
}
if (submission?.status === 'pending') {
return 'Заявка отправлена и ожидает подтверждения HR.';
}
if (submission?.status === 'rejected') {
return 'Предыдущая заявка была отклонена. Проверьте комментарий и отправьте снова.';
}
if (!registrationOpen) {
return 'Регистрация закрыта: лимит мест или срок записи истёк.';
}
return null;
})();
const [status, setStatus] = useState<string | null>(initialStatus);
const [loading, setLoading] = useState(false);
const submissionStatus = submission?.status;
const isApproved = submissionStatus === 'approved';
const isPending = submissionStatus === 'pending';
const canSubmit = !locked && (registrationOpen || Boolean(submission));
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
setStatus('Не удалось авторизовать отправку. Перезагрузите страницу.');
return;
}
if (!canSubmit) {
setStatus('Регистрация закрыта.');
return;
}
try {
setLoading(true);
setStatus(null);
const formData = new FormData();
formData.append('comment', comment.trim());
const updated = await apiFetch<ExistingSubmission & { status: SubmissionStatus; comment: string | null }>(
`/api/missions/${missionId}/submit`,
{
method: 'POST',
body: formData,
authToken: token,
},
);
setComment(updated.comment ?? '');
if (updated.status === 'approved') {
setStatus('Регистрация подтверждена HR.');
} else {
setStatus('Заявка отправлена! HR свяжется с вами при необходимости.');
}
} catch (error) {
if (error instanceof Error) {
setStatus(error.message);
} else {
setStatus('Не удалось отправить заявку. Попробуйте позже.');
}
} finally {
setLoading(false);
}
}
return (
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
<h3>Запись на офлайн-мероприятие</h3>
<p style={{ color: 'var(--text-muted)' }}>
{eventLocation ?? 'Офлайн событие'}
{eventAddress ? ` · ${eventAddress}` : ''}
</p>
<div style={{ display: 'grid', gap: '0.5rem', margin: '1rem 0' }}>
{eventStartsAt && (
<div>
<strong>Начало:</strong> {formatDateTime(eventStartsAt)}
</div>
)}
{eventEndsAt && (
<div>
<strong>Завершение:</strong> {formatDateTime(eventEndsAt)}
</div>
)}
<div>
<strong>Участников:</strong> {registeredCount}
{capacity ? ` из ${capacity}` : ''}
</div>
{registrationDeadline && (
<div>
<strong>Запись до:</strong> {formatDateTime(registrationDeadline)}
</div>
)}
{registrationUrl && (
<div>
<a href={registrationUrl} target="_blank" rel="noreferrer" className="secondary">
Открыть страницу мероприятия
</a>
</div>
)}
{contactPerson && (
<div>
<strong>Контакт HR:</strong> {contactPerson}
{contactPhone ? ` · ${contactPhone}` : ''}
</div>
)}
{registrationNotes && (
<div style={{ color: 'var(--accent-light)' }}>{registrationNotes}</div>
)}
</div>
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
Комментарий (опционально)
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="Например: приеду с запасным удостоверением"
disabled={!canSubmit || isApproved}
/>
</label>
<button className="primary" type="submit" disabled={!canSubmit || loading || isApproved}>
{isApproved ? 'Регистрация подтверждена' : isPending ? 'Заявка отправлена' : registrationOpen ? 'Записаться' : 'Регистрация закрыта'}
</button>
{status && (
<p
style={{
marginTop: '1rem',
color: status.includes('подтверждена') ? 'var(--accent-light)' : status.includes('отправлена') ? 'var(--accent-light)' : 'var(--error)',
}}
>
{status}
</p>
)}
</form>
);
}

View File

@ -1,7 +1,10 @@
'use client';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { apiFetch } from '../lib/api';
// Компетенции и артефакты из профиля пользователя.
type Competency = {
competency: {
@ -18,11 +21,18 @@ type Artifact = {
};
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
interface ProfilePhotoResponse {
photo: string | null;
detail?: string | null;
}
export interface ProfileProps {
fullName: string;
mana: number;
competencies: Competency[];
artifacts: Artifact[];
token: string;
profilePhotoUploaded: boolean;
progress: {
current_rank: { title: string } | null;
next_rank: { title: string } | null;
@ -57,6 +67,44 @@ const Card = styled.div`
gap: 1.5rem;
`;
const PhotoSection = styled.div`
display: flex;
gap: 1.5rem;
align-items: center;
`;
const PhotoPreview = styled.div`
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(108, 92, 231, 0.45);
background: rgba(162, 155, 254, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
`;
const PhotoImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const PhotoActions = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
`;
const StatusMessage = styled.p<{ $kind: 'success' | 'error' }>`
margin: 0;
font-size: 0.85rem;
color: ${({ $kind }) => ($kind === 'success' ? 'var(--accent-light)' : 'var(--error)')};
`;
const ProgressBar = styled.div<{ value: number }>`
position: relative;
height: 12px;
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
`;
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
export function ProgressOverview({
fullName,
mana,
competencies,
artifacts,
token,
profilePhotoUploaded,
progress
}: ProfileProps) {
const xpPercent = Math.round(progress.xp.progress_percent * 100);
const hasNextRank = Boolean(progress.next_rank);
const [photoData, setPhotoData] = useState<string | null>(null);
const [hasPhoto, setHasPhoto] = useState(profilePhotoUploaded);
const [status, setStatus] = useState<string | null>(null);
const [statusKind, setStatusKind] = useState<'success' | 'error'>('success');
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setHasPhoto(profilePhotoUploaded);
}, [profilePhotoUploaded]);
useEffect(() => {
if (!hasPhoto) {
setPhotoData(null);
return;
}
let cancelled = false;
async function loadPhoto() {
try {
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', { authToken: token });
if (!cancelled) {
setPhotoData(response.photo ?? null);
}
} catch (error) {
if (!cancelled) {
console.error('Не удалось загрузить фото профиля', error);
setStatusKind('error');
setStatus('Не получилось загрузить фото. Попробуйте обновить страницу.');
}
}
}
void loadPhoto();
return () => {
cancelled = true;
};
}, [hasPhoto, token]);
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
setStatus('Разрешены только изображения в форматах JPG, PNG или WEBP.');
event.target.value = '';
return;
}
if (file.size > 5 * 1024 * 1024) {
setStatus('Размер файла не должен превышать 5 МБ.');
event.target.value = '';
return;
}
const formData = new FormData();
formData.append('photo', file);
try {
setUploading(true);
setStatus(null);
setStatusKind('success');
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
method: 'POST',
body: formData,
authToken: token
});
setPhotoData(response.photo ?? null);
setHasPhoto(Boolean(response.photo));
setStatusKind('success');
setStatus(response.detail ?? 'Фотография обновлена.');
} catch (error) {
if (error instanceof Error) {
setStatusKind('error');
setStatus(error.message);
} else {
setStatusKind('error');
setStatus('Не удалось сохранить фото. Попробуйте позже.');
}
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
async function handleRemove() {
try {
setUploading(true);
setStatus(null);
setStatusKind('success');
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
method: 'DELETE',
authToken: token
});
setPhotoData(null);
setHasPhoto(false);
setStatusKind('success');
setStatus(response.detail ?? 'Фотография удалена.');
} catch (error) {
if (error instanceof Error) {
setStatusKind('error');
setStatus(error.message);
} else {
setStatusKind('error');
setStatus('Не удалось удалить фото. Попробуйте ещё раз.');
}
} finally {
setUploading(false);
}
}
return (
<Card>
<PhotoSection>
<PhotoPreview>
{photoData ? <PhotoImage src={photoData} alt="Фото профиля" /> : <span role="img" aria-label="Профиль">🧑🚀</span>}
</PhotoPreview>
<PhotoActions>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<label className="secondary" style={{ cursor: 'pointer' }}>
Загрузить фото
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={handleUpload}
disabled={uploading}
/>
</label>
<button className="ghost" type="button" onClick={handleRemove} disabled={!hasPhoto || uploading}>
Удалить
</button>
</div>
<small style={{ color: 'var(--text-muted)' }}>
Добавьте свою фотографию, чтобы HR быстрее узнавал вас при общении на офлайн-миссиях.
</small>
{status && <StatusMessage $kind={statusKind}>{status}</StatusMessage>}
</PhotoActions>
</PhotoSection>
<header>
<h2 style={{ margin: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>

View File

@ -10,6 +10,7 @@ type StoreItem = {
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
};
const Card = styled.div`
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
<div className="grid">
{items.map((item) => (
<Card key={item.id}>
{item.image_url && (
<img
src={item.image_url}
alt={item.name}
style={{
width: '100%',
maxHeight: '180px',
borderRadius: '10px',
marginBottom: '1rem',
objectFit: 'cover',
}}
/>
)}
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
<p style={{ marginTop: '1rem' }}>{item.cost_mana} · остаток {item.stock}</p>

View File

@ -20,6 +20,17 @@ type MissionBase = {
xp_reward: number;
mana_reward: number;
difficulty: Difficulty;
format: 'online' | 'offline';
event_location?: string | null;
event_address?: string | null;
event_starts_at?: string | null;
event_ends_at?: string | null;
registration_deadline?: string | null;
registration_url?: string | null;
registration_notes?: string | null;
capacity?: number | null;
contact_person?: string | null;
contact_phone?: string | null;
is_active: boolean;
};
@ -76,6 +87,17 @@ type FormState = {
xp_reward: number;
mana_reward: number;
difficulty: Difficulty;
format: 'online' | 'offline';
event_location: string;
event_address: string;
event_starts_at: string;
event_ends_at: string;
registration_deadline: string;
registration_url: string;
registration_notes: string;
capacity: number | '';
contact_person: string;
contact_phone: string;
minimum_rank_id: number | '';
artifact_id: number | '';
branch_id: number | '';
@ -91,6 +113,17 @@ const initialFormState: FormState = {
xp_reward: 0,
mana_reward: 0,
difficulty: 'medium',
format: 'online',
event_location: '',
event_address: '',
event_starts_at: '',
event_ends_at: '',
registration_deadline: '',
registration_url: '',
registration_notes: '',
capacity: '',
contact_person: '',
contact_phone: '',
minimum_rank_id: '',
artifact_id: '',
branch_id: '',
@ -108,6 +141,25 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const toInputDateTime = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60000);
return local.toISOString().slice(0, 16);
};
const fromInputDateTime = (value: string) => {
if (!value) return null;
const date = new Date(value);
return date.toISOString();
};
const sanitizeString = (value: string) => {
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
};
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
// пока загрузка детальной карточки не завершилась.
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
@ -126,6 +178,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
xp_reward: mission.xp_reward,
mana_reward: mission.mana_reward,
difficulty: mission.difficulty,
format: mission.format,
event_location: mission.event_location ?? '',
event_address: mission.event_address ?? '',
event_starts_at: toInputDateTime(mission.event_starts_at),
event_ends_at: toInputDateTime(mission.event_ends_at),
registration_deadline: toInputDateTime(mission.registration_deadline),
registration_url: mission.registration_url ?? '',
registration_notes: mission.registration_notes ?? '',
capacity: mission.capacity ?? '',
contact_person: mission.contact_person ?? '',
contact_phone: mission.contact_phone ?? '',
minimum_rank_id: mission.minimum_rank_id ?? '',
artifact_id: mission.artifact_id ?? '',
branch_id: (() => {
@ -174,6 +237,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
xp_reward: baseMission.xp_reward,
mana_reward: baseMission.mana_reward,
difficulty: baseMission.difficulty,
format: baseMission.format,
event_location: baseMission.event_location ?? '',
event_address: baseMission.event_address ?? '',
event_starts_at: toInputDateTime(baseMission.event_starts_at),
event_ends_at: toInputDateTime(baseMission.event_ends_at),
registration_deadline: toInputDateTime(baseMission.registration_deadline),
registration_url: baseMission.registration_url ?? '',
registration_notes: baseMission.registration_notes ?? '',
capacity: baseMission.capacity ?? '',
contact_person: baseMission.contact_person ?? '',
contact_phone: baseMission.contact_phone ?? '',
is_active: baseMission.is_active
}));
}
@ -222,6 +296,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
xp_reward: Number(form.xp_reward),
mana_reward: Number(form.mana_reward),
difficulty: form.difficulty,
format: form.format,
event_location: sanitizeString(form.event_location),
event_address: sanitizeString(form.event_address),
event_starts_at: fromInputDateTime(form.event_starts_at),
event_ends_at: fromInputDateTime(form.event_ends_at),
registration_deadline: fromInputDateTime(form.registration_deadline),
registration_url: sanitizeString(form.registration_url),
registration_notes: sanitizeString(form.registration_notes),
capacity: form.capacity === '' ? null : Number(form.capacity),
contact_person: sanitizeString(form.contact_person),
contact_phone: sanitizeString(form.contact_phone),
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
prerequisite_ids: form.prerequisite_ids,
@ -312,6 +397,13 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
))}
</select>
</label>
<label>
Формат
<select value={form.format} onChange={(event) => updateField('format', event.target.value as 'online' | 'offline')}>
<option value="online">Онлайн</option>
<option value="offline">Офлайн встреча</option>
</select>
</label>
<label>
Доступен с ранга
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
@ -339,6 +431,54 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
</label>
</div>
{form.format === 'offline' && (
<fieldset style={{ border: '1px solid rgba(162,155,254,0.3)', borderRadius: '16px', padding: '1rem' }}>
<legend style={{ padding: '0 0.5rem' }}>Офлайн-детали</legend>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
<label>
Локация / площадка
<input value={form.event_location} onChange={(event) => updateField('event_location', event.target.value)} placeholder="Например: Кампус Алабуга" />
</label>
<label>
Адрес
<input value={form.event_address} onChange={(event) => updateField('event_address', event.target.value)} placeholder="Город, улица, дом" />
</label>
<label>
Начало
<input type="datetime-local" value={form.event_starts_at} onChange={(event) => updateField('event_starts_at', event.target.value)} />
</label>
<label>
Завершение
<input type="datetime-local" value={form.event_ends_at} onChange={(event) => updateField('event_ends_at', event.target.value)} />
</label>
<label>
Дедлайн регистрации
<input type="datetime-local" value={form.registration_deadline} onChange={(event) => updateField('registration_deadline', event.target.value)} />
</label>
<label>
Вместимость
<input type="number" min={0} value={form.capacity === '' ? '' : form.capacity} onChange={(event) => updateField('capacity', event.target.value === '' ? '' : Number(event.target.value))} placeholder="Например: 40" />
</label>
<label>
Ссылка на мероприятие
<input type="url" value={form.registration_url} onChange={(event) => updateField('registration_url', event.target.value)} placeholder="https://..." />
</label>
<label>
Дополнительные заметки
<textarea value={form.registration_notes} onChange={(event) => updateField('registration_notes', event.target.value)} rows={3} placeholder="Что взять с собой, как пройти и т.д." />
</label>
<label>
Контактное лицо
<input value={form.contact_person} onChange={(event) => updateField('contact_person', event.target.value)} placeholder="Имя HR" />
</label>
<label>
Телефон/чат
<input value={form.contact_phone} onChange={(event) => updateField('contact_phone', event.target.value)} placeholder="+7..." />
</label>
</div>
</fieldset>
)}
<label>
Ветка
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>

View File

@ -0,0 +1,237 @@
'use client';
import { FormEvent, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../lib/api';
export type StoreItem = {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
};
interface Props {
token: string;
items: StoreItem[];
}
interface FormState {
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string;
}
const emptyForm: FormState = {
name: '',
description: '',
cost_mana: 0,
stock: 0,
image_url: '',
};
export function AdminStoreManager({ token, items }: Props) {
const router = useRouter();
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
const [form, setForm] = useState<FormState>(emptyForm);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
const resetForm = () => {
setForm({ ...emptyForm });
setSelectedId('new');
};
const handleSelect = (id: number | 'new') => {
setStatus(null);
setError(null);
setSelectedId(id);
if (id === 'new') {
setForm({ ...emptyForm });
} else {
const item = itemById.get(id);
if (item) {
// Подставляем актуальные значения товара в форму.
setForm({
name: item.name,
description: item.description,
cost_mana: item.cost_mana,
stock: item.stock,
image_url: item.image_url ?? '',
});
}
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
setStatus(null);
setError(null);
try {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
cost_mana: form.cost_mana,
stock: form.stock,
image_url: form.image_url.trim() === '' ? null : form.image_url.trim(),
};
if (!payload.name || !payload.description) {
throw new Error('Название и описание не должны быть пустыми.');
}
if (selectedId === 'new') {
await apiFetch('/api/admin/store/items', {
method: 'POST',
body: JSON.stringify(payload),
authToken: token,
});
setStatus('Товар добавлен. Мы обновили список, можно проверить карточку ниже.');
resetForm();
} else {
await apiFetch(`/api/admin/store/items/${selectedId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
authToken: token,
});
setStatus('Изменения сохранены. Страница магазина обновится автоматически.');
}
router.refresh();
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Не удалось сохранить товар.';
setError(message);
} finally {
setSaving(false);
}
};
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<h3>Магазин</h3>
<p style={{ color: 'var(--text-muted)' }}>
Управляйте призами: загружайте изображения, задавайте стоимость в мане и поддерживайте актуальный остаток.
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: '260px' }}>
<label className="label" htmlFor="store-item-select">
Выберите товар
</label>
<select
id="store-item-select"
value={selectedId === 'new' ? 'new' : selectedId}
onChange={(event) => {
const value = event.target.value === 'new' ? 'new' : Number(event.target.value);
handleSelect(value);
}}
>
<option value="new">Добавить новый</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<button type="button" className="secondary" onClick={resetForm} disabled={saving}>
Очистить форму
</button>
</div>
<form onSubmit={handleSubmit} style={{ marginTop: '1.5rem', display: 'grid', gap: '1rem' }}>
<div>
<label className="label" htmlFor="store-name">
Название
</label>
<input
id="store-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Например, экскурсия по кампусу"
/>
</div>
<div>
<label className="label" htmlFor="store-description">
Описание
</label>
<textarea
id="store-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
rows={4}
placeholder="Коротко расскажите, что получит пилот"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem' }}>
<div>
<label className="label" htmlFor="store-cost">
Стоимость ()
</label>
<input
id="store-cost"
type="number"
min={0}
value={form.cost_mana}
onChange={(event) => setForm((prev) => ({ ...prev, cost_mana: Number(event.target.value) }))}
/>
</div>
<div>
<label className="label" htmlFor="store-stock">
Остаток
</label>
<input
id="store-stock"
type="number"
min={0}
value={form.stock}
onChange={(event) => setForm((prev) => ({ ...prev, stock: Number(event.target.value) }))}
/>
</div>
</div>
<div>
<label className="label" htmlFor="store-image">
Ссылка на изображение
</label>
<input
id="store-image"
value={form.image_url}
onChange={(event) => setForm((prev) => ({ ...prev, image_url: event.target.value }))}
placeholder="Например, /store/excursion-alabuga.svg"
/>
<small style={{ color: 'var(--text-muted)' }}>
Изображение можно сохранить в public/store и указать относительный путь.
</small>
</div>
<button className="primary" type="submit" disabled={saving}>
{saving ? 'Сохраняем...' : selectedId === 'new' ? 'Добавить товар' : 'Сохранить изменения'}
</button>
</form>
{status && (
<p style={{ color: 'var(--accent-light)', marginTop: '1rem' }}>
{status}
</p>
)}
{error && (
<p style={{ color: 'var(--error)', marginTop: '1rem' }}>
{error}
</p>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import os
import sys
@ -17,7 +18,7 @@ from app.db.session import SessionLocal
from app.models.artifact import Artifact, ArtifactRarity
from app.models.branch import Branch, BranchMission
from app.models.coding import CodingChallenge
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty, MissionFormat
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.onboarding import OnboardingSlide
from app.models.store import StoreItem
@ -168,7 +169,12 @@ def seed() -> None:
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
category="training",
)
session.add_all([branch, python_branch])
offline_branch = Branch(
title="Офлайн мероприятия",
description="Живые встречи в кампусе и городе",
category="event",
)
session.add_all([branch, python_branch, offline_branch])
session.flush()
# Миссии
@ -214,12 +220,67 @@ def seed() -> None:
difficulty=MissionDifficulty.MEDIUM,
minimum_rank_id=ranks[0].id,
)
now = datetime.now(timezone.utc)
mission_campus_tour = Mission(
title="Экскурсия по кампусу",
description="Познакомьтесь с производственными линиями и учебными площадками вместе с наставником.",
xp_reward=140,
mana_reward=70,
difficulty=MissionDifficulty.EASY,
format=MissionFormat.OFFLINE,
event_location="Кампус Алабуга Политех",
event_address="Республика Татарстан, Елабуга, территория ОЭЗ 'Алабуга'",
event_starts_at=now + timedelta(days=3, hours=10),
event_ends_at=now + timedelta(days=3, hours=13),
registration_deadline=now + timedelta(days=2, hours=18),
capacity=25,
contact_person="Наталья из HR",
contact_phone="+7 (900) 123-45-67",
registration_notes="Возьмите паспорт для прохода на территорию.",
)
mission_sport_day = Mission(
title="Спортивный день в технопарке",
description="Присоединяйтесь к командным активностям и познакомьтесь с будущими коллегами в неформальной обстановке.",
xp_reward=160,
mana_reward=90,
difficulty=MissionDifficulty.MEDIUM,
format=MissionFormat.OFFLINE,
event_location="Спортивный центр Алабуги",
event_address="Елабуга, проспект Строителей, 5",
event_starts_at=now + timedelta(days=7, hours=17),
event_ends_at=now + timedelta(days=7, hours=20),
registration_deadline=now + timedelta(days=6, hours=12),
capacity=40,
contact_person="Игорь, координатор мероприятий",
contact_phone="+7 (900) 765-43-21",
registration_notes="Форма одежды спортивная. Будет организован трансфер от кампуса.",
)
mission_open_lecture = Mission(
title="Лекция капитана по культуре Алабуги",
description="Живой рассказ о миссии компании, ценностях и истории от капитана программы.",
xp_reward=180,
mana_reward=110,
difficulty=MissionDifficulty.MEDIUM,
format=MissionFormat.OFFLINE,
event_location="Конференц-зал HQ",
event_address="Елабуга, ул. Промышленная, 2",
event_starts_at=now + timedelta(days=10, hours=15),
event_ends_at=now + timedelta(days=10, hours=17),
registration_deadline=now + timedelta(days=8, hours=18),
capacity=60,
contact_person="Алина, программа адаптации",
contact_phone="+7 (900) 555-19-82",
registration_notes="Перед лекцией будет кофе-брейк, приходите на 15 минут раньше.",
)
session.add_all([
mission_documents,
mission_resume,
mission_interview,
mission_onboarding,
mission_python_basics,
mission_campus_tour,
mission_sport_day,
mission_open_lecture,
])
session.flush()
@ -250,6 +311,21 @@ def seed() -> None:
competency_id=competencies[2].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_campus_tour.id,
competency_id=competencies[0].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_sport_day.id,
competency_id=competencies[3].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_open_lecture.id,
competency_id=competencies[5].id,
level_delta=1,
),
]
)
@ -260,6 +336,9 @@ def seed() -> None:
BranchMission(branch_id=branch.id, mission_id=mission_interview.id, order=3),
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
BranchMission(branch_id=python_branch.id, mission_id=mission_python_basics.id, order=1),
BranchMission(branch_id=offline_branch.id, mission_id=mission_campus_tour.id, order=1),
BranchMission(branch_id=offline_branch.id, mission_id=mission_sport_day.id, order=2),
BranchMission(branch_id=offline_branch.id, mission_id=mission_open_lecture.id, order=3),
]
)
@ -383,12 +462,14 @@ def seed() -> None:
description="Личный тур по цехам Алабуги",
cost_mana=200,
stock=5,
image_url="/store/excursion-alabuga.svg",
),
StoreItem(
name="Мерч экипажа",
description="Футболка с эмблемой миссии",
cost_mana=150,
stock=10,
image_url="/store/alabuga-crew-shirt.svg",
),
]
)