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.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 Пароль -