CORS is not working

This commit is contained in:
danilgryaznev 2025-09-27 12:11:19 +03:00
parent 8345614c8d
commit 6d84504569
10 changed files with 498 additions and 89 deletions

View File

@ -9,8 +9,10 @@ from app.api.deps import get_current_user
from app.db.session import get_db
from app.models.rank import Rank
from app.models.user import User
from app.schemas.progress import ProgressSnapshot
from app.schemas.rank import RankBase
from app.schemas.user import UserProfile
from app.services.rank import build_progress_snapshot
router = APIRouter(prefix="/api", tags=["profile"])
@ -35,3 +37,16 @@ def list_ranks(
ranks = db.query(Rank).order_by(Rank.required_xp).all()
return [RankBase.model_validate(rank) for rank in ranks]
@router.get("/progress", response_model=ProgressSnapshot, summary="Прогресс до следующего ранга")
def get_progress(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> ProgressSnapshot:
"""Возвращаем агрегированную информацию о выполненных условиях следующего ранга."""
db.refresh(current_user)
_ = current_user.submissions
_ = current_user.competencies
snapshot = build_progress_snapshot(current_user, db)
return snapshot

View File

@ -23,7 +23,11 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 12
backend_cors_origins: List[str] = ["http://localhost:3000", "http://frontend:3000"]
backend_cors_origins: List[str] = [
"http://localhost:3000",
"http://frontend:3000",
"http://0.0.0.0:3000",
]
sqlite_path: Path = Path("/data/app.db")

View File

@ -0,0 +1,59 @@
"""Pydantic-схемы для отображения прогресса по рангу."""
from __future__ import annotations
from pydantic import BaseModel, Field
class ProgressRank(BaseModel):
"""Краткое описание ранга, пригодное для отображения на дашборде."""
id: int
title: str
description: str
required_xp: int
class Config:
from_attributes = True
class ProgressXPMetrics(BaseModel):
"""Статистика по опыту: от базового значения до целевого порога."""
baseline: int = Field(description="Количество XP, с которого начинается текущий этап прогресса")
current: int = Field(description="Текущее значение XP пользователя")
target: int = Field(description="Порог XP, необходимый для следующего ранга")
remaining: int = Field(description="Сколько XP осталось набрать")
progress_percent: float = Field(description="Прогресс на отрезке от baseline до target в долях единицы")
class ProgressMissionRequirement(BaseModel):
"""Статус обязательной миссии для следующего ранга."""
mission_id: int
mission_title: str
is_completed: bool
class ProgressCompetencyRequirement(BaseModel):
"""Статус требования по компетенции."""
competency_id: int
competency_name: str
required_level: int
current_level: int
is_met: bool
class ProgressSnapshot(BaseModel):
"""Итоговая структура прогресса: XP, обязательные миссии и компетенции."""
current_rank: ProgressRank | None
next_rank: ProgressRank | None
xp: ProgressXPMetrics
mission_requirements: list[ProgressMissionRequirement]
competency_requirements: list[ProgressCompetencyRequirement]
completed_missions: int
total_missions: int
met_competencies: int
total_competencies: int

View File

@ -2,13 +2,20 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app.models.journal import JournalEventType
from app.models.mission import SubmissionStatus
from app.models.rank import Rank
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.user import User
from app.services.journal import log_event
from app.schemas.progress import (
ProgressCompetencyRequirement,
ProgressMissionRequirement,
ProgressRank,
ProgressSnapshot,
ProgressXPMetrics,
)
def _eligible_rank(user: User, db: Session) -> Rank | None:
@ -60,3 +67,106 @@ def apply_rank_upgrade(user: User, db: Session) -> Rank | None:
payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id},
)
return new_rank
def build_progress_snapshot(user: User, db: Session) -> ProgressSnapshot:
"""Собираем агрегированное представление прогресса пользователя."""
ranks = (
db.query(Rank)
.options(
selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission),
selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency),
)
.order_by(Rank.required_xp)
.all()
)
current_rank_obj = next((rank for rank in ranks if rank.id == user.current_rank_id), None)
approved_missions = {
submission.mission_id
for submission in user.submissions
if submission.status == SubmissionStatus.APPROVED
}
competency_levels = {item.competency_id: item.level for item in user.competencies}
highest_met_rank: Rank | None = None
next_rank_obj: Rank | None = None
for rank in ranks:
missions_ok = all(req.mission_id in approved_missions for req in rank.mission_requirements)
competencies_ok = all(
competency_levels.get(req.competency_id, 0) >= req.required_level
for req in rank.competency_requirements
)
xp_ok = user.xp >= rank.required_xp
if xp_ok and missions_ok and competencies_ok:
highest_met_rank = rank
continue
if next_rank_obj is None:
next_rank_obj = rank
break
baseline_rank = current_rank_obj or highest_met_rank
baseline_xp = baseline_rank.required_xp if baseline_rank else 0
if next_rank_obj:
target_xp = next_rank_obj.required_xp
remaining_xp = max(0, target_xp - user.xp)
denominator = max(target_xp - baseline_xp, 1)
progress_percent = min(1.0, max(0.0, (user.xp - baseline_xp) / denominator))
else:
target_xp = max(user.xp, baseline_xp)
remaining_xp = 0
progress_percent = 1.0
mission_requirements: list[ProgressMissionRequirement] = []
if next_rank_obj:
for requirement in next_rank_obj.mission_requirements:
title = requirement.mission.title if requirement.mission else f"Миссия #{requirement.mission_id}"
mission_requirements.append(
ProgressMissionRequirement(
mission_id=requirement.mission_id,
mission_title=title,
is_completed=requirement.mission_id in approved_missions,
)
)
competency_requirements: list[ProgressCompetencyRequirement] = []
if next_rank_obj:
for requirement in next_rank_obj.competency_requirements:
current_level = competency_levels.get(requirement.competency_id, 0)
competency_requirements.append(
ProgressCompetencyRequirement(
competency_id=requirement.competency_id,
competency_name=requirement.competency.name if requirement.competency else "",
required_level=requirement.required_level,
current_level=current_level,
is_met=current_level >= requirement.required_level,
)
)
completed_missions = sum(1 for item in mission_requirements if item.is_completed)
met_competencies = sum(1 for item in competency_requirements if item.is_met)
xp_metrics = ProgressXPMetrics(
baseline=baseline_xp,
current=user.xp,
target=target_xp,
remaining=remaining_xp,
progress_percent=round(progress_percent, 4),
)
return ProgressSnapshot(
current_rank=ProgressRank.model_validate(current_rank_obj) if current_rank_obj else None,
next_rank=ProgressRank.model_validate(next_rank_obj) if next_rank_obj else None,
xp=xp_metrics,
mission_requirements=mission_requirements,
competency_requirements=competency_requirements,
completed_missions=completed_missions,
total_missions=len(mission_requirements),
met_competencies=met_competencies,
total_competencies=len(competency_requirements),
)

View File

@ -3,9 +3,9 @@
from __future__ import annotations
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.rank import Rank, RankMissionRequirement
from app.models.user import User, UserRole
from app.services.rank import apply_rank_upgrade
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
from app.services.rank import apply_rank_upgrade, build_progress_snapshot
def test_rank_upgrade_after_requirements(db_session):
@ -46,3 +46,74 @@ def test_rank_upgrade_after_requirements(db_session):
assert new_rank is not None
assert user.current_rank_id == pilot.id
def test_progress_snapshot_highlights_remaining_conditions(db_session):
"""Снэпшот прогресса показывает, что ещё нужно закрыть."""
novice = Rank(title="Новичок", description="Старт", required_xp=0)
pilot = Rank(title="Пилот", description="Готов к полёту", required_xp=100)
mission = Mission(title="Тренировка", description="Базовое обучение", xp_reward=40, mana_reward=0)
competency = Competency(
name="Коммуникация",
description="Чёткая передача информации",
category=CompetencyCategory.COMMUNICATION,
)
db_session.add_all([novice, pilot, mission, competency])
db_session.flush()
db_session.add_all(
[
RankMissionRequirement(rank_id=pilot.id, mission_id=mission.id),
RankCompetencyRequirement(
rank_id=pilot.id,
competency_id=competency.id,
required_level=1,
),
]
)
user = User(
email="progress@alabuga.space",
full_name="Прогресс Тест",
role=UserRole.PILOT,
hashed_password="hash",
xp=60,
current_rank_id=novice.id,
)
db_session.add(user)
db_session.flush()
db_session.add(UserCompetency(user_id=user.id, competency_id=competency.id, level=0))
db_session.commit()
db_session.refresh(user)
snapshot = build_progress_snapshot(user, db_session)
assert snapshot.next_rank and snapshot.next_rank.title == "Пилот"
assert snapshot.xp.remaining == 40
assert snapshot.completed_missions == 0
assert snapshot.total_missions == 1
assert snapshot.met_competencies == 0
assert snapshot.total_competencies == 1
submission = MissionSubmission(
user_id=user.id,
mission_id=mission.id,
status=SubmissionStatus.APPROVED,
awarded_xp=mission.xp_reward,
)
user.xp = 120
user_competency = user.competencies[0]
user_competency.level = 2
db_session.add_all([submission, user, user_competency])
db_session.commit()
db_session.refresh(user)
snapshot_after = build_progress_snapshot(user, db_session)
assert snapshot_after.completed_missions == snapshot_after.total_missions
assert snapshot_after.met_competencies == snapshot_after.total_competencies
assert snapshot_after.xp.remaining == 0

View File

@ -23,7 +23,7 @@ interface MissionSummary {
description: string;
xp_reward: number;
mana_reward: number;
difficulty: string;
difficulty: 'easy' | 'medium' | 'hard';
is_active: boolean;
}

View File

@ -6,7 +6,6 @@ interface ProfileResponse {
full_name: string;
xp: number;
mana: number;
current_rank_id: number | null;
competencies: Array<{
competency: { id: number; name: string };
level: number;
@ -18,54 +17,63 @@ interface ProfileResponse {
}>;
}
interface RankResponse {
id: number;
title: string;
description: string;
required_xp: number;
interface ProgressResponse {
current_rank: { id: number; title: string; description: string; required_xp: number } | null;
next_rank: { id: number; title: string; description: string; required_xp: number } | null;
xp: {
baseline: number;
current: number;
target: number;
remaining: number;
progress_percent: number;
};
mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>;
competency_requirements: Array<{
competency_id: number;
competency_name: string;
required_level: number;
current_level: number;
is_met: boolean;
}>;
completed_missions: number;
total_missions: number;
met_competencies: number;
total_competencies: number;
}
async function fetchProfile() {
const token = await getDemoToken();
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp);
const currentIndex = Math.max(
orderedRanks.findIndex((rank) => rank.id === profile.current_rank_id),
0
);
const currentRank = orderedRanks[currentIndex] ?? null;
const nextRank = orderedRanks[currentIndex + 1] ?? null;
const baselineXp = currentRank ? currentRank.required_xp : 0;
const progress = Math.max(profile.xp - baselineXp, 0);
const target = nextRank ? nextRank.required_xp - baselineXp : 0;
const [profile, progress] = await Promise.all([
apiFetch<ProfileResponse>('/api/me', { authToken: token }),
apiFetch<ProgressResponse>('/api/progress', { authToken: token })
]);
return { token, profile, currentRank, nextRank, progress, target };
return { token, profile, progress };
}
export default async function DashboardPage() {
const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile();
const { token, profile, progress } = await fetchProfile();
return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
<div>
<ProgressOverview
fullName={profile.full_name}
xp={profile.xp}
mana={profile.mana}
rank={currentRank}
competencies={profile.competencies}
artifacts={profile.artifacts}
nextRankTitle={nextRank?.title}
xpProgress={progress}
xpTarget={target}
progress={progress}
/>
</div>
<aside className="card">
<h3>Ближайшая цель</h3>
<p style={{ color: 'var(--text-muted)' }}>
Выполните миссии ветки «Получение оффера», чтобы закрепиться в экипаже и открыть доступ к
сложным задачам.
<p style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>
Следующий рубеж: {progress.next_rank ? `ранг «${progress.next_rank.title}»` : 'вы на максимальном ранге демо-версии'}.
Закройте ключевые миссии и подтяните компетенции, чтобы взять оффер.
</p>
<p style={{ marginTop: '1rem' }}>
Осталось {progress.xp.remaining} XP · {progress.completed_missions}/{progress.total_missions} миссий ·{' '}
{progress.met_competencies}/{progress.total_competencies} компетенций.
</p>
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">

View File

@ -2,11 +2,7 @@
import styled from 'styled-components';
type Rank = {
id: number | null;
title?: string;
};
// Компетенции и артефакты из профиля пользователя.
type Competency = {
competency: {
id: number;
@ -21,16 +17,35 @@ type Artifact = {
rarity: string;
};
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
export interface ProfileProps {
fullName: string;
xp: number;
mana: number;
rank?: Rank;
competencies: Competency[];
artifacts: Artifact[];
nextRankTitle?: string;
xpProgress: number;
xpTarget: number;
progress: {
current_rank: { title: string } | null;
next_rank: { title: string } | null;
xp: {
baseline: number;
current: number;
target: number;
remaining: number;
progress_percent: number;
};
mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>;
competency_requirements: Array<{
competency_id: number;
competency_name: string;
required_level: number;
current_level: number;
is_met: boolean;
}>;
completed_missions: number;
total_missions: number;
met_competencies: number;
total_competencies: number;
};
}
const Card = styled.div`
@ -38,6 +53,8 @@ const Card = styled.div`
border-radius: 16px;
padding: 1.5rem;
border: 1px solid rgba(108, 92, 231, 0.4);
display: grid;
gap: 1.5rem;
`;
const ProgressBar = styled.div<{ value: number }>`
@ -57,60 +74,153 @@ const ProgressBar = styled.div<{ value: number }>`
}
`;
export function ProgressOverview({
fullName,
xp,
mana,
rank,
competencies,
artifacts,
nextRankTitle,
xpProgress,
xpTarget
}: ProfileProps) {
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
const RequirementRow = styled.div<{ $completed?: boolean }>`
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.35)' : 'rgba(162, 155, 254, 0.25)')};
background: ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.18)' : 'rgba(162, 155, 254, 0.12)')};
`;
const RequirementTitle = styled.span`
font-weight: 500;
`;
const ChecklistGrid = styled.div`
display: grid;
gap: 0.75rem;
`;
const SectionTitle = styled.h3`
margin: 0;
font-size: 1.1rem;
`;
const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: ${({ $kind }) => ($kind === 'success' ? 'rgba(0, 184, 148, 0.25)' : 'rgba(255, 118, 117, 0.18)')};
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
`;
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
const xpPercent = Math.round(progress.xp.progress_percent * 100);
const hasNextRank = Boolean(progress.next_rank);
return (
<Card>
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
<div style={{ marginTop: '1rem' }}>
<strong>Опыт:</strong>
<header>
<h2 style={{ margin: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
Текущий ранг: {progress.current_rank?.title ?? 'не назначен'} · Цель:{' '}
{hasNextRank ? `«${progress.next_rank?.title}»` : 'достигнут максимум в демо'}
</p>
</header>
<section>
<SectionTitle>Опыт до следующего ранга</SectionTitle>
<p style={{ margin: '0.25rem 0', color: 'var(--text-muted)' }}>
{progress.xp.current} XP из {progress.xp.target} · осталось {progress.xp.remaining} XP
</p>
<ProgressBar value={xpPercent} />
{nextRankTitle ? (
<small style={{ color: 'var(--text-muted)' }}>
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
</small>
) : (
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
</section>
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
<div>
<SectionTitle>Ключевые миссии</SectionTitle>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
{progress.completed_missions}/{progress.total_missions} выполнено.
</p>
<ChecklistGrid>
{progress.mission_requirements.length === 0 && (
<RequirementRow $completed>
<RequirementTitle>Все миссии для ранга уже зачтены.</RequirementTitle>
</RequirementRow>
)}
{progress.mission_requirements.map((mission) => (
<RequirementRow key={mission.mission_id} $completed={mission.is_completed}>
<RequirementTitle>{mission.mission_title}</RequirementTitle>
<InlineBadge $kind={mission.is_completed ? 'success' : 'warning'}>
{mission.is_completed ? 'готово' : 'ожидает'}
</InlineBadge>
</RequirementRow>
))}
</ChecklistGrid>
</div>
<div style={{ marginTop: '1rem' }}>
<strong>Мана:</strong>
<p style={{ margin: '0.5rem 0' }}>{mana} </p>
<div>
<SectionTitle>Компетенции ранга</SectionTitle>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
{progress.met_competencies}/{progress.total_competencies} требований закрыто.
</p>
<ChecklistGrid>
{progress.competency_requirements.length === 0 && (
<RequirementRow $completed>
<RequirementTitle>Дополнительные требования к компетенциям отсутствуют.</RequirementTitle>
</RequirementRow>
)}
{progress.competency_requirements.map((competency) => {
const percentage = competency.required_level
? Math.min(100, (competency.current_level / competency.required_level) * 100)
: 100;
const delta = Math.max(0, competency.required_level - competency.current_level);
return (
<RequirementRow key={competency.competency_id} $completed={competency.is_met}>
<div style={{ flex: 1 }}>
<RequirementTitle>{competency.competency_name}</RequirementTitle>
<small style={{ color: 'var(--text-muted)' }}>
Уровень {competency.current_level} / {competency.required_level}
</small>
<ProgressBar value={percentage} />
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Компетенции</strong>
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
<InlineBadge $kind={competency.is_met ? 'success' : 'warning'}>
{competency.is_met ? 'готово' : `нужно +${delta}`}
</InlineBadge>
</RequirementRow>
);
})}
</ChecklistGrid>
</div>
</section>
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
<div className="card" style={{ margin: 0 }}>
<SectionTitle>Мана экипажа</SectionTitle>
<p style={{ marginTop: '0.5rem', fontSize: '1.75rem', fontWeight: 600 }}>{mana} </p>
<small style={{ color: 'var(--text-muted)' }}>Тратьте в магазине на мерч и бонусы.</small>
</div>
<div className="card" style={{ margin: 0 }}>
<SectionTitle>Текущие компетенции</SectionTitle>
<ul style={{ listStyle: 'none', padding: 0, margin: '0.75rem 0 0', display: 'grid', gap: '0.5rem' }}>
{competencies.map((item) => (
<li key={item.competency.id} style={{ marginBottom: '0.25rem' }}>
<span className="badge">{item.competency.name}</span> уровень {item.level}
<li key={item.competency.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className="badge">{item.competency.name}</span>
<span>уровень {item.level}</span>
</li>
))}
{competencies.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Компетенции ещё не открыты.</li>}
</ul>
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Артефакты</strong>
</section>
<section>
<SectionTitle>Артефакты</SectionTitle>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{artifacts.length === 0 && <p>Ещё нет трофеев выполните миссии!</p>}
{artifacts.length === 0 && <p>Выполните миссии, чтобы собрать коллекцию трофеев.</p>}
{artifacts.map((artifact) => (
<div key={artifact.id} className="card">
<div key={artifact.id} className="card" style={{ margin: 0 }}>
<span className="badge">{artifact.rarity}</span>
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
</div>
))}
</div>
</div>
</section>
</Card>
);
}

View File

@ -1,6 +1,6 @@
'use client';
import { FormEvent, useState } from 'react';
import { FormEvent, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../lib/api';
@ -108,6 +108,10 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
// пока загрузка детальной карточки не завершилась.
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
const resetForm = () => {
setForm(initialFormState);
};
@ -160,6 +164,20 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
}
const id = Number(value);
const baseMission = missionById.get(id);
if (baseMission) {
setForm((prev) => ({
...prev,
title: baseMission.title,
description: baseMission.description,
xp_reward: baseMission.xp_reward,
mana_reward: baseMission.mana_reward,
difficulty: baseMission.difficulty,
is_active: baseMission.is_active
}));
}
setSelectedId(id);
void loadMission(id);
};

View File

@ -44,6 +44,8 @@ a {
main {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
@ -75,6 +77,18 @@ main {
background: rgba(8, 11, 26, 0.6);
padding: 0.75rem;
color: var(--text);
width: 100%;
}
.admin-form textarea {
min-height: 120px;
resize: vertical;
}
.admin-form .checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.grid {