Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
moolcoov
2025-03-01 13:41:53 +03:00
33 changed files with 840 additions and 173 deletions
+1 -1
View File
@@ -273,7 +273,7 @@ services:
- name: web - name: web
target: 3000 target: 3000
published: 8004 published: 8004
host_ip: 127.0.0.1 host_ip: 0.0.0.0
protocol: tcp protocol: tcp
restart: unless-stopped restart: unless-stopped
shm_size: 4mb shm_size: 4mb
+3 -2
View File
@@ -18,6 +18,7 @@ router = Router(tags=["competition"])
status.OK: schemas.CompetitionOut, status.OK: schemas.CompetitionOut,
status.BAD_REQUEST: global_schemas.BadRequestError, status.BAD_REQUEST: global_schemas.BadRequestError,
status.UNAUTHORIZED: global_schemas.UnauthorizedError, status.UNAUTHORIZED: global_schemas.UnauthorizedError,
status.NOT_FOUND: global_schemas.NotFoundError,
}, },
) )
def get_competition( def get_competition(
@@ -30,14 +31,14 @@ def get_competition(
@router.get( @router.get(
"competitions", "competitions",
response={ response={
status.OK: list[schemas.CompetitionListInstanceOut], status.OK: list[schemas.CompetitionOut],
status.BAD_REQUEST: global_schemas.BadRequestError, status.BAD_REQUEST: global_schemas.BadRequestError,
status.UNAUTHORIZED: global_schemas.UnauthorizedError, status.UNAUTHORIZED: global_schemas.UnauthorizedError,
}, },
) )
def list_competitions( def list_competitions(
request: HttpRequest, is_participating: bool request: HttpRequest, is_participating: bool
) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: ) -> tuple[status, list[schemas.CompetitionOut]]:
user = request.auth user = request.auth
if is_participating: if is_participating:
competitions = Competition.objects.filter(participants=user) competitions = Competition.objects.filter(participants=user)
+26
View File
@@ -0,0 +1,26 @@
from abc import ABC
from typing import Optional
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from django.urls import resolve
from ninja.errors import AuthenticationError
from ninja.security import APIKeyQuery
from ninja.security.apikey import APIKeyBase
from apps.review.models import Reviewer
class APIKeyPath(APIKeyBase, ABC):
openapi_in: str = "path"
def _get_key(self, request: HttpRequest) -> Optional[str]:
func, args, kwargs = resolve(request.path)
return kwargs.get(self.param_name)
class ReviewerAuth(APIKeyPath):
param_name = "token"
def authenticate(self, request, token):
if not (reviewer := Reviewer.objects.filter(token=token).first()):
raise AuthenticationError
return reviewer
+37
View File
@@ -0,0 +1,37 @@
from typing import List, Literal
from uuid import UUID
from django.http import HttpRequest
from ninja import Schema, ModelSchema
from apps.review.models import Reviewer
from apps.task.models import CompetetionTaskSumbission
class PingOut(Schema):
status: str = "ok"
class ReviewerOut(ModelSchema):
id: UUID
class Meta:
model = Reviewer
exclude = ("token",)
class SubmissionOut(ModelSchema):
id: UUID
status: Literal["sent", "checking", "checked"]
class Meta:
model = CompetetionTaskSumbission
exclude = (
"user",
)
class SubmissionsOut(Schema):
submissions: list[SubmissionOut] = []
@staticmethod
def resolve_submissions(self, context: HttpRequest) -> List[SubmissionOut]:
print(CompetetionTaskSumbission.objects.all())
return list(CompetetionTaskSumbission.objects.all())
+34
View File
@@ -0,0 +1,34 @@
from http import HTTPStatus as status
from django.http import HttpRequest
from ninja import Router
from api.v1.review import schemas
from api.v1 import schemas as global_schemas
router = Router(tags=["review"])
@router.get(
"{token}/tasks",
response={
status.OK: schemas.SubmissionsOut,
},
)
def ping(request: HttpRequest, token) -> tuple[status, schemas.SubmissionsOut]:
return status.OK, schemas.SubmissionsOut()
@router.get(
"{token}",
response={
status.OK: schemas.ReviewerOut,
status.UNAUTHORIZED: global_schemas.UnauthorizedError
},
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query"
)
def get_reviewer(
request: HttpRequest,
token: str
):
return status.OK, request.auth
+8
View File
@@ -6,7 +6,9 @@ from api.v1 import handlers
from api.v1.auth import BearerAuth from api.v1.auth import BearerAuth
from api.v1.competition.views import router as competition_router from api.v1.competition.views import router as competition_router
from api.v1.ping.views import router as ping_router from api.v1.ping.views import router as ping_router
from api.v1.review.auth import ReviewerAuth
from api.v1.user.views import router as user_router from api.v1.user.views import router as user_router
from api.v1.review.views import router as review_router
router = NinjaAPI( router = NinjaAPI(
title="DataRush API", title="DataRush API",
@@ -30,6 +32,12 @@ router.add_router(
competition_router, competition_router,
auth=BearerAuth(), auth=BearerAuth(),
) )
router.add_router(
"review",
review_router,
auth=ReviewerAuth(),
)
for exception, handler in handlers.exception_handlers: for exception, handler in handlers.exception_handlers:
+21
View File
@@ -0,0 +1,21 @@
from typing import Literal
from uuid import UUID
from ninja import ModelSchema, Schema
from apps.competition.models import State
from apps.task.models import CompetitionTask
class TaskOutSchema(ModelSchema):
class Meta:
model = CompetitionTask
fields = ["id", "competition", "title", "description", "type"]
class TaskSubmissionIn(Schema):
type: Literal["input", "file", "code"]
content: str
class TaskSubmissionOut(Schema):
submission_id: UUID
+115
View File
@@ -0,0 +1,115 @@
from http import HTTPStatus as status
from uuid import UUID
from django.shortcuts import get_object_or_404
from ninja import Router
from django.shortcuts import get_object_or_404
from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError
from api.v1.ping.schemas import PingOut
from api.v1.task.schemas import (
TaskOutSchema,
TaskSubmissionOut,
TaskSubmissionIn,
)
from apps.task.models import (
Competition,
CompetitionTask,
CompetetionTaskSumbission,
)
from apps.competition.models import State
router = Router(tags=["competition"])
@router.post(
"competitions/{competition_id}/start",
description="Start a competition completing (open access to tasks)",
response={
status.OK: PingOut,
status.UNAUTHORIZED: UnauthorizedError,
status.NOT_FOUND: NotFoundError,
},
)
def start_competition(request, competition_id: UUID) -> PingOut:
competition = get_object_or_404(Competition, pk=competition_id)
state_obj, _ = State.objects.update_or_create(
user=request.auth, competition=competition, state="started"
)
return status.OK, PingOut()
@router.get(
"competitions/{competition_id}/tasks",
description="Get all tasks of competition (works only if user started competition)",
response={
status.OK: list[TaskOutSchema],
status.UNAUTHORIZED: UnauthorizedError,
status.FORBIDDEN: ForbiddenError,
status.NOT_FOUND: NotFoundError,
},
)
def get_competition_tasks(request, competition_id: UUID) -> list[TaskOutSchema]:
competition = get_object_or_404(Competition, pk=competition_id)
state = State.objects.filter(
user=request.auth, competition=competition, state="started"
).first()
if not state:
return 403, ForbiddenError()
return status.OK, CompetitionTask.objects.filter(competition=competition).all()
@router.get(
"competitions/{competition_id}/tasks/{task_id}",
description="Get a task of competition task",
response={
status.OK: TaskOutSchema,
status.UNAUTHORIZED: UnauthorizedError,
status.FORBIDDEN: ForbiddenError,
status.NOT_FOUND: NotFoundError,
},
)
def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
@router.post(
"competitions/{competition_id}/tasks/{task_id}/submit",
description="Submit task solution",
response={
status.OK: TaskSubmissionOut,
status.UNAUTHORIZED: UnauthorizedError,
status.FORBIDDEN: ForbiddenError,
status.NOT_FOUND: NotFoundError,
},
)
def submit_task(
request, competition_id: str, task_id: str, submission: TaskSubmissionIn
) -> PingOut:
user = request.auth
competetion = get_object_or_404(Competition, id=competition_id)
task = get_object_or_404(
CompetitionTask, competetion=competetion, id=task_id
)
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
CompetetionTaskSumbission.objects.create(
user=user,
task=task,
status=CompetetionTaskSumbission.StatusChoices.CHECKED,
result={"correct": submission.content == task.answer_file_path},
)
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
CompetetionTaskSumbission.objects.create(
user=user,
task=task,
status=CompetetionTaskSumbission.StatusChoices.SENT,
)
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
CompetetionTaskSumbission.objects.create(
user=user,
task=task,
status=CompetetionTaskSumbission.StatusChoices.CHECKING,
)
return TaskSubmissionOut(id=CompetetionTaskSumbission.id)
@@ -1,5 +1,7 @@
# Generated by Django 5.1.6 on 2025-02-28 21:27 # Generated by Django 5.1.6 on 2025-03-01 10:26
import datetime
import django.db.models.deletion
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('user', '0001_initial'),
] ]
operations = [ operations = [
@@ -23,10 +26,24 @@ class Migration(migrations.Migration):
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')),
('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), ('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')),
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), ('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')),
('participants', models.ManyToManyField(related_name='participants', to='user.user')),
], ],
options={ options={
'verbose_name': 'соревнование', 'verbose_name': 'соревнование',
'verbose_name_plural': 'соревнования', 'verbose_name_plural': 'соревнования',
}, },
), ),
migrations.CreateModel(
name='State',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)),
('changed_at', models.DateTimeField(default=datetime.datetime.now)),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
],
options={
'abstract': False,
},
),
] ]
@@ -1,19 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-28 22:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0001_initial'),
('user', '0002_user_status'),
]
operations = [
migrations.AddField(
model_name='competition',
name='participants',
field=models.ManyToManyField(related_name='participants', to='user.user'),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-28 23:26
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0002_competition_participants'),
('user', '0003_alter_user_status'),
]
operations = [
migrations.CreateModel(
name='State',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
],
options={
'abstract': False,
},
),
]
@@ -1,3 +1,5 @@
from datetime import datetime
from django.db import models from django.db import models
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -49,3 +51,4 @@ class State(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
state = models.CharField(choices=StateChoices.choices, max_length=11) state = models.CharField(choices=StateChoices.choices, max_length=11)
changed_at = models.DateTimeField(default=datetime.now)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "apps.review"
label = "review"
@@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-01 08:47
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Reviewer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('surname', models.CharField(max_length=100)),
('token', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
]
+10
View File
@@ -0,0 +1,10 @@
from django.db import models
from apps.core.models import BaseModel
class Reviewer(BaseModel):
name = models.CharField(max_length=100)
surname = models.CharField(max_length=100)
token = models.CharField(max_length=100)
@@ -0,0 +1,53 @@
# Generated by Django 5.1.6 on 2025-03-01 10:26
import apps.task.models
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('competition', '0001_initial'),
('user', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CompetitionTask',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.TextField(max_length=50, verbose_name='заголовок')),
('description', models.TextField(max_length=300, verbose_name='описание')),
('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)),
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)),
('points', models.IntegerField(blank=True, null=True)),
('answer_file_path', models.TextField(blank=True, null=True)),
('criteries', models.JSONField(blank=True, null=True)),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='CompetetionTaskSumbission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)),
('content', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_content_upload_to)),
('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)),
('result', models.JSONField(blank=True, default=None, null=True)),
('earned_points', models.IntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
],
options={
'abstract': False,
},
),
]
+51 -7
View File
@@ -1,11 +1,12 @@
from random import choice
from uuid import uuid4 from uuid import uuid4
from competition.models import Competition
from core.models import BaseModel
from django.db import models from django.db import models
from apps.task.validators import ContestTaskCriteriesValidator from apps.task.validators import ContestTaskCriteriesValidator
from apps.competition.models import Competition
from apps.core.models import BaseModel
from apps.user.models import User
class CompetitionTask(BaseModel): class CompetitionTask(BaseModel):
class CompetitionTaskType(models.TextChoices): class CompetitionTaskType(models.TextChoices):
@@ -14,21 +15,64 @@ class CompetitionTask(BaseModel):
REVIEW = "review" REVIEW = "review"
def answer_file_upload_to(instance, filename) -> str: def answer_file_upload_to(instance, filename) -> str:
return f"/tasks/{instance.id}/answer/{uuid4}" return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.TextField(verbose_name="заголовок", max_length=50) title = models.TextField(verbose_name="заголовок", max_length=50)
description = models.TextField(verbose_name="описание", max_length=300) description = models.TextField(verbose_name="описание", max_length=300)
type = models.CharField(choices=CompetitionTaskType) type = models.CharField(choices=CompetitionTaskType, max_length=8)
# only when "input" or "checker" type # only when "input" or "checker" type
correct_answer_file = models.FileField(upload_to=answer_file_upload_to) correct_answer_file = models.FileField(
upload_to=answer_file_upload_to, null=True, blank=True
)
points = models.IntegerField(null=True, blank=True)
# only when "checker" type # only when "checker" type
answer_file_path = models.TextField() answer_file_path = models.TextField(null=True, blank=True)
# only when "review" type # only when "review" type
criteries = models.JSONField(blank=True, null=True) criteries = models.JSONField(blank=True, null=True)
def clean(self): def clean(self):
ContestTaskCriteriesValidator()(self) ContestTaskCriteriesValidator()(self)
class CompetetionTaskSumbission(BaseModel):
class StatusChoices(models.TextChoices):
SENT = "sent"
CHECKING = "checking"
CHECKED = "checked"
def submission_content_upload_to(instance, filename) -> str:
return f"/submissions/{instance.id}/content"
def submission_stdout_upload_to(instance, filename) -> str:
return f"/submissions/{instance.id}/stdout"
user = models.ForeignKey(User, on_delete=models.CASCADE)
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE)
status = models.CharField(
choices=StatusChoices.choices,
default=StatusChoices.SENT,
max_length=8,
)
# code or text or file
content = models.FileField(upload_to=submission_content_upload_to)
# only if task type is checker
stdout = models.FileField(
upload_to=submission_stdout_upload_to, null=True, blank=True
)
# depends on task type:
# - input: {"correct": boolean}
# - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]}
# - code: {"correct": boolean}
result = models.JSONField(default=None, null=True, blank=True)
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
earned_points = models.IntegerField()
timestamp = models.DateTimeField(auto_now_add=True)
+128
View File
@@ -0,0 +1,128 @@
import tempfile
import os
import sys
import ast
from io import StringIO
import hashlib
from config.celery import app
ALLOWED_MODULES = {
"pandas",
"numpy",
"matplotlib",
"seaborn",
"scipy",
"sklearn",
"datetime",
"json",
"csv",
"math",
"statistics",
}
class SecurityException(Exception):
pass
def validate_code(code_str):
try:
tree = ast.parse(code_str)
except SyntaxError as e:
raise SecurityException(f"Syntax error: {str(e)}")
class ImportVisitor(ast.NodeVisitor):
def visit_Import(self, node):
for alias in node.names:
module = alias.name.split(".")[0]
if module not in ALLOWED_MODULES:
raise SecurityException(f"Disallowed import: {module}")
def visit_ImportFrom(self, node):
if node.module:
module = node.module.split(".")[0]
if module not in ALLOWED_MODULES:
raise SecurityException(
f"Disallowed import from: {module}"
)
class SecurityVisitor(ast.NodeVisitor):
def generic_visit(self, node):
if isinstance(node, (ast.Call, ast.Attribute)):
if "system" in getattr(node, "attr", ""):
raise SecurityException("Dangerous system call detected")
super().generic_visit(node)
try:
ImportVisitor().visit(tree)
SecurityVisitor().visit(tree)
except SecurityException as e:
raise
except Exception as e:
raise SecurityException(f"Security check failed: {str(e)}")
def secure_exec(code_str, result_path):
original_dir = os.getcwd()
original_stdout = sys.stdout
sys.stdout = captured_stdout = StringIO()
result_content = None
with tempfile.TemporaryDirectory() as temp_dir:
try:
os.chdir(temp_dir)
restricted_globals = {
"__builtins__": {
"open": lambda f, *a, **kw: open(f, *a, **kw),
"print": print,
"str": str,
"int": int,
"float": float,
"bool": bool,
"list": list,
"dict": dict,
"tuple": tuple,
"set": set,
}
}
exec(code_str, restricted_globals)
if result_path == "stdout":
result_content = captured_stdout.getvalue().encode("utf-8")
else:
with open(result_path, "rb") as f:
result_content = f.read()
except Exception as e:
raise RuntimeError(f"Execution error: {str(e)}")
finally:
os.chdir(original_dir)
sys.stdout = original_stdout
return result_content
@app.task(bind=True)
def analyze_data_task(self, code_str, result_path, expected_bytes):
try:
validate_code(code_str)
result_content = secure_exec(code_str, result_path)
result_hash = hashlib.sha256(result_content).hexdigest()
expected_hash = hashlib.sha256(expected_bytes).hexdigest()
return {
"success": True,
"match": result_hash == expected_hash,
"result_hash": result_hash,
"expected_hash": expected_hash,
}
except SecurityException as e:
return {"success": False, "error": f"Security violation: {str(e)}"}
except RuntimeError as e:
return {"success": False, "error": f"Execution error: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Unexpected error: {str(e)}"}
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-02-28 20:46 # Generated by Django 5.1.6 on 2025-03-01 08:47
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')),
('username', models.SlugField(unique=True, verbose_name='Юзернейм')), ('username', models.SlugField(unique=True, verbose_name='Юзернейм')),
('password', models.TextField(verbose_name='Пароль')), ('password', models.TextField(verbose_name='Пароль')),
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
], ],
options={ options={
'verbose_name': 'пользователь', 'verbose_name': 'пользователь',
@@ -1,18 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-28 22:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='status',
field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-28 22:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0002_user_status'),
]
operations = [
migrations.AlterField(
model_name='user',
name='status',
field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10),
),
]
+2
View File
@@ -445,6 +445,8 @@ INSTALLED_APPS = [
"apps.core", "apps.core",
"apps.user", "apps.user",
"apps.competition", "apps.competition",
"apps.review",
"apps.task"
] ]
# GUID # GUID
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import Navbar from "@/widgets/Navbar";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { Competition } from "@/shared/types";
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
const CompetitionPreview = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [competition, setCompetition] = useState<Competition | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchCompetition = async () => {
try {
setTimeout(() => {
const found = mockCompetitions.find((comp) => comp.id === id);
setCompetition(found || null);
setIsLoading(false);
}, 500);
} catch (error) {
console.error("Error fetching competition:", error);
setIsLoading(false);
}
};
fetchCompetition();
}, [id]);
const handleBack = () => {
navigate(-1);
};
const handleContinue = () => {
if (competition?.id) {
if (mockTasks && mockTasks.length > 0) {
const firstTaskId = mockTasks[0].id;
navigate(`/competition/${competition.id}/tasks/${firstTaskId}`);
} else {
navigate(`/competition/${competition.id}/tasks`);
}
}
};
return (
<>
<Navbar />
<div className="container mx-auto mt-16 px-4 py-8">
<button
onClick={handleBack}
className="font-hse-sans mb-8 flex items-center text-gray-600"
>
<ArrowLeft size={16} className="mr-2" />
Назад к соревнованиям
</button>
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<p className="font-hse-sans text-gray-500">Загрузка...</p>
</div>
) : competition ? (
<div className="mx-auto max-w-5xl overflow-hidden rounded-lg bg-white shadow-lg">
<div className="h-80 w-full overflow-hidden">
<img
src={competition.imageUrl}
alt={competition.name}
className="h-full w-full object-cover"
/>
</div>
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="font-hse-sans mr-6 flex-1 text-3xl font-semibold">
{competition.name}
</h1>
<Button
className="font-hse-sans min-w-[180px] bg-yellow-400 px-12 text-base text-black hover:bg-yellow-500"
onClick={handleContinue}
>
Продолжить
</Button>
</div>
<div className="font-hse-sans text-lg leading-relaxed text-gray-700">
<p>{competition.description}</p>
</div>
</div>
</div>
) : (
<div className="py-12 text-center">
<h2 className="font-hse-sans mb-2 text-2xl font-bold">
Соревнование не найдено
</h2>
<p className="font-hse-sans text-gray-600">
Запрошенное соревнование не существует или было удалено.
</p>
</div>
)}
</div>
</>
);
};
export default CompetitionPreview;
@@ -0,0 +1,23 @@
import { TaskStatus } from "@/shared/types/types";
const getTaskBgColor = (status: TaskStatus): string => {
switch (status) {
case "uncleared": return "bg-[var(--color-task-uncleared)]";
case "checking": return "bg-[var(--color-task-checking)]";
case "correct": return "bg-[var(--color-task-correct)]";
case "partial": return "bg-[var(--color-task-partial)]";
case "wrong": return "bg-[var(--color-task-wrong)]";
}
};
const getTaskTextColor = (status: TaskStatus): string => {
switch (status) {
case "uncleared": return "text-[var(--color-task-text-uncleared)]";
case "checking": return "text-[var(--color-task-text-checking)]";
case "correct": return "text-[var(--color-task-text-correct)]";
case "partial": return "text-[var(--color-task-text-partial)]";
case "wrong": return "text-[var(--color-task-text-wrong)]";
}
};
export {getTaskBgColor, getTaskTextColor}
@@ -1,58 +1,47 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Task, TaskStatus } from "@/shared/types"; import { Task } from "@/shared/types";
import { getTaskBgColor, getTaskTextColor } from "./utils/utils";
const sampleTasks: Task[] = [ import { mockTasks } from "@/shared/mocks/mocks";
{ id: "1", number: "1.1", status: "uncleared" }, import { Button } from "@/components/ui/button";
{ id: "2", number: "1.2", status: "checking" }, import { Calendar } from "lucide-react";
{ id: "3", number: "1.3", status: "correct" },
{ id: "4", number: "2.1", status: "partial" },
{ id: "5", number: "2.2", status: "wrong" },
{ id: "6", number: "2.3", status: "uncleared" },
{ id: "7", number: "3.1", status: "checking" },
{ id: "8", number: "3.2", status: "correct" },
];
const CompetitionRunnerPage = () => { const CompetitionRunnerPage = () => {
const { id } = useParams<{ id: string }>(); const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const navigate = useNavigate();
const [competitionTitle, setCompetitionTitle] = useState( const [competitionTitle, setCompetitionTitle] = useState(
"Олимпиада DANO 2025. Индивидуальный этап", "Олимпиада DANO 2025. Индивидуальный этап",
); );
const [tasks, setTasks] = useState<Task[]>(sampleTasks); const [tasks] = useState<Task[]>(mockTasks);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
taskId || null,
);
const [answer, setAnswer] = useState("");
const getTaskBgColor = (status: TaskStatus): string => { useEffect(() => {
switch (status) { if (taskId) {
case "uncleared": setSelectedTaskId(taskId);
return "bg-[var(--color-task-uncleared)]"; } else if (tasks.length > 0) {
case "checking": navigate(`/competition/${id}/tasks/${tasks[0].id}`, { replace: true });
return "bg-[var(--color-task-checking)]";
case "correct":
return "bg-[var(--color-task-correct)]";
case "partial":
return "bg-[var(--color-task-partial)]";
case "wrong":
return "bg-[var(--color-task-wrong)]";
} }
}; }, [taskId, tasks, id, navigate]);
const getTaskTextColor = (status: TaskStatus): string => {
switch (status) {
case "uncleared":
return "text-gray-600";
case "checking":
return "text-gray-800";
case "correct":
return "text-green-800";
case "partial":
return "text-green-700";
case "wrong":
return "text-red-800";
}
};
const handleTaskClick = (taskId: string) => { const handleTaskClick = (taskId: string) => {
if (selectedTaskId !== taskId) {
setSelectedTaskId(taskId); setSelectedTaskId(taskId);
navigate(`/competition/${id}/tasks/${taskId}`);
}
};
const currentTask = tasks.find((t) => t.id === selectedTaskId);
const handleSubmit = () => {
console.log("Submitting answer:", answer);
// Submit logic here
};
const handleHistoryClick = () => {
console.log("View history");
}; };
return ( return (
@@ -65,11 +54,11 @@ const CompetitionRunnerPage = () => {
</h1> </h1>
</div> </div>
<div className="scrollbar-thin scrollbar-thumb-gray-300 flex items-center gap-3 overflow-x-auto pb-4"> <div className="no-scrollbar flex items-center justify-center gap-2 overflow-x-auto pb-3">
{tasks.map((task) => ( {tasks.map((task) => (
<div <div
key={task.id} key={task.id}
className={`${getTaskBgColor(task.status)} ${getTaskTextColor(task.status)} font-hse-sans flex-shrink-0 cursor-pointer rounded-lg px-4 py-2 text-sm font-medium transition-transform hover:scale-105 ${selectedTaskId === task.id ? "ring-2 ring-black" : ""}`} className={`${getTaskBgColor(task.status)} ${getTaskTextColor(task.status)} font-hse-sans flex-shrink-0 cursor-pointer rounded-lg px-3 py-1.5 text-sm font-medium transition-all hover:brightness-95 ${selectedTaskId === task.id ? "scale-105 transform shadow-md" : ""}`}
onClick={() => handleTaskClick(task.id)} onClick={() => handleTaskClick(task.id)}
> >
{task.number} {task.number}
@@ -79,21 +68,84 @@ const CompetitionRunnerPage = () => {
</div> </div>
</div> </div>
<div className="container mx-auto px-4 py-8"> <div className="min-h-screen bg-[#F8F8F8] pb-8">
<div className="rounded-lg bg-white p-6 shadow-sm"> <div className="mx-auto max-w-6xl px-4 py-6">
{selectedTaskId ? ( {currentTask ? (
<div className="font-hse-sans"> <div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<h2 className="mb-4 text-lg font-medium"> {/* Left Container - Task Description */}
Задание {tasks.find((t) => t.id === selectedTaskId)?.number} <div className="flex-1 rounded-lg bg-white p-6">
<h2 className="mb-4 text-xl font-medium">
Задача {currentTask.number}
</h2> </h2>
<p className="text-gray-700">
Содержание задания будет отображаться здесь. <div className="prose max-w-none text-gray-700">
<p>
Рассмотрим последовательность чисел 2, 3, 5, 9, 17, 33, 65,
129, ... Каждый член этой последовательности, начиная с
третьего, равен сумме двух предыдущих членов.
</p> </p>
<p className="mt-4">
Найдите сумму первых 15 членов этой последовательности.
</p>
<p className="mt-4">В ответе укажите целое число.</p>
</div>
</div>
{/* Right Container - Solution Area */}
<div className="flex flex-col gap-4 md:w-[350px]">
{/* Solution Status Card */}
<div
className={`${getTaskBgColor(currentTask.status)} relative rounded-lg p-4`}
>
<div className="flex flex-col">
<span
className={`${getTaskTextColor(currentTask.status)} font-medium`}
>
Решение 12345
</span>
<span
className={`${getTaskTextColor(currentTask.status)} mt-1`}
>
Зачтено 5/10 баллов
</span>
</div>
<div className="absolute right-3 bottom-2 text-xs text-gray-600">
1 марта, 08:41
</div>
</div>
{/* Answer Input */}
<div className="rounded-lg bg-white p-4">
<textarea
className="font-hse-sans h-32 w-full rounded-md border border-gray-300 p-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Введите ответ"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-between gap-3">
<Button
variant="outline"
className="font-hse-sans"
onClick={handleHistoryClick}
>
История
</Button>
<Button
className="font-hse-sans bg-yellow-400 text-black hover:bg-yellow-500"
onClick={handleSubmit}
>
Отправить решение
</Button>
</div>
</div>
</div> </div>
) : ( ) : (
<p className="font-hse-sans text-gray-500"> <div className="flex h-40 items-center justify-center rounded-lg bg-white">
Выберите задание для просмотра <p className="font-hse-sans text-gray-500">Загрузка задания...</p>
</p> </div>
)} )}
</div> </div>
</div> </div>
+11 -12
View File
@@ -1,4 +1,4 @@
import { Competition, CompetitionStatus } from "../types"; import { Competition, CompetitionStatus, Task } from "../types";
const mockCompetitions: Competition[] = [ const mockCompetitions: Competition[] = [
{ {
@@ -52,16 +52,15 @@ const mockCompetitions: Competition[] = [
}, },
]; ];
const mockTasks = { const mockTasks: Task[] = [
"1": [ { id: "1", number: "1.1", status: "uncleared" },
{ id: "1.1", number: "1.1", status: "uncleared" }, { id: "2", number: "1.2", status: "checking" },
{ id: "1.2", number: "1.2", status: "checking" }, { id: "3", number: "1.3", status: "correct" },
{ id: "1.3", number: "1.3", status: "correct" }, { id: "4", number: "2.1", status: "partial" },
], { id: "5", number: "2.2", status: "wrong" },
"2": [ { id: "6", number: "2.3", status: "uncleared" },
{ id: "2.1", number: "1.1", status: "uncleared" }, { id: "7", number: "3.1", status: "checking" },
{ id: "2.2", number: "1.2", status: "uncleared" }, { id: "8", number: "3.2", status: "correct" },
], ];
};
export { mockCompetitions, mockTasks }; export { mockCompetitions, mockTasks };
+18 -5
View File
@@ -38,6 +38,18 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0); --sidebar-ring: oklch(0.87 0 0);
--yellow-standard: oklch(0.9 0.1763 97.07);
--task-uncleared: oklch(0.955 0 0);
--task-text-uncleared: oklch(0.321 0 0);
--task-checking: oklch(0.941 0.0983 95.95);
--task-text-checking: oklch(0.588 0.120264 87.3807);
--task-correct: oklch(0.962 0.0561 158.62);
--task-text-correct: oklch(0.598 0.19517 143.8056);
--task-partial: oklch(0.971 0.0616 131.35);
--task-text-partial: oklch(0.639 0.1595 124.48);
--task-wrong: oklch(0.906 0.0484 18.08);
--task-text-wrong: oklch(0.433 0.17767 29.2339);
} }
@theme inline { @theme inline {
@@ -77,11 +89,6 @@
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
--task-uncleared: oklch(0.955 0 0);
--task-checking: oklch(0.899 0.1763 97.07);
--task-correct: oklch(0.962 0.0561 158.62);
--task-partial: oklch(0.971 0.0616 131.35);
--task-wrong: oklch(0.906 0.0484 18.08);
} }
@theme inline { @theme inline {
@@ -122,11 +129,17 @@
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-yellow-standard: var(--yellow-standard);
--color-task-uncleared: var(--task-uncleared); --color-task-uncleared: var(--task-uncleared);
--color-task-text-uncleared: var(--task-text-uncleared);
--color-task-checking: var(--task-checking); --color-task-checking: var(--task-checking);
--color-task-text-checking: var(--task-text-checking);
--color-task-correct: var(--task-correct); --color-task-correct: var(--task-correct);
--color-task-text-correct: var(--task-text-correct);
--color-task-partial: var(--task-partial); --color-task-partial: var(--task-partial);
--color-task-text-partial: var(--task-text-partial);
--color-task-wrong: var(--task-wrong); --color-task-wrong: var(--task-wrong);
--color-task-text-wrong: var(--task-text-wrong);
} }
@layer base { @layer base {
@@ -0,0 +1,24 @@
import { ChevronDown } from "lucide-react";
const Navbar = () => {
return (
<nav className="bg-white border-b border-gray-200 py-3 px-4 fixed top-0 left-0 right-0 z-10">
<div className="container mx-auto flex justify-between items-center">
<div className="flex items-center">
<div className="bg-black px-3 py-2 rounded font-hse-sans">
<span className="font-bold text-yellow-400">DATA</span>
<span className="font-bold text-white">RUSH</span>
</div>
</div>
<div className="flex items-center cursor-pointer">
<span className="mr-2 font-semibold font-hse-sans">itqdev</span>
<ChevronDown size={16} />
</div>
</div>
</nav>
);
};
export default Navbar