Merge pull request #11 from Danieli4/codex/add-offline-event-management-feature-jtwmcv

Add offline mission fixes and profile photo uploads
This commit is contained in:
Danil Gryaznev 2025-09-30 21:56:23 -06:00 committed by GitHub
commit d89769e4b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 406 additions and 23 deletions

View File

@ -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")

View File

@ -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,

View File

@ -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,

View File

@ -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="Перечень рангов")

View File

@ -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)"))

View File

@ -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(

View File

@ -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

View File

@ -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:

View File

@ -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}"

View File

@ -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}
/>
</div>

View File

@ -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<any>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
@ -80,17 +79,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: { e
Пароль
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
</label>
<label className={styles.field}>
Интересующая ветка (необязательно)
<select className={styles.input} name="preferredBranch" defaultValue="">
<option value="">Выберите ветку</option>
<option value="Получение оффера">Получение оффера</option>
<option value="Рекрутинг">Рекрутинг</option>
<option value="Квесты">Квесты</option>
<option value="Симулятор">Симулятор</option>
<option value="Лекторий">Лекторий</option>
</select>
</label>
<label className={styles.field}>
Что хотите добиться?
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />

View File

@ -1,7 +1,10 @@
'use client';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { apiFetch } from '../lib/api';
// Компетенции и артефакты из профиля пользователя.
type Competency = {
competency: {
@ -18,11 +21,18 @@ type Artifact = {
};
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
interface ProfilePhotoResponse {
photo: string | null;
detail?: string | null;
}
export interface ProfileProps {
fullName: string;
mana: number;
competencies: Competency[];
artifacts: Artifact[];
token: string;
profilePhotoUploaded: boolean;
progress: {
current_rank: { title: string } | null;
next_rank: { title: string } | null;
@ -57,6 +67,44 @@ const Card = styled.div`
gap: 1.5rem;
`;
const PhotoSection = styled.div`
display: flex;
gap: 1.5rem;
align-items: center;
`;
const PhotoPreview = styled.div`
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(108, 92, 231, 0.45);
background: rgba(162, 155, 254, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
`;
const PhotoImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const PhotoActions = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
`;
const StatusMessage = styled.p<{ $kind: 'success' | 'error' }>`
margin: 0;
font-size: 0.85rem;
color: ${({ $kind }) => ($kind === 'success' ? 'var(--accent-light)' : 'var(--error)')};
`;
const ProgressBar = styled.div<{ value: number }>`
position: relative;
height: 12px;
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
`;
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
export function ProgressOverview({
fullName,
mana,
competencies,
artifacts,
token,
profilePhotoUploaded,
progress
}: ProfileProps) {
const xpPercent = Math.round(progress.xp.progress_percent * 100);
const hasNextRank = Boolean(progress.next_rank);
const [photoData, setPhotoData] = useState<string | null>(null);
const [hasPhoto, setHasPhoto] = useState(profilePhotoUploaded);
const [status, setStatus] = useState<string | null>(null);
const [statusKind, setStatusKind] = useState<'success' | 'error'>('success');
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setHasPhoto(profilePhotoUploaded);
}, [profilePhotoUploaded]);
useEffect(() => {
if (!hasPhoto) {
setPhotoData(null);
return;
}
let cancelled = false;
async function loadPhoto() {
try {
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', { authToken: token });
if (!cancelled) {
setPhotoData(response.photo ?? null);
}
} catch (error) {
if (!cancelled) {
console.error('Не удалось загрузить фото профиля', error);
setStatusKind('error');
setStatus('Не получилось загрузить фото. Попробуйте обновить страницу.');
}
}
}
void loadPhoto();
return () => {
cancelled = true;
};
}, [hasPhoto, token]);
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
setStatus('Разрешены только изображения в форматах JPG, PNG или WEBP.');
event.target.value = '';
return;
}
if (file.size > 5 * 1024 * 1024) {
setStatus('Размер файла не должен превышать 5 МБ.');
event.target.value = '';
return;
}
const formData = new FormData();
formData.append('photo', file);
try {
setUploading(true);
setStatus(null);
setStatusKind('success');
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
method: 'POST',
body: formData,
authToken: token
});
setPhotoData(response.photo ?? null);
setHasPhoto(Boolean(response.photo));
setStatusKind('success');
setStatus(response.detail ?? 'Фотография обновлена.');
} catch (error) {
if (error instanceof Error) {
setStatusKind('error');
setStatus(error.message);
} else {
setStatusKind('error');
setStatus('Не удалось сохранить фото. Попробуйте позже.');
}
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
async function handleRemove() {
try {
setUploading(true);
setStatus(null);
setStatusKind('success');
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
method: 'DELETE',
authToken: token
});
setPhotoData(null);
setHasPhoto(false);
setStatusKind('success');
setStatus(response.detail ?? 'Фотография удалена.');
} catch (error) {
if (error instanceof Error) {
setStatusKind('error');
setStatus(error.message);
} else {
setStatusKind('error');
setStatus('Не удалось удалить фото. Попробуйте ещё раз.');
}
} finally {
setUploading(false);
}
}
return (
<Card>
<PhotoSection>
<PhotoPreview>
{photoData ? <PhotoImage src={photoData} alt="Фото профиля" /> : <span role="img" aria-label="Профиль">🧑🚀</span>}
</PhotoPreview>
<PhotoActions>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<label className="secondary" style={{ cursor: 'pointer' }}>
Загрузить фото
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={handleUpload}
disabled={uploading}
/>
</label>
<button className="ghost" type="button" onClick={handleRemove} disabled={!hasPhoto || uploading}>
Удалить
</button>
</div>
<small style={{ color: 'var(--text-muted)' }}>
Добавьте свою фотографию, чтобы HR быстрее узнавал вас при общении на офлайн-миссиях.
</small>
{status && <StatusMessage $kind={statusKind}>{status}</StatusMessage>}
</PhotoActions>
</PhotoSection>
<header>
<h2 style={{ margin: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>