Fix Alembic script path for migrations

This commit is contained in:
Danil Gryaznev 2025-09-30 21:07:52 -06:00
parent 989a413162
commit 0f8cb7b1b4
12 changed files with 871 additions and 15 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

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

@ -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
@ -384,6 +412,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 +601,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

@ -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,6 +62,10 @@ 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)"))
@ -70,6 +81,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

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

@ -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,25 @@ 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)
if mission.registration_deadline and mission.registration_deadline < current_time:
return False
if mission.capacity is not None and participant_count >= mission.capacity:
return False
return mission.is_active

View File

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

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

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