Fix legacy DB migrations for offline mission fields
This commit is contained in:
parent
989a413162
commit
f60c8cd57a
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal file
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""offline missions fields
|
||||
|
||||
Revision ID: 20241010_0008
|
||||
Revises: 3c5430b2cbd3
|
||||
Create Date: 2024-10-10 00:08:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20241010_0008"
|
||||
down_revision = "3c5430b2cbd3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
mission_format_enum = sa.Enum("online", "offline", name="missionformat")
|
||||
|
||||
def upgrade() -> None:
|
||||
mission_format_enum.create(op.get_bind(), checkfirst=True)
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("format", mission_format_enum, nullable=False, server_default="online"))
|
||||
batch_op.add_column(sa.Column("event_location", sa.String(length=160), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_address", sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_starts_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_ends_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_deadline", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_url", sa.String(length=512), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_notes", sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column("capacity", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("contact_person", sa.String(length=120), nullable=True))
|
||||
batch_op.add_column(sa.Column("contact_phone", sa.String(length=64), nullable=True))
|
||||
op.execute("UPDATE missions SET format = 'online' WHERE format IS NULL")
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.alter_column("format", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.drop_column("contact_phone")
|
||||
batch_op.drop_column("contact_person")
|
||||
batch_op.drop_column("capacity")
|
||||
batch_op.drop_column("registration_notes")
|
||||
batch_op.drop_column("registration_url")
|
||||
batch_op.drop_column("registration_deadline")
|
||||
batch_op.drop_column("event_ends_at")
|
||||
batch_op.drop_column("event_starts_at")
|
||||
batch_op.drop_column("event_address")
|
||||
batch_op.drop_column("event_location")
|
||||
batch_op.drop_column("format")
|
||||
mission_format_enum.drop(op.get_bind(), checkfirst=True)
|
||||
|
|
@ -38,7 +38,7 @@ from app.schemas.rank import (
|
|||
RankUpdate,
|
||||
)
|
||||
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
|
||||
|
||||
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:
|
||||
"""Формируем детальную схему миссии."""
|
||||
|
||||
participant_count = sum(
|
||||
1 for submission in mission.submissions if submission.status != SubmissionStatus.REJECTED
|
||||
)
|
||||
is_registration_open = registration_is_open(mission, participant_count=participant_count)
|
||||
|
||||
return MissionDetail(
|
||||
id=mission.id,
|
||||
title=mission.title,
|
||||
|
|
@ -54,6 +59,17 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
|||
xp_reward=mission.xp_reward,
|
||||
mana_reward=mission.mana_reward,
|
||||
difficulty=mission.difficulty,
|
||||
format=mission.format,
|
||||
event_location=mission.event_location,
|
||||
event_address=mission.event_address,
|
||||
event_starts_at=mission.event_starts_at,
|
||||
event_ends_at=mission.event_ends_at,
|
||||
registration_deadline=mission.registration_deadline,
|
||||
registration_url=mission.registration_url,
|
||||
registration_notes=mission.registration_notes,
|
||||
capacity=mission.capacity,
|
||||
contact_person=mission.contact_person,
|
||||
contact_phone=mission.contact_phone,
|
||||
is_active=mission.is_active,
|
||||
minimum_rank_id=mission.minimum_rank_id,
|
||||
artifact_id=mission.artifact_id,
|
||||
|
|
@ -68,6 +84,8 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
|||
],
|
||||
created_at=mission.created_at,
|
||||
updated_at=mission.updated_at,
|
||||
registered_participants=participant_count,
|
||||
registration_open=is_registration_open,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -143,6 +161,7 @@ def _load_mission(db: Session, mission_id: int) -> Mission:
|
|||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
selectinload(Mission.submissions),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.one()
|
||||
|
|
@ -172,6 +191,7 @@ def admin_mission_detail(
|
|||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
selectinload(Mission.submissions),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.first()
|
||||
|
|
@ -414,6 +434,17 @@ def create_mission_endpoint(
|
|||
xp_reward=mission_in.xp_reward,
|
||||
mana_reward=mission_in.mana_reward,
|
||||
difficulty=mission_in.difficulty,
|
||||
format=mission_in.format,
|
||||
event_location=mission_in.event_location,
|
||||
event_address=mission_in.event_address,
|
||||
event_starts_at=mission_in.event_starts_at,
|
||||
event_ends_at=mission_in.event_ends_at,
|
||||
registration_deadline=mission_in.registration_deadline,
|
||||
registration_url=mission_in.registration_url,
|
||||
registration_notes=mission_in.registration_notes,
|
||||
capacity=mission_in.capacity,
|
||||
contact_person=mission_in.contact_person,
|
||||
contact_phone=mission_in.contact_phone,
|
||||
minimum_rank_id=mission_in.minimum_rank_id,
|
||||
artifact_id=mission_in.artifact_id,
|
||||
)
|
||||
|
|
@ -475,7 +506,25 @@ def update_mission_endpoint(
|
|||
|
||||
payload = mission_in.model_dump(exclude_unset=True)
|
||||
|
||||
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
|
||||
for attr in [
|
||||
"title",
|
||||
"description",
|
||||
"xp_reward",
|
||||
"mana_reward",
|
||||
"difficulty",
|
||||
"is_active",
|
||||
"format",
|
||||
"event_location",
|
||||
"event_address",
|
||||
"event_starts_at",
|
||||
"event_ends_at",
|
||||
"registration_deadline",
|
||||
"registration_url",
|
||||
"registration_notes",
|
||||
"capacity",
|
||||
"contact_person",
|
||||
"contact_phone",
|
||||
]:
|
||||
if attr in payload:
|
||||
setattr(mission, attr, payload[attr])
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
|
|
@ -27,7 +30,7 @@ from app.schemas.coding import (
|
|||
CodingRunResponse,
|
||||
)
|
||||
from app.services.coding import count_completed_challenges, evaluate_challenge
|
||||
from app.services.mission import UNSET, submit_mission
|
||||
from app.services.mission import UNSET, registration_is_open, submit_mission
|
||||
from app.services.storage import delete_submission_document, save_submission_document
|
||||
from app.core.config import settings
|
||||
|
||||
|
|
@ -282,12 +285,29 @@ def list_missions(
|
|||
|
||||
mission_titles = {mission.id: mission.title for mission in missions}
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
submission_status_map = {
|
||||
submission.mission_id: submission.status for submission in current_user.submissions
|
||||
}
|
||||
coding_progress = count_completed_challenges(
|
||||
db,
|
||||
mission_ids=[mission.id for mission in missions if mission.coding_challenges],
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
mission_ids = [mission.id for mission in missions]
|
||||
registration_counts = {
|
||||
mission_id: count
|
||||
for mission_id, count in (
|
||||
db.query(MissionSubmission.mission_id, func.count(MissionSubmission.id))
|
||||
.filter(
|
||||
MissionSubmission.mission_id.in_(mission_ids),
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.group_by(MissionSubmission.mission_id)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
|
||||
response: list[MissionBase] = []
|
||||
for mission in missions:
|
||||
is_available, reasons = _mission_availability(
|
||||
|
|
@ -310,6 +330,14 @@ def list_missions(
|
|||
dto.has_coding_challenges = bool(mission.coding_challenges)
|
||||
dto.coding_challenge_count = len(mission.coding_challenges)
|
||||
dto.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
||||
dto.submission_status = submission_status_map.get(mission.id)
|
||||
participants = registration_counts.get(mission.id, 0)
|
||||
dto.registered_participants = participants
|
||||
dto.registration_open = registration_is_open(
|
||||
mission,
|
||||
participant_count=participants,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
response.append(dto)
|
||||
|
||||
return response
|
||||
|
|
@ -384,6 +412,24 @@ def get_mission(
|
|||
data.has_coding_challenges = bool(mission.coding_challenges)
|
||||
data.coding_challenge_count = len(mission.coding_challenges)
|
||||
data.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
||||
data.submission_status = next(
|
||||
(submission.status for submission in current_user.submissions if submission.mission_id == mission.id),
|
||||
None,
|
||||
)
|
||||
participant_count = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(
|
||||
MissionSubmission.mission_id == mission.id,
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
data.registered_participants = participant_count
|
||||
data.registration_open = registration_is_open(
|
||||
mission,
|
||||
participant_count=participant_count,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
if mission.id in completed_missions:
|
||||
data.is_completed = True
|
||||
data.is_available = False
|
||||
|
|
@ -555,6 +601,25 @@ async def submit(
|
|||
.first()
|
||||
)
|
||||
|
||||
participant_count = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(
|
||||
MissionSubmission.mission_id == mission.id,
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
registration_open_state = registration_is_open(
|
||||
mission,
|
||||
participant_count=participant_count,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
if not registration_open_state and not existing_submission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Регистрация на офлайн-мероприятие закрыта.",
|
||||
)
|
||||
|
||||
def _has_upload(upload: UploadFile | None) -> bool:
|
||||
return bool(upload and upload.filename)
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ def run_migrations() -> None:
|
|||
if "mission_submissions" in tables:
|
||||
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
|
||||
|
||||
mission_columns = set()
|
||||
if "missions" in tables:
|
||||
mission_columns = {column["name"] for column in inspector.get_columns("missions")}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if "preferred_branch" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||
|
|
@ -70,6 +74,39 @@ def run_migrations() -> None:
|
|||
if "resume_link" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
|
||||
|
||||
if "missions" in tables:
|
||||
# Легаси-базы без alembic_version пропускали миграцию с офлайн-полями,
|
||||
# поэтому докидываем недостающие колонки вручную, чтобы API /admin не падало.
|
||||
if "format" not in mission_columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE missions ADD COLUMN format VARCHAR(20) NOT NULL DEFAULT 'online'"
|
||||
)
|
||||
)
|
||||
conn.execute(text("UPDATE missions SET format = 'online' WHERE format IS NULL"))
|
||||
if "event_location" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_location VARCHAR(160)"))
|
||||
if "event_address" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_address VARCHAR(255)"))
|
||||
if "event_starts_at" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_starts_at TIMESTAMP"))
|
||||
if "event_ends_at" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_ends_at TIMESTAMP"))
|
||||
if "registration_deadline" not in mission_columns:
|
||||
conn.execute(
|
||||
text("ALTER TABLE missions ADD COLUMN registration_deadline TIMESTAMP")
|
||||
)
|
||||
if "registration_url" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_url VARCHAR(512)"))
|
||||
if "registration_notes" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_notes TEXT"))
|
||||
if "capacity" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN capacity INTEGER"))
|
||||
if "contact_person" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_person VARCHAR(120)"))
|
||||
if "contact_phone" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_phone VARCHAR(64)"))
|
||||
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,20 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum as SQLEnum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
|
@ -22,6 +32,13 @@ class MissionDifficulty(str, Enum):
|
|||
HARD = "hard"
|
||||
|
||||
|
||||
class MissionFormat(str, Enum):
|
||||
"""Формат проведения миссии."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class Mission(Base, TimestampMixin):
|
||||
"""Игровая миссия."""
|
||||
|
||||
|
|
@ -35,6 +52,19 @@ class Mission(Base, TimestampMixin):
|
|||
difficulty: Mapped[MissionDifficulty] = mapped_column(
|
||||
SQLEnum(MissionDifficulty), default=MissionDifficulty.MEDIUM, nullable=False
|
||||
)
|
||||
format: Mapped[MissionFormat] = mapped_column(
|
||||
SQLEnum(MissionFormat), default=MissionFormat.ONLINE, nullable=False
|
||||
)
|
||||
event_location: Mapped[Optional[str]] = mapped_column(String(160))
|
||||
event_address: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
event_starts_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
event_ends_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
registration_deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
registration_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
registration_notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
capacity: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
contact_person: Mapped[Optional[str]] = mapped_column(String(120))
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
minimum_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"))
|
||||
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Optional
|
|||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||
from app.models.mission import MissionDifficulty, MissionFormat, SubmissionStatus
|
||||
|
||||
|
||||
class MissionBase(BaseModel):
|
||||
|
|
@ -19,6 +19,17 @@ class MissionBase(BaseModel):
|
|||
xp_reward: int
|
||||
mana_reward: int
|
||||
difficulty: MissionDifficulty
|
||||
format: MissionFormat
|
||||
event_location: Optional[str] = None
|
||||
event_address: Optional[str] = None
|
||||
event_starts_at: Optional[datetime] = None
|
||||
event_ends_at: Optional[datetime] = None
|
||||
registration_deadline: Optional[datetime] = None
|
||||
registration_url: Optional[str] = None
|
||||
registration_notes: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
contact_person: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
is_active: bool
|
||||
is_available: bool = True
|
||||
locked_reasons: list[str] = Field(default_factory=list)
|
||||
|
|
@ -27,6 +38,9 @@ class MissionBase(BaseModel):
|
|||
has_coding_challenges: bool = False
|
||||
coding_challenge_count: int = 0
|
||||
completed_coding_challenges: int = 0
|
||||
submission_status: Optional[SubmissionStatus] = None
|
||||
registered_participants: int = 0
|
||||
registration_open: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -67,6 +81,17 @@ class MissionCreate(BaseModel):
|
|||
xp_reward: int
|
||||
mana_reward: int
|
||||
difficulty: MissionDifficulty = MissionDifficulty.MEDIUM
|
||||
format: MissionFormat = MissionFormat.ONLINE
|
||||
event_location: Optional[str] = None
|
||||
event_address: Optional[str] = None
|
||||
event_starts_at: Optional[datetime] = None
|
||||
event_ends_at: Optional[datetime] = None
|
||||
registration_deadline: Optional[datetime] = None
|
||||
registration_url: Optional[str] = None
|
||||
registration_notes: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
contact_person: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
minimum_rank_id: Optional[int] = None
|
||||
artifact_id: Optional[int] = None
|
||||
prerequisite_ids: list[int] = []
|
||||
|
|
@ -83,6 +108,17 @@ class MissionUpdate(BaseModel):
|
|||
xp_reward: Optional[int] = None
|
||||
mana_reward: Optional[int] = None
|
||||
difficulty: Optional[MissionDifficulty] = None
|
||||
format: Optional[MissionFormat] = None
|
||||
event_location: Optional[str | None] = None
|
||||
event_address: Optional[str | None] = None
|
||||
event_starts_at: Optional[datetime | None] = None
|
||||
event_ends_at: Optional[datetime | None] = None
|
||||
registration_deadline: Optional[datetime | None] = None
|
||||
registration_url: Optional[str | None] = None
|
||||
registration_notes: Optional[str | None] = None
|
||||
capacity: Optional[int | None] = None
|
||||
contact_person: Optional[str | None] = None
|
||||
contact_phone: Optional[str | None] = None
|
||||
minimum_rank_id: Optional[int | None] = None
|
||||
artifact_id: Optional[int | None] = None
|
||||
prerequisite_ids: Optional[list[int]] = None
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.journal import JournalEventType
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.mission import Mission, MissionFormat, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User, UserArtifact, UserCompetency
|
||||
from app.services.journal import log_event
|
||||
from app.services.rank import apply_rank_upgrade
|
||||
|
|
@ -171,3 +172,25 @@ def reject_submission(db: Session, submission: MissionSubmission, comment: str |
|
|||
)
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
def registration_is_open(
|
||||
mission: Mission,
|
||||
*,
|
||||
participant_count: int,
|
||||
now: datetime | None = None,
|
||||
) -> bool:
|
||||
"""Проверяем, доступна ли запись на офлайн-мероприятие."""
|
||||
|
||||
if mission.format != MissionFormat.OFFLINE:
|
||||
return True
|
||||
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { apiFetch } from '../../../lib/api';
|
|||
import { requireSession } from '../../../lib/auth/session';
|
||||
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
||||
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
|
||||
import { OfflineMissionRegistration } from '../../../components/OfflineMissionRegistration';
|
||||
|
||||
interface MissionDetail {
|
||||
id: number;
|
||||
|
|
@ -10,6 +11,7 @@ interface MissionDetail {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
format: 'online' | 'offline';
|
||||
minimum_rank_id: number | null;
|
||||
artifact_id: number | null;
|
||||
prerequisites: number[];
|
||||
|
|
@ -25,6 +27,19 @@ interface MissionDetail {
|
|||
has_coding_challenges: boolean;
|
||||
coding_challenge_count: number;
|
||||
completed_coding_challenges: number;
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||
registered_participants: number;
|
||||
registration_open: boolean;
|
||||
}
|
||||
|
||||
async function fetchMission(id: number, token: string) {
|
||||
|
|
@ -90,6 +105,40 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
<h2>{mission.title}</h2>
|
||||
<span className="badge">{mission.difficulty}</span>
|
||||
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>{mission.description}</p>
|
||||
{mission.format === 'offline' && (
|
||||
<div className="card" style={{ marginTop: '1rem', background: 'rgba(162, 155, 254, 0.08)' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>Офлайн событие</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, color: 'var(--text-muted)' }}>
|
||||
{mission.event_location && <li>📍 {mission.event_location}</li>}
|
||||
{mission.event_address && <li>🧭 {mission.event_address}</li>}
|
||||
{mission.event_starts_at && (
|
||||
<li>
|
||||
🗓 Старт: {new Date(mission.event_starts_at).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
{mission.event_ends_at && (
|
||||
<li>
|
||||
🕘 Завершение: {new Date(mission.event_ends_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
👥 Зарегистрировано: {mission.registered_participants}
|
||||
{mission.capacity ? ` из ${mission.capacity}` : ''}
|
||||
</li>
|
||||
{mission.registration_deadline && (
|
||||
<li>
|
||||
⏳ Запись до: {new Date(mission.registration_deadline).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
{mission.registration_notes && <li>ℹ️ {mission.registration_notes}</li>}
|
||||
{mission.registration_open ? (
|
||||
<li style={{ color: 'var(--accent-light)' }}>Регистрация открыта</li>
|
||||
) : (
|
||||
<li style={{ color: 'var(--error)' }}>Регистрация закрыта</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem' }}>
|
||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||
</p>
|
||||
|
|
@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
initialState={codingState}
|
||||
initialCompleted={mission.is_completed}
|
||||
/>
|
||||
) : mission.format === 'offline' ? (
|
||||
<OfflineMissionRegistration
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
locked={!mission.is_available && !mission.is_completed}
|
||||
registrationOpen={mission.registration_open}
|
||||
registeredCount={mission.registered_participants}
|
||||
capacity={mission.capacity}
|
||||
submission={submission ? { id: submission.id, comment: submission.comment, status: submission.status } : null}
|
||||
eventLocation={mission.event_location}
|
||||
eventAddress={mission.event_address}
|
||||
eventStartsAt={mission.event_starts_at}
|
||||
eventEndsAt={mission.event_ends_at}
|
||||
registrationDeadline={mission.registration_deadline}
|
||||
registrationUrl={mission.registration_url}
|
||||
registrationNotes={mission.registration_notes}
|
||||
contactPerson={mission.contact_person}
|
||||
contactPhone={mission.contact_phone}
|
||||
/>
|
||||
) : (
|
||||
<MissionSubmissionForm
|
||||
missionId={mission.id}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ export interface MissionSummary {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
format: 'online' | 'offline';
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
is_active: boolean;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
|
|
@ -17,6 +28,9 @@ export interface MissionSummary {
|
|||
has_coding_challenges: boolean;
|
||||
coding_challenge_count: number;
|
||||
completed_coding_challenges: number;
|
||||
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||
registered_participants: number;
|
||||
registration_open: boolean;
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -38,13 +52,32 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
const locked = !mission.is_available && !completed;
|
||||
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||
const linkDisabled = locked;
|
||||
const actionLabel = completed
|
||||
? 'Миссия выполнена'
|
||||
: mission.is_available
|
||||
? mission.has_coding_challenges
|
||||
? 'Решать задачи'
|
||||
: 'Открыть брифинг'
|
||||
: 'Заблокировано';
|
||||
let actionLabel = 'Открыть брифинг';
|
||||
if (completed) {
|
||||
actionLabel = mission.format === 'offline' ? 'Регистрация подтверждена' : 'Миссия выполнена';
|
||||
} else if (!mission.is_available) {
|
||||
actionLabel = 'Заблокировано';
|
||||
} else if (mission.format === 'offline') {
|
||||
if (mission.submission_status === 'pending') {
|
||||
actionLabel = 'Заявка отправлена';
|
||||
} else if (mission.submission_status === 'approved') {
|
||||
actionLabel = 'Регистрация подтверждена';
|
||||
} else if (!mission.registration_open) {
|
||||
actionLabel = 'Регистрация закрыта';
|
||||
} else {
|
||||
actionLabel = 'Записаться';
|
||||
}
|
||||
} else if (mission.has_coding_challenges) {
|
||||
actionLabel = 'Решать задачи';
|
||||
}
|
||||
|
||||
const offlineDetails = mission.format === 'offline'
|
||||
? {
|
||||
date: mission.event_starts_at ? new Date(mission.event_starts_at) : null,
|
||||
end: mission.event_ends_at ? new Date(mission.event_ends_at) : null,
|
||||
deadline: mission.registration_deadline ? new Date(mission.registration_deadline) : null
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||
|
|
@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
🗂 Требуется загрузка документов
|
||||
</p>
|
||||
)}
|
||||
{mission.format === 'offline' && offlineDetails?.date && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--accent-light)' }}>
|
||||
<div>📍 {mission.event_location ?? 'Офлайн мероприятие'}</div>
|
||||
<div>
|
||||
🗓 {offlineDetails.date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} ·
|
||||
{' '}
|
||||
{offlineDetails.date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
{offlineDetails.end &&
|
||||
` – ${offlineDetails.end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
<div>
|
||||
👥 {mission.registered_participants}
|
||||
{mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано
|
||||
</div>
|
||||
{!mission.registration_open && mission.submission_status !== 'approved' && (
|
||||
<div style={{ color: 'var(--error)' }}>Регистрация завершена</div>
|
||||
)}
|
||||
{mission.registration_open && offlineDetails.deadline && (
|
||||
<div>
|
||||
⏳ Запись до{' '}
|
||||
{offlineDetails.deadline.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}{' '}
|
||||
{offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||
{mission.has_coding_challenges && (
|
||||
|
|
|
|||
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
type SubmissionStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
interface ExistingSubmission {
|
||||
id: number;
|
||||
comment: string | null;
|
||||
status: SubmissionStatus;
|
||||
}
|
||||
|
||||
interface OfflineMissionRegistrationProps {
|
||||
missionId: number;
|
||||
token?: string;
|
||||
locked?: boolean;
|
||||
registrationOpen: boolean;
|
||||
registeredCount: number;
|
||||
capacity?: number | null;
|
||||
submission?: ExistingSubmission | null;
|
||||
eventLocation?: string | null;
|
||||
eventAddress?: string | null;
|
||||
eventStartsAt?: string | null;
|
||||
eventEndsAt?: string | null;
|
||||
registrationDeadline?: string | null;
|
||||
registrationUrl?: string | null;
|
||||
registrationNotes?: string | null;
|
||||
contactPerson?: string | null;
|
||||
contactPhone?: string | null;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
const formattedDate = date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
const formattedTime = date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${formattedDate} · ${formattedTime}`;
|
||||
}
|
||||
|
||||
export function OfflineMissionRegistration({
|
||||
missionId,
|
||||
token,
|
||||
locked = false,
|
||||
registrationOpen,
|
||||
registeredCount,
|
||||
capacity,
|
||||
submission,
|
||||
eventLocation,
|
||||
eventAddress,
|
||||
eventStartsAt,
|
||||
eventEndsAt,
|
||||
registrationDeadline,
|
||||
registrationUrl,
|
||||
registrationNotes,
|
||||
contactPerson,
|
||||
contactPhone,
|
||||
}: OfflineMissionRegistrationProps) {
|
||||
const [comment, setComment] = useState(submission?.comment ?? '');
|
||||
const initialStatus = (() => {
|
||||
if (submission?.status === 'approved') {
|
||||
return 'Регистрация подтверждена HR. Встретимся офлайн!';
|
||||
}
|
||||
if (submission?.status === 'pending') {
|
||||
return 'Заявка отправлена и ожидает подтверждения HR.';
|
||||
}
|
||||
if (submission?.status === 'rejected') {
|
||||
return 'Предыдущая заявка была отклонена. Проверьте комментарий и отправьте снова.';
|
||||
}
|
||||
if (!registrationOpen) {
|
||||
return 'Регистрация закрыта: лимит мест или срок записи истёк.';
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const [status, setStatus] = useState<string | null>(initialStatus);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submissionStatus = submission?.status;
|
||||
const isApproved = submissionStatus === 'approved';
|
||||
const isPending = submissionStatus === 'pending';
|
||||
const canSubmit = !locked && (registrationOpen || Boolean(submission));
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
setStatus('Не удалось авторизовать отправку. Перезагрузите страницу.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSubmit) {
|
||||
setStatus('Регистрация закрыта.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const formData = new FormData();
|
||||
formData.append('comment', comment.trim());
|
||||
const updated = await apiFetch<ExistingSubmission & { status: SubmissionStatus; comment: string | null }>(
|
||||
`/api/missions/${missionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
authToken: token,
|
||||
},
|
||||
);
|
||||
setComment(updated.comment ?? '');
|
||||
if (updated.status === 'approved') {
|
||||
setStatus('Регистрация подтверждена HR.');
|
||||
} else {
|
||||
setStatus('Заявка отправлена! HR свяжется с вами при необходимости.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatus('Не удалось отправить заявку. Попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
|
||||
<h3>Запись на офлайн-мероприятие</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{eventLocation ?? 'Офлайн событие'}
|
||||
{eventAddress ? ` · ${eventAddress}` : ''}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', margin: '1rem 0' }}>
|
||||
{eventStartsAt && (
|
||||
<div>
|
||||
<strong>Начало:</strong> {formatDateTime(eventStartsAt)}
|
||||
</div>
|
||||
)}
|
||||
{eventEndsAt && (
|
||||
<div>
|
||||
<strong>Завершение:</strong> {formatDateTime(eventEndsAt)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>Участников:</strong> {registeredCount}
|
||||
{capacity ? ` из ${capacity}` : ''}
|
||||
</div>
|
||||
{registrationDeadline && (
|
||||
<div>
|
||||
<strong>Запись до:</strong> {formatDateTime(registrationDeadline)}
|
||||
</div>
|
||||
)}
|
||||
{registrationUrl && (
|
||||
<div>
|
||||
<a href={registrationUrl} target="_blank" rel="noreferrer" className="secondary">
|
||||
Открыть страницу мероприятия
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{contactPerson && (
|
||||
<div>
|
||||
<strong>Контакт HR:</strong> {contactPerson}
|
||||
{contactPhone ? ` · ${contactPhone}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{registrationNotes && (
|
||||
<div style={{ color: 'var(--accent-light)' }}>{registrationNotes}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Комментарий (опционально)
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={4}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="Например: приеду с запасным удостоверением"
|
||||
disabled={!canSubmit || isApproved}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="primary" type="submit" disabled={!canSubmit || loading || isApproved}>
|
||||
{isApproved ? 'Регистрация подтверждена' : isPending ? 'Заявка отправлена' : registrationOpen ? 'Записаться' : 'Регистрация закрыта'}
|
||||
</button>
|
||||
|
||||
{status && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
color: status.includes('подтверждена') ? 'var(--accent-light)' : status.includes('отправлена') ? 'var(--accent-light)' : 'var(--error)',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,17 @@ type MissionBase = {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
format: 'online' | 'offline';
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -76,6 +87,17 @@ type FormState = {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
format: 'online' | 'offline';
|
||||
event_location: string;
|
||||
event_address: string;
|
||||
event_starts_at: string;
|
||||
event_ends_at: string;
|
||||
registration_deadline: string;
|
||||
registration_url: string;
|
||||
registration_notes: string;
|
||||
capacity: number | '';
|
||||
contact_person: string;
|
||||
contact_phone: string;
|
||||
minimum_rank_id: number | '';
|
||||
artifact_id: number | '';
|
||||
branch_id: number | '';
|
||||
|
|
@ -91,6 +113,17 @@ const initialFormState: FormState = {
|
|||
xp_reward: 0,
|
||||
mana_reward: 0,
|
||||
difficulty: 'medium',
|
||||
format: 'online',
|
||||
event_location: '',
|
||||
event_address: '',
|
||||
event_starts_at: '',
|
||||
event_ends_at: '',
|
||||
registration_deadline: '',
|
||||
registration_url: '',
|
||||
registration_notes: '',
|
||||
capacity: '',
|
||||
contact_person: '',
|
||||
contact_phone: '',
|
||||
minimum_rank_id: '',
|
||||
artifact_id: '',
|
||||
branch_id: '',
|
||||
|
|
@ -108,6 +141,25 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toInputDateTime = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const fromInputDateTime = (value: string) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const sanitizeString = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
};
|
||||
|
||||
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
|
||||
// пока загрузка детальной карточки не завершилась.
|
||||
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
|
||||
|
|
@ -126,6 +178,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: mission.xp_reward,
|
||||
mana_reward: mission.mana_reward,
|
||||
difficulty: mission.difficulty,
|
||||
format: mission.format,
|
||||
event_location: mission.event_location ?? '',
|
||||
event_address: mission.event_address ?? '',
|
||||
event_starts_at: toInputDateTime(mission.event_starts_at),
|
||||
event_ends_at: toInputDateTime(mission.event_ends_at),
|
||||
registration_deadline: toInputDateTime(mission.registration_deadline),
|
||||
registration_url: mission.registration_url ?? '',
|
||||
registration_notes: mission.registration_notes ?? '',
|
||||
capacity: mission.capacity ?? '',
|
||||
contact_person: mission.contact_person ?? '',
|
||||
contact_phone: mission.contact_phone ?? '',
|
||||
minimum_rank_id: mission.minimum_rank_id ?? '',
|
||||
artifact_id: mission.artifact_id ?? '',
|
||||
branch_id: (() => {
|
||||
|
|
@ -174,6 +237,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: baseMission.xp_reward,
|
||||
mana_reward: baseMission.mana_reward,
|
||||
difficulty: baseMission.difficulty,
|
||||
format: baseMission.format,
|
||||
event_location: baseMission.event_location ?? '',
|
||||
event_address: baseMission.event_address ?? '',
|
||||
event_starts_at: toInputDateTime(baseMission.event_starts_at),
|
||||
event_ends_at: toInputDateTime(baseMission.event_ends_at),
|
||||
registration_deadline: toInputDateTime(baseMission.registration_deadline),
|
||||
registration_url: baseMission.registration_url ?? '',
|
||||
registration_notes: baseMission.registration_notes ?? '',
|
||||
capacity: baseMission.capacity ?? '',
|
||||
contact_person: baseMission.contact_person ?? '',
|
||||
contact_phone: baseMission.contact_phone ?? '',
|
||||
is_active: baseMission.is_active
|
||||
}));
|
||||
}
|
||||
|
|
@ -222,6 +296,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: Number(form.xp_reward),
|
||||
mana_reward: Number(form.mana_reward),
|
||||
difficulty: form.difficulty,
|
||||
format: form.format,
|
||||
event_location: sanitizeString(form.event_location),
|
||||
event_address: sanitizeString(form.event_address),
|
||||
event_starts_at: fromInputDateTime(form.event_starts_at),
|
||||
event_ends_at: fromInputDateTime(form.event_ends_at),
|
||||
registration_deadline: fromInputDateTime(form.registration_deadline),
|
||||
registration_url: sanitizeString(form.registration_url),
|
||||
registration_notes: sanitizeString(form.registration_notes),
|
||||
capacity: form.capacity === '' ? null : Number(form.capacity),
|
||||
contact_person: sanitizeString(form.contact_person),
|
||||
contact_phone: sanitizeString(form.contact_phone),
|
||||
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
|
||||
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
||||
prerequisite_ids: form.prerequisite_ids,
|
||||
|
|
@ -312,6 +397,13 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Формат
|
||||
<select value={form.format} onChange={(event) => updateField('format', event.target.value as 'online' | 'offline')}>
|
||||
<option value="online">Онлайн</option>
|
||||
<option value="offline">Офлайн встреча</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Доступен с ранга
|
||||
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||
|
|
@ -339,6 +431,54 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{form.format === 'offline' && (
|
||||
<fieldset style={{ border: '1px solid rgba(162,155,254,0.3)', borderRadius: '16px', padding: '1rem' }}>
|
||||
<legend style={{ padding: '0 0.5rem' }}>Офлайн-детали</legend>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||
<label>
|
||||
Локация / площадка
|
||||
<input value={form.event_location} onChange={(event) => updateField('event_location', event.target.value)} placeholder="Например: Кампус Алабуга" />
|
||||
</label>
|
||||
<label>
|
||||
Адрес
|
||||
<input value={form.event_address} onChange={(event) => updateField('event_address', event.target.value)} placeholder="Город, улица, дом" />
|
||||
</label>
|
||||
<label>
|
||||
Начало
|
||||
<input type="datetime-local" value={form.event_starts_at} onChange={(event) => updateField('event_starts_at', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Завершение
|
||||
<input type="datetime-local" value={form.event_ends_at} onChange={(event) => updateField('event_ends_at', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Дедлайн регистрации
|
||||
<input type="datetime-local" value={form.registration_deadline} onChange={(event) => updateField('registration_deadline', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Вместимость
|
||||
<input type="number" min={0} value={form.capacity === '' ? '' : form.capacity} onChange={(event) => updateField('capacity', event.target.value === '' ? '' : Number(event.target.value))} placeholder="Например: 40" />
|
||||
</label>
|
||||
<label>
|
||||
Ссылка на мероприятие
|
||||
<input type="url" value={form.registration_url} onChange={(event) => updateField('registration_url', event.target.value)} placeholder="https://..." />
|
||||
</label>
|
||||
<label>
|
||||
Дополнительные заметки
|
||||
<textarea value={form.registration_notes} onChange={(event) => updateField('registration_notes', event.target.value)} rows={3} placeholder="Что взять с собой, как пройти и т.д." />
|
||||
</label>
|
||||
<label>
|
||||
Контактное лицо
|
||||
<input value={form.contact_person} onChange={(event) => updateField('contact_person', event.target.value)} placeholder="Имя HR" />
|
||||
</label>
|
||||
<label>
|
||||
Телефон/чат
|
||||
<input value={form.contact_phone} onChange={(event) => updateField('contact_phone', event.target.value)} placeholder="+7..." />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<label>
|
||||
Ветка
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -17,7 +18,7 @@ from app.db.session import SessionLocal
|
|||
from app.models.artifact import Artifact, ArtifactRarity
|
||||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.coding import CodingChallenge
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty, MissionFormat
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.onboarding import OnboardingSlide
|
||||
from app.models.store import StoreItem
|
||||
|
|
@ -168,7 +169,12 @@ def seed() -> None:
|
|||
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
|
||||
category="training",
|
||||
)
|
||||
session.add_all([branch, python_branch])
|
||||
offline_branch = Branch(
|
||||
title="Офлайн мероприятия",
|
||||
description="Живые встречи в кампусе и городе",
|
||||
category="event",
|
||||
)
|
||||
session.add_all([branch, python_branch, offline_branch])
|
||||
session.flush()
|
||||
|
||||
# Миссии
|
||||
|
|
@ -214,12 +220,67 @@ def seed() -> None:
|
|||
difficulty=MissionDifficulty.MEDIUM,
|
||||
minimum_rank_id=ranks[0].id,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
mission_campus_tour = Mission(
|
||||
title="Экскурсия по кампусу",
|
||||
description="Познакомьтесь с производственными линиями и учебными площадками вместе с наставником.",
|
||||
xp_reward=140,
|
||||
mana_reward=70,
|
||||
difficulty=MissionDifficulty.EASY,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Кампус Алабуга Политех",
|
||||
event_address="Республика Татарстан, Елабуга, территория ОЭЗ 'Алабуга'",
|
||||
event_starts_at=now + timedelta(days=3, hours=10),
|
||||
event_ends_at=now + timedelta(days=3, hours=13),
|
||||
registration_deadline=now + timedelta(days=2, hours=18),
|
||||
capacity=25,
|
||||
contact_person="Наталья из HR",
|
||||
contact_phone="+7 (900) 123-45-67",
|
||||
registration_notes="Возьмите паспорт для прохода на территорию.",
|
||||
)
|
||||
mission_sport_day = Mission(
|
||||
title="Спортивный день в технопарке",
|
||||
description="Присоединяйтесь к командным активностям и познакомьтесь с будущими коллегами в неформальной обстановке.",
|
||||
xp_reward=160,
|
||||
mana_reward=90,
|
||||
difficulty=MissionDifficulty.MEDIUM,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Спортивный центр Алабуги",
|
||||
event_address="Елабуга, проспект Строителей, 5",
|
||||
event_starts_at=now + timedelta(days=7, hours=17),
|
||||
event_ends_at=now + timedelta(days=7, hours=20),
|
||||
registration_deadline=now + timedelta(days=6, hours=12),
|
||||
capacity=40,
|
||||
contact_person="Игорь, координатор мероприятий",
|
||||
contact_phone="+7 (900) 765-43-21",
|
||||
registration_notes="Форма одежды – спортивная. Будет организован трансфер от кампуса.",
|
||||
)
|
||||
mission_open_lecture = Mission(
|
||||
title="Лекция капитана по культуре Алабуги",
|
||||
description="Живой рассказ о миссии компании, ценностях и истории от капитана программы.",
|
||||
xp_reward=180,
|
||||
mana_reward=110,
|
||||
difficulty=MissionDifficulty.MEDIUM,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Конференц-зал HQ",
|
||||
event_address="Елабуга, ул. Промышленная, 2",
|
||||
event_starts_at=now + timedelta(days=10, hours=15),
|
||||
event_ends_at=now + timedelta(days=10, hours=17),
|
||||
registration_deadline=now + timedelta(days=8, hours=18),
|
||||
capacity=60,
|
||||
contact_person="Алина, программа адаптации",
|
||||
contact_phone="+7 (900) 555-19-82",
|
||||
registration_notes="Перед лекцией будет кофе-брейк, приходите на 15 минут раньше.",
|
||||
)
|
||||
session.add_all([
|
||||
mission_documents,
|
||||
mission_resume,
|
||||
mission_interview,
|
||||
mission_onboarding,
|
||||
mission_python_basics,
|
||||
mission_campus_tour,
|
||||
mission_sport_day,
|
||||
mission_open_lecture,
|
||||
])
|
||||
session.flush()
|
||||
|
||||
|
|
@ -250,6 +311,21 @@ def seed() -> None:
|
|||
competency_id=competencies[2].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_campus_tour.id,
|
||||
competency_id=competencies[0].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_sport_day.id,
|
||||
competency_id=competencies[3].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_open_lecture.id,
|
||||
competency_id=competencies[5].id,
|
||||
level_delta=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -260,6 +336,9 @@ def seed() -> None:
|
|||
BranchMission(branch_id=branch.id, mission_id=mission_interview.id, order=3),
|
||||
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
|
||||
BranchMission(branch_id=python_branch.id, mission_id=mission_python_basics.id, order=1),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_campus_tour.id, order=1),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_sport_day.id, order=2),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_open_lecture.id, order=3),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user