Add offline mission fixes and profile photo uploads

This commit is contained in:
Danil Gryaznev 2025-09-30 21:54:45 -06:00
parent 989a413162
commit 7333552d24
21 changed files with 1275 additions and 37 deletions

View File

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

View File

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

View File

@ -38,7 +38,7 @@ 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.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 +47,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 +59,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 +84,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,
) )
@ -143,6 +161,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()
@ -172,6 +191,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 +434,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 +506,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])

View File

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

View File

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

View File

@ -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="Перечень рангов")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import shutil import shutil
import mimetypes
import base64
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}"

View File

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

View File

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

View File

@ -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="Например: хочу собрать портфолио и познакомиться с командой" />

View File

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

View File

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

View File

@ -1,7 +1,10 @@
'use client'; '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' }}>

View File

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

View File

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