Merge pull request #8 from Danieli4/codex/add-offline-event-management-feature

Add offline mission events and registration flow
This commit is contained in:
Danil Gryaznev 2025-09-30 20:39:13 -06:00 committed by GitHub
commit e993fe8479
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 827 additions and 15 deletions

View File

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

View File

@ -38,7 +38,7 @@ from app.schemas.rank import (
RankUpdate,
)
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])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,17 @@ type MissionBase = {
xp_reward: number;
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' }}>

View File

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