diff --git a/backend/alembic/versions/20241010_0008_offline_missions.py b/backend/alembic/versions/20241010_0008_offline_missions.py new file mode 100644 index 0000000..1133eec --- /dev/null +++ b/backend/alembic/versions/20241010_0008_offline_missions.py @@ -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) diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index 8e876b6..b657296 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -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]) diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index 57efe95..d3c6a19 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -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) diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py index 1885129..3666571 100644 --- a/backend/app/models/mission.py +++ b/backend/app/models/mission.py @@ -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) diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 01e7f02..516023a 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -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 diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py index 99b407b..26193b0 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -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 diff --git a/frontend/src/app/missions/[id]/page.tsx b/frontend/src/app/missions/[id]/page.tsx index 5def991..800ea40 100644 --- a/frontend/src/app/missions/[id]/page.tsx +++ b/frontend/src/app/missions/[id]/page.tsx @@ -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) {

{mission.title}

{mission.difficulty}

{mission.description}

+ {mission.format === 'offline' && ( +
+

Офлайн событие

+ +
+ )}

Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡

@@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) { initialState={codingState} initialCompleted={mission.is_completed} /> + ) : mission.format === 'offline' ? ( + ) : ( @@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) { 🗂 Требуется загрузка документов

)} + {mission.format === 'offline' && offlineDetails?.date && ( +
+
📍 {mission.event_location ?? 'Офлайн мероприятие'}
+
+ 🗓 {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' })}`} +
+
+ 👥 {mission.registered_participants} + {mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано +
+ {!mission.registration_open && mission.submission_status !== 'approved' && ( +
Регистрация завершена
+ )} + {mission.registration_open && offlineDetails.deadline && ( +
+ ⏳ Запись до{' '} + {offlineDetails.deadline.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long' + })}{' '} + {offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+ )} +
+ )}

{mission.title}

{mission.description}

{mission.has_coding_challenges && ( diff --git a/frontend/src/components/OfflineMissionRegistration.tsx b/frontend/src/components/OfflineMissionRegistration.tsx new file mode 100644 index 0000000..3f7a910 --- /dev/null +++ b/frontend/src/components/OfflineMissionRegistration.tsx @@ -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(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) { + 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( + `/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 ( +
+

Запись на офлайн-мероприятие

+

+ {eventLocation ?? 'Офлайн событие'} + {eventAddress ? ` · ${eventAddress}` : ''} +

+
+ {eventStartsAt && ( +
+ Начало: {formatDateTime(eventStartsAt)} +
+ )} + {eventEndsAt && ( +
+ Завершение: {formatDateTime(eventEndsAt)} +
+ )} +
+ Участников: {registeredCount} + {capacity ? ` из ${capacity}` : ''} +
+ {registrationDeadline && ( +
+ Запись до: {formatDateTime(registrationDeadline)} +
+ )} + {registrationUrl && ( + + )} + {contactPerson && ( +
+ Контакт HR: {contactPerson} + {contactPhone ? ` · ${contactPhone}` : ''} +
+ )} + {registrationNotes && ( +
{registrationNotes}
+ )} +
+ +