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, full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password), hashed_password=get_password_hash(user_in.password),
role=UserRole.PILOT, role=UserRole.PILOT,
preferred_branch=user_in.preferred_branch,
motivation=user_in.motivation, motivation=user_in.motivation,
current_rank_id=base_rank.id if base_rank else None, current_rank_id=base_rank.id if base_rank else None,
is_email_confirmed=not settings.require_email_confirmation, is_email_confirmed=not settings.require_email_confirmation,

View File

@ -398,6 +398,17 @@ def get_mission(
xp_reward=mission.xp_reward, xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward, mana_reward=mission.mana_reward,
difficulty=mission.difficulty, 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_active=mission.is_active,
is_available=is_available, is_available=is_available,
locked_reasons=reasons, locked_reasons=reasons,

View File

@ -2,7 +2,7 @@
from __future__ import annotations 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 sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user 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.models.mission import SubmissionStatus
from app.schemas.progress import ProgressSnapshot from app.schemas.progress import ProgressSnapshot
from app.schemas.rank import RankBase 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.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"]) router = APIRouter(prefix="/api", tags=["profile"])
@ -29,7 +39,89 @@ def get_profile(
_ = item.competency _ = item.competency
for artifact in current_user.artifacts: for artifact in current_user.artifacts:
_ = artifact.artifact _ = 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="Перечень рангов") @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)")) conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
if "motivation" not in user_columns: if "motivation" not in user_columns:
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT")) 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: if "passport_path" not in submission_columns:
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)")) 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) preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте. # Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True) 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") current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship( competencies: Mapped[List["UserCompetency"]] = relationship(

View File

@ -69,6 +69,8 @@ class UserProfile(UserRead):
competencies: list[UserCompetencyRead] competencies: list[UserCompetencyRead]
artifacts: list[UserArtifactRead] artifacts: list[UserArtifactRead]
profile_photo_uploaded: bool = False
profile_photo_updated_at: Optional[datetime] = None
class LeaderboardEntry(BaseModel): class LeaderboardEntry(BaseModel):
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
email: EmailStr email: EmailStr
full_name: str full_name: str
password: str password: str
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
preferred_branch: Optional[str] = None
motivation: 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) 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 return False
if mission.capacity is not None and participant_count >= mission.capacity: 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 from pathlib import Path
import shutil import shutil
import mimetypes
import base64
from fastapi import UploadFile from fastapi import UploadFile
@ -40,8 +42,34 @@ def save_submission_document(
return relative_path 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: if not relative_path:
return return
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
parent = file_path.parent parent = file_path.parent
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()): if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
parent.rmdir() 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; name: string;
rarity: string; rarity: string;
}>; }>;
profile_photo_uploaded: boolean;
} }
interface ProgressResponse { interface ProgressResponse {
@ -64,6 +65,8 @@ export default async function DashboardPage() {
mana={profile.mana} mana={profile.mana}
competencies={profile.competencies} competencies={profile.competencies}
artifacts={profile.artifacts} artifacts={profile.artifacts}
token={session.token}
profilePhotoUploaded={profile.profile_photo_uploaded}
progress={progress} progress={progress}
/> />
</div> </div>

View File

@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
const email = String(formData.get('email') ?? '').trim(); const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '').trim(); const password = String(formData.get('password') ?? '').trim();
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки. // Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
const motivation = String(formData.get('motivation') ?? '').trim() || undefined; const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
if (!fullName || !email || !password) { if (!fullName || !email || !password) {
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
try { try {
// 2. Собираем payload в формате, который ожидает FastAPI. // 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', { const response = await apiFetch<any>('/auth/register', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) 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="Придумайте пароль" /> <input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
</label> </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}> <label className={styles.field}>
Что хотите добиться? Что хотите добиться?
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" /> <textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />

View File

@ -1,7 +1,10 @@
'use client'; 'use client';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { apiFetch } from '../lib/api';
// Компетенции и артефакты из профиля пользователя. // Компетенции и артефакты из профиля пользователя.
type Competency = { type Competency = {
competency: { competency: {
@ -18,11 +21,18 @@ type Artifact = {
}; };
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком. // Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
interface ProfilePhotoResponse {
photo: string | null;
detail?: string | null;
}
export interface ProfileProps { export interface ProfileProps {
fullName: string; fullName: string;
mana: number; mana: number;
competencies: Competency[]; competencies: Competency[];
artifacts: Artifact[]; artifacts: Artifact[];
token: string;
profilePhotoUploaded: boolean;
progress: { progress: {
current_rank: { title: string } | null; current_rank: { title: string } | null;
next_rank: { title: string } | null; next_rank: { title: string } | null;
@ -57,6 +67,44 @@ const Card = styled.div`
gap: 1.5rem; 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 }>` const ProgressBar = styled.div<{ value: number }>`
position: relative; position: relative;
height: 12px; height: 12px;
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')}; 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 xpPercent = Math.round(progress.xp.progress_percent * 100);
const hasNextRank = Boolean(progress.next_rank); 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 ( return (
<Card> <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> <header>
<h2 style={{ margin: 0 }}>{fullName}</h2> <h2 style={{ margin: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}> <p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>