Add store item images and admin management
This commit is contained in:
parent
989a413162
commit
e546dfc319
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal file
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal 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)
|
||||||
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal file
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal 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")
|
||||||
25
backend/alembic/versions/20241014_0010_store_item_images.py
Normal file
25
backend/alembic/versions/20241014_0010_store_item_images.py
Normal 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")
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.mission import (
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
)
|
)
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
|
from app.models.store import StoreItem
|
||||||
from app.models.user import Competency, User, UserRole
|
from app.models.user import Competency, User, UserRole
|
||||||
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||||
|
|
@ -38,7 +39,8 @@ from app.schemas.rank import (
|
||||||
RankUpdate,
|
RankUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.user import CompetencyBase
|
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
|
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
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:
|
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(
|
return MissionDetail(
|
||||||
id=mission.id,
|
id=mission.id,
|
||||||
title=mission.title,
|
title=mission.title,
|
||||||
|
|
@ -54,6 +61,17 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
||||||
xp_reward=mission.xp_reward,
|
xp_reward=mission.xp_reward,
|
||||||
mana_reward=mission.mana_reward,
|
mana_reward=mission.mana_reward,
|
||||||
difficulty=mission.difficulty,
|
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_active=mission.is_active,
|
||||||
minimum_rank_id=mission.minimum_rank_id,
|
minimum_rank_id=mission.minimum_rank_id,
|
||||||
artifact_id=mission.artifact_id,
|
artifact_id=mission.artifact_id,
|
||||||
|
|
@ -68,6 +86,8 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
||||||
],
|
],
|
||||||
created_at=mission.created_at,
|
created_at=mission.created_at,
|
||||||
updated_at=mission.updated_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:
|
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.prerequisites),
|
||||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||||
selectinload(Mission.branches),
|
selectinload(Mission.branches),
|
||||||
|
selectinload(Mission.submissions),
|
||||||
)
|
)
|
||||||
.filter(Mission.id == mission_id)
|
.filter(Mission.id == mission_id)
|
||||||
.one()
|
.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]
|
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="Детали миссии")
|
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
|
||||||
def admin_mission_detail(
|
def admin_mission_detail(
|
||||||
mission_id: int,
|
mission_id: int,
|
||||||
|
|
@ -172,6 +295,7 @@ def admin_mission_detail(
|
||||||
selectinload(Mission.prerequisites),
|
selectinload(Mission.prerequisites),
|
||||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||||
selectinload(Mission.branches),
|
selectinload(Mission.branches),
|
||||||
|
selectinload(Mission.submissions),
|
||||||
)
|
)
|
||||||
.filter(Mission.id == mission_id)
|
.filter(Mission.id == mission_id)
|
||||||
.first()
|
.first()
|
||||||
|
|
@ -414,6 +538,17 @@ def create_mission_endpoint(
|
||||||
xp_reward=mission_in.xp_reward,
|
xp_reward=mission_in.xp_reward,
|
||||||
mana_reward=mission_in.mana_reward,
|
mana_reward=mission_in.mana_reward,
|
||||||
difficulty=mission_in.difficulty,
|
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,
|
minimum_rank_id=mission_in.minimum_rank_id,
|
||||||
artifact_id=mission_in.artifact_id,
|
artifact_id=mission_in.artifact_id,
|
||||||
)
|
)
|
||||||
|
|
@ -475,7 +610,25 @@ def update_mission_endpoint(
|
||||||
|
|
||||||
payload = mission_in.model_dump(exclude_unset=True)
|
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:
|
if attr in payload:
|
||||||
setattr(mission, attr, payload[attr])
|
setattr(mission, attr, payload[attr])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | di
|
||||||
full_name=user_in.full_name,
|
full_name=user_in.full_name,
|
||||||
hashed_password=get_password_hash(user_in.password),
|
hashed_password=get_password_hash(user_in.password),
|
||||||
role=UserRole.PILOT,
|
role=UserRole.PILOT,
|
||||||
preferred_branch=user_in.preferred_branch,
|
|
||||||
motivation=user_in.motivation,
|
motivation=user_in.motivation,
|
||||||
current_rank_id=base_rank.id if base_rank else None,
|
current_rank_id=base_rank.id if base_rank else None,
|
||||||
is_email_confirmed=not settings.require_email_confirmation,
|
is_email_confirmed=not settings.require_email_confirmation,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
|
|
@ -27,7 +30,7 @@ from app.schemas.coding import (
|
||||||
CodingRunResponse,
|
CodingRunResponse,
|
||||||
)
|
)
|
||||||
from app.services.coding import count_completed_challenges, evaluate_challenge
|
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.services.storage import delete_submission_document, save_submission_document
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
@ -282,12 +285,29 @@ def list_missions(
|
||||||
|
|
||||||
mission_titles = {mission.id: mission.title for mission in missions}
|
mission_titles = {mission.id: mission.title for mission in missions}
|
||||||
completed_missions = _load_user_progress(current_user)
|
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(
|
coding_progress = count_completed_challenges(
|
||||||
db,
|
db,
|
||||||
mission_ids=[mission.id for mission in missions if mission.coding_challenges],
|
mission_ids=[mission.id for mission in missions if mission.coding_challenges],
|
||||||
user=current_user,
|
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] = []
|
response: list[MissionBase] = []
|
||||||
for mission in missions:
|
for mission in missions:
|
||||||
is_available, reasons = _mission_availability(
|
is_available, reasons = _mission_availability(
|
||||||
|
|
@ -310,6 +330,14 @@ def list_missions(
|
||||||
dto.has_coding_challenges = bool(mission.coding_challenges)
|
dto.has_coding_challenges = bool(mission.coding_challenges)
|
||||||
dto.coding_challenge_count = len(mission.coding_challenges)
|
dto.coding_challenge_count = len(mission.coding_challenges)
|
||||||
dto.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
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)
|
response.append(dto)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
@ -370,6 +398,17 @@ def get_mission(
|
||||||
xp_reward=mission.xp_reward,
|
xp_reward=mission.xp_reward,
|
||||||
mana_reward=mission.mana_reward,
|
mana_reward=mission.mana_reward,
|
||||||
difficulty=mission.difficulty,
|
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_active=mission.is_active,
|
||||||
is_available=is_available,
|
is_available=is_available,
|
||||||
locked_reasons=reasons,
|
locked_reasons=reasons,
|
||||||
|
|
@ -384,6 +423,24 @@ def get_mission(
|
||||||
data.has_coding_challenges = bool(mission.coding_challenges)
|
data.has_coding_challenges = bool(mission.coding_challenges)
|
||||||
data.coding_challenge_count = len(mission.coding_challenges)
|
data.coding_challenge_count = len(mission.coding_challenges)
|
||||||
data.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
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:
|
if mission.id in completed_missions:
|
||||||
data.is_completed = True
|
data.is_completed = True
|
||||||
data.is_available = False
|
data.is_available = False
|
||||||
|
|
@ -555,6 +612,25 @@ async def submit(
|
||||||
.first()
|
.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:
|
def _has_upload(upload: UploadFile | None) -> bool:
|
||||||
return bool(upload and upload.filename)
|
return bool(upload and upload.filename)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
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.models.mission import SubmissionStatus
|
||||||
from app.schemas.progress import ProgressSnapshot
|
from app.schemas.progress import ProgressSnapshot
|
||||||
from app.schemas.rank import RankBase
|
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.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"])
|
router = APIRouter(prefix="/api", tags=["profile"])
|
||||||
|
|
||||||
|
|
@ -29,7 +39,89 @@ def get_profile(
|
||||||
_ = item.competency
|
_ = item.competency
|
||||||
for artifact in current_user.artifacts:
|
for artifact in current_user.artifacts:
|
||||||
_ = artifact.artifact
|
_ = 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="Перечень рангов")
|
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ def run_migrations() -> None:
|
||||||
|
|
||||||
config = Config(str(ALEMBIC_CONFIG))
|
config = Config(str(ALEMBIC_CONFIG))
|
||||||
config.set_main_option("sqlalchemy.url", str(settings.database_url))
|
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)
|
script = ScriptDirectory.from_config(config)
|
||||||
head_revision = script.get_current_head()
|
head_revision = script.get_current_head()
|
||||||
|
|
||||||
|
|
@ -55,11 +62,17 @@ def run_migrations() -> None:
|
||||||
if "mission_submissions" in tables:
|
if "mission_submissions" in tables:
|
||||||
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
|
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:
|
with engine.begin() as conn:
|
||||||
if "preferred_branch" not in user_columns:
|
if "preferred_branch" not in user_columns:
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||||
if "motivation" not in user_columns:
|
if "motivation" not in user_columns:
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
|
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:
|
if "passport_path" not in submission_columns:
|
||||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
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:
|
if "resume_link" not in submission_columns:
|
||||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
|
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("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
||||||
conn.execute(text("DELETE FROM alembic_version"))
|
conn.execute(text("DELETE FROM alembic_version"))
|
||||||
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
|
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,20 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
@ -22,6 +32,13 @@ class MissionDifficulty(str, Enum):
|
||||||
HARD = "hard"
|
HARD = "hard"
|
||||||
|
|
||||||
|
|
||||||
|
class MissionFormat(str, Enum):
|
||||||
|
"""Формат проведения миссии."""
|
||||||
|
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
class Mission(Base, TimestampMixin):
|
class Mission(Base, TimestampMixin):
|
||||||
"""Игровая миссия."""
|
"""Игровая миссия."""
|
||||||
|
|
||||||
|
|
@ -35,6 +52,19 @@ class Mission(Base, TimestampMixin):
|
||||||
difficulty: Mapped[MissionDifficulty] = mapped_column(
|
difficulty: Mapped[MissionDifficulty] = mapped_column(
|
||||||
SQLEnum(MissionDifficulty), default=MissionDifficulty.MEDIUM, nullable=False
|
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"))
|
minimum_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"))
|
||||||
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
|
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
|
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
stock: Mapped[int] = mapped_column(Integer, default=0, 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")
|
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class User(Base, TimestampMixin):
|
||||||
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
||||||
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
||||||
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
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")
|
current_rank = relationship("Rank", back_populates="pilots")
|
||||||
competencies: Mapped[List["UserCompetency"]] = relationship(
|
competencies: Mapped[List["UserCompetency"]] = relationship(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, computed_field
|
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):
|
class MissionBase(BaseModel):
|
||||||
|
|
@ -19,6 +19,17 @@ class MissionBase(BaseModel):
|
||||||
xp_reward: int
|
xp_reward: int
|
||||||
mana_reward: int
|
mana_reward: int
|
||||||
difficulty: MissionDifficulty
|
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_active: bool
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
locked_reasons: list[str] = Field(default_factory=list)
|
locked_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
@ -27,6 +38,9 @@ class MissionBase(BaseModel):
|
||||||
has_coding_challenges: bool = False
|
has_coding_challenges: bool = False
|
||||||
coding_challenge_count: int = 0
|
coding_challenge_count: int = 0
|
||||||
completed_coding_challenges: int = 0
|
completed_coding_challenges: int = 0
|
||||||
|
submission_status: Optional[SubmissionStatus] = None
|
||||||
|
registered_participants: int = 0
|
||||||
|
registration_open: bool = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -67,6 +81,17 @@ class MissionCreate(BaseModel):
|
||||||
xp_reward: int
|
xp_reward: int
|
||||||
mana_reward: int
|
mana_reward: int
|
||||||
difficulty: MissionDifficulty = MissionDifficulty.MEDIUM
|
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
|
minimum_rank_id: Optional[int] = None
|
||||||
artifact_id: Optional[int] = None
|
artifact_id: Optional[int] = None
|
||||||
prerequisite_ids: list[int] = []
|
prerequisite_ids: list[int] = []
|
||||||
|
|
@ -83,6 +108,17 @@ class MissionUpdate(BaseModel):
|
||||||
xp_reward: Optional[int] = None
|
xp_reward: Optional[int] = None
|
||||||
mana_reward: Optional[int] = None
|
mana_reward: Optional[int] = None
|
||||||
difficulty: Optional[MissionDifficulty] = 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
|
minimum_rank_id: Optional[int | None] = None
|
||||||
artifact_id: Optional[int | None] = None
|
artifact_id: Optional[int | None] = None
|
||||||
prerequisite_ids: Optional[list[int]] = None
|
prerequisite_ids: Optional[list[int]] = None
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,41 @@ from pydantic import BaseModel
|
||||||
from app.models.store import OrderStatus
|
from app.models.store import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
class StoreItemRead(BaseModel):
|
class StoreItemBase(BaseModel):
|
||||||
"""Товар магазина."""
|
"""Базовые поля товара магазина."""
|
||||||
|
|
||||||
id: int
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
cost_mana: int
|
cost_mana: int
|
||||||
stock: int
|
stock: int
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StoreItemRead(StoreItemBase):
|
||||||
|
"""Товар магазина для чтения."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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):
|
class OrderRead(BaseModel):
|
||||||
"""Информация о заказе."""
|
"""Информация о заказе."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ class UserProfile(UserRead):
|
||||||
|
|
||||||
competencies: list[UserCompetencyRead]
|
competencies: list[UserCompetencyRead]
|
||||||
artifacts: list[UserArtifactRead]
|
artifacts: list[UserArtifactRead]
|
||||||
|
profile_photo_uploaded: bool = False
|
||||||
|
profile_photo_updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(BaseModel):
|
class LeaderboardEntry(BaseModel):
|
||||||
|
|
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
full_name: str
|
full_name: str
|
||||||
password: str
|
password: str
|
||||||
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
|
|
||||||
preferred_branch: Optional[str] = None
|
|
||||||
motivation: Optional[str] = None
|
motivation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfilePhotoResponse(BaseModel):
|
||||||
|
"""Ответ с данными загруженной фотографии."""
|
||||||
|
|
||||||
|
photo: Optional[str] = None
|
||||||
|
detail: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.journal import JournalEventType
|
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.models.user import User, UserArtifact, UserCompetency
|
||||||
from app.services.journal import log_event
|
from app.services.journal import log_event
|
||||||
from app.services.rank import apply_rank_upgrade
|
from app.services.rank import apply_rank_upgrade
|
||||||
|
|
@ -171,3 +172,29 @@ def reject_submission(db: Session, submission: MissionSubmission, comment: str |
|
||||||
)
|
)
|
||||||
|
|
||||||
return submission
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
import base64
|
||||||
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
|
@ -40,8 +42,34 @@ def save_submission_document(
|
||||||
return relative_path
|
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:
|
if not relative_path:
|
||||||
return
|
return
|
||||||
|
|
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
|
||||||
parent = file_path.parent
|
parent = file_path.parent
|
||||||
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
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}"
|
||||||
|
|
|
||||||
38
frontend/public/store/alabuga-crew-shirt.svg
Normal file
38
frontend/public/store/alabuga-crew-shirt.svg
Normal 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 |
34
frontend/public/store/excursion-alabuga.svg
Normal file
34
frontend/public/store/excursion-alabuga.svg
Normal 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 |
|
|
@ -3,6 +3,7 @@ import { AdminMissionManager } from '../../components/admin/AdminMissionManager'
|
||||||
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||||
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||||
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||||
|
import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { requireRole } from '../../lib/auth/session';
|
import { requireRole } from '../../lib/auth/session';
|
||||||
|
|
||||||
|
|
@ -61,6 +62,15 @@ interface ArtifactSummary {
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StoreItemSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cost_mana: number;
|
||||||
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SubmissionStats {
|
interface SubmissionStats {
|
||||||
pending: number;
|
pending: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
|
|
@ -85,14 +95,24 @@ export default async function AdminPage() {
|
||||||
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
|
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
|
||||||
const session = await requireRole('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<Submission[]>('/api/admin/submissions', { authToken: session.token }),
|
||||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
||||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
||||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
||||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
||||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { 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 (
|
return (
|
||||||
|
|
@ -167,6 +187,7 @@ export default async function AdminPage() {
|
||||||
/>
|
/>
|
||||||
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||||
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
||||||
|
<AdminStoreManager token={session.token} items={storeItems} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { apiFetch } from '../../../lib/api';
|
||||||
import { requireSession } from '../../../lib/auth/session';
|
import { requireSession } from '../../../lib/auth/session';
|
||||||
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
||||||
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
|
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
|
||||||
|
import { OfflineMissionRegistration } from '../../../components/OfflineMissionRegistration';
|
||||||
|
|
||||||
interface MissionDetail {
|
interface MissionDetail {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -10,6 +11,7 @@ interface MissionDetail {
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
|
format: 'online' | 'offline';
|
||||||
minimum_rank_id: number | null;
|
minimum_rank_id: number | null;
|
||||||
artifact_id: number | null;
|
artifact_id: number | null;
|
||||||
prerequisites: number[];
|
prerequisites: number[];
|
||||||
|
|
@ -25,6 +27,19 @@ interface MissionDetail {
|
||||||
has_coding_challenges: boolean;
|
has_coding_challenges: boolean;
|
||||||
coding_challenge_count: number;
|
coding_challenge_count: number;
|
||||||
completed_coding_challenges: 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) {
|
async function fetchMission(id: number, token: string) {
|
||||||
|
|
@ -90,6 +105,40 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
<h2>{mission.title}</h2>
|
<h2>{mission.title}</h2>
|
||||||
<span className="badge">{mission.difficulty}</span>
|
<span className="badge">{mission.difficulty}</span>
|
||||||
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>{mission.description}</p>
|
<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' }}>
|
<p style={{ marginTop: '1rem' }}>
|
||||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
initialState={codingState}
|
initialState={codingState}
|
||||||
initialCompleted={mission.is_completed}
|
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
|
<MissionSubmissionForm
|
||||||
missionId={mission.id}
|
missionId={mission.id}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface ProfileResponse {
|
||||||
name: string;
|
name: string;
|
||||||
rarity: string;
|
rarity: string;
|
||||||
}>;
|
}>;
|
||||||
|
profile_photo_uploaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressResponse {
|
interface ProgressResponse {
|
||||||
|
|
@ -64,6 +65,8 @@ export default async function DashboardPage() {
|
||||||
mana={profile.mana}
|
mana={profile.mana}
|
||||||
competencies={profile.competencies}
|
competencies={profile.competencies}
|
||||||
artifacts={profile.artifacts}
|
artifacts={profile.artifacts}
|
||||||
|
token={session.token}
|
||||||
|
profilePhotoUploaded={profile.profile_photo_uploaded}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
|
||||||
const email = String(formData.get('email') ?? '').trim();
|
const email = String(formData.get('email') ?? '').trim();
|
||||||
const password = String(formData.get('password') ?? '').trim();
|
const password = String(formData.get('password') ?? '').trim();
|
||||||
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
||||||
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
|
|
||||||
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
||||||
|
|
||||||
if (!fullName || !email || !password) {
|
if (!fullName || !email || !password) {
|
||||||
|
|
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Собираем payload в формате, который ожидает FastAPI.
|
// 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', {
|
const response = await apiFetch<any>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
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="Придумайте пароль" />
|
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
|
||||||
</label>
|
</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}>
|
<label className={styles.field}>
|
||||||
Что хотите добиться?
|
Что хотите добиться?
|
||||||
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface StoreItem {
|
||||||
description: string;
|
description: string;
|
||||||
cost_mana: number;
|
cost_mana: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStore(token: string) {
|
async function fetchStore(token: string) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,17 @@ export interface MissionSummary {
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
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_active: boolean;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
|
|
@ -17,6 +28,9 @@ export interface MissionSummary {
|
||||||
has_coding_challenges: boolean;
|
has_coding_challenges: boolean;
|
||||||
coding_challenge_count: number;
|
coding_challenge_count: number;
|
||||||
completed_coding_challenges: number;
|
completed_coding_challenges: number;
|
||||||
|
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||||
|
registered_participants: number;
|
||||||
|
registration_open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -38,13 +52,32 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
const locked = !mission.is_available && !completed;
|
const locked = !mission.is_available && !completed;
|
||||||
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||||
const linkDisabled = locked;
|
const linkDisabled = locked;
|
||||||
const actionLabel = completed
|
let actionLabel = 'Открыть брифинг';
|
||||||
? 'Миссия выполнена'
|
if (completed) {
|
||||||
: mission.is_available
|
actionLabel = mission.format === 'offline' ? 'Регистрация подтверждена' : 'Миссия выполнена';
|
||||||
? mission.has_coding_challenges
|
} 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 (
|
return (
|
||||||
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||||
|
|
@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
🗂 Требуется загрузка документов
|
🗂 Требуется загрузка документов
|
||||||
</p>
|
</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>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||||
{mission.has_coding_challenges && (
|
{mission.has_coding_challenges && (
|
||||||
|
|
|
||||||
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
// Компетенции и артефакты из профиля пользователя.
|
// Компетенции и артефакты из профиля пользователя.
|
||||||
type Competency = {
|
type Competency = {
|
||||||
competency: {
|
competency: {
|
||||||
|
|
@ -18,11 +21,18 @@ type Artifact = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||||
|
interface ProfilePhotoResponse {
|
||||||
|
photo: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileProps {
|
export interface ProfileProps {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
mana: number;
|
mana: number;
|
||||||
competencies: Competency[];
|
competencies: Competency[];
|
||||||
artifacts: Artifact[];
|
artifacts: Artifact[];
|
||||||
|
token: string;
|
||||||
|
profilePhotoUploaded: boolean;
|
||||||
progress: {
|
progress: {
|
||||||
current_rank: { title: string } | null;
|
current_rank: { title: string } | null;
|
||||||
next_rank: { title: string } | null;
|
next_rank: { title: string } | null;
|
||||||
|
|
@ -57,6 +67,44 @@ const Card = styled.div`
|
||||||
gap: 1.5rem;
|
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 }>`
|
const ProgressBar = styled.div<{ value: number }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
||||||
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
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 xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||||
const hasNextRank = Boolean(progress.next_rank);
|
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 (
|
return (
|
||||||
<Card>
|
<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>
|
<header>
|
||||||
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type StoreItem = {
|
||||||
description: string;
|
description: string;
|
||||||
cost_mana: number;
|
cost_mana: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.id}>
|
<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>
|
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
|
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
|
||||||
<p style={{ marginTop: '1rem' }}>{item.cost_mana} ⚡ · остаток {item.stock}</p>
|
<p style={{ marginTop: '1rem' }}>{item.cost_mana} ⚡ · остаток {item.stock}</p>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ type MissionBase = {
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: Difficulty;
|
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;
|
is_active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -76,6 +87,17 @@ type FormState = {
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: Difficulty;
|
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 | '';
|
minimum_rank_id: number | '';
|
||||||
artifact_id: number | '';
|
artifact_id: number | '';
|
||||||
branch_id: number | '';
|
branch_id: number | '';
|
||||||
|
|
@ -91,6 +113,17 @@ const initialFormState: FormState = {
|
||||||
xp_reward: 0,
|
xp_reward: 0,
|
||||||
mana_reward: 0,
|
mana_reward: 0,
|
||||||
difficulty: 'medium',
|
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: '',
|
minimum_rank_id: '',
|
||||||
artifact_id: '',
|
artifact_id: '',
|
||||||
branch_id: '',
|
branch_id: '',
|
||||||
|
|
@ -108,6 +141,25 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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]);
|
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,
|
xp_reward: mission.xp_reward,
|
||||||
mana_reward: mission.mana_reward,
|
mana_reward: mission.mana_reward,
|
||||||
difficulty: mission.difficulty,
|
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 ?? '',
|
minimum_rank_id: mission.minimum_rank_id ?? '',
|
||||||
artifact_id: mission.artifact_id ?? '',
|
artifact_id: mission.artifact_id ?? '',
|
||||||
branch_id: (() => {
|
branch_id: (() => {
|
||||||
|
|
@ -174,6 +237,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
xp_reward: baseMission.xp_reward,
|
xp_reward: baseMission.xp_reward,
|
||||||
mana_reward: baseMission.mana_reward,
|
mana_reward: baseMission.mana_reward,
|
||||||
difficulty: baseMission.difficulty,
|
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
|
is_active: baseMission.is_active
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +296,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
xp_reward: Number(form.xp_reward),
|
xp_reward: Number(form.xp_reward),
|
||||||
mana_reward: Number(form.mana_reward),
|
mana_reward: Number(form.mana_reward),
|
||||||
difficulty: form.difficulty,
|
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),
|
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
|
||||||
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
||||||
prerequisite_ids: form.prerequisite_ids,
|
prerequisite_ids: form.prerequisite_ids,
|
||||||
|
|
@ -312,6 +397,13 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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))}>
|
<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>
|
</label>
|
||||||
</div>
|
</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>
|
<label>
|
||||||
Ветка
|
Ветка
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
|
|
||||||
237
frontend/src/components/admin/AdminStoreManager.tsx
Normal file
237
frontend/src/components/admin/AdminStoreManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -17,7 +18,7 @@ from app.db.session import SessionLocal
|
||||||
from app.models.artifact import Artifact, ArtifactRarity
|
from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.coding import CodingChallenge
|
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.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.onboarding import OnboardingSlide
|
from app.models.onboarding import OnboardingSlide
|
||||||
from app.models.store import StoreItem
|
from app.models.store import StoreItem
|
||||||
|
|
@ -168,7 +169,12 @@ def seed() -> None:
|
||||||
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
|
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
|
||||||
category="training",
|
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()
|
session.flush()
|
||||||
|
|
||||||
# Миссии
|
# Миссии
|
||||||
|
|
@ -214,12 +220,67 @@ def seed() -> None:
|
||||||
difficulty=MissionDifficulty.MEDIUM,
|
difficulty=MissionDifficulty.MEDIUM,
|
||||||
minimum_rank_id=ranks[0].id,
|
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([
|
session.add_all([
|
||||||
mission_documents,
|
mission_documents,
|
||||||
mission_resume,
|
mission_resume,
|
||||||
mission_interview,
|
mission_interview,
|
||||||
mission_onboarding,
|
mission_onboarding,
|
||||||
mission_python_basics,
|
mission_python_basics,
|
||||||
|
mission_campus_tour,
|
||||||
|
mission_sport_day,
|
||||||
|
mission_open_lecture,
|
||||||
])
|
])
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
@ -250,6 +311,21 @@ def seed() -> None:
|
||||||
competency_id=competencies[2].id,
|
competency_id=competencies[2].id,
|
||||||
level_delta=1,
|
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_interview.id, order=3),
|
||||||
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
|
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=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="Личный тур по цехам Алабуги",
|
description="Личный тур по цехам Алабуги",
|
||||||
cost_mana=200,
|
cost_mana=200,
|
||||||
stock=5,
|
stock=5,
|
||||||
|
image_url="/store/excursion-alabuga.svg",
|
||||||
),
|
),
|
||||||
StoreItem(
|
StoreItem(
|
||||||
name="Мерч экипажа",
|
name="Мерч экипажа",
|
||||||
description="Футболка с эмблемой миссии",
|
description="Футболка с эмблемой миссии",
|
||||||
cost_mana=150,
|
cost_mana=150,
|
||||||
stock=10,
|
stock=10,
|
||||||
|
image_url="/store/alabuga-crew-shirt.svg",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user