Fix Alembic script path for migrations
This commit is contained in:
parent
989a413162
commit
0f8cb7b1b4
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,
|
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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,17 @@ export interface MissionSummary {
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
|
format: 'online' | 'offline';
|
||||||
|
event_location?: string | null;
|
||||||
|
event_address?: string | null;
|
||||||
|
event_starts_at?: string | null;
|
||||||
|
event_ends_at?: string | null;
|
||||||
|
registration_deadline?: string | null;
|
||||||
|
registration_url?: string | null;
|
||||||
|
registration_notes?: string | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
contact_person?: string | null;
|
||||||
|
contact_phone?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
|
|
@ -17,6 +28,9 @@ export interface MissionSummary {
|
||||||
has_coding_challenges: boolean;
|
has_coding_challenges: boolean;
|
||||||
coding_challenge_count: number;
|
coding_challenge_count: number;
|
||||||
completed_coding_challenges: number;
|
completed_coding_challenges: number;
|
||||||
|
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||||
|
registered_participants: number;
|
||||||
|
registration_open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -38,13 +52,32 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
const locked = !mission.is_available && !completed;
|
const locked = !mission.is_available && !completed;
|
||||||
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||||
const linkDisabled = locked;
|
const linkDisabled = locked;
|
||||||
const actionLabel = completed
|
let actionLabel = 'Открыть брифинг';
|
||||||
? 'Миссия выполнена'
|
if (completed) {
|
||||||
: mission.is_available
|
actionLabel = mission.format === 'offline' ? 'Регистрация подтверждена' : 'Миссия выполнена';
|
||||||
? mission.has_coding_challenges
|
} else if (!mission.is_available) {
|
||||||
? 'Решать задачи'
|
actionLabel = 'Заблокировано';
|
||||||
: 'Открыть брифинг'
|
} else if (mission.format === 'offline') {
|
||||||
: 'Заблокировано';
|
if (mission.submission_status === 'pending') {
|
||||||
|
actionLabel = 'Заявка отправлена';
|
||||||
|
} else if (mission.submission_status === 'approved') {
|
||||||
|
actionLabel = 'Регистрация подтверждена';
|
||||||
|
} else if (!mission.registration_open) {
|
||||||
|
actionLabel = 'Регистрация закрыта';
|
||||||
|
} else {
|
||||||
|
actionLabel = 'Записаться';
|
||||||
|
}
|
||||||
|
} else if (mission.has_coding_challenges) {
|
||||||
|
actionLabel = 'Решать задачи';
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlineDetails = mission.format === 'offline'
|
||||||
|
? {
|
||||||
|
date: mission.event_starts_at ? new Date(mission.event_starts_at) : null,
|
||||||
|
end: mission.event_ends_at ? new Date(mission.event_ends_at) : null,
|
||||||
|
deadline: mission.registration_deadline ? new Date(mission.registration_deadline) : null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||||
|
|
@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
🗂 Требуется загрузка документов
|
🗂 Требуется загрузка документов
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{mission.format === 'offline' && offlineDetails?.date && (
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--accent-light)' }}>
|
||||||
|
<div>📍 {mission.event_location ?? 'Офлайн мероприятие'}</div>
|
||||||
|
<div>
|
||||||
|
🗓 {offlineDetails.date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} ·
|
||||||
|
{' '}
|
||||||
|
{offlineDetails.date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{offlineDetails.end &&
|
||||||
|
` – ${offlineDetails.end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
👥 {mission.registered_participants}
|
||||||
|
{mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано
|
||||||
|
</div>
|
||||||
|
{!mission.registration_open && mission.submission_status !== 'approved' && (
|
||||||
|
<div style={{ color: 'var(--error)' }}>Регистрация завершена</div>
|
||||||
|
)}
|
||||||
|
{mission.registration_open && offlineDetails.deadline && (
|
||||||
|
<div>
|
||||||
|
⏳ Запись до{' '}
|
||||||
|
{offlineDetails.deadline.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})}{' '}
|
||||||
|
{offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||||
{mission.has_coding_challenges && (
|
{mission.has_coding_challenges && (
|
||||||
|
|
|
||||||
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
type SubmissionStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
interface ExistingSubmission {
|
||||||
|
id: number;
|
||||||
|
comment: string | null;
|
||||||
|
status: SubmissionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfflineMissionRegistrationProps {
|
||||||
|
missionId: number;
|
||||||
|
token?: string;
|
||||||
|
locked?: boolean;
|
||||||
|
registrationOpen: boolean;
|
||||||
|
registeredCount: number;
|
||||||
|
capacity?: number | null;
|
||||||
|
submission?: ExistingSubmission | null;
|
||||||
|
eventLocation?: string | null;
|
||||||
|
eventAddress?: string | null;
|
||||||
|
eventStartsAt?: string | null;
|
||||||
|
eventEndsAt?: string | null;
|
||||||
|
registrationDeadline?: string | null;
|
||||||
|
registrationUrl?: string | null;
|
||||||
|
registrationNotes?: string | null;
|
||||||
|
contactPerson?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
const formattedDate = date.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
});
|
||||||
|
const formattedTime = date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return `${formattedDate} · ${formattedTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineMissionRegistration({
|
||||||
|
missionId,
|
||||||
|
token,
|
||||||
|
locked = false,
|
||||||
|
registrationOpen,
|
||||||
|
registeredCount,
|
||||||
|
capacity,
|
||||||
|
submission,
|
||||||
|
eventLocation,
|
||||||
|
eventAddress,
|
||||||
|
eventStartsAt,
|
||||||
|
eventEndsAt,
|
||||||
|
registrationDeadline,
|
||||||
|
registrationUrl,
|
||||||
|
registrationNotes,
|
||||||
|
contactPerson,
|
||||||
|
contactPhone,
|
||||||
|
}: OfflineMissionRegistrationProps) {
|
||||||
|
const [comment, setComment] = useState(submission?.comment ?? '');
|
||||||
|
const initialStatus = (() => {
|
||||||
|
if (submission?.status === 'approved') {
|
||||||
|
return 'Регистрация подтверждена HR. Встретимся офлайн!';
|
||||||
|
}
|
||||||
|
if (submission?.status === 'pending') {
|
||||||
|
return 'Заявка отправлена и ожидает подтверждения HR.';
|
||||||
|
}
|
||||||
|
if (submission?.status === 'rejected') {
|
||||||
|
return 'Предыдущая заявка была отклонена. Проверьте комментарий и отправьте снова.';
|
||||||
|
}
|
||||||
|
if (!registrationOpen) {
|
||||||
|
return 'Регистрация закрыта: лимит мест или срок записи истёк.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
const [status, setStatus] = useState<string | null>(initialStatus);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submissionStatus = submission?.status;
|
||||||
|
const isApproved = submissionStatus === 'approved';
|
||||||
|
const isPending = submissionStatus === 'pending';
|
||||||
|
const canSubmit = !locked && (registrationOpen || Boolean(submission));
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token) {
|
||||||
|
setStatus('Не удалось авторизовать отправку. Перезагрузите страницу.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSubmit) {
|
||||||
|
setStatus('Регистрация закрыта.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('comment', comment.trim());
|
||||||
|
const updated = await apiFetch<ExistingSubmission & { status: SubmissionStatus; comment: string | null }>(
|
||||||
|
`/api/missions/${missionId}/submit`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
authToken: token,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setComment(updated.comment ?? '');
|
||||||
|
if (updated.status === 'approved') {
|
||||||
|
setStatus('Регистрация подтверждена HR.');
|
||||||
|
} else {
|
||||||
|
setStatus('Заявка отправлена! HR свяжется с вами при необходимости.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setStatus(error.message);
|
||||||
|
} else {
|
||||||
|
setStatus('Не удалось отправить заявку. Попробуйте позже.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
|
||||||
|
<h3>Запись на офлайн-мероприятие</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{eventLocation ?? 'Офлайн событие'}
|
||||||
|
{eventAddress ? ` · ${eventAddress}` : ''}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gap: '0.5rem', margin: '1rem 0' }}>
|
||||||
|
{eventStartsAt && (
|
||||||
|
<div>
|
||||||
|
<strong>Начало:</strong> {formatDateTime(eventStartsAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventEndsAt && (
|
||||||
|
<div>
|
||||||
|
<strong>Завершение:</strong> {formatDateTime(eventEndsAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>Участников:</strong> {registeredCount}
|
||||||
|
{capacity ? ` из ${capacity}` : ''}
|
||||||
|
</div>
|
||||||
|
{registrationDeadline && (
|
||||||
|
<div>
|
||||||
|
<strong>Запись до:</strong> {formatDateTime(registrationDeadline)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{registrationUrl && (
|
||||||
|
<div>
|
||||||
|
<a href={registrationUrl} target="_blank" rel="noreferrer" className="secondary">
|
||||||
|
Открыть страницу мероприятия
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contactPerson && (
|
||||||
|
<div>
|
||||||
|
<strong>Контакт HR:</strong> {contactPerson}
|
||||||
|
{contactPhone ? ` · ${contactPhone}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{registrationNotes && (
|
||||||
|
<div style={{ color: 'var(--accent-light)' }}>{registrationNotes}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
|
Комментарий (опционально)
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
|
placeholder="Например: приеду с запасным удостоверением"
|
||||||
|
disabled={!canSubmit || isApproved}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="primary" type="submit" disabled={!canSubmit || loading || isApproved}>
|
||||||
|
{isApproved ? 'Регистрация подтверждена' : isPending ? 'Заявка отправлена' : registrationOpen ? 'Записаться' : 'Регистрация закрыта'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
color: status.includes('подтверждена') ? 'var(--accent-light)' : status.includes('отправлена') ? 'var(--accent-light)' : 'var(--error)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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' }}>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user