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/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 d3c6a19..b0cf4d9 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -398,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, 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 b41eabe..28c71a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -71,6 +71,8 @@ def run_migrations() -> None: 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)")) 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/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 26193b0..c1a3d03 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -187,7 +187,12 @@ def registration_is_open( current_time = now or datetime.now(timezone.utc) - if mission.registration_deadline and mission.registration_deadline < current_time: + 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: diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 36b896b..d972764 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -4,6 +4,8 @@ from __future__ import annotations from pathlib import Path import shutil +import mimetypes +import base64 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/page.tsx b/frontend/src/app/page.tsx index 52bf536..dfb1e8a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -15,6 +15,7 @@ interface ProfileResponse { name: string; rarity: string; }>; + 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 Пароль -