From 0396d7387dba53d2f6f839eb8d860ca9a17e749e Mon Sep 17 00:00:00 2001
From: Dana <143515389+danaaalber@users.noreply.github.com>
Date: Mon, 29 Sep 2025 23:53:32 +0500
Subject: [PATCH 1/7] Alabuga colors update
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Изменения:
- Добавлен --accent-dark: #005dac для градиентов
- Обновились цвета с фиолетовых на синие:
.card border: rgba(106, 207, 246, 0.2)
.admin-form input border: rgba(106, 207, 246, 0.3)
.badge background: rgba(106, 207, 246, 0.15)
.secondary border: rgba(106, 207, 246, 0.4)
- Градиент кнопки изменился: var(--accent) → var(--accent-dark)
- Добавились новые компоненты:
Стили для чекбоксов
Сообщения успеха/ошибки (alert)
Варианты бэджей (success/error)
Анимации при наведении
- Улучшенный UX:
Фокус-стили для полей ввода
Плавные переходы
Эффекты при наведении
---
frontend/src/styles/globals.css | 98 +++++++++++++++++++++++++++++----
1 file changed, 86 insertions(+), 12 deletions(-)
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index d94a25e..fe77182 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -2,11 +2,12 @@
color-scheme: dark;
--bg: #080b1a;
--bg-panel: #111633;
- --accent: #6c5ce7;
- --accent-light: #a29bfe;
+ --accent: #00AEEF;
+ --accent-light: #6ACFF6;
+ --accent-dark: #005dac;
--text: #f5f6fa;
--text-muted: #b2bec3;
- --success: #00b894;
+ --success: #00B894;
--error: #ff7675;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@@ -14,9 +15,11 @@
body {
margin: 0;
padding: 0;
- background: radial-gradient(circle at 20% 20%, rgba(108, 92, 231, 0.2), transparent),
- radial-gradient(circle at 80% 0%, rgba(0, 184, 148, 0.2), transparent),
- var(--bg);
+ background: radial-gradient(circle at 20% 30%, rgba(106, 207, 246, 0.15), transparent 40%),
+ radial-gradient(circle at 80% 20%, rgba(0, 174, 239, 0.1), transparent 30%),
+ radial-gradient(circle at 40% 80%, rgba(0, 93, 172, 0.08), transparent 35%),
+ linear-gradient(135deg, #1a2a75 0%, #004a8c 50%, #1a2a75 100%),
+ var(--bg);
color: var(--text);
min-height: 100vh;
position: relative;
@@ -50,7 +53,7 @@ main {
.card {
background: rgba(17, 22, 51, 0.85);
- border: 1px solid rgba(162, 155, 254, 0.2);
+ border: 1px solid rgba(106, 207, 246, 0.2);
border-radius: 16px;
padding: 1.5rem;
backdrop-filter: blur(10px);
@@ -59,7 +62,7 @@ main {
.admin-form {
display: grid;
- gap: 1rem;
+ gap: 1.5rem;
}
.admin-form label {
@@ -67,17 +70,28 @@ main {
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
+ color: var(--accent-light);
}
.admin-form input,
.admin-form textarea,
.admin-form select {
border-radius: 12px;
- border: 1px solid rgba(162, 155, 254, 0.3);
+ border: 1px solid rgba(106, 207, 246, 0.3);
background: rgba(8, 11, 26, 0.6);
padding: 0.75rem;
color: var(--text);
width: 100%;
+ font-family: inherit;
+ transition: all 0.3s ease;
+}
+
+.admin-form input:focus,
+.admin-form textarea:focus,
+.admin-form select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(0, 174, 239, 0.2);
}
.admin-form textarea {
@@ -89,6 +103,16 @@ main {
flex-direction: row;
align-items: center;
gap: 0.5rem;
+ cursor: pointer;
+}
+
+.admin-form .checkbox input[type="checkbox"] {
+ width: auto;
+ accent-color: var(--accent);
+}
+
+.admin-form .checkbox span {
+ color: var(--text);
}
.grid {
@@ -98,36 +122,86 @@ main {
}
.badge {
- background: rgba(162, 155, 254, 0.15);
+ background: rgba(106, 207, 246, 0.15);
border-radius: 9999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: var(--accent-light);
text-transform: uppercase;
letter-spacing: 0.08em;
+ display: inline-block;
+ border: 1px solid rgba(106, 207, 246, 0.2);
+}
+
+.badge.success {
+ background: rgba(0, 184, 148, 0.15);
+ color: var(--success);
+ border-color: rgba(0, 184, 148, 0.3);
+}
+
+.badge.error {
+ background: rgba(255, 118, 117, 0.15);
+ color: var(--error);
+ border-color: rgba(255, 118, 117, 0.3);
}
.primary {
padding: 0.75rem 1.5rem;
border-radius: 12px;
border: none;
- background: linear-gradient(135deg, var(--accent), #00b894);
+ background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: white;
font-weight: 600;
cursor: pointer;
+ font-family: inherit;
+ transition: all 0.3s ease;
+}
+
+.primary:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 20px rgba(0, 174, 239, 0.3);
}
.primary:disabled {
opacity: 0.4;
cursor: not-allowed;
+ transform: none !important;
+ box-shadow: none !important;
}
.secondary {
padding: 0.75rem 1.5rem;
border-radius: 12px;
- border: 1px solid rgba(162, 155, 254, 0.4);
+ border: 1px solid rgba(106, 207, 246, 0.4);
background: transparent;
color: var(--text-muted);
font-weight: 500;
cursor: pointer;
+ font-family: inherit;
+ transition: all 0.3s ease;
+}
+
+.secondary:hover {
+ border-color: var(--accent-light);
+ color: var(--accent-light);
+ background: rgba(106, 207, 246, 0.1);
+}
+
+.alert {
+ padding: 1rem;
+ border-radius: 12px;
+ margin: 1rem 0;
+ border: 1px solid;
+}
+
+.alert.success {
+ background: rgba(0, 184, 148, 0.1);
+ border-color: rgba(0, 184, 148, 0.3);
+ color: var(--success);
+}
+
+.alert.error {
+ background: rgba(255, 118, 117, 0.1);
+ border-color: rgba(255, 118, 117, 0.3);
+ color: var(--error);
}
From 0c5cf8aa0ce50949d39379754f56678583582a57 Mon Sep 17 00:00:00 2001
From: Danil Gryaznev
Date: Tue, 30 Sep 2025 20:38:39 -0600
Subject: [PATCH 2/7] Add offline mission events with registration support
---
.../20241010_0008_offline_missions.py | 55 +++++
backend/app/api/routes/admin.py | 53 ++++-
backend/app/api/routes/missions.py | 67 +++++-
backend/app/models/mission.py | 32 ++-
backend/app/schemas/mission.py | 38 +++-
backend/app/services/mission.py | 25 ++-
frontend/src/app/missions/[id]/page.tsx | 68 ++++++
frontend/src/components/MissionList.tsx | 76 ++++++-
.../components/OfflineMissionRegistration.tsx | 205 ++++++++++++++++++
.../components/admin/AdminMissionManager.tsx | 140 ++++++++++++
scripts/seed_data.py | 83 ++++++-
11 files changed, 827 insertions(+), 15 deletions(-)
create mode 100644 backend/alembic/versions/20241010_0008_offline_missions.py
create mode 100644 frontend/src/components/OfflineMissionRegistration.tsx
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.event_location && - 📍 {mission.event_location}
}
+ {mission.event_address && - 🧭 {mission.event_address}
}
+ {mission.event_starts_at && (
+ -
+ 🗓 Старт: {new Date(mission.event_starts_at).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
+
+ )}
+ {mission.event_ends_at && (
+ -
+ 🕘 Завершение: {new Date(mission.event_ends_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
+
+ )}
+ -
+ 👥 Зарегистрировано: {mission.registered_participants}
+ {mission.capacity ? ` из ${mission.capacity}` : ''}
+
+ {mission.registration_deadline && (
+ -
+ ⏳ Запись до: {new Date(mission.registration_deadline).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
+
+ )}
+ {mission.registration_notes && - ℹ️ {mission.registration_notes}
}
+ {mission.registration_open ? (
+ - Регистрация открыта
+ ) : (
+ - Регистрация закрыта
+ )}
+
+
+ )}
Награда: {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 (
+
+ );
+}
diff --git a/frontend/src/components/admin/AdminMissionManager.tsx b/frontend/src/components/admin/AdminMissionManager.tsx
index 4813b48..ceab917 100644
--- a/frontend/src/components/admin/AdminMissionManager.tsx
+++ b/frontend/src/components/admin/AdminMissionManager.tsx
@@ -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(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
))}
+
+ {form.format === 'offline' && (
+
+ )}
+