Leadetboard, file downloader
This commit is contained in:
parent
cb49eb2e05
commit
b2927601a9
25
README.md
25
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Alabuga Gamification Platform
|
# «Автостопом по Алабуге»
|
||||||
|
|
||||||
Проект реализует прототип геймифицированного модуля для кадровой системы «Алабуги». Мы создаём космический лор, ранги, миссии, журнал событий и магазин артефактов. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript).
|
Проект «Автостопом по Алабуге» — это геймифицированный гид по карьерной Галактике Алабуги. Как и в «Автостопом по Галактике», экипаж полагается на остроумные подсказки, лор и рейтинг пилотов, чтобы не потерять полотенце в бюрократических туманностях. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript).
|
||||||
|
|
||||||
## Содержимое репозитория
|
## Содержимое репозитория
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
- API: http://localhost:8000 (документация Swagger — `/docs`).
|
- API: http://localhost:8000 (документация Swagger — `/docs`).
|
||||||
- Фронтенд: http://localhost:3000.
|
- Фронтенд: http://localhost:3000.
|
||||||
|
|
||||||
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера.
|
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера. Загружаемые кандидатами файлы помещаются в каталог, заданный переменной `ALABUGA_UPLOADS_PATH` (по умолчанию `./data/uploads`).
|
||||||
|
|
||||||
## Пользовательские учётные записи (сидированные)
|
## Пользовательские учётные записи (сидированные)
|
||||||
|
|
||||||
|
|
@ -50,9 +50,11 @@ Docker Compose автоматически переопределяет `ALABUGA_
|
||||||
2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
|
2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
|
||||||
3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
|
3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
|
||||||
4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||||
5. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
5. **Документы для миссии #1**: на странице `/missions/1` прикрепите паспорт (PDF/изображение), свежую фотографию и резюме (файл или ссылка). После отправки файлы можно скачать из блока «Загружено ранее».
|
||||||
6. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
|
6. **Выполнение миссии**: отправьте отчёт и документы, затем переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||||
7. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
7. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
|
||||||
|
8. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
||||||
|
9. **Лидерборд**: откройте `/leaderboard` (доступно и пилотам, и HR), чтобы увидеть текущие позиции по опыту и уровню компетенций.
|
||||||
|
|
||||||
### Подтверждение электронной почты
|
### Подтверждение электронной почты
|
||||||
|
|
||||||
|
|
@ -65,6 +67,16 @@ Docker Compose автоматически переопределяет `ALABUGA_
|
||||||
|
|
||||||
Демо-учётные записи в сид-данных имеют уже подтверждённый e-mail.
|
Демо-учётные записи в сид-данных имеют уже подтверждённый e-mail.
|
||||||
|
|
||||||
|
### Очистка тестовых данных
|
||||||
|
|
||||||
|
Чтобы удалить отправленные миссии, журнал и вложения, выполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m scripts.reset_demo_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт прогонит миграции, очистит таблицы `mission_submissions`, `orders`, `journal_entries`, сбросит опыт/ману пользователей и удалит весь каталог с загруженными документами (`ALABUGA_UPLOADS_PATH`).
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -84,6 +96,7 @@ pytest
|
||||||
- Онбординг с сохранением прогресса и космическим лором.
|
- Онбординг с сохранением прогресса и космическим лором.
|
||||||
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
||||||
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
||||||
|
- Лидерборд пилотов по опыту с отображением ключевых компетенций.
|
||||||
|
|
||||||
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ HR-платформа [hr.alabuga.ru]
|
||||||
|
|
||||||
- основная платформа для авторизации в экосистеме «Алабуги». На этой
|
- основная платформа для авторизации в экосистеме «Алабуги». На этой
|
||||||
платформе расположены бизнес-симуляции, в которые играют кандидаты и
|
платформе расположены бизнес-симуляции, в которые играют кандидаты и
|
||||||
|
з
|
||||||
сотрудники
|
сотрудники
|
||||||
|
|
||||||
Карьера.100 лидеров [career.alabuga.space]
|
Карьера.100 лидеров [career.alabuga.space]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Alabuga Gamification API Environment Variables
|
# Alabuga Gamification API Environment Variables
|
||||||
|
|
||||||
# Debug mode (enables auto-creation of demo users)
|
# General settings
|
||||||
|
ALABUGA_ENVIRONMENT=local
|
||||||
ALABUGA_DEBUG=true
|
ALABUGA_DEBUG=true
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
|
|
@ -13,6 +14,7 @@ ALABUGA_REQUIRE_EMAIL_CONFIRMATION=false
|
||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
ALABUGA_SQLITE_PATH=/data/app.db
|
ALABUGA_SQLITE_PATH=/data/app.db
|
||||||
|
ALABUGA_UPLOADS_PATH=/data/uploads
|
||||||
|
|
||||||
# CORS settings (JSON array format)
|
# CORS settings (JSON array format)
|
||||||
ALABUGA_BACKEND_CORS_ORIGINS=["http://localhost:3000", "http://frontend:3000", "http://0.0.0.0:3000"]
|
ALABUGA_BACKEND_CORS_ORIGINS=["http://localhost:3000", "http://frontend:3000", "http://0.0.0.0:3000"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Add submission document fields"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20240927_0005'
|
||||||
|
down_revision = '20240927_0004'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('mission_submissions', sa.Column('passport_path', sa.String(length=512), nullable=True))
|
||||||
|
op.add_column('mission_submissions', sa.Column('photo_path', sa.String(length=512), nullable=True))
|
||||||
|
op.add_column('mission_submissions', sa.Column('resume_path', sa.String(length=512), nullable=True))
|
||||||
|
op.add_column('mission_submissions', sa.Column('resume_link', sa.String(length=512), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('mission_submissions', 'resume_link')
|
||||||
|
op.drop_column('mission_submissions', 'resume_path')
|
||||||
|
op.drop_column('mission_submissions', 'photo_path')
|
||||||
|
op.drop_column('mission_submissions', 'passport_path')
|
||||||
|
|
@ -3,26 +3,30 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi.responses import FileResponse
|
||||||
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
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User
|
from app.models.user import User, UserRole
|
||||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||||
from app.schemas.mission import (
|
from app.schemas.mission import (
|
||||||
MissionBase,
|
MissionBase,
|
||||||
MissionDetail,
|
MissionDetail,
|
||||||
MissionSubmissionCreate,
|
|
||||||
MissionSubmissionRead,
|
MissionSubmissionRead,
|
||||||
)
|
)
|
||||||
from app.services.mission import submit_mission
|
from app.services.mission import UNSET, submit_mission
|
||||||
|
from app.services.storage import delete_submission_document, save_submission_document
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
||||||
|
|
||||||
|
# Для миссии #1 требуется обязательное прикрепление документов.
|
||||||
|
REQUIRED_DOCUMENT_MISSIONS = {1}
|
||||||
|
|
||||||
|
|
||||||
def _load_user_progress(user: User) -> set[int]:
|
def _load_user_progress(user: User) -> set[int]:
|
||||||
"""Возвращаем идентификаторы успешно завершённых миссий."""
|
"""Возвращаем идентификаторы успешно завершённых миссий."""
|
||||||
|
|
@ -194,6 +198,13 @@ def list_missions(
|
||||||
mission_titles=mission_titles,
|
mission_titles=mission_titles,
|
||||||
)
|
)
|
||||||
dto = MissionBase.model_validate(mission)
|
dto = MissionBase.model_validate(mission)
|
||||||
|
dto.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||||
|
if mission.id in completed_missions:
|
||||||
|
dto.is_completed = True
|
||||||
|
dto.is_available = False
|
||||||
|
dto.locked_reasons = ["Миссия уже завершена"]
|
||||||
|
else:
|
||||||
|
dto.is_completed = False
|
||||||
dto.is_available = is_available
|
dto.is_available = is_available
|
||||||
dto.locked_reasons = reasons
|
dto.locked_reasons = reasons
|
||||||
response.append(dto)
|
response.append(dto)
|
||||||
|
|
@ -260,18 +271,28 @@ def get_mission(
|
||||||
created_at=mission.created_at,
|
created_at=mission.created_at,
|
||||||
updated_at=mission.updated_at,
|
updated_at=mission.updated_at,
|
||||||
)
|
)
|
||||||
|
data.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||||
|
if mission.id in completed_missions:
|
||||||
|
data.is_completed = True
|
||||||
|
data.is_available = False
|
||||||
|
data.locked_reasons = ["Миссия уже завершена"]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт")
|
@router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт")
|
||||||
def submit(
|
async def submit(
|
||||||
mission_id: int,
|
mission_id: int,
|
||||||
submission_in: MissionSubmissionCreate,
|
|
||||||
*,
|
*,
|
||||||
|
comment: str | None = Form(None),
|
||||||
|
proof_url: str | None = Form(None),
|
||||||
|
resume_link: str | None = Form(None),
|
||||||
|
passport: UploadFile | None = File(None),
|
||||||
|
photo: UploadFile | None = File(None),
|
||||||
|
resume_file: UploadFile | None = File(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> MissionSubmissionRead:
|
) -> MissionSubmissionRead:
|
||||||
"""Пилот отправляет доказательство выполнения миссии."""
|
"""Пилот отправляет доказательство выполнения миссии и сопроводительные документы."""
|
||||||
|
|
||||||
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
||||||
if not mission:
|
if not mission:
|
||||||
|
|
@ -297,13 +318,88 @@ def submit(
|
||||||
)
|
)
|
||||||
if not is_available:
|
if not is_available:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
|
||||||
|
|
||||||
|
existing_submission = (
|
||||||
|
db.query(MissionSubmission)
|
||||||
|
.filter(MissionSubmission.user_id == current_user.id, MissionSubmission.mission_id == mission.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_upload(upload: UploadFile | None) -> bool:
|
||||||
|
return bool(upload and upload.filename)
|
||||||
|
|
||||||
|
passport_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||||
|
photo_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||||
|
resume_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
|
||||||
|
|
||||||
|
if passport_required and not (
|
||||||
|
(existing_submission and existing_submission.passport_path) or _has_upload(passport)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Загрузите скан паспорта кандидата.")
|
||||||
|
|
||||||
|
if photo_required and not (
|
||||||
|
(existing_submission and existing_submission.photo_path) or _has_upload(photo)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Добавьте актуальную фотографию кандидата.")
|
||||||
|
|
||||||
|
existing_resume_sources = bool(
|
||||||
|
existing_submission
|
||||||
|
and (existing_submission.resume_path or existing_submission.resume_link)
|
||||||
|
)
|
||||||
|
resume_link_trimmed = (resume_link or "").strip()
|
||||||
|
resume_file_provided = _has_upload(resume_file)
|
||||||
|
if resume_required and not (existing_resume_sources or resume_link_trimmed or resume_file_provided):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Добавьте ссылку на резюме или загрузите файл с резюме.",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_passport_path = None
|
||||||
|
new_photo_path = None
|
||||||
|
new_resume_path = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if _has_upload(passport):
|
||||||
|
new_passport_path = save_submission_document(
|
||||||
|
upload=passport,
|
||||||
|
user_id=current_user.id,
|
||||||
|
mission_id=mission.id,
|
||||||
|
kind="passport",
|
||||||
|
)
|
||||||
|
|
||||||
|
if _has_upload(photo):
|
||||||
|
new_photo_path = save_submission_document(
|
||||||
|
upload=photo,
|
||||||
|
user_id=current_user.id,
|
||||||
|
mission_id=mission.id,
|
||||||
|
kind="photo",
|
||||||
|
)
|
||||||
|
|
||||||
|
if resume_file_provided:
|
||||||
|
new_resume_path = save_submission_document(
|
||||||
|
upload=resume_file,
|
||||||
|
user_id=current_user.id,
|
||||||
|
mission_id=mission.id,
|
||||||
|
kind="resume",
|
||||||
|
)
|
||||||
|
|
||||||
submission = submit_mission(
|
submission = submit_mission(
|
||||||
db=db,
|
db=db,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
mission=mission,
|
mission=mission,
|
||||||
comment=submission_in.comment,
|
comment=(comment or "").strip() or None,
|
||||||
proof_url=submission_in.proof_url,
|
proof_url=(proof_url or "").strip() or None,
|
||||||
|
passport_path=new_passport_path if new_passport_path is not None else UNSET,
|
||||||
|
photo_path=new_photo_path if new_photo_path is not None else UNSET,
|
||||||
|
resume_path=new_resume_path if new_resume_path is not None else UNSET,
|
||||||
|
resume_link=(resume_link_trimmed or None) if resume_link is not None else UNSET,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
delete_submission_document(new_passport_path)
|
||||||
|
delete_submission_document(new_photo_path)
|
||||||
|
delete_submission_document(new_resume_path)
|
||||||
|
raise
|
||||||
|
|
||||||
return MissionSubmissionRead.model_validate(submission)
|
return MissionSubmissionRead.model_validate(submission)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -328,3 +424,45 @@ def get_submission(
|
||||||
if not submission:
|
if not submission:
|
||||||
return None
|
return None
|
||||||
return MissionSubmissionRead.model_validate(submission)
|
return MissionSubmissionRead.model_validate(submission)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/submissions/{submission_id}/files/{document}",
|
||||||
|
summary="Скачиваем загруженные файлы",
|
||||||
|
)
|
||||||
|
def download_submission_file(
|
||||||
|
submission_id: int,
|
||||||
|
document: str,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Возвращаем файл паспорта, фото или резюме."""
|
||||||
|
|
||||||
|
submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first()
|
||||||
|
if not submission:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена")
|
||||||
|
|
||||||
|
if submission.user_id != current_user.id and current_user.role != UserRole.HR:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Нет доступа к файлу")
|
||||||
|
|
||||||
|
attribute_map = {
|
||||||
|
"passport": submission.passport_path,
|
||||||
|
"photo": submission.photo_path,
|
||||||
|
"resume": submission.resume_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
relative_path = attribute_map.get(document)
|
||||||
|
if not relative_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||||
|
|
||||||
|
file_path = settings.uploads_path / relative_path
|
||||||
|
resolved = file_path.resolve()
|
||||||
|
base = settings.uploads_path.resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||||
|
|
||||||
|
if not resolved.exists():
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
|
||||||
|
|
||||||
|
return FileResponse(resolved, filename=resolved.name)
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.rank import Rank
|
from app.models.rank import Rank
|
||||||
from app.models.user import User
|
from app.models.user import User, UserRole, UserCompetency
|
||||||
|
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 UserProfile
|
from app.schemas.user import LeaderboardEntry, UserCompetencyRead, UserProfile
|
||||||
from app.services.rank import build_progress_snapshot
|
from app.services.rank import build_progress_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["profile"])
|
router = APIRouter(prefix="/api", tags=["profile"])
|
||||||
|
|
@ -52,3 +53,40 @@ def get_progress(
|
||||||
_ = current_user.competencies
|
_ = current_user.competencies
|
||||||
snapshot = build_progress_snapshot(current_user, db)
|
snapshot = build_progress_snapshot(current_user, db)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leaderboard", response_model=list[LeaderboardEntry], summary="Лидерборд пилотов")
|
||||||
|
def leaderboard(
|
||||||
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
) -> list[LeaderboardEntry]:
|
||||||
|
"""Возвращаем пилотов, отсортированных по опыту с перечислением компетенций."""
|
||||||
|
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.role == UserRole.PILOT)
|
||||||
|
.options(
|
||||||
|
selectinload(User.current_rank),
|
||||||
|
selectinload(User.competencies).selectinload(UserCompetency.competency),
|
||||||
|
selectinload(User.submissions),
|
||||||
|
)
|
||||||
|
.order_by(User.xp.desc(), User.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
leaderboard: list[LeaderboardEntry] = []
|
||||||
|
for user in users:
|
||||||
|
completed = sum(1 for submission in user.submissions if submission.status == SubmissionStatus.APPROVED)
|
||||||
|
competencies = [UserCompetencyRead.model_validate(entry) for entry in user.competencies]
|
||||||
|
leaderboard.append(
|
||||||
|
LeaderboardEntry(
|
||||||
|
user_id=user.id,
|
||||||
|
full_name=user.full_name,
|
||||||
|
rank_title=user.current_rank.title if user.current_rank else None,
|
||||||
|
xp=user.xp,
|
||||||
|
mana=user.mana,
|
||||||
|
completed_missions=completed,
|
||||||
|
competencies=competencies,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return leaderboard
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore")
|
model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore")
|
||||||
|
|
||||||
project_name: str = "Alabuga Gamification API"
|
project_name: str = "Alabuga Gamification API"
|
||||||
|
environment: str = "local"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
secret_key: str = "super-secret-key-change-me"
|
secret_key: str = "super-secret-key-change-me"
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
|
|
@ -31,6 +32,7 @@ class Settings(BaseSettings):
|
||||||
]
|
]
|
||||||
|
|
||||||
sqlite_path: Path = Path("/data/app.db")
|
sqlite_path: Path = Path("/data/app.db")
|
||||||
|
uploads_path: Path = Path("./data/uploads")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
|
|
@ -48,7 +50,11 @@ def get_settings() -> Settings:
|
||||||
if not settings.sqlite_path.is_absolute():
|
if not settings.sqlite_path.is_absolute():
|
||||||
settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve()
|
settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve()
|
||||||
|
|
||||||
|
if not settings.uploads_path.is_absolute():
|
||||||
|
settings.uploads_path = (BASE_DIR / settings.uploads_path).resolve()
|
||||||
|
|
||||||
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.uploads_path.mkdir(parents=True, exist_ok=True)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,94 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import models # noqa: F401 - важно, чтобы Base знала обо всех моделях
|
||||||
from app.api.routes import admin, auth, journal, missions, onboarding, store, users
|
from app.api.routes import admin, auth, journal, missions, onboarding, store, users
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal, engine
|
||||||
from app.models.user import User, UserRole
|
|
||||||
from app.models.rank import Rank
|
from app.models.rank import Rank
|
||||||
# Import all models to ensure they're registered with Base.metadata
|
from app.models.user import User, UserRole
|
||||||
from app import models # This imports all models through the __init__.py
|
|
||||||
|
ALEMBIC_CONFIG = Path(__file__).resolve().parents[1] / "alembic.ini"
|
||||||
|
|
||||||
app = FastAPI(title=settings.project_name)
|
app = FastAPI(title=settings.project_name)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations() -> None:
|
||||||
|
"""Прогоняем миграции Alembic, поддерживая легаси-базы без alembic_version."""
|
||||||
|
|
||||||
|
config = Config(str(ALEMBIC_CONFIG))
|
||||||
|
config.set_main_option("sqlalchemy.url", str(settings.database_url))
|
||||||
|
script = ScriptDirectory.from_config(config)
|
||||||
|
head_revision = script.get_current_head()
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
current_revision: str | None = None
|
||||||
|
if "alembic_version" in tables:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
row = conn.execute(text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone()
|
||||||
|
current_revision = row[0] if row else None
|
||||||
|
|
||||||
|
if "alembic_version" not in tables or current_revision is None:
|
||||||
|
if not tables:
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_columns = set()
|
||||||
|
if "users" in tables:
|
||||||
|
user_columns = {column["name"] for column in inspector.get_columns("users")}
|
||||||
|
|
||||||
|
submission_columns = set()
|
||||||
|
if "mission_submissions" in tables:
|
||||||
|
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
|
||||||
|
|
||||||
|
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 "passport_path" not in submission_columns:
|
||||||
|
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
||||||
|
if "photo_path" not in submission_columns:
|
||||||
|
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN photo_path VARCHAR(512)"))
|
||||||
|
if "resume_path" not in submission_columns:
|
||||||
|
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_path VARCHAR(512)"))
|
||||||
|
if "resume_link" not in submission_columns:
|
||||||
|
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
|
||||||
|
|
||||||
def create_demo_users() -> None:
|
def create_demo_users() -> None:
|
||||||
"""Create demo users if they don't exist."""
|
"""Создаём демо-пользователей, чтобы упростить проверку сценариев."""
|
||||||
|
|
||||||
session: Session = SessionLocal()
|
session: Session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Check if demo users already exist
|
|
||||||
pilot_exists = session.query(User).filter(User.email == "candidate@alabuga.space").first()
|
pilot_exists = session.query(User).filter(User.email == "candidate@alabuga.space").first()
|
||||||
hr_exists = session.query(User).filter(User.email == "hr@alabuga.space").first()
|
hr_exists = session.query(User).filter(User.email == "hr@alabuga.space").first()
|
||||||
|
|
||||||
if pilot_exists and hr_exists:
|
if pilot_exists and hr_exists:
|
||||||
print("✅ Demo users already exist")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get base rank (or None if no ranks exist)
|
|
||||||
base_rank = session.query(Rank).order_by(Rank.required_xp).first()
|
base_rank = session.query(Rank).order_by(Rank.required_xp).first()
|
||||||
|
|
||||||
# Create pilot demo user
|
|
||||||
if not pilot_exists:
|
if not pilot_exists:
|
||||||
pilot = User(
|
pilot = User(
|
||||||
email="candidate@alabuga.space",
|
email="candidate@alabuga.space",
|
||||||
|
|
@ -46,9 +102,7 @@ def create_demo_users() -> None:
|
||||||
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
|
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
|
||||||
)
|
)
|
||||||
session.add(pilot)
|
session.add(pilot)
|
||||||
print("✅ Created demo pilot user: candidate@alabuga.space / orbita123")
|
|
||||||
|
|
||||||
# Create HR demo user
|
|
||||||
if not hr_exists:
|
if not hr_exists:
|
||||||
hr_rank = session.query(Rank).order_by(Rank.required_xp.desc()).first()
|
hr_rank = session.query(Rank).order_by(Rank.required_xp.desc()).first()
|
||||||
hr = User(
|
hr = User(
|
||||||
|
|
@ -61,19 +115,12 @@ def create_demo_users() -> None:
|
||||||
preferred_branch="Куратор миссий",
|
preferred_branch="Куратор миссий",
|
||||||
)
|
)
|
||||||
session.add(hr)
|
session.add(hr)
|
||||||
print("✅ Created demo HR user: hr@alabuga.space / orbita123")
|
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Failed to create demo users: {e}")
|
|
||||||
session.rollback()
|
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.project_name)
|
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.backend_cors_origins,
|
allow_origins=settings.backend_cors_origins,
|
||||||
|
|
@ -93,9 +140,11 @@ app.include_router(admin.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def on_startup() -> None:
|
||||||
"""Create demo users on startup if in debug mode."""
|
"""При запуске обновляем схему БД и подготавливаем демо-данные."""
|
||||||
if settings.debug:
|
|
||||||
|
run_migrations()
|
||||||
|
if settings.environment != "production":
|
||||||
create_demo_users()
|
create_demo_users()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -103,4 +152,4 @@ async def startup_event():
|
||||||
def healthcheck() -> dict[str, str]:
|
def healthcheck() -> dict[str, str]:
|
||||||
"""Простой ответ для Docker healthcheck."""
|
"""Простой ответ для Docker healthcheck."""
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok", "environment": settings.environment}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ class MissionSubmission(Base, TimestampMixin):
|
||||||
)
|
)
|
||||||
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
proof_url: Mapped[Optional[str]] = mapped_column(String(512))
|
proof_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
passport_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||||
|
photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||||
|
resume_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||||
|
resume_link: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||||
awarded_xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
awarded_xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
awarded_mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
awarded_mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, computed_field
|
||||||
|
|
||||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||||
|
|
||||||
|
|
@ -22,6 +22,9 @@ class MissionBase(BaseModel):
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
locked_reasons: list[str] = Field(default_factory=list)
|
locked_reasons: list[str] = Field(default_factory=list)
|
||||||
|
is_completed: bool = False
|
||||||
|
requires_documents: bool = False
|
||||||
|
is_completed: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -92,6 +95,7 @@ class MissionSubmissionCreate(BaseModel):
|
||||||
|
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
proof_url: Optional[str] = None
|
proof_url: Optional[str] = None
|
||||||
|
resume_link: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MissionSubmissionRead(BaseModel):
|
class MissionSubmissionRead(BaseModel):
|
||||||
|
|
@ -102,9 +106,40 @@ class MissionSubmissionRead(BaseModel):
|
||||||
status: SubmissionStatus
|
status: SubmissionStatus
|
||||||
comment: Optional[str]
|
comment: Optional[str]
|
||||||
proof_url: Optional[str]
|
proof_url: Optional[str]
|
||||||
|
resume_link: Optional[str]
|
||||||
awarded_xp: int
|
awarded_xp: int
|
||||||
awarded_mana: int
|
awarded_mana: int
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
passport_path: Optional[str] = Field(default=None, exclude=True)
|
||||||
|
photo_path: Optional[str] = Field(default=None, exclude=True)
|
||||||
|
resume_path: Optional[str] = Field(default=None, exclude=True)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
|
@property
|
||||||
|
def passport_url(self) -> Optional[str]:
|
||||||
|
"""Ссылка для скачивания файла паспорта."""
|
||||||
|
|
||||||
|
if self.passport_path:
|
||||||
|
return f"/api/missions/submissions/{self.id}/files/passport"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
|
@property
|
||||||
|
def photo_url(self) -> Optional[str]:
|
||||||
|
"""Ссылка на загруженную фотографию."""
|
||||||
|
|
||||||
|
if self.photo_path:
|
||||||
|
return f"/api/missions/submissions/{self.id}/files/photo"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
|
@property
|
||||||
|
def resume_url(self) -> Optional[str]:
|
||||||
|
"""Ссылка на загруженный файл резюме."""
|
||||||
|
|
||||||
|
if self.resume_path:
|
||||||
|
return f"/api/missions/submissions/{self.id}/files/resume"
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,21 @@ class UserProfile(UserRead):
|
||||||
artifacts: list[UserArtifactRead]
|
artifacts: list[UserArtifactRead]
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardEntry(BaseModel):
|
||||||
|
"""Строка лидерборда по опыту и компетенциям."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
full_name: str
|
||||||
|
rank_title: Optional[str]
|
||||||
|
xp: int
|
||||||
|
mana: int
|
||||||
|
completed_missions: int
|
||||||
|
competencies: list[UserCompetencyRead]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
"""Создание пользователя (используется для сидов)."""
|
"""Создание пользователя (используется для сидов)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -10,10 +12,23 @@ from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User, UserArtifact, UserCompetency
|
from app.models.user import User, UserArtifact, UserCompetency
|
||||||
from app.services.journal import log_event
|
from app.services.journal import log_event
|
||||||
from app.services.rank import apply_rank_upgrade
|
from app.services.rank import apply_rank_upgrade
|
||||||
|
from app.services.storage import delete_submission_document
|
||||||
|
|
||||||
|
|
||||||
|
UNSET: Any = object()
|
||||||
|
|
||||||
|
|
||||||
def submit_mission(
|
def submit_mission(
|
||||||
*, db: Session, user: User, mission: Mission, comment: str | None, proof_url: str | None
|
*,
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
mission: Mission,
|
||||||
|
comment: str | None,
|
||||||
|
proof_url: str | None,
|
||||||
|
passport_path: Any = UNSET,
|
||||||
|
photo_path: Any = UNSET,
|
||||||
|
resume_path: Any = UNSET,
|
||||||
|
resume_link: Any = UNSET,
|
||||||
) -> MissionSubmission:
|
) -> MissionSubmission:
|
||||||
"""Создаём или обновляем отправку."""
|
"""Создаём или обновляем отправку."""
|
||||||
|
|
||||||
|
|
@ -30,6 +45,25 @@ def submit_mission(
|
||||||
|
|
||||||
submission.comment = comment
|
submission.comment = comment
|
||||||
submission.proof_url = proof_url
|
submission.proof_url = proof_url
|
||||||
|
|
||||||
|
if passport_path is not UNSET:
|
||||||
|
if isinstance(passport_path, str) and submission.passport_path and submission.passport_path != passport_path:
|
||||||
|
delete_submission_document(submission.passport_path)
|
||||||
|
submission.passport_path = passport_path if isinstance(passport_path, str) else None
|
||||||
|
|
||||||
|
if photo_path is not UNSET:
|
||||||
|
if isinstance(photo_path, str) and submission.photo_path and submission.photo_path != photo_path:
|
||||||
|
delete_submission_document(submission.photo_path)
|
||||||
|
submission.photo_path = photo_path if isinstance(photo_path, str) else None
|
||||||
|
|
||||||
|
if resume_path is not UNSET:
|
||||||
|
if isinstance(resume_path, str) and submission.resume_path and submission.resume_path != resume_path:
|
||||||
|
delete_submission_document(submission.resume_path)
|
||||||
|
submission.resume_path = resume_path if isinstance(resume_path, str) else None
|
||||||
|
|
||||||
|
if resume_link is not UNSET:
|
||||||
|
submission.resume_link = resume_link if isinstance(resume_link, str) else None
|
||||||
|
|
||||||
submission.status = SubmissionStatus.PENDING
|
submission.status = SubmissionStatus.PENDING
|
||||||
|
|
||||||
db.add(submission)
|
db.add(submission)
|
||||||
|
|
|
||||||
59
backend/app/services/storage.py
Normal file
59
backend/app/services/storage.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Утилиты для сохранения и удаления загруженных файлов."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_within_base(path: Path) -> None:
|
||||||
|
"""Проверяем, что путь находится внутри каталога загрузок."""
|
||||||
|
|
||||||
|
base = settings.uploads_path.resolve()
|
||||||
|
resolved = path.resolve()
|
||||||
|
if not resolved.is_relative_to(base):
|
||||||
|
raise ValueError("Путь выходит за пределы каталога uploads")
|
||||||
|
|
||||||
|
|
||||||
|
def save_submission_document(
|
||||||
|
*, upload: UploadFile, user_id: int, mission_id: int, kind: str
|
||||||
|
) -> str:
|
||||||
|
"""Сохраняем вложение пользователя и возвращаем относительный путь."""
|
||||||
|
|
||||||
|
extension = Path(upload.filename or "").suffix or ".bin"
|
||||||
|
sanitized_extension = extension[:16]
|
||||||
|
|
||||||
|
target_dir = settings.uploads_path / f"user_{user_id}" / f"mission_{mission_id}"
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
target_path = target_dir / f"{kind}{sanitized_extension}"
|
||||||
|
with target_path.open("wb") as buffer:
|
||||||
|
upload.file.seek(0)
|
||||||
|
shutil.copyfileobj(upload.file, buffer)
|
||||||
|
upload.file.seek(0)
|
||||||
|
|
||||||
|
relative_path = target_path.relative_to(settings.uploads_path).as_posix()
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def delete_submission_document(relative_path: str | None) -> None:
|
||||||
|
"""Удаляем файл вложения, если он существует."""
|
||||||
|
|
||||||
|
if not relative_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = settings.uploads_path / relative_path
|
||||||
|
try:
|
||||||
|
_ensure_within_base(file_path)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
parent = file_path.parent
|
||||||
|
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||||
|
parent.rmdir()
|
||||||
|
|
@ -12,6 +12,10 @@ interface Submission {
|
||||||
status: string;
|
status: string;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
proof_url: string | null;
|
proof_url: string | null;
|
||||||
|
resume_link: string | null;
|
||||||
|
passport_url: string | null;
|
||||||
|
photo_url: string | null;
|
||||||
|
resume_url: string | null;
|
||||||
awarded_xp: number;
|
awarded_xp: number;
|
||||||
awarded_mana: number;
|
awarded_mana: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import '../styles/globals.css';
|
||||||
import { getSession } from '../lib/auth/session';
|
import { getSession } from '../lib/auth/session';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Alabuga Mission Control',
|
title: 'Автостопом по Алабуге',
|
||||||
description: 'Космический модуль геймификации для пилотов Алабуги'
|
description: 'Галактогид по миссиям и рангам Алабуги в духе «Автостопом по Галактике»'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -24,6 +24,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
} else if (isHr && !viewingAsPilot) {
|
} else if (isHr && !viewingAsPilot) {
|
||||||
links = [
|
links = [
|
||||||
{ href: '/admin', label: 'HR панель' },
|
{ href: '/admin', label: 'HR панель' },
|
||||||
|
{ href: '/leaderboard', label: 'Лидерборд' },
|
||||||
{ href: '/admin/view-as', label: 'Просмотр от лица пилота' },
|
{ href: '/admin/view-as', label: 'Просмотр от лица пилота' },
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -33,6 +34,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
{ href: '/missions', label: 'Миссии' },
|
{ href: '/missions', label: 'Миссии' },
|
||||||
{ href: '/journal', label: 'Журнал' },
|
{ href: '/journal', label: 'Журнал' },
|
||||||
{ href: '/store', label: 'Магазин' },
|
{ href: '/store', label: 'Магазин' },
|
||||||
|
{ href: '/leaderboard', label: 'Лидерборд' },
|
||||||
];
|
];
|
||||||
if (isHr) {
|
if (isHr) {
|
||||||
// Дополнительный пункт для HR: быстрый выход из режима просмотра.
|
// Дополнительный пункт для HR: быстрый выход из режима просмотра.
|
||||||
|
|
@ -57,9 +59,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
|
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Автостопом по Алабуге</h1>
|
||||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
||||||
Путь пилота от искателя до командира космической эскадры
|
Всегда держите полотенце под рукой и следуйте подсказкам бортового гидронавигатора
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||||
|
|
|
||||||
105
frontend/src/app/leaderboard/page.tsx
Normal file
105
frontend/src/app/leaderboard/page.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { requireSession } from '../../lib/auth/session';
|
||||||
|
|
||||||
|
interface CompetencyEntry {
|
||||||
|
competency: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
user_id: number;
|
||||||
|
full_name: string;
|
||||||
|
rank_title: string | null;
|
||||||
|
xp: number;
|
||||||
|
mana: number;
|
||||||
|
completed_missions: number;
|
||||||
|
competencies: CompetencyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLeaderboard(token: string) {
|
||||||
|
return apiFetch<LeaderboardRow[]>('/api/leaderboard', { authToken: token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompetencyChips({ competencies }: { competencies: CompetencyEntry[] }) {
|
||||||
|
if (!competencies.length) {
|
||||||
|
return <span style={{ color: 'var(--text-muted)' }}>Нет данных</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{competencies.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.competency.id}
|
||||||
|
style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(108, 92, 231, 0.18)',
|
||||||
|
border: '1px solid rgba(108,92,231,0.35)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.competency.name} · {entry.level}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LeaderboardPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const rows = await fetchLeaderboard(session.token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>Лидерборд пилотов</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', maxWidth: '720px' }}>
|
||||||
|
Здесь собраны все пилоты программы, отсортированные по опыту. HR может использовать таблицу как быстрый срез
|
||||||
|
прогресса и компетенций, а кандидаты — видеть своё место в космофлоте.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '1.5rem', overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '720px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>#</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Пилот</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Ранг</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Опыт</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Мана</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Завершено миссий</th>
|
||||||
|
<th style={{ padding: '0.75rem 1rem' }}>Компетенции</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={row.user_id} style={{ borderTop: '1px solid rgba(162, 155, 254, 0.15)' }}>
|
||||||
|
<td style={{ padding: '0.75rem 1rem', width: '48px' }}>{index + 1}</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<strong>{row.full_name}</strong>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>{row.rank_title ?? '—'}</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>{row.xp}</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>{row.mana}</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>{row.completed_missions}</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<CompetencyChips competencies={row.competencies} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} style={{ padding: '1rem 1rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
Пока нет данных — завершите первую миссию, чтобы попасть в лидерборд.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export default async function LoginPage({
|
||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
<SpaceBackground />
|
<SpaceBackground />
|
||||||
<form className={styles.form} action={authenticate}>
|
<form className={styles.form} action={authenticate}>
|
||||||
<h1>Вход в Mission Control</h1>
|
<h1>Вход в «Автостопом по Алабуге»</h1>
|
||||||
{/* Подсказка для демо-режима, чтобы не искать логин/пароль в README. */}
|
{/* Подсказка для демо-режима, чтобы не искать логин/пароль в README. */}
|
||||||
<p className={styles.hint}>
|
<p className={styles.hint}>
|
||||||
Используйте демо-учётные записи: <strong>candidate@alabuga.space / orbita123</strong> или
|
Используйте демо-учётные записи: <strong>candidate@alabuga.space / orbita123</strong> или
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,27 @@ interface MissionDetail {
|
||||||
}>;
|
}>;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
|
is_completed: boolean;
|
||||||
|
requires_documents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMission(id: number, token: string) {
|
async function fetchMission(id: number, token: string) {
|
||||||
const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token });
|
const [mission, submission] = await Promise.all([
|
||||||
return { mission };
|
apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token }),
|
||||||
|
apiFetch<MissionSubmission | null>(`/api/missions/${id}/submission`, { authToken: token })
|
||||||
|
]);
|
||||||
|
return { mission, submission };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissionSubmission {
|
||||||
|
id: number;
|
||||||
|
comment: string | null;
|
||||||
|
proof_url: string | null;
|
||||||
|
resume_link: string | null;
|
||||||
|
passport_url: string | null;
|
||||||
|
photo_url: string | null;
|
||||||
|
resume_url: string | null;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MissionPageProps {
|
interface MissionPageProps {
|
||||||
|
|
@ -34,7 +50,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
const missionId = Number(params.id);
|
const missionId = Number(params.id);
|
||||||
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const { mission } = await fetchMission(missionId, session.token);
|
const { mission, submission } = await fetchMission(missionId, session.token);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -44,7 +60,22 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>
|
||||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||||
</p>
|
</p>
|
||||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
{mission.is_completed && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
border: '1px solid rgba(85, 239, 196, 0.35)',
|
||||||
|
background: 'rgba(85, 239, 196, 0.12)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Миссия завершена</strong>
|
||||||
|
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
||||||
|
HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!mission.is_available && !mission.is_completed && mission.locked_reasons.length > 0 && (
|
||||||
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
||||||
<strong>Миссия заблокирована</strong>
|
<strong>Миссия заблокирована</strong>
|
||||||
<ul style={{ marginTop: '0.5rem' }}>
|
<ul style={{ marginTop: '0.5rem' }}>
|
||||||
|
|
@ -65,7 +96,14 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<MissionSubmissionForm missionId={mission.id} token={session.token} locked={!mission.is_available} />
|
<MissionSubmissionForm
|
||||||
|
missionId={mission.id}
|
||||||
|
token={session.token}
|
||||||
|
locked={!mission.is_available && !mission.is_completed}
|
||||||
|
completed={mission.is_completed}
|
||||||
|
requiresDocuments={mission.requires_documents}
|
||||||
|
submission={submission ?? undefined}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export interface MissionSummary {
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
|
is_completed: boolean;
|
||||||
|
requires_documents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -28,29 +30,49 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{missions.map((mission) => (
|
{missions.map((mission) => {
|
||||||
<Card key={mission.id}>
|
const completed = mission.is_completed;
|
||||||
|
const locked = !mission.is_available && !completed;
|
||||||
|
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||||
|
const linkDisabled = locked;
|
||||||
|
const actionLabel = completed
|
||||||
|
? 'Миссия выполнена'
|
||||||
|
: mission.is_available
|
||||||
|
? 'Открыть брифинг'
|
||||||
|
: 'Заблокировано';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span className="badge">{mission.difficulty}</span>
|
<span className="badge">{mission.difficulty}</span>
|
||||||
|
{completed && <span style={{ color: '#55efc4', fontSize: '0.85rem' }}>✓ завершено</span>}
|
||||||
|
</div>
|
||||||
|
{mission.requires_documents && !completed && (
|
||||||
|
<p style={{ marginTop: '0.25rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||||
|
🗂 Требуется загрузка документов
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||||
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
{locked && mission.locked_reasons.length > 0 && (
|
||||||
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
className={mission.is_available ? 'primary' : 'secondary'}
|
className={primaryClass}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
pointerEvents: mission.is_available ? 'auto' : 'none',
|
pointerEvents: linkDisabled ? 'none' : 'auto',
|
||||||
opacity: mission.is_available ? 1 : 0.5
|
opacity: linkDisabled ? 0.5 : 1
|
||||||
}}
|
}}
|
||||||
href={mission.is_available ? `/missions/${mission.id}` : '#'}
|
href={linkDisabled ? '#' : `/missions/${mission.id}`}
|
||||||
>
|
>
|
||||||
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
|
{actionLabel}
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,46 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
type ExistingSubmission = {
|
||||||
|
id: number;
|
||||||
|
comment: string | null;
|
||||||
|
proof_url: string | null;
|
||||||
|
resume_link: string | null;
|
||||||
|
passport_url: string | null;
|
||||||
|
photo_url: string | null;
|
||||||
|
resume_url: string | null;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
};
|
||||||
|
|
||||||
interface MissionSubmissionFormProps {
|
interface MissionSubmissionFormProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
submission?: ExistingSubmission | null;
|
||||||
|
completed?: boolean;
|
||||||
|
requiresDocuments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
|
export function MissionSubmissionForm({ missionId, token, locked = false, submission, completed = false, requiresDocuments = false }: MissionSubmissionFormProps) {
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState(submission?.comment ?? '');
|
||||||
const [proofUrl, setProofUrl] = useState('');
|
const [proofUrl, setProofUrl] = useState(submission?.proof_url ?? '');
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [resumeLink, setResumeLink] = useState(submission?.resume_link ?? '');
|
||||||
|
const initialStatus = submission?.status === 'approved' || completed
|
||||||
|
? 'Миссия уже зачтена. Вы можете просматривать прикреплённые документы.'
|
||||||
|
: null;
|
||||||
|
const [status, setStatus] = useState<string | null>(initialStatus);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentSubmission, setCurrentSubmission] = useState<ExistingSubmission | null>(submission ?? null);
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
const passportInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const resumeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isApproved = completed || currentSubmission?.status === 'approved';
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatus('Не удалось получить токен демо-пользователя.');
|
setStatus('Не удалось получить токен демо-пользователя.');
|
||||||
|
|
@ -27,20 +52,76 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isApproved) {
|
||||||
|
setStatus('Миссия уже зачтена. Дополнительная отправка не требуется.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportFile = passportInputRef.current?.files?.[0];
|
||||||
|
const photoFile = photoInputRef.current?.files?.[0];
|
||||||
|
const resumeFile = resumeInputRef.current?.files?.[0];
|
||||||
|
const resumeTrimmed = resumeLink.trim();
|
||||||
|
|
||||||
|
if (requiresDocuments) {
|
||||||
|
if (!currentSubmission?.passport_url && !passportFile) {
|
||||||
|
setStatus('Добавьте скан паспорта кандидата.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentSubmission?.photo_url && !photoFile) {
|
||||||
|
setStatus('Приложите фотографию кандидата.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasResumeAttachment = Boolean(currentSubmission?.resume_url || currentSubmission?.resume_link);
|
||||||
|
if (!hasResumeAttachment && !resumeFile && !resumeTrimmed) {
|
||||||
|
setStatus('Укажите ссылку на резюме или загрузите файл.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('comment', comment.trim());
|
||||||
|
formData.append('proof_url', proofUrl.trim());
|
||||||
|
formData.append('resume_link', resumeTrimmed);
|
||||||
|
|
||||||
|
if (passportFile) {
|
||||||
|
formData.append('passport', passportFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photoFile) {
|
||||||
|
formData.append('photo', photoFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resumeFile) {
|
||||||
|
formData.append('resume_file', resumeFile);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
await apiFetch(`/api/missions/${missionId}/submit`, {
|
const updated = await apiFetch<ExistingSubmission>(`/api/missions/${missionId}/submit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ comment, proof_url: proofUrl }),
|
body: formData,
|
||||||
authToken: token
|
authToken: token
|
||||||
});
|
});
|
||||||
setStatus('Отчёт отправлен! HR проверит миссию в панели модерации.');
|
|
||||||
setComment('');
|
setCurrentSubmission(updated);
|
||||||
setProofUrl('');
|
setComment(updated.comment ?? '');
|
||||||
|
setProofUrl(updated.proof_url ?? '');
|
||||||
|
setResumeLink(updated.resume_link ?? '');
|
||||||
|
|
||||||
|
if (passportInputRef.current) passportInputRef.current.value = '';
|
||||||
|
if (photoInputRef.current) photoInputRef.current.value = '';
|
||||||
|
if (resumeInputRef.current) resumeInputRef.current.value = '';
|
||||||
|
|
||||||
|
const nextStatus = updated.status === 'approved'
|
||||||
|
? 'Миссия уже зачтена. Вы можете просматривать прикреплённые документы.'
|
||||||
|
: 'Отчёт и документы отправлены! HR проверит миссию в панели модерации.';
|
||||||
|
setStatus(nextStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setStatus(error.message);
|
setStatus(error.message);
|
||||||
|
} else {
|
||||||
|
setStatus('Не удалось отправить данные. Попробуйте позже.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -48,9 +129,14 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
|
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }} encType="multipart/form-data">
|
||||||
<h3>Отправить отчёт</h3>
|
<h3>Отправить отчёт</h3>
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
{requiresDocuments && (
|
||||||
|
<p style={{ marginTop: '0.25rem', color: 'var(--text-muted)' }}>
|
||||||
|
Для этой миссии необходимо приложить паспорт, фотографию и резюме. Файлы попадут напрямую в панель HR.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
Комментарий
|
Комментарий
|
||||||
<textarea
|
<textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
|
|
@ -58,24 +144,103 @@ export function MissionSubmissionForm({ missionId, token, locked = false }: Miss
|
||||||
rows={4}
|
rows={4}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="Опишите, что сделали."
|
placeholder="Опишите, что сделали."
|
||||||
disabled={locked}
|
disabled={locked || isApproved}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
Ссылка на доказательство
|
Ссылка на доказательство (опционально)
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={proofUrl}
|
value={proofUrl}
|
||||||
onChange={(event) => setProofUrl(event.target.value)}
|
onChange={(event) => setProofUrl(event.target.value)}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
disabled={locked}
|
disabled={locked || isApproved}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary" type="submit" disabled={loading || locked}>
|
|
||||||
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
|
<fieldset style={{ border: '1px solid rgba(162, 155, 254, 0.35)', borderRadius: '16px', padding: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<legend style={{ padding: '0 0.5rem' }}>Документы</legend>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
|
Паспорт (PDF или изображение)
|
||||||
|
<input
|
||||||
|
ref={passportInputRef}
|
||||||
|
type="file"
|
||||||
|
name="passport"
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
disabled={locked || isApproved}
|
||||||
|
required={requiresDocuments && !currentSubmission?.passport_url}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
|
Фотография кандидата
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
name="photo"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
disabled={locked || isApproved}
|
||||||
|
required={requiresDocuments && !currentSubmission?.photo_url}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||||
|
Резюме (можно приложить файл и/или ссылку)
|
||||||
|
<input
|
||||||
|
ref={resumeInputRef}
|
||||||
|
type="file"
|
||||||
|
name="resume_file"
|
||||||
|
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
disabled={locked || isApproved}
|
||||||
|
required={requiresDocuments && !currentSubmission?.resume_url && !currentSubmission?.resume_link}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={resumeLink}
|
||||||
|
onChange={(event) => setResumeLink(event.target.value)}
|
||||||
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
|
placeholder="https://disk.yandex.ru/..."
|
||||||
|
disabled={locked || isApproved}
|
||||||
|
required={requiresDocuments && !currentSubmission?.resume_url && !currentSubmission?.resume_link}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{currentSubmission && (
|
||||||
|
<div style={{ marginTop: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
<strong>Загружено ранее:</strong>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '0.5rem 0 0', padding: 0 }}>
|
||||||
|
<li>
|
||||||
|
Паспорт: {currentSubmission.passport_url ? <a href={currentSubmission.passport_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Фото: {currentSubmission.photo_url ? <a href={currentSubmission.photo_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Резюме (файл): {currentSubmission.resume_url ? <a href={currentSubmission.resume_url} target="_blank" rel="noreferrer">скачать</a> : 'файл не прикреплён'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Резюме (ссылка): {currentSubmission.resume_link ? <a href={currentSubmission.resume_link} target="_blank" rel="noreferrer">открыть</a> : 'ссылка не указана'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button className="primary" type="submit" disabled={loading || locked || isApproved}>
|
||||||
|
{locked ? 'Недоступно' : isApproved ? 'Миссия выполнена' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||||
</button>
|
</button>
|
||||||
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
{status && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
color: status.includes('зачтена') ? 'var(--accent-light)' : status.startsWith('Отчёт') ? 'var(--accent-light)' : 'var(--error)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ type Submission = {
|
||||||
status: string;
|
status: string;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
proof_url: string | null;
|
proof_url: string | null;
|
||||||
|
resume_link: string | null;
|
||||||
|
passport_url: string | null;
|
||||||
|
photo_url: string | null;
|
||||||
|
resume_url: string | null;
|
||||||
awarded_xp: number;
|
awarded_xp: number;
|
||||||
awarded_mana: number;
|
awarded_mana: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -55,6 +59,23 @@ export function AdminSubmissionCard({ submission, token }: Props) {
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<strong>Документы кандидата:</strong>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||||
|
<li>
|
||||||
|
Паспорт: {submission.passport_url ? <a href={submission.passport_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Фото: {submission.photo_url ? <a href={submission.photo_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Резюме (файл): {submission.resume_url ? <a href={submission.resume_url} target="_blank" rel="noreferrer">скачать</a> : 'нет файла'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Резюме (ссылка): {submission.resume_link ? <a href={submission.resume_link} target="_blank" rel="noreferrer">открыть</a> : 'не указана'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<small style={{ color: 'var(--text-muted)' }}>
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||||
</small>
|
</small>
|
||||||
|
|
@ -79,4 +100,3 @@ export function AdminSubmissionCard({ submission, token }: Props) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ export interface RequestOptions extends RequestInit {
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||||
|
|
||||||
|
if (!isFormData && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
if (options.authToken) {
|
if (options.authToken) {
|
||||||
headers.set('Authorization', `Bearer ${options.authToken}`);
|
headers.set('Authorization', `Bearer ${options.authToken}`);
|
||||||
}
|
}
|
||||||
|
|
@ -20,8 +24,25 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
const detail = data?.detail ?? data?.message ?? data?.error;
|
||||||
|
if (detail) {
|
||||||
|
throw new Error(String(detail));
|
||||||
|
}
|
||||||
|
throw new Error(`Запрос завершился ошибкой (${response.status}).`);
|
||||||
|
} catch (parseError) {
|
||||||
|
if (parseError instanceof Error) {
|
||||||
|
throw parseError;
|
||||||
|
}
|
||||||
|
throw new Error(`Запрос завершился ошибкой (${response.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(`API error ${response.status}: ${text}`);
|
throw new Error(text || `Запрос завершился ошибкой (${response.status}).`);
|
||||||
}
|
}
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
|
|
|
||||||
53
scripts/reset_demo_data.py
Normal file
53
scripts/reset_demo_data.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Удаление тестовых данных и вложений."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.append(str(ROOT / 'backend'))
|
||||||
|
|
||||||
|
from app.main import run_migrations
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.mission import MissionSubmission
|
||||||
|
from app.models.store import Order
|
||||||
|
from app.models.journal import JournalEntry
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Очищаем пользовательскую активность и загруженные документы."""
|
||||||
|
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
session: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
session.query(MissionSubmission).delete()
|
||||||
|
session.query(Order).delete()
|
||||||
|
session.query(JournalEntry).delete()
|
||||||
|
|
||||||
|
# Сбрасываем опыт и ману демо-пользователям, чтобы они начинали «с нуля».
|
||||||
|
for user in session.query(User).all():
|
||||||
|
user.xp = 0
|
||||||
|
user.mana = 0
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
uploads_dir: Path = settings.uploads_path
|
||||||
|
if uploads_dir.exists():
|
||||||
|
shutil.rmtree(uploads_dir)
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("Demo data cleared. Mission submissions, journal entries, orders и загруженные файлы удалены.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reset()
|
||||||
Loading…
Reference in New Issue
Block a user