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/alembic/versions/20241014_0010_store_item_images.py b/backend/alembic/versions/20241014_0010_store_item_images.py
new file mode 100644
index 0000000..325f1e5
--- /dev/null
+++ b/backend/alembic/versions/20241014_0010_store_item_images.py
@@ -0,0 +1,25 @@
+"""Add image url to store items"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "20241014_0010"
+down_revision = "20241012_0009"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Добавляем колонку с изображением товара."""
+
+ op.add_column("store_items", sa.Column("image_url", sa.String(length=255), nullable=True))
+
+
+def downgrade() -> None:
+ """Удаляем колонку с изображением товара."""
+
+ op.drop_column("store_items", "image_url")
diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py
index 8e876b6..edbdb85 100644
--- a/backend/app/api/routes/admin.py
+++ b/backend/app/api/routes/admin.py
@@ -19,6 +19,7 @@ from app.models.mission import (
SubmissionStatus,
)
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
+from app.models.store import StoreItem
from app.models.user import Competency, User, UserRole
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
@@ -38,7 +39,8 @@ from app.schemas.rank import (
RankUpdate,
)
from app.schemas.user import CompetencyBase
-from app.services.mission import approve_submission, reject_submission
+from app.schemas.store import StoreItemCreate, StoreItemRead, StoreItemUpdate
+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 +49,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 +61,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 +86,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,
)
@@ -120,6 +140,15 @@ def _branch_to_read(branch: Branch) -> BranchRead:
)
+def _sanitize_optional(value: str | None) -> str | None:
+ """Обрезаем пробелы и заменяем пустые строки на None."""
+
+ if value is None:
+ return None
+ stripped = value.strip()
+ return stripped or None
+
+
def _load_rank(db: Session, rank_id: int) -> Rank:
"""Загружаем ранг с зависимостями."""
@@ -143,6 +172,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()
@@ -157,6 +187,99 @@ def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(requir
return [MissionBase.model_validate(mission) for mission in missions]
+@router.get("/store/items", response_model=list[StoreItemRead], summary="Товары магазина (HR)")
+def admin_store_items(
+ *, db: Session = Depends(get_db), current_user=Depends(require_hr)
+) -> list[StoreItemRead]:
+ """Возвращаем товары магазина для панели HR."""
+
+ items = db.query(StoreItem).order_by(StoreItem.name).all()
+ return [StoreItemRead.model_validate(item) for item in items]
+
+
+@router.post(
+ "/store/items",
+ response_model=StoreItemRead,
+ status_code=status.HTTP_201_CREATED,
+ summary="Создать товар",
+)
+def admin_store_create(
+ item_in: StoreItemCreate,
+ *,
+ db: Session = Depends(get_db),
+ current_user=Depends(require_hr),
+) -> StoreItemRead:
+ """Создаём новый товар в магазине."""
+
+ name = item_in.name.strip()
+ description = item_in.description.strip()
+ if not name or not description:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Название и описание не могут быть пустыми",
+ )
+
+ item = StoreItem(
+ name=name,
+ description=description,
+ cost_mana=item_in.cost_mana,
+ stock=item_in.stock,
+ image_url=_sanitize_optional(item_in.image_url),
+ )
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+ return StoreItemRead.model_validate(item)
+
+
+@router.patch(
+ "/store/items/{item_id}",
+ response_model=StoreItemRead,
+ summary="Обновить товар",
+)
+def admin_store_update(
+ item_id: int,
+ item_in: StoreItemUpdate,
+ *,
+ db: Session = Depends(get_db),
+ current_user=Depends(require_hr),
+) -> StoreItemRead:
+ """Редактируем существующий товар."""
+
+ item = db.query(StoreItem).filter(StoreItem.id == item_id).first()
+ if not item:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Товар не найден")
+
+ update_data = item_in.model_dump(exclude_unset=True)
+ if "name" in update_data and update_data["name"] is not None:
+ new_name = update_data["name"].strip()
+ if not new_name:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Название не может быть пустым",
+ )
+ item.name = new_name
+ if "description" in update_data and update_data["description"] is not None:
+ new_description = update_data["description"].strip()
+ if not new_description:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Описание не может быть пустым",
+ )
+ item.description = new_description
+ if "cost_mana" in update_data and update_data["cost_mana"] is not None:
+ item.cost_mana = update_data["cost_mana"]
+ if "stock" in update_data and update_data["stock"] is not None:
+ item.stock = update_data["stock"]
+ if "image_url" in update_data:
+ item.image_url = _sanitize_optional(update_data["image_url"])
+
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+ return StoreItemRead.model_validate(item)
+
+
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
def admin_mission_detail(
mission_id: int,
@@ -172,6 +295,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 +538,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 +610,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/store.py b/backend/app/models/store.py
index 8c2251f..9b8d55d 100644
--- a/backend/app/models/store.py
+++ b/backend/app/models/store.py
@@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
description: Mapped[str] = mapped_column(Text, nullable=False)
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ image_url: Mapped[Optional[str]] = mapped_column(String(255))
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")
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/store.py b/backend/app/schemas/store.py
index 0988635..eeee67b 100644
--- a/backend/app/schemas/store.py
+++ b/backend/app/schemas/store.py
@@ -10,19 +10,41 @@ from pydantic import BaseModel
from app.models.store import OrderStatus
-class StoreItemRead(BaseModel):
- """Товар магазина."""
+class StoreItemBase(BaseModel):
+ """Базовые поля товара магазина."""
- id: int
name: str
description: str
cost_mana: int
stock: int
+ image_url: Optional[str] = None
+
+
+class StoreItemRead(StoreItemBase):
+ """Товар магазина для чтения."""
+
+ id: int
class Config:
from_attributes = True
+class StoreItemCreate(StoreItemBase):
+ """Запрос на создание товара."""
+
+ pass
+
+
+class StoreItemUpdate(BaseModel):
+ """Запрос на обновление товара."""
+
+ name: Optional[str] = None
+ description: Optional[str] = None
+ cost_mana: Optional[int] = None
+ stock: Optional[int] = None
+ image_url: Optional[str] = None
+
+
class OrderRead(BaseModel):
"""Информация о заказе."""
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/public/store/alabuga-crew-shirt.svg b/frontend/public/store/alabuga-crew-shirt.svg
new file mode 100644
index 0000000..ace901b
--- /dev/null
+++ b/frontend/public/store/alabuga-crew-shirt.svg
@@ -0,0 +1,38 @@
+
diff --git a/frontend/public/store/excursion-alabuga.svg b/frontend/public/store/excursion-alabuga.svg
new file mode 100644
index 0000000..98bd9bf
--- /dev/null
+++ b/frontend/public/store/excursion-alabuga.svg
@@ -0,0 +1,34 @@
+
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index 9d81408..9166c0e 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -3,6 +3,7 @@ import { AdminMissionManager } from '../../components/admin/AdminMissionManager'
import { AdminRankManager } from '../../components/admin/AdminRankManager';
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
+import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
import { apiFetch } from '../../lib/api';
import { requireRole } from '../../lib/auth/session';
@@ -61,6 +62,15 @@ interface ArtifactSummary {
image_url?: string | null;
}
+interface StoreItemSummary {
+ id: number;
+ name: string;
+ description: string;
+ cost_mana: number;
+ stock: number;
+ image_url: string | null;
+}
+
interface SubmissionStats {
pending: number;
approved: number;
@@ -85,14 +95,24 @@ export default async function AdminPage() {
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
const session = await requireRole('hr');
- const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
+ const [
+ submissions,
+ missions,
+ branches,
+ ranks,
+ competencies,
+ artifacts,
+ stats,
+ storeItems,
+ ] = await Promise.all([
apiFetch {mission.description}
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
{mission.title}
{mission.difficulty}
Офлайн событие
+
+ {mission.event_location &&
+
{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] = useStatediff --git a/frontend/src/components/StoreItems.tsx b/frontend/src/components/StoreItems.tsx index 97695af..1a64ba4 100644 --- a/frontend/src/components/StoreItems.tsx +++ b/frontend/src/components/StoreItems.tsx @@ -10,6 +10,7 @@ type StoreItem = { description: string; cost_mana: number; stock: number; + image_url: string | null; }; const Card = styled.div` @@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
{item.description}
{item.cost_mana} ⚡ · остаток {item.stock}
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