From b042cb163008c9918e9881b33095b11a07dfb97c Mon Sep 17 00:00:00 2001
From: Danil Gryaznev
Date: Tue, 30 Sep 2025 22:21:11 -0600
Subject: [PATCH] Fix storage helpers missing imports
---
.../20241010_0008_offline_missions.py | 55 +++++
.../versions/20241012_0009_profile_photos.py | 22 ++
backend/app/api/routes/admin.py | 53 ++++-
backend/app/api/routes/auth.py | 1 -
backend/app/api/routes/missions.py | 78 ++++++-
backend/app/api/routes/users.py | 98 ++++++++-
backend/app/main.py | 46 ++++
backend/app/models/mission.py | 32 ++-
backend/app/models/user.py | 1 +
backend/app/schemas/mission.py | 38 +++-
backend/app/schemas/user.py | 11 +-
backend/app/services/mission.py | 29 ++-
backend/app/services/storage.py | 60 ++++-
frontend/src/app/missions/[id]/page.tsx | 68 ++++++
frontend/src/app/page.tsx | 3 +
frontend/src/app/register/page.tsx | 14 +-
frontend/src/components/MissionList.tsx | 76 ++++++-
.../components/OfflineMissionRegistration.tsx | 205 ++++++++++++++++++
frontend/src/components/ProgressOverview.tsx | 201 ++++++++++++++++-
.../components/admin/AdminMissionManager.tsx | 140 ++++++++++++
scripts/seed_data.py | 83 ++++++-
21 files changed, 1276 insertions(+), 38 deletions(-)
create mode 100644 backend/alembic/versions/20241010_0008_offline_missions.py
create mode 100644 backend/alembic/versions/20241012_0009_profile_photos.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/alembic/versions/20241012_0009_profile_photos.py b/backend/alembic/versions/20241012_0009_profile_photos.py
new file mode 100644
index 0000000..4b8bea9
--- /dev/null
+++ b/backend/alembic/versions/20241012_0009_profile_photos.py
@@ -0,0 +1,22 @@
+"""Добавляем колонку для фото профиля кандидата."""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "20241012_0009"
+down_revision = "20241010_0008"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("users", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=512), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users", schema=None) as batch_op:
+ batch_op.drop_column("profile_photo_path")
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/auth.py b/backend/app/api/routes/auth.py
index 6ef6bfc..d7021ad 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -66,7 +66,6 @@ def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | di
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
role=UserRole.PILOT,
- preferred_branch=user_in.preferred_branch,
motivation=user_in.motivation,
current_rank_id=base_rank.id if base_rank else None,
is_email_confirmed=not settings.require_email_confirmation,
diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py
index 57efe95..b0cf4d9 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
@@ -370,6 +398,17 @@ def get_mission(
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,
is_available=is_available,
locked_reasons=reasons,
@@ -384,6 +423,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 +612,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/api/routes/users.py b/backend/app/api/routes/users.py
index 98de829..5548170 100644
--- a/backend/app/api/routes/users.py
+++ b/backend/app/api/routes/users.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user
@@ -12,8 +12,18 @@ from app.models.user import User, UserRole, UserCompetency
from app.models.mission import SubmissionStatus
from app.schemas.progress import ProgressSnapshot
from app.schemas.rank import RankBase
-from app.schemas.user import LeaderboardEntry, UserCompetencyRead, UserProfile
+from app.schemas.user import (
+ LeaderboardEntry,
+ ProfilePhotoResponse,
+ UserCompetencyRead,
+ UserProfile,
+)
from app.services.rank import build_progress_snapshot
+from app.services.storage import (
+ build_photo_data_url,
+ delete_profile_photo,
+ save_profile_photo,
+)
router = APIRouter(prefix="/api", tags=["profile"])
@@ -29,7 +39,89 @@ def get_profile(
_ = item.competency
for artifact in current_user.artifacts:
_ = artifact.artifact
- return UserProfile.model_validate(current_user)
+
+ profile = UserProfile.model_validate(current_user)
+ profile.profile_photo_uploaded = bool(current_user.profile_photo_path)
+ profile.profile_photo_updated_at = (
+ current_user.updated_at if current_user.profile_photo_path else None
+ )
+ return profile
+
+
+@router.get(
+ "/me/photo",
+ response_model=ProfilePhotoResponse,
+ summary="Получаем фото профиля кандидата",
+)
+def get_profile_photo(
+ *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
+) -> ProfilePhotoResponse:
+ """Читаем сохранённое изображение и возвращаем его в виде data URL."""
+
+ db.refresh(current_user)
+ if not current_user.profile_photo_path:
+ return ProfilePhotoResponse(photo=None, detail="Фотография не загружена")
+
+ try:
+ photo = build_photo_data_url(current_user.profile_photo_path)
+ except FileNotFoundError:
+ # Если файл удалили вручную, сбрасываем ссылку в базе, чтобы не мешать пользователю загрузить новую.
+ current_user.profile_photo_path = None
+ db.add(current_user)
+ db.commit()
+ return ProfilePhotoResponse(photo=None, detail="Файл не найден")
+
+ return ProfilePhotoResponse(photo=photo)
+
+
+@router.post(
+ "/me/photo",
+ response_model=ProfilePhotoResponse,
+ status_code=status.HTTP_200_OK,
+ summary="Загружаем фото профиля",
+)
+def upload_profile_photo(
+ photo: UploadFile = File(...),
+ *,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> ProfilePhotoResponse:
+ """Сохраняем изображение и возвращаем обновлённый data URL."""
+
+ try:
+ relative_path = save_profile_photo(upload=photo, user_id=current_user.id)
+ except ValueError as error:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
+
+ delete_profile_photo(current_user.profile_photo_path)
+ current_user.profile_photo_path = relative_path
+ db.add(current_user)
+ db.commit()
+ db.refresh(current_user)
+
+ photo_url = build_photo_data_url(relative_path)
+ return ProfilePhotoResponse(photo=photo_url, detail="Фотография обновлена")
+
+
+@router.delete(
+ "/me/photo",
+ response_model=ProfilePhotoResponse,
+ summary="Удаляем фото профиля",
+)
+def delete_profile_photo_endpoint(
+ *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
+) -> ProfilePhotoResponse:
+ """Удаляем сохранённое фото и очищаем ссылку в профиле."""
+
+ if not current_user.profile_photo_path:
+ return ProfilePhotoResponse(photo=None, detail="Фотография уже удалена")
+
+ delete_profile_photo(current_user.profile_photo_path)
+ current_user.profile_photo_path = None
+ db.add(current_user)
+ db.commit()
+
+ return ProfilePhotoResponse(photo=None, detail="Фотография удалена")
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
diff --git a/backend/app/main.py b/backend/app/main.py
index 26d1d7a..28c71a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -30,6 +30,13 @@ def run_migrations() -> None:
config = Config(str(ALEMBIC_CONFIG))
config.set_main_option("sqlalchemy.url", str(settings.database_url))
+ # Alembic трактует относительный script_location относительно текущей рабочей
+ # директории процесса. В тестах и фронтенд-сервере мы запускаем backend из
+ # корня репозитория, поэтому явно подсказываем абсолютный путь до папки с
+ # миграциями, чтобы `alembic` не падал с "Path doesn't exist: alembic".
+ config.set_main_option(
+ "script_location", str(Path(__file__).resolve().parents[1] / "alembic")
+ )
script = ScriptDirectory.from_config(config)
head_revision = script.get_current_head()
@@ -55,11 +62,17 @@ def run_migrations() -> None:
if "mission_submissions" in tables:
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
+ mission_columns = set()
+ if "missions" in tables:
+ mission_columns = {column["name"] for column in inspector.get_columns("missions")}
+
with engine.begin() as conn:
if "preferred_branch" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
if "motivation" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
+ if "profile_photo_path" not in user_columns:
+ conn.execute(text("ALTER TABLE users ADD COLUMN profile_photo_path VARCHAR(512)"))
if "passport_path" not in submission_columns:
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
@@ -70,6 +83,39 @@ def run_migrations() -> None:
if "resume_link" not in submission_columns:
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
+ if "missions" in tables:
+ # Легаси-базы без alembic_version пропускали миграцию с офлайн-полями,
+ # поэтому докидываем недостающие колонки вручную, чтобы API /admin не падало.
+ if "format" not in mission_columns:
+ conn.execute(
+ text(
+ "ALTER TABLE missions ADD COLUMN format VARCHAR(20) NOT NULL DEFAULT 'online'"
+ )
+ )
+ conn.execute(text("UPDATE missions SET format = 'online' WHERE format IS NULL"))
+ if "event_location" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN event_location VARCHAR(160)"))
+ if "event_address" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN event_address VARCHAR(255)"))
+ if "event_starts_at" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN event_starts_at TIMESTAMP"))
+ if "event_ends_at" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN event_ends_at TIMESTAMP"))
+ if "registration_deadline" not in mission_columns:
+ conn.execute(
+ text("ALTER TABLE missions ADD COLUMN registration_deadline TIMESTAMP")
+ )
+ if "registration_url" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN registration_url VARCHAR(512)"))
+ if "registration_notes" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN registration_notes TEXT"))
+ if "capacity" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN capacity INTEGER"))
+ if "contact_person" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN contact_person VARCHAR(120)"))
+ if "contact_phone" not in mission_columns:
+ conn.execute(text("ALTER TABLE missions ADD COLUMN contact_phone VARCHAR(64)"))
+
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL)"))
conn.execute(text("DELETE FROM alembic_version"))
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
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/models/user.py b/backend/app/models/user.py
index 3d92237..f31360f 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -46,6 +46,7 @@ class User(Base, TimestampMixin):
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ profile_photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(
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/schemas/user.py b/backend/app/schemas/user.py
index b073323..e684db6 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -69,6 +69,8 @@ class UserProfile(UserRead):
competencies: list[UserCompetencyRead]
artifacts: list[UserArtifactRead]
+ profile_photo_uploaded: bool = False
+ profile_photo_updated_at: Optional[datetime] = None
class LeaderboardEntry(BaseModel):
@@ -108,6 +110,11 @@ class UserRegister(BaseModel):
email: EmailStr
full_name: str
password: str
- # Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
- preferred_branch: Optional[str] = None
motivation: Optional[str] = None
+
+
+class ProfilePhotoResponse(BaseModel):
+ """Ответ с данными загруженной фотографии."""
+
+ photo: Optional[str] = None
+ detail: Optional[str] = None
diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py
index 99b407b..b70a0ea 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,29 @@ 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)
+
+ deadline = mission.registration_deadline
+ if deadline and deadline.tzinfo is None:
+ deadline = deadline.replace(tzinfo=timezone.utc)
+
+ if deadline and 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/backend/app/services/storage.py b/backend/app/services/storage.py
index 36b896b..166ba62 100644
--- a/backend/app/services/storage.py
+++ b/backend/app/services/storage.py
@@ -2,8 +2,10 @@
from __future__ import annotations
-from pathlib import Path
+import base64
+import mimetypes
import shutil
+from pathlib import Path
from fastapi import UploadFile
@@ -40,8 +42,34 @@ def save_submission_document(
return relative_path
-def delete_submission_document(relative_path: str | None) -> None:
- """Удаляем файл вложения, если он существует."""
+def save_profile_photo(*, upload: UploadFile, user_id: int) -> str:
+ """Сохраняем фото профиля кандидата."""
+
+ allowed_types = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+ }
+
+ content_type = upload.content_type or mimetypes.guess_type(upload.filename or "")[0]
+ if content_type not in allowed_types:
+ raise ValueError("Допустимы только изображения JPG, PNG или WEBP")
+
+ extension = allowed_types[content_type]
+ target_dir = settings.uploads_path / f"user_{user_id}" / "profile"
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ target_path = target_dir / f"photo{extension}"
+ with target_path.open("wb") as buffer:
+ upload.file.seek(0)
+ shutil.copyfileobj(upload.file, buffer)
+ upload.file.seek(0)
+
+ return target_path.relative_to(settings.uploads_path).as_posix()
+
+
+def _delete_relative_file(relative_path: str | None) -> None:
+ """Удаляем файл и очищаем пустые каталоги."""
if not relative_path:
return
@@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
parent = file_path.parent
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
+
+
+def delete_submission_document(relative_path: str | None) -> None:
+ """Удаляем файл вложения, если он существует."""
+
+ _delete_relative_file(relative_path)
+
+
+def delete_profile_photo(relative_path: str | None) -> None:
+ """Удаляем сохранённую фотографию профиля."""
+
+ _delete_relative_file(relative_path)
+
+
+def build_photo_data_url(relative_path: str) -> str:
+ """Формируем data URL для изображения, чтобы отдать его фронту."""
+
+ file_path = settings.uploads_path / relative_path
+ _ensure_within_base(file_path)
+ if not file_path.exists():
+ raise FileNotFoundError("Файл не найден")
+
+ mime_type = mimetypes.guess_type(file_path.name)[0] or "image/jpeg"
+ with file_path.open("rb") as fh:
+ encoded = base64.b64encode(fh.read()).decode("ascii")
+ return f"data:{mime_type};base64,{encoded}"
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' ? (
+
) : (
;
+ profile_photo_uploaded: boolean;
}
interface ProgressResponse {
@@ -64,6 +65,8 @@ export default async function DashboardPage() {
mana={profile.mana}
competencies={profile.competencies}
artifacts={profile.artifacts}
+ token={session.token}
+ profilePhotoUploaded={profile.profile_photo_uploaded}
progress={progress}
/>
diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx
index 09f7e21..868f4f6 100644
--- a/frontend/src/app/register/page.tsx
+++ b/frontend/src/app/register/page.tsx
@@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '').trim();
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
- const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
if (!fullName || !email || !password) {
@@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
try {
// 2. Собираем payload в формате, который ожидает FastAPI.
- const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation };
+ const payload = { full_name: fullName, email, password, motivation };
const response = await apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
@@ -80,17 +79,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: { e
Пароль
-
)}
+ {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/ProgressOverview.tsx b/frontend/src/components/ProgressOverview.tsx
index 45e4796..e437898 100644
--- a/frontend/src/components/ProgressOverview.tsx
+++ b/frontend/src/components/ProgressOverview.tsx
@@ -1,7 +1,10 @@
'use client';
+import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
+import { apiFetch } from '../lib/api';
+
// Компетенции и артефакты из профиля пользователя.
type Competency = {
competency: {
@@ -18,11 +21,18 @@ type Artifact = {
};
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
+interface ProfilePhotoResponse {
+ photo: string | null;
+ detail?: string | null;
+}
+
export interface ProfileProps {
fullName: string;
mana: number;
competencies: Competency[];
artifacts: Artifact[];
+ token: string;
+ profilePhotoUploaded: boolean;
progress: {
current_rank: { title: string } | null;
next_rank: { title: string } | null;
@@ -57,6 +67,44 @@ const Card = styled.div`
gap: 1.5rem;
`;
+const PhotoSection = styled.div`
+ display: flex;
+ gap: 1.5rem;
+ align-items: center;
+`;
+
+const PhotoPreview = styled.div`
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 2px solid rgba(108, 92, 231, 0.45);
+ background: rgba(162, 155, 254, 0.18);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+`;
+
+const PhotoImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`;
+
+const PhotoActions = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+`;
+
+const StatusMessage = styled.p<{ $kind: 'success' | 'error' }>`
+ margin: 0;
+ font-size: 0.85rem;
+ color: ${({ $kind }) => ($kind === 'success' ? 'var(--accent-light)' : 'var(--error)')};
+`;
+
const ProgressBar = styled.div<{ value: number }>`
position: relative;
height: 12px;
@@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
`;
-export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
+export function ProgressOverview({
+ fullName,
+ mana,
+ competencies,
+ artifacts,
+ token,
+ profilePhotoUploaded,
+ progress
+}: ProfileProps) {
const xpPercent = Math.round(progress.xp.progress_percent * 100);
const hasNextRank = Boolean(progress.next_rank);
+ const [photoData, setPhotoData] = useState(null);
+ const [hasPhoto, setHasPhoto] = useState(profilePhotoUploaded);
+ const [status, setStatus] = useState(null);
+ const [statusKind, setStatusKind] = useState<'success' | 'error'>('success');
+ const [uploading, setUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ setHasPhoto(profilePhotoUploaded);
+ }, [profilePhotoUploaded]);
+
+ useEffect(() => {
+ if (!hasPhoto) {
+ setPhotoData(null);
+ return;
+ }
+
+ let cancelled = false;
+ async function loadPhoto() {
+ try {
+ const response = await apiFetch('/api/me/photo', { authToken: token });
+ if (!cancelled) {
+ setPhotoData(response.photo ?? null);
+ }
+ } catch (error) {
+ if (!cancelled) {
+ console.error('Не удалось загрузить фото профиля', error);
+ setStatusKind('error');
+ setStatus('Не получилось загрузить фото. Попробуйте обновить страницу.');
+ }
+ }
+ }
+
+ void loadPhoto();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [hasPhoto, token]);
+
+ async function handleUpload(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
+ setStatus('Разрешены только изображения в форматах JPG, PNG или WEBP.');
+ event.target.value = '';
+ return;
+ }
+
+ if (file.size > 5 * 1024 * 1024) {
+ setStatus('Размер файла не должен превышать 5 МБ.');
+ event.target.value = '';
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('photo', file);
+
+ try {
+ setUploading(true);
+ setStatus(null);
+ setStatusKind('success');
+ const response = await apiFetch('/api/me/photo', {
+ method: 'POST',
+ body: formData,
+ authToken: token
+ });
+ setPhotoData(response.photo ?? null);
+ setHasPhoto(Boolean(response.photo));
+ setStatusKind('success');
+ setStatus(response.detail ?? 'Фотография обновлена.');
+ } catch (error) {
+ if (error instanceof Error) {
+ setStatusKind('error');
+ setStatus(error.message);
+ } else {
+ setStatusKind('error');
+ setStatus('Не удалось сохранить фото. Попробуйте позже.');
+ }
+ } finally {
+ setUploading(false);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }
+ }
+
+ async function handleRemove() {
+ try {
+ setUploading(true);
+ setStatus(null);
+ setStatusKind('success');
+ const response = await apiFetch('/api/me/photo', {
+ method: 'DELETE',
+ authToken: token
+ });
+ setPhotoData(null);
+ setHasPhoto(false);
+ setStatusKind('success');
+ setStatus(response.detail ?? 'Фотография удалена.');
+ } catch (error) {
+ if (error instanceof Error) {
+ setStatusKind('error');
+ setStatus(error.message);
+ } else {
+ setStatusKind('error');
+ setStatus('Не удалось удалить фото. Попробуйте ещё раз.');
+ }
+ } finally {
+ setUploading(false);
+ }
+ }
return (
+
+
+ {photoData ? : 🧑🚀}
+
+
+
+
+
+
+
+ Добавьте свою фотографию, чтобы HR быстрее узнавал вас при общении на офлайн-миссиях.
+
+ {status && {status}}
+
+
+
{fullName}
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' && (
+
+ )}
+