Merge branch 'main' into codex/add-python-programming-mission-module-ygunqb
This commit is contained in:
commit
bebfb769c6
|
|
@ -0,0 +1,64 @@
|
|||
"""Add python mission tables"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20240927_0007'
|
||||
down_revision = '20240927_0006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'python_challenges',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('mission_id', sa.Integer(), sa.ForeignKey('missions.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('order', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('input_data', sa.Text(), nullable=True),
|
||||
sa.Column('expected_output', sa.Text(), nullable=False),
|
||||
sa.Column('starter_code', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), server_onupdate=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('mission_id', 'order', name='uq_python_challenge_mission_order'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'python_user_progress',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('mission_id', sa.Integer(), sa.ForeignKey('missions.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('current_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), server_onupdate=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('user_id', 'mission_id', name='uq_python_progress_user_mission'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'python_submissions',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('progress_id', sa.Integer(), sa.ForeignKey('python_user_progress.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('challenge_id', sa.Integer(), sa.ForeignKey('python_challenges.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('code', sa.Text(), nullable=False),
|
||||
sa.Column('stdout', sa.Text(), nullable=True),
|
||||
sa.Column('stderr', sa.Text(), nullable=True),
|
||||
sa.Column('is_passed', sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_index('ix_python_submissions_progress_id', 'python_submissions', ['progress_id'])
|
||||
op.create_index('ix_python_submissions_challenge_id', 'python_submissions', ['challenge_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_python_submissions_challenge_id', table_name='python_submissions')
|
||||
op.drop_index('ix_python_submissions_progress_id', table_name='python_submissions')
|
||||
op.drop_table('python_submissions')
|
||||
op.drop_table('python_user_progress')
|
||||
op.drop_table('python_challenges')
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""Merge heads: python and coding mission branches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3c5430b2cbd3"
|
||||
down_revision = ("20240927_0007", "20241005_0007")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Экспортируем роутеры для подключения в приложении."""
|
||||
|
||||
from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401
|
||||
from . import admin, auth, journal, missions, onboarding, store, users, python # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"admin",
|
||||
|
|
@ -10,4 +10,5 @@ __all__ = [
|
|||
"onboarding",
|
||||
"store",
|
||||
"users",
|
||||
"python",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.models.coding import CodingAttempt, CodingChallenge
|
|||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.python import PythonChallenge, PythonUserProgress
|
||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||
from app.schemas.mission import (
|
||||
MissionBase,
|
||||
|
|
|
|||
46
backend/app/api/routes/python.py
Normal file
46
backend/app/api/routes/python.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""API для миссии "Основы Python"."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.mission import Mission
|
||||
from app.models.user import User
|
||||
from app.schemas.python import PythonMissionState, PythonSubmitRequest, PythonSubmissionRead
|
||||
from app.services.python_mission import build_state, submit_code
|
||||
|
||||
router = APIRouter(prefix="/api/python-mission", tags=["python-mission"])
|
||||
|
||||
|
||||
def _get_mission(db: Session, mission_id: int) -> Mission:
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
||||
if not mission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||
return mission
|
||||
|
||||
|
||||
@router.get("/{mission_id}", response_model=PythonMissionState)
|
||||
def mission_state(
|
||||
mission_id: int,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> PythonMissionState:
|
||||
mission = _get_mission(db, mission_id)
|
||||
return build_state(db, current_user, mission)
|
||||
|
||||
|
||||
@router.post("/{mission_id}/submit", response_model=PythonSubmissionRead)
|
||||
def mission_submit(
|
||||
mission_id: int,
|
||||
payload: PythonSubmitRequest,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> PythonSubmissionRead:
|
||||
mission = _get_mission(db, mission_id)
|
||||
submission = submit_code(db, current_user, mission, challenge_id=payload.challenge_id, code=payload.code)
|
||||
return PythonSubmissionRead.model_validate(submission)
|
||||
|
|
@ -13,7 +13,7 @@ from sqlalchemy import inspect, text
|
|||
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, python
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import SessionLocal, engine
|
||||
|
|
@ -136,6 +136,7 @@ app.include_router(missions.router)
|
|||
app.include_router(journal.router)
|
||||
app.include_router(onboarding.router)
|
||||
app.include_router(store.router)
|
||||
app.include_router(python.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .journal import JournalEntry # noqa: F401
|
|||
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401
|
||||
from .coding import CodingAttempt, CodingChallenge # noqa: F401
|
||||
from .onboarding import OnboardingSlide, OnboardingState # noqa: F401
|
||||
from .python import PythonChallenge, PythonSubmission, PythonUserProgress # noqa: F401
|
||||
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
||||
from .store import Order, StoreItem # noqa: F401
|
||||
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
||||
|
|
@ -23,6 +24,9 @@ __all__ = [
|
|||
"MissionSubmission",
|
||||
"OnboardingSlide",
|
||||
"OnboardingState",
|
||||
"PythonChallenge",
|
||||
"PythonSubmission",
|
||||
"PythonUserProgress",
|
||||
"Rank",
|
||||
"RankCompetencyRequirement",
|
||||
"RankMissionRequirement",
|
||||
|
|
|
|||
59
backend/app/models/python.py
Normal file
59
backend/app/models/python.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Модели миссии по Python и пользовательского прогресса."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class PythonChallenge(Base, TimestampMixin):
|
||||
"""Задание в рамках миссии "Основы Python"."""
|
||||
|
||||
__tablename__ = "python_challenges"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id", ondelete="CASCADE"), nullable=False)
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
input_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
expected_output: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
starter_code: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
submissions: Mapped[list["PythonSubmission"]] = relationship("PythonSubmission", back_populates="challenge")
|
||||
|
||||
|
||||
class PythonUserProgress(Base, TimestampMixin):
|
||||
"""Прогресс пользователя по задачам Python-миссии."""
|
||||
|
||||
__tablename__ = "python_user_progress"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id", ondelete="CASCADE"), nullable=False)
|
||||
current_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
submissions: Mapped[list["PythonSubmission"]] = relationship("PythonSubmission", back_populates="progress")
|
||||
|
||||
|
||||
class PythonSubmission(Base, TimestampMixin):
|
||||
"""Хранение попыток решения конкретной задачи."""
|
||||
|
||||
__tablename__ = "python_submissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
progress_id: Mapped[int] = mapped_column(ForeignKey("python_user_progress.id", ondelete="CASCADE"), nullable=False)
|
||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("python_challenges.id", ondelete="CASCADE"), nullable=False)
|
||||
code: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
stdout: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
stderr: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
is_passed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
progress: Mapped[PythonUserProgress] = relationship("PythonUserProgress", back_populates="submissions")
|
||||
challenge: Mapped[PythonChallenge] = relationship("PythonChallenge", back_populates="submissions")
|
||||
56
backend/app/schemas/python.py
Normal file
56
backend/app/schemas/python.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Pydantic-схемы для Python-миссии."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PythonSubmissionRead(BaseModel):
|
||||
"""История попыток по конкретному заданию."""
|
||||
|
||||
id: int
|
||||
challenge_id: int
|
||||
code: str
|
||||
stdout: Optional[str]
|
||||
stderr: Optional[str]
|
||||
is_passed: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PythonChallengeRead(BaseModel):
|
||||
"""Описание задания, доступное пользователю."""
|
||||
|
||||
id: int
|
||||
order: int
|
||||
title: str
|
||||
description: str
|
||||
input_data: Optional[str]
|
||||
starter_code: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PythonMissionState(BaseModel):
|
||||
"""Сводка по прогрессу в Python-миссии."""
|
||||
|
||||
mission_id: int
|
||||
total_challenges: int
|
||||
completed_challenges: int
|
||||
is_completed: bool
|
||||
next_challenge: Optional[PythonChallengeRead]
|
||||
last_submissions: list[PythonSubmissionRead]
|
||||
last_code: Optional[str] = None
|
||||
|
||||
|
||||
class PythonSubmitRequest(BaseModel):
|
||||
"""Запрос на проверку решения задачи."""
|
||||
|
||||
challenge_id: int
|
||||
code: str
|
||||
181
backend/app/services/python_mission.py
Normal file
181
backend/app/services/python_mission.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Логика миссии по Python."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.mission import Mission, MissionSubmission
|
||||
from app.models.python import PythonChallenge, PythonSubmission, PythonUserProgress
|
||||
from app.models.user import User
|
||||
from app.schemas.python import PythonMissionState, PythonChallengeRead, PythonSubmissionRead
|
||||
from app.services.mission import submit_mission
|
||||
|
||||
EVAL_TIMEOUT_SECONDS = 3
|
||||
|
||||
|
||||
def _normalize_stdout(value: str | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return value.replace('\r\n', '\n').rstrip('\n').strip()
|
||||
|
||||
|
||||
def get_progress(db: Session, user: User, mission: Mission) -> PythonUserProgress:
|
||||
progress = (
|
||||
db.query(PythonUserProgress)
|
||||
.filter(PythonUserProgress.user_id == user.id, PythonUserProgress.mission_id == mission.id)
|
||||
.first()
|
||||
)
|
||||
if not progress:
|
||||
progress = PythonUserProgress(user_id=user.id, mission_id=mission.id, current_order=0)
|
||||
db.add(progress)
|
||||
db.commit()
|
||||
db.refresh(progress)
|
||||
return progress
|
||||
|
||||
|
||||
def build_state(db: Session, user: User, mission: Mission) -> PythonMissionState:
|
||||
progress = get_progress(db, user, mission)
|
||||
challenges = (
|
||||
db.query(PythonChallenge)
|
||||
.filter(PythonChallenge.mission_id == mission.id)
|
||||
.order_by(PythonChallenge.order)
|
||||
.all()
|
||||
)
|
||||
total = len(challenges)
|
||||
completed = progress.current_order
|
||||
|
||||
next_challenge: Optional[PythonChallenge] = None
|
||||
last_code: Optional[str] = None
|
||||
last_submissions: list[PythonSubmission] = []
|
||||
|
||||
if completed < total:
|
||||
next_challenge = challenges[completed]
|
||||
last_submission = (
|
||||
db.query(PythonSubmission)
|
||||
.filter(
|
||||
PythonSubmission.progress_id == progress.id,
|
||||
PythonSubmission.challenge_id == next_challenge.id,
|
||||
)
|
||||
.order_by(PythonSubmission.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if last_submission:
|
||||
last_code = last_submission.code
|
||||
else:
|
||||
last_submission = (
|
||||
db.query(PythonSubmission)
|
||||
.filter(PythonSubmission.progress_id == progress.id)
|
||||
.order_by(PythonSubmission.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if last_submission:
|
||||
last_code = last_submission.code
|
||||
|
||||
last_submissions = (
|
||||
db.query(PythonSubmission)
|
||||
.filter(PythonSubmission.progress_id == progress.id)
|
||||
.order_by(PythonSubmission.created_at.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
return PythonMissionState(
|
||||
mission_id=mission.id,
|
||||
total_challenges=total,
|
||||
completed_challenges=completed,
|
||||
is_completed=completed >= total,
|
||||
next_challenge=PythonChallengeRead.model_validate(next_challenge) if next_challenge else None,
|
||||
last_submissions=[PythonSubmissionRead.model_validate(item) for item in last_submissions],
|
||||
last_code=last_code,
|
||||
)
|
||||
|
||||
|
||||
def submit_code(db: Session, user: User, mission: Mission, challenge_id: int, code: str) -> PythonSubmission:
|
||||
progress = get_progress(db, user, mission)
|
||||
|
||||
challenge = (
|
||||
db.query(PythonChallenge)
|
||||
.filter(PythonChallenge.id == challenge_id, PythonChallenge.mission_id == mission.id)
|
||||
.first()
|
||||
)
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задание не найдено")
|
||||
|
||||
expected_order = progress.current_order + 1
|
||||
if challenge.order != expected_order:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Сначала выполните предыдущее задание",
|
||||
)
|
||||
|
||||
prepared_code = dedent(code)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", prepared_code],
|
||||
input=(challenge.input_data or ""),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=EVAL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Время выполнения превышено")
|
||||
|
||||
stdout = completed.stdout or ""
|
||||
stderr = completed.stderr or ""
|
||||
|
||||
expected = _normalize_stdout(challenge.expected_output)
|
||||
actual = _normalize_stdout(stdout)
|
||||
|
||||
is_passed = completed.returncode == 0 and actual == expected
|
||||
|
||||
submission = PythonSubmission(
|
||||
progress_id=progress.id,
|
||||
challenge_id=challenge.id,
|
||||
code=prepared_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
is_passed=is_passed,
|
||||
)
|
||||
db.add(submission)
|
||||
|
||||
if is_passed:
|
||||
progress.current_order = challenge.order
|
||||
total = (
|
||||
db.query(PythonChallenge)
|
||||
.filter(PythonChallenge.mission_id == mission.id)
|
||||
.count()
|
||||
)
|
||||
if progress.current_order >= total:
|
||||
progress.completed_at = datetime.utcnow()
|
||||
ensure_mission_completed(db, user, mission)
|
||||
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
db.refresh(progress)
|
||||
return submission
|
||||
|
||||
|
||||
def ensure_mission_completed(db: Session, user: User, mission: Mission) -> None:
|
||||
existing_submission = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(MissionSubmission.user_id == user.id, MissionSubmission.mission_id == mission.id)
|
||||
.first()
|
||||
)
|
||||
if existing_submission and existing_submission.status.value == "approved":
|
||||
return
|
||||
|
||||
submit_mission(
|
||||
db=db,
|
||||
user=user,
|
||||
mission=mission,
|
||||
comment="Задачи Python-миссии решены",
|
||||
proof_url=None,
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
interface CodingChallengeState {
|
||||
|
|
@ -52,11 +52,11 @@ export function CodingMissionPanel({ missionId, token, initialState, initialComp
|
|||
const [loading, setLoading] = useState(false);
|
||||
const previousChallengeIdRef = useRef<number | null>(null);
|
||||
|
||||
|
||||
const activeChallenge = useMemo(() => {
|
||||
if (!state || !state.challenges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.current_challenge_id) {
|
||||
const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id);
|
||||
if (current) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from app.models.mission import Mission, MissionCompetencyReward, MissionDifficul
|
|||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.onboarding import OnboardingSlide
|
||||
from app.models.store import StoreItem
|
||||
from app.models.python import PythonChallenge
|
||||
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole, UserArtifact
|
||||
from app.models.journal import JournalEntry, JournalEventType
|
||||
from app.main import run_migrations
|
||||
|
|
@ -355,6 +356,7 @@ def seed() -> None:
|
|||
RankMissionRequirement(rank_id=ranks[1].id, mission_id=mission_resume.id),
|
||||
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_interview.id),
|
||||
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_onboarding.id),
|
||||
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_python_basics.id),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user