mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 22:37:10 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
@@ -54,6 +54,24 @@ build_backend-staticfiles:
|
||||
DOCKERFILE_PATH: "Dockerfile.staticfiles"
|
||||
IMAGE_NAME: "$CI_REGISTRY_IMAGE/backend-staticfiles"
|
||||
|
||||
build_checker:
|
||||
<<: *build-template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
variables:
|
||||
CONTEXT: "${CI_PROJECT_DIR}/services/checker"
|
||||
DOCKERFILE_PATH: "Dockerfile"
|
||||
IMAGE_NAME: "$CI_REGISTRY_IMAGE/checker"
|
||||
|
||||
build_custom-python:
|
||||
<<: *build-template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
variables:
|
||||
CONTEXT: "${CI_PROJECT_DIR}/services/checker"
|
||||
DOCKERFILE_PATH: "Dockerfile.checker"
|
||||
IMAGE_NAME: "$CI_REGISTRY_IMAGE/custom-python"
|
||||
|
||||
build_docs:
|
||||
<<: *build-template
|
||||
rules:
|
||||
|
||||
@@ -370,6 +370,23 @@ services:
|
||||
restart: unless-stopped
|
||||
shm_size: 4mb
|
||||
|
||||
checker:
|
||||
image: gitlab.prodcontest.ru:5050/team-15/project/checker:latest
|
||||
build:
|
||||
context: ./services/checker
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- name: web
|
||||
target: 8000
|
||||
published: 8009
|
||||
host_ip: 0.0.0.0
|
||||
protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/run/docker.sock
|
||||
target: /var/run/docker.sock
|
||||
|
||||
proxy:
|
||||
image: docker.io/nginx:1.27-alpine3.21
|
||||
configs:
|
||||
|
||||
@@ -18,3 +18,5 @@ MINIO_ENDPOINT=minio:9000
|
||||
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
|
||||
MINIO_ACCESS_KEY=admin
|
||||
MINIO_SECRET_KEY=password
|
||||
|
||||
CHECKER_API_ENDPOINT=http://checker:8000
|
||||
|
||||
@@ -24,4 +24,6 @@ FROM docker.io/nginx:latest
|
||||
|
||||
COPY --from=builder /app/static /usr/share/nginx/html
|
||||
|
||||
COPY ../checker/checker_requirements.txt .
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
|
||||
class AchievementSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Achievement
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from http import HTTPStatus as status
|
||||
|
||||
from ninja import Router
|
||||
|
||||
from api.v1.achievement.schemas import AchievementSchema
|
||||
from api.v1.schemas import UnauthorizedError
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response={
|
||||
status.OK: list[AchievementSchema],
|
||||
status.UNAUTHORIZED: UnauthorizedError,
|
||||
},
|
||||
)
|
||||
def get_all_achievements(request):
|
||||
return Achievement.objects.all()
|
||||
@@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema):
|
||||
competition: UUID = Field(..., alias="task.competition.id")
|
||||
competition_name: str = Field(..., alias="task.competition.title")
|
||||
task_position: int = Field(..., alias="task.in_competition_position")
|
||||
task_title: str = Field(..., alias="task.title")
|
||||
|
||||
@staticmethod
|
||||
def resolve_criteries(self, context) -> list[CriteriaOut] | None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus as status
|
||||
from statistics import median
|
||||
from uuid import UUID
|
||||
|
||||
from django.http import HttpRequest
|
||||
@@ -84,15 +85,22 @@ def evaluate_submission(
|
||||
review.evaluation = evaluation
|
||||
review.state = ReviewStatusChoices.CHECKED.value
|
||||
review.submission.checked_at = datetime.now()
|
||||
|
||||
points = 0
|
||||
for criterea in evaluation:
|
||||
points += criterea["mark"]
|
||||
review.submission.earned_points = (
|
||||
points # TODO: оценка не от последнего проверяющего а средняя по всем
|
||||
)
|
||||
review.save()
|
||||
|
||||
submission_evaluations = Review.objects.filter(
|
||||
submission=submission
|
||||
).values_list("evaluation", flat=True)
|
||||
|
||||
marks = []
|
||||
for evaluation in submission_evaluations:
|
||||
mark = 0
|
||||
for criterea in evaluation:
|
||||
mark += criterea["mark"]
|
||||
marks.append(mark)
|
||||
earned_points = median(marks)
|
||||
|
||||
review.submission.earned_points = earned_points
|
||||
|
||||
all_checked = not submission.reviews.exclude(
|
||||
state=ReviewStatusChoices.CHECKED
|
||||
).exists()
|
||||
@@ -101,4 +109,5 @@ def evaluate_submission(
|
||||
CompetitionTaskSubmission.StatusChoices.CHECKED.value
|
||||
)
|
||||
review.submission.save()
|
||||
|
||||
return status.OK, review.submission
|
||||
|
||||
@@ -3,10 +3,27 @@ from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
|
||||
from apps.task.models import (
|
||||
CompetitionTask,
|
||||
CompetitionTaskAttachment,
|
||||
CompetitionTaskSubmission,
|
||||
)
|
||||
|
||||
|
||||
class TaskOutSchema(ModelSchema):
|
||||
status: Literal["sent", "checked", "checking", "not_submitted"] = None
|
||||
type: Literal["input", "checker", "review"] = None
|
||||
|
||||
@staticmethod
|
||||
def resolve_status(
|
||||
self, context
|
||||
) -> Literal["sent", "checked", "checking", "not_submitted"]:
|
||||
if submission := CompetitionTaskSubmission.objects.filter(
|
||||
task=self, user=context.get("request").auth
|
||||
).first():
|
||||
return submission.status
|
||||
return "not_submitted"
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTask
|
||||
fields = [
|
||||
@@ -14,7 +31,6 @@ class TaskOutSchema(ModelSchema):
|
||||
"competition",
|
||||
"title",
|
||||
"description",
|
||||
"type",
|
||||
"in_competition_position",
|
||||
"points",
|
||||
]
|
||||
@@ -29,10 +45,19 @@ class HistorySubmissionOut(ModelSchema):
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTaskSubmission
|
||||
fields = ("id", "earned_points", "timestamp", "content",)
|
||||
fields = (
|
||||
"id",
|
||||
"earned_points",
|
||||
"timestamp",
|
||||
"content",
|
||||
)
|
||||
|
||||
|
||||
class TaskAttachmentSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = CompetitionTaskAttachment
|
||||
fields = ("id", "file", "public",)
|
||||
fields = (
|
||||
"id",
|
||||
"file",
|
||||
"public",
|
||||
)
|
||||
|
||||
@@ -155,5 +155,5 @@ def get_submissions_history(request, competition_id: UUID, task_id: UUID):
|
||||
def get_task_attachments(request, competition_id: UUID, task_id: UUID):
|
||||
task = get_object_or_404(CompetitionTask, id=task_id)
|
||||
return status.OK, CompetitionTaskAttachment.objects.filter(
|
||||
competition_id=competition_id, task=task, user=request.auth
|
||||
)
|
||||
task=task
|
||||
).all()
|
||||
|
||||
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
|
||||
class UserSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "username", "created_at",]
|
||||
fields = ["id", "email", "username", "created_at", "achievements"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
|
||||
|
||||
@admin.register(Achievement)
|
||||
class AchievementAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"name",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AchievementConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.achievement"
|
||||
verbose_name = "Ачивки"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:09
|
||||
|
||||
import apps.achievement.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Achievement',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=30, unique=True, verbose_name='название')),
|
||||
('description', models.TextField(verbose_name='описание')),
|
||||
('icon', models.FileField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ачивка',
|
||||
'verbose_name_plural': 'ачивки',
|
||||
},
|
||||
),
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='need_count',
|
||||
field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'),
|
||||
),
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 13:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')]
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0002_achievement_need_count_achievement_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='achievement',
|
||||
name='need_count',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='achievement',
|
||||
name='type',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='achievement',
|
||||
name='slug',
|
||||
field=models.SlugField(unique=True, verbose_name='слаг'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
class Achievement(BaseModel):
|
||||
class AchievementType(models.TextChoices):
|
||||
CORRECT_TASKS = "correct_tasks", "Выполненные задания"
|
||||
|
||||
def image_url_upload_to(instance, filename):
|
||||
return f"achievements/{instance.id}/icon/{filename}"
|
||||
|
||||
name = models.CharField(
|
||||
max_length=30, verbose_name="название", unique=True
|
||||
)
|
||||
description = models.TextField(verbose_name="описание")
|
||||
icon = models.FileField(
|
||||
verbose_name="иконка достижения",
|
||||
upload_to=image_url_upload_to,
|
||||
)
|
||||
|
||||
slug = models.SlugField(verbose_name="слаг", unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "ачивка"
|
||||
verbose_name_plural = "ачивки"
|
||||
@@ -15,7 +15,7 @@ class Competition(BaseModel):
|
||||
SOLO = "solo", "Индивидуальный"
|
||||
|
||||
def image_url_upload_to(instance, filename):
|
||||
return f"/competitions/{instance.id}/image"
|
||||
return f"competitions/{instance.id}/image/{filename}"
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="название")
|
||||
description = models.TextField(verbose_name="описание")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.competition.models import Competition, State
|
||||
from apps.review.models import Review, Reviewer
|
||||
from apps.review.models import Reviewer
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||
from apps.user.models import User, UserRole
|
||||
|
||||
@@ -99,11 +99,13 @@ class Command(BaseCommand):
|
||||
title = f"Task {i} for {comp.title}"
|
||||
description = f"Task description for task {i} in {comp.title}"
|
||||
task = CompetitionTask.objects.create(
|
||||
in_competition_position=i,
|
||||
competition=comp,
|
||||
title=title,
|
||||
description=description,
|
||||
type=task_type,
|
||||
points=random.randint(1, 10),
|
||||
submission_reviewers_count=random.randint(2, 10),
|
||||
max_attempts=random.randint(1, 10),
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.review.models import Review, Reviewer
|
||||
from apps.review.models import Reviewer
|
||||
|
||||
|
||||
@admin.register(Reviewer)
|
||||
class ReviewersAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "surname",)
|
||||
search_fields = ("name", "surname",)
|
||||
list_display = (
|
||||
"name",
|
||||
"surname",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"surname",
|
||||
)
|
||||
|
||||
@@ -5,3 +5,6 @@ class CoreConfig(AppConfig):
|
||||
name = "apps.review"
|
||||
label = "review"
|
||||
verbose_name = "Проверка"
|
||||
|
||||
def ready(self):
|
||||
import apps.review.signals
|
||||
|
||||
@@ -24,22 +24,24 @@ class ReviewStatusChoices(models.TextChoices):
|
||||
|
||||
|
||||
class Review(BaseModel):
|
||||
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
|
||||
verbose_name="проверяющий")
|
||||
reviewer = models.ForeignKey(
|
||||
Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий"
|
||||
)
|
||||
submission = models.ForeignKey(
|
||||
"task.CompetitionTaskSubmission",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reviews",
|
||||
verbose_name="посылка"
|
||||
verbose_name="посылка",
|
||||
)
|
||||
|
||||
evaluation = models.JSONField(default=list, null=True, blank=True,
|
||||
verbose_name="выполнение")
|
||||
evaluation = models.JSONField(
|
||||
default=list, null=True, blank=True, verbose_name="выполнение"
|
||||
)
|
||||
state = models.CharField(
|
||||
choices=ReviewStatusChoices.choices,
|
||||
default=ReviewStatusChoices.NOT_CHECKED.value,
|
||||
max_length=11,
|
||||
verbose_name="состояние"
|
||||
verbose_name="состояние",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# myapp/signals.py
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.review.models import Review
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=CompetitionTask.reviewers.through)
|
||||
def print_reviewers(sender, instance, action, **kwargs):
|
||||
if action in ['post_add', 'post_remove', 'post_clear']:
|
||||
submissions = CompetitionTaskSubmission.objects.filter(task=instance)
|
||||
for submission in submissions:
|
||||
submission.send_on_review()
|
||||
@@ -1,7 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \
|
||||
CompetitionTaskSubmission
|
||||
from apps.task.models import (
|
||||
CompetitionTask,
|
||||
CompetitionTaskAttachment,
|
||||
CompetitionTaskSubmission,
|
||||
)
|
||||
|
||||
|
||||
class CompletionAttachmentInline(admin.StackedInline):
|
||||
@@ -12,15 +15,22 @@ class CompletionAttachmentInline(admin.StackedInline):
|
||||
@admin.register(CompetitionTask)
|
||||
class CompetitionTaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "type", "points")
|
||||
filter_horizontal = (
|
||||
"reviewers",
|
||||
)
|
||||
filter_horizontal = ("reviewers",)
|
||||
|
||||
|
||||
@admin.register(CompetitionTaskSubmission)
|
||||
class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ("task", "user", "status",)
|
||||
search_fields = ("task__id", "task__title", "user__username", "user__email")
|
||||
list_display = (
|
||||
"task",
|
||||
"user",
|
||||
"status",
|
||||
)
|
||||
search_fields = (
|
||||
"task__id",
|
||||
"task__title",
|
||||
"user__username",
|
||||
"user__email",
|
||||
)
|
||||
filter = ("plagiarism_checked",)
|
||||
ordering = ["-timestamp"]
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:09
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0002_initial'),
|
||||
('task', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='competitiontasksubmission',
|
||||
options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='reviewers',
|
||||
field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='checked_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='content',
|
||||
field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='earned_points',
|
||||
field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='plagiarism_checked',
|
||||
field=models.BooleanField(default=False, verbose_name='проверено на плагиат'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='result',
|
||||
field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='stdout',
|
||||
field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontasksubmission',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:23
|
||||
|
||||
import tinymce.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0002_alter_competitiontasksubmission_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='description',
|
||||
field=tinymce.models.HTMLField(verbose_name='описание'),
|
||||
),
|
||||
]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:49
|
||||
|
||||
import martor.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0003_alter_competitiontask_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competitiontask',
|
||||
name='submission_reviewers_count',
|
||||
field=models.PositiveSmallIntegerField(blank=True, default=1, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='description',
|
||||
field=martor.models.MartorField(verbose_name='описание'),
|
||||
),
|
||||
]
|
||||
@@ -2,11 +2,11 @@ from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from tinymce.models import HTMLField
|
||||
from martor.models import MartorField
|
||||
|
||||
from apps.competition.models import Competition
|
||||
from apps.core.models import BaseModel
|
||||
from apps.review.models import Review, ReviewStatusChoices, Reviewer
|
||||
from apps.review.models import Review, Reviewer, ReviewStatusChoices
|
||||
from apps.user.models import User
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ class CompetitionTask(BaseModel):
|
||||
REVIEW = "review", "Ручная"
|
||||
|
||||
def answer_file_upload_to(instance, filename) -> str:
|
||||
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
|
||||
return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
|
||||
|
||||
in_competition_position = models.PositiveSmallIntegerField(
|
||||
null=True, blank=True
|
||||
)
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||
title = models.CharField(verbose_name="заголовок", max_length=50)
|
||||
description = HTMLField(verbose_name="описание", max_length=300)
|
||||
description = MartorField(verbose_name="описание")
|
||||
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
type = models.CharField(
|
||||
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
||||
@@ -55,7 +55,10 @@ class CompetitionTask(BaseModel):
|
||||
Reviewer,
|
||||
blank=True,
|
||||
verbose_name="ревьюверы",
|
||||
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему"
|
||||
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему",
|
||||
)
|
||||
submission_reviewers_count = models.PositiveSmallIntegerField(
|
||||
default=1, null=True, blank=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@@ -79,12 +82,12 @@ class CompetitionTaskCriteria(BaseModel):
|
||||
|
||||
class CompetitionTaskAttachment(BaseModel):
|
||||
def file_upload_at(instance, filename):
|
||||
return f"/attachment/{instance.id}/file"
|
||||
return f"attachment/{instance.id}/file/{filename}"
|
||||
|
||||
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
|
||||
verbose_name="задание")
|
||||
file = models.FileField(upload_to=file_upload_at,
|
||||
verbose_name="файл")
|
||||
task = models.ForeignKey(
|
||||
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
|
||||
)
|
||||
file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
|
||||
bind_at = models.FilePathField(verbose_name="путь сохранения")
|
||||
public = models.BooleanField(default=False, verbose_name="публичный")
|
||||
|
||||
@@ -96,50 +99,61 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
CHECKED = "checked"
|
||||
|
||||
def submission_content_upload_to(instance, filename) -> str:
|
||||
return f"submissions/{instance.id}/content"
|
||||
return f"submissions/{instance.id}/content/{filename}"
|
||||
|
||||
def submission_stdout_upload_to(instance, filename) -> str:
|
||||
return f"/submissions/{instance.id}/stdout"
|
||||
return f"submissions/{instance.id}/stdout/{filename}"
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE,
|
||||
verbose_name="пользователь")
|
||||
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
|
||||
verbose_name="задание")
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, verbose_name="пользователь"
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
choices=StatusChoices.choices,
|
||||
default=StatusChoices.SENT,
|
||||
max_length=8,
|
||||
verbose_name="статус"
|
||||
verbose_name="статус",
|
||||
)
|
||||
|
||||
# code or text or file
|
||||
content = models.FileField(upload_to=submission_content_upload_to,
|
||||
verbose_name="содержание посылки")
|
||||
content = models.FileField(
|
||||
upload_to=submission_content_upload_to,
|
||||
verbose_name="содержание посылки",
|
||||
)
|
||||
|
||||
# only if task type is checker
|
||||
stdout = models.FileField(
|
||||
upload_to=submission_stdout_upload_to, null=True, blank=True,
|
||||
upload_to=submission_stdout_upload_to,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="вывод программы",
|
||||
help_text="Используется только при проверке чекером"
|
||||
help_text="Используется только при проверке чекером",
|
||||
)
|
||||
|
||||
# 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,
|
||||
verbose_name="результат проверки")
|
||||
result = models.JSONField(
|
||||
default=None, null=True, blank=True, verbose_name="результат проверки"
|
||||
)
|
||||
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
|
||||
earned_points = models.IntegerField(null=True, blank=True,
|
||||
verbose_name="баллы за задание")
|
||||
earned_points = models.IntegerField(
|
||||
null=True, blank=True, verbose_name="баллы за задание"
|
||||
)
|
||||
|
||||
checked_at = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name="дата проверки")
|
||||
plagiarism_checked = models.BooleanField(default=False,
|
||||
verbose_name="проверено на плагиат")
|
||||
timestamp = models.DateTimeField(auto_now_add=True,
|
||||
verbose_name="дата отправки")
|
||||
checked_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name="дата проверки"
|
||||
)
|
||||
plagiarism_checked = models.BooleanField(
|
||||
default=False, verbose_name="проверено на плагиат"
|
||||
)
|
||||
timestamp = models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="дата отправки"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "посылка"
|
||||
@@ -152,8 +166,8 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
if not self.task.reviewers.exists():
|
||||
return
|
||||
|
||||
reviewer = (
|
||||
self.task.reviewers.annotate(
|
||||
reviewers_count = self.task.submission_reviewers_count
|
||||
reviewers = self.task.reviewers.annotate(
|
||||
pending_count=Count(
|
||||
"review",
|
||||
filter=Q(
|
||||
@@ -163,11 +177,12 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("pending_count")
|
||||
.first()
|
||||
)
|
||||
review = Review.objects.create(
|
||||
).order_by("pending_count")[
|
||||
:reviewers_count
|
||||
] # да это медленно работает и чо
|
||||
|
||||
for reviewer in reviewers:
|
||||
Review.objects.update_or_create(
|
||||
reviewer=reviewer,
|
||||
submission=self,
|
||||
)
|
||||
|
||||
@@ -1,148 +1,50 @@
|
||||
import ast
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from io import StringIO
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from config.celery import app
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
ALLOWED_MODULES = {
|
||||
"pandas",
|
||||
"numpy",
|
||||
"matplotlib",
|
||||
"seaborn",
|
||||
"scipy",
|
||||
"sklearn",
|
||||
"datetime",
|
||||
"json",
|
||||
"csv",
|
||||
"math",
|
||||
"statistics",
|
||||
"statsmodels",
|
||||
}
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SecurityException(Exception):
|
||||
pass
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def analyze_data_task(self, submission_id):
|
||||
from .models import CompetitionTaskSubmission
|
||||
|
||||
|
||||
def validate_code(code_str):
|
||||
try:
|
||||
tree = ast.parse(code_str)
|
||||
except SyntaxError as e:
|
||||
raise SecurityException(f"Syntax error: {e!s}")
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise SecurityException(f"Security check failed: {e!s}")
|
||||
|
||||
|
||||
def secure_exec(code_str, result_path, input_files=None):
|
||||
original_dir = os.getcwd()
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = captured_stdout = StringIO()
|
||||
result_content = None
|
||||
|
||||
if input_files is None:
|
||||
input_files = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
os.chdir(temp_dir)
|
||||
|
||||
for file in input_files:
|
||||
file_path = os.path.join(temp_dir, file["bind_at"])
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file["content"])
|
||||
|
||||
restricted_globals = {
|
||||
"__builtins__": {
|
||||
"open": open,
|
||||
"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: {e!s}")
|
||||
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_file_link, submission_id, input_files=[]
|
||||
):
|
||||
try:
|
||||
validate_code(code_str)
|
||||
|
||||
result_content = secure_exec(code_str, result_path, input_files)
|
||||
|
||||
result_hash = hashlib.sha256(result_content).hexdigest()
|
||||
expected_hash = hashlib.sha256(expected_bytes).hexdigest()
|
||||
|
||||
with contextlib.suppress(CompetitionTaskSubmission.DoesNotExist):
|
||||
submission = CompetitionTaskSubmission.objects.get(id=submission_id)
|
||||
submission.result = {"correct": True}
|
||||
try:
|
||||
code = submission.content.read().decode()
|
||||
files = [
|
||||
(f.name, f.file.open("rb"))
|
||||
for f in submission.task.attachments.filter(public=True)
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"match": result_hash == expected_hash,
|
||||
"result_hash": result_hash,
|
||||
"expected_hash": expected_hash,
|
||||
response = requests.post(
|
||||
f"{settings.CHECKER_API_ENDPOINT}/execute",
|
||||
files=[("files", (f.name, f)) for f in files]
|
||||
+ [
|
||||
("code", code),
|
||||
("expected_hash", submission.task.correct_answer_hash),
|
||||
],
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
submission.stdout.save("output.txt", ContentFile(result["output"]))
|
||||
submission.result = {
|
||||
"correct": result["hash_match"],
|
||||
"result_hash": result["result_hash"],
|
||||
"error": result.get("error"),
|
||||
}
|
||||
submission.earned_points = (
|
||||
submission.task.points if result["hash_match"] else 0
|
||||
)
|
||||
submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
|
||||
|
||||
except SecurityException as e:
|
||||
return {"success": False, "error": f"Security violation: {e!s}"}
|
||||
except RuntimeError as e:
|
||||
return {"success": False, "error": f"Execution error: {e!s}"}
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.retry(countdown=2**self.request.retries)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Unexpected error: {e!s}"}
|
||||
submission.result = {"error": str(e)}
|
||||
submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
|
||||
submission.earned_points = 0
|
||||
finally:
|
||||
submission.save()
|
||||
|
||||
@@ -28,5 +28,3 @@ with open("file.txt") as f:
|
||||
print(result)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["match"])
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ from apps.user.models import User
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ("email", "username")
|
||||
search_fields = ("id", "email", "username")
|
||||
filter_horizontal = ("achievements",)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 12:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('achievement', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='achievements',
|
||||
field=models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.db import models
|
||||
|
||||
from apps.achievement.models import Achievement
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
@@ -16,6 +17,10 @@ class User(BaseModel):
|
||||
|
||||
created_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
achievements = models.ManyToManyField(
|
||||
Achievement, blank=True, verbose_name="ачивки пользователя"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_password(password: str):
|
||||
return make_password(password)
|
||||
|
||||
@@ -7,7 +7,9 @@ from pathlib import Path
|
||||
|
||||
import django_stubs_ext
|
||||
import environ
|
||||
from health_check.plugins import plugin_dir
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from integrations.checker.healthcheck import CheckerHealthCheck
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -30,18 +32,12 @@ ALLOWED_HOSTS = env(
|
||||
|
||||
# Integrations
|
||||
|
||||
YANDEX_CLOUD_FOLDER_ID = env("YANDEX_CLOUD_FOLDER_ID", default=None)
|
||||
|
||||
YANDEX_CLOUD_API_KEY = env("YANDEX_CLOUD_API_KEY", default=None)
|
||||
|
||||
YANDEX_CLOUD_INTEGRATION_ENABLED = (
|
||||
YANDEX_CLOUD_FOLDER_ID and YANDEX_CLOUD_API_KEY
|
||||
)
|
||||
CHECKER_API_ENDPOINT = env("CHECKER_API_ENDPOINT", default=None)
|
||||
|
||||
|
||||
# Register healthchecks
|
||||
|
||||
# plugin_dir.register(SomeHealthCheckClass)
|
||||
plugin_dir.register(CheckerHealthCheck)
|
||||
|
||||
|
||||
# Caching
|
||||
@@ -442,6 +438,7 @@ INSTALLED_APPS = [
|
||||
"ninja",
|
||||
"minio_storage",
|
||||
"tinymce",
|
||||
"martor",
|
||||
# Internal apps
|
||||
"apps.core",
|
||||
"apps.user",
|
||||
@@ -449,6 +446,7 @@ INSTALLED_APPS = [
|
||||
"apps.review",
|
||||
"apps.task",
|
||||
"apps.team",
|
||||
"apps.achievement",
|
||||
]
|
||||
|
||||
# tinymce
|
||||
@@ -458,15 +456,58 @@ TINYMCE_DEFAULT_CONFIG = {
|
||||
"menubar": False,
|
||||
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
|
||||
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
|
||||
"code,help,wordcount",
|
||||
"code,help,wordcount,markdown",
|
||||
"toolbar": "undo redo | formatselect | "
|
||||
"bold italic backcolor | alignleft aligncenter "
|
||||
"alignright alignjustify | bullist numlist outdent indent | "
|
||||
"removeformat | help",
|
||||
"skin": "oxide-dark",
|
||||
"content_css": "dark",
|
||||
"textpattern_patterns": [
|
||||
{"start": "*", "end": "*", "format": "italic"},
|
||||
{"start": "**", "end": "**", "format": "bold"},
|
||||
{"start": "#", "format": "h1"},
|
||||
{"start": "##", "format": "h2"},
|
||||
{"start": "###", "format": "h3"},
|
||||
{"start": "####", "format": "h4"},
|
||||
{"start": "#####", "format": "h5"},
|
||||
{"start": "######", "format": "h6"},
|
||||
{"start": "1. ", "cmd": "InsertOrderedList"},
|
||||
{"start": "* ", "cmd": "InsertUnorderedList"},
|
||||
{"start": "- ", "cmd": "InsertUnorderedList"},
|
||||
],
|
||||
}
|
||||
|
||||
# martor
|
||||
|
||||
MARTOR_THEME = "bootstrap"
|
||||
|
||||
MARTOR_ENABLE_CONFIGS = {
|
||||
"emoji": "true", # to enable/disable emoji icons.
|
||||
"imgur": "true", # to enable/disable imgur/custom uploader.
|
||||
"mention": "false", # to enable/disable mention
|
||||
"jquery": "true", # to include/revoke jquery (require for admin default django)
|
||||
"living": "false", # to enable/disable live updates in preview
|
||||
"spellcheck": "false", # to enable/disable spellcheck in form textareas
|
||||
"hljs": "true", # to enable/disable hljs highlighting in preview
|
||||
}
|
||||
|
||||
MARTOR_TOOLBAR_BUTTONS = [
|
||||
"bold",
|
||||
"italic",
|
||||
"horizontal",
|
||||
"heading",
|
||||
"pre-code",
|
||||
"blockquote",
|
||||
"unordered-list",
|
||||
"ordered-list",
|
||||
"link",
|
||||
"emoji",
|
||||
"direct-mention",
|
||||
"toggle-maximize",
|
||||
"help",
|
||||
]
|
||||
|
||||
# GUID
|
||||
|
||||
DJANGO_GUID = {
|
||||
|
||||
@@ -14,6 +14,8 @@ admin.site.index_title = "DataRush"
|
||||
urlpatterns = [
|
||||
# tinymce
|
||||
path("tinymce/", include("tinymce.urls")),
|
||||
# martor
|
||||
path("martor/", include("martor.urls")),
|
||||
# Admin urls
|
||||
path("admin/", admin.site.urls),
|
||||
# API urls
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from http import HTTPStatus as status
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from health_check.backends import BaseHealthCheckBackend
|
||||
|
||||
|
||||
class CheckerHealthCheck(BaseHealthCheckBackend):
|
||||
critical_service = False
|
||||
|
||||
def check_status(self) -> None:
|
||||
try:
|
||||
response = httpx.get(
|
||||
f"{settings.CHECKER_API_ENDPOINT}/ping", timeout=1
|
||||
)
|
||||
if response.status_code >= status.INTERNAL_SERVER_ERROR:
|
||||
self.add_error("Checker service is unaccessible")
|
||||
except httpx.HTTPError:
|
||||
self.add_error("Checker service is unaccessible")
|
||||
|
||||
def identifier(self) -> str:
|
||||
return self.__class__.__name__
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"django-tinymce>=4.1.0",
|
||||
"gunicorn>=23.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"martor>=1.6.45",
|
||||
"pillow>=11.1.0",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pydantic>=2.10.5",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Ruff files
|
||||
.ruff_cache
|
||||
@@ -0,0 +1,42 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM docker.io/python:3.11-alpine3.20 AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONOPTIMIZE=2 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN uv sync --no-dev --no-install-project --no-cache
|
||||
|
||||
|
||||
# Stage 2: Start the application
|
||||
FROM docker.io/python:3.11-alpine3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN adduser -D -g '' app && chown -R app:app ./
|
||||
|
||||
USER app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONOPTIMIZE=2 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/ping || exit 1
|
||||
|
||||
CMD uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM docker.io/python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY checker_requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r checker_requirements.txt
|
||||
|
||||
CMD ["python"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# DataRush Checker
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure you have the following installed on your system:
|
||||
|
||||
- [Python](https://www.python.org/) (>=3.10,<3.12)
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- [Docker](https://www.docker.com/) (for containerized setup)
|
||||
|
||||
## Basic setup
|
||||
|
||||
### Installation
|
||||
|
||||
#### Clone the project
|
||||
|
||||
```bash
|
||||
git clone git@gitlab.prodcontest.ru:team-15/project.git
|
||||
```
|
||||
|
||||
#### Go to the project directory
|
||||
|
||||
```bash
|
||||
cd project/services/checker
|
||||
```
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
##### For dev environment
|
||||
|
||||
```bash
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
##### For prod environment
|
||||
|
||||
```bash
|
||||
uv sync --no-dev
|
||||
```
|
||||
|
||||
#### Running
|
||||
|
||||
##### Apply migrations
|
||||
|
||||
```bash
|
||||
uv run python manage.py migrate
|
||||
```
|
||||
|
||||
##### Start celery worker
|
||||
|
||||
```bash
|
||||
celery -A config worker -l INFO
|
||||
```
|
||||
|
||||
##### Start server
|
||||
|
||||
In dev mode:
|
||||
|
||||
```bash
|
||||
uv run python manage.py runserver
|
||||
```
|
||||
|
||||
In prod mode:
|
||||
|
||||
```bash
|
||||
uv run gunicorn config.wsgi
|
||||
```
|
||||
|
||||
## Containerized setup
|
||||
|
||||
### Clone the project
|
||||
|
||||
```bash
|
||||
git clone git@gitlab.prodcontest.ru:team-15/project.git
|
||||
```
|
||||
|
||||
### Go to the project directory
|
||||
|
||||
```bash
|
||||
cd project/services/checker
|
||||
```
|
||||
|
||||
### Build docker image
|
||||
|
||||
```bash
|
||||
docker build -t datarush-checker .
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
pandas==2.2.3
|
||||
numpy==2.2.3
|
||||
matplotlib==3.10.1
|
||||
scipy==1.15.2
|
||||
scikit-learn==1.6.1
|
||||
seaborn==0.13.2
|
||||
statsmodels==0.14.4
|
||||
@@ -0,0 +1,289 @@
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from pydantic import BaseModel, Field, HttpUrl, constr
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import docker
|
||||
import hashlib
|
||||
import os
|
||||
import base64
|
||||
import tempfile
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
|
||||
app = FastAPI()
|
||||
docker_client = docker.from_env()
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
DOCKER_IMAGE = "gitlab.python:3-slim"
|
||||
CONTAINER_TIMEOUT = 60
|
||||
MAX_FILE_SIZE = 4 * 1024 * 1024
|
||||
ALLOWED_FILENAME_CHARS = r"[^a-zA-Z0-9_\-.]"
|
||||
|
||||
|
||||
class FileDetails(BaseModel):
|
||||
url: HttpUrl = Field(
|
||||
..., description="URL to download the file from (supports HTTP/HTTPS)"
|
||||
)
|
||||
bind_path: str = Field(
|
||||
...,
|
||||
description="Container path to bind the file (absolute)",
|
||||
)
|
||||
|
||||
|
||||
class ExecutionRequest(BaseModel):
|
||||
code: str = Field(..., description="Base64 encoded Python code to execute")
|
||||
answer_file_path: str = Field(
|
||||
"stdout", description="Base64 encoded path to result file or 'stdout'"
|
||||
)
|
||||
expected_hash: str | None = Field(
|
||||
None, description="Optional SHA-256 hash of expected output"
|
||||
)
|
||||
files: list[FileDetails] = Field(
|
||||
[], description="List of files to mount in container"
|
||||
)
|
||||
|
||||
|
||||
class ExecutionResponse(BaseModel):
|
||||
success: bool = Field(..., description="Execution success status")
|
||||
hash_match: bool | None = Field(
|
||||
None, description="Output hash matches expected (if provided)"
|
||||
)
|
||||
output: str = Field(..., description="Captured stdout or file contents")
|
||||
result_hash: str = Field(..., description="SHA-256 hash of output")
|
||||
error: str = Field(..., description="Execution errors or stderr")
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
status: str = Field(..., description="Service health status")
|
||||
docker: str = Field(..., description="Docker daemon status")
|
||||
|
||||
|
||||
def decode_base64(encoded_str: str, field_name: str) -> str:
|
||||
try:
|
||||
return base64.b64decode(encoded_str).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"Base64 decode failed for {field_name}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid Base64 in {field_name}",
|
||||
)
|
||||
|
||||
|
||||
def sanitize_filename(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
base_name = os.path.basename(parsed.path)
|
||||
|
||||
if not base_name:
|
||||
base_name = "file"
|
||||
|
||||
clean = re.sub(ALLOWED_FILENAME_CHARS, "", base_name)[:255]
|
||||
return clean or "file"
|
||||
|
||||
|
||||
async def download_file(
|
||||
session: aiohttp.ClientSession, url: str, dest_path: str
|
||||
) -> None:
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to download {url} - Status {resp.status}",
|
||||
)
|
||||
|
||||
content = b""
|
||||
async for chunk in resp.content.iter_chunked(8192):
|
||||
content += chunk
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File size exceeds 4MB limit",
|
||||
)
|
||||
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(content)
|
||||
logger.info(f"Downloaded {url} to {dest_path}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Download error for {url}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Download failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
def run_container_safely(
|
||||
tmp_dir: str,
|
||||
command: list[str],
|
||||
bound_files: dict[str, str],
|
||||
timeout: int = CONTAINER_TIMEOUT,
|
||||
) -> dict:
|
||||
container = None
|
||||
try:
|
||||
volumes = {tmp_dir: {"bind": "/execution", "mode": "rw"}}
|
||||
for host_path, container_path in bound_files.items():
|
||||
volumes[host_path] = {"bind": container_path, "mode": "ro"}
|
||||
|
||||
container = docker_client.containers.run(
|
||||
image=DOCKER_IMAGE,
|
||||
command=command,
|
||||
volumes=volumes,
|
||||
working_dir="/execution",
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
detach=True,
|
||||
mem_limit="100m",
|
||||
network_mode="none",
|
||||
cpu_period=100000,
|
||||
cpu_quota=50000,
|
||||
user="root",
|
||||
security_opt=["no-new-privileges"],
|
||||
)
|
||||
|
||||
exit_code = container.wait(timeout=timeout)["StatusCode"]
|
||||
stdout = container.logs(stdout=True, stderr=False).decode().strip()
|
||||
stderr = container.logs(stdout=False, stderr=True).decode().strip()
|
||||
|
||||
return {"stdout": stdout, "stderr": stderr, "status": exit_code}
|
||||
|
||||
except docker.errors.DockerException as e:
|
||||
logger.error(f"Docker error: {str(e)}")
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"Container error: {str(e)}",
|
||||
"status": -1,
|
||||
}
|
||||
finally:
|
||||
if container:
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except docker.errors.DockerException:
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/execute", response_model=ExecutionResponse)
|
||||
async def execute_code(request: ExecutionRequest) -> ExecutionResponse:
|
||||
try:
|
||||
code = decode_base64(request.code, "code")
|
||||
answer_path = (
|
||||
decode_base64(request.answer_file_path, "answer_file_path")
|
||||
if request.answer_file_path != "stdout"
|
||||
else "stdout"
|
||||
)
|
||||
except HTTPException as e:
|
||||
return ExecutionResponse(
|
||||
success=False,
|
||||
output="",
|
||||
result_hash="",
|
||||
error=e.detail,
|
||||
hash_match=None,
|
||||
)
|
||||
|
||||
if answer_path != "stdout":
|
||||
if os.path.isabs(answer_path) or not validate_file_path(answer_path):
|
||||
return ExecutionResponse(
|
||||
success=False,
|
||||
output="",
|
||||
result_hash="",
|
||||
error="Invalid answer file path",
|
||||
hash_match=None,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
bound_files = {}
|
||||
if request.files:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
download_tasks = []
|
||||
for file in request.files:
|
||||
filename = sanitize_filename(str(file.url))
|
||||
dest_path = os.path.join(tmp_dir, filename)
|
||||
bound_files[dest_path] = file.bind_path
|
||||
download_tasks.append(
|
||||
download_file(session, str(file.url), dest_path)
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.gather(*download_tasks)
|
||||
except HTTPException as e:
|
||||
return ExecutionResponse(
|
||||
success=False,
|
||||
output="",
|
||||
result_hash="",
|
||||
error=e.detail,
|
||||
hash_match=None,
|
||||
)
|
||||
|
||||
code_path = os.path.join(tmp_dir, "submission.py")
|
||||
with open(code_path, "w") as f:
|
||||
f.write(code)
|
||||
os.chmod(code_path, 0o444)
|
||||
|
||||
if answer_path == "stdout":
|
||||
cmd = ["python", "submission.py"]
|
||||
else:
|
||||
cmd = [
|
||||
"sh",
|
||||
"-c",
|
||||
f"python submission.py && cat {answer_path} || echo 'EXECUTION_FAILED'",
|
||||
]
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
run_container_safely,
|
||||
tmp_dir,
|
||||
cmd,
|
||||
bound_files,
|
||||
CONTAINER_TIMEOUT,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Container execution failed: {str(e)}")
|
||||
return ExecutionResponse(
|
||||
success=False,
|
||||
output="",
|
||||
result_hash="",
|
||||
error=f"Execution failed: {str(e)}",
|
||||
hash_match=None,
|
||||
)
|
||||
|
||||
output = result["stdout"]
|
||||
error = result["stderr"]
|
||||
success = result["status"] == 0
|
||||
|
||||
if answer_path != "stdout" and not output:
|
||||
error += "\nNo output captured - check answer file path"
|
||||
|
||||
result_hash = hashlib.sha256(output.encode()).hexdigest()
|
||||
|
||||
return ExecutionResponse(
|
||||
success=success,
|
||||
hash_match=(
|
||||
result_hash == request.expected_hash
|
||||
if request.expected_hash
|
||||
else None
|
||||
),
|
||||
output=output[:5000],
|
||||
result_hash=result_hash,
|
||||
error=error[:5000],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthCheckResponse)
|
||||
async def health_check() -> HealthCheckResponse:
|
||||
try:
|
||||
docker_client.ping()
|
||||
return HealthCheckResponse(status="healthy", docker="connected")
|
||||
except docker.errors.DockerException:
|
||||
return HealthCheckResponse(status="degraded", docker="unavailable")
|
||||
|
||||
|
||||
def validate_file_path(path: str) -> bool:
|
||||
return (
|
||||
not os.path.isabs(path)
|
||||
and os.path.basename(path) == path
|
||||
and all(c.isalnum() or c in {"_", "-", "."} for c in path)
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "checker"
|
||||
version = "0.1.0"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"aiohttp>=3.11.13",
|
||||
"docker>=7.1.0",
|
||||
"fastapi>=0.115.11",
|
||||
"python-multipart>=0.0.20",
|
||||
"regex>=2024.11.6",
|
||||
"uvicorn>=0.34.0",
|
||||
]
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
GREEN='\033[1;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
uvx ruff format .
|
||||
uvx ruff check . --fix
|
||||
printf "${GREEN}Linters/formatters runned${NC}\n"
|
||||
@@ -10,7 +10,6 @@ import LoginPage from "./pages/Login";
|
||||
import { AuthLayout } from "./widgets/auth-layout";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReviewPage from "./pages/Review";
|
||||
import CompetitionConstructor from "./pages/CompetitionConstructor";
|
||||
import UserProfile from "./pages/UserProfile";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -32,15 +31,6 @@ const App = () => {
|
||||
element={<CompetitionSession />}
|
||||
/>
|
||||
|
||||
<Route path="/constructor/:id" element={<CompetitionConstructor />} />
|
||||
|
||||
<Route path="/constructor/new" element={<CompetitionConstructor />} />
|
||||
|
||||
<Route
|
||||
path="/constructor/:id/tasks/:taskId"
|
||||
element={<CompetitionConstructor />}
|
||||
/>
|
||||
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -2,38 +2,56 @@ import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { mockTasks } from "@/shared/mocks/mocks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCompetition } from "@/shared/api/competitions";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { getCompetition, startCompetition } from "@/shared/api/competitions";
|
||||
import { getCompetitionTasks } from "@/shared/api/session";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
|
||||
const CompetitionPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const competitionId = id || "";
|
||||
|
||||
const { data: competition, isLoading } = useQuery({
|
||||
queryKey: ["competition", id],
|
||||
queryFn: async () => getCompetition(id || ""),
|
||||
const competitionQuery = useQuery({
|
||||
queryKey: ["competition", competitionId],
|
||||
queryFn: () => getCompetition(competitionId),
|
||||
enabled: !!competitionId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startCompetition(competitionId),
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
const tasks = await getCompetitionTasks(competitionId);
|
||||
|
||||
if (tasks && tasks.length > 0) {
|
||||
navigate(`/competition/${competitionId}/tasks/${tasks[0].id}`);
|
||||
} else {
|
||||
navigate(`/competition/${competitionId}/tasks`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tasks:", error);
|
||||
navigate(`/competition/${competitionId}/tasks`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to start competition:", error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
startMutation.mutate();
|
||||
};
|
||||
|
||||
if (competitionQuery.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!id || !competition) {
|
||||
if (!competitionId || !competitionQuery.data) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const competition = competitionQuery.data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -46,15 +64,13 @@ const CompetitionPage = () => {
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{competition.image_url && (
|
||||
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={competition.image_url}
|
||||
src={competition.image_url ? competition.image_url : '/DANO.png'}
|
||||
alt={competition.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-8 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
@@ -66,8 +82,12 @@ const CompetitionPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full *:w-full md:w-96">
|
||||
<Button size={"lg"} onClick={handleContinue}>
|
||||
Продолжить
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleStart}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Task } from "@/shared/types";
|
||||
import { Settings, Plus } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ConstructorHeaderProps {
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
competitionId: string;
|
||||
onAddTaskClick: () => void;
|
||||
}
|
||||
|
||||
const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({
|
||||
title,
|
||||
tasks,
|
||||
competitionId,
|
||||
onAddTaskClick
|
||||
}) => {
|
||||
return (
|
||||
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<div className="py-4 text-center">
|
||||
<h1 className="font-hse-sans text-xl font-semibold">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
|
||||
<Link
|
||||
to={`/constructor/${competitionId}/tasks/settings`}
|
||||
className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:bg-gray-200 flex-shrink-0 flex items-center"
|
||||
>
|
||||
<Settings size={16} className="mr-1" />
|
||||
</Link>
|
||||
|
||||
{tasks.map((task) => (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/constructor/${competitionId}/tasks/${task.id}`}
|
||||
className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:bg-blue-200 flex-shrink-0"
|
||||
>
|
||||
{task.number}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg flex items-center px-2 h-8"
|
||||
onClick={onAddTaskClick}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructorHeader;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Navigate, useNavigate } from "react-router-dom";
|
||||
import { Task, TaskStatus } from "@/shared/types";
|
||||
import ConstructorHeader from "./components/ConstructorHeader";
|
||||
import TaskCreationModal from "./modules/TaskCreationModal";
|
||||
|
||||
const CompetitionConstructor = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
||||
const isSettings = taskId === "settings";
|
||||
|
||||
const handleOpenTaskModal = () => {
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseTaskModal = () => {
|
||||
setIsTaskModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateTask = (taskData: Partial<Task>) => {
|
||||
const newTask: Task = {
|
||||
id: `task-${Date.now()}`,
|
||||
number: taskData.number || `${tasks.length + 1}`,
|
||||
status: TaskStatus.Uncleared,
|
||||
solutionType: taskData.solutionType || "input",
|
||||
description: taskData.description || "",
|
||||
requirements: taskData.requirements,
|
||||
attachments: taskData.attachments || []
|
||||
};
|
||||
|
||||
setTasks([...tasks, newTask]);
|
||||
setIsTaskModalOpen(false);
|
||||
navigate(`/constructor/${id}/tasks/${newTask.id}`);
|
||||
};
|
||||
|
||||
if (!taskId) {
|
||||
if (tasks.length > 0) {
|
||||
return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />;
|
||||
} else {
|
||||
return <Navigate to={`/constructor/${id}/tasks/settings`} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<ConstructorHeader
|
||||
title={competitionTitle}
|
||||
tasks={tasks}
|
||||
competitionId={id || ""}
|
||||
onAddTaskClick={handleOpenTaskModal}
|
||||
/>
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={handleCloseTaskModal}
|
||||
onCreateTask={handleCreateTask}
|
||||
taskCount={tasks.length}
|
||||
/>
|
||||
|
||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{isSettings ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2>
|
||||
<p className="text-gray-500 font-hse-sans">
|
||||
Здесь будет форма настроек олимпиады
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">
|
||||
{`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`}
|
||||
</h2>
|
||||
<p className="text-gray-500 font-hse-sans">
|
||||
Здесь будет форма редактирования задачи
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionConstructor;
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskDescriptionFieldProps {
|
||||
description: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskDescriptionField: React.FC<TaskDescriptionFieldProps> = ({ description, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label htmlFor="description" className="text-right pt-2">
|
||||
Описание
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3 min-h-[100px]"
|
||||
placeholder="Введите описание задачи"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDescriptionField;
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FileIcon, X, Upload } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskFileAttachmentsProps {
|
||||
files: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const TaskFileAttachments: React.FC<TaskFileAttachmentsProps> = ({ files, onChange }) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
onChange([...files, ...newFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
onChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right pt-2">
|
||||
Файлы
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.map((file, index) => (
|
||||
<FileListItem
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => removeFile(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FileUploadButton onChange={handleFileChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileListItemProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({ file, onRemove }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-2 border rounded-md">
|
||||
<div className="flex items-center">
|
||||
<FileIcon size={16} className="mr-2 text-gray-500" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({Math.round(file.size / 1024)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const FileUploadButton: React.FC<FileUploadButtonProps> = ({ onChange }) => {
|
||||
return (
|
||||
<label className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 p-2 border border-dashed rounded-md hover:bg-gray-50 transition-colors">
|
||||
<Upload size={16} className="text-gray-500" />
|
||||
<span className="text-sm text-gray-700">Добавить файлы</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskFileAttachments;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskNumberFieldProps {
|
||||
number: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskNumberField: React.FC<TaskNumberFieldProps> = ({ number, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="task-number" className="text-right">
|
||||
Номер задачи
|
||||
</Label>
|
||||
<Input
|
||||
id="task-number"
|
||||
value={number}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskNumberField;
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskRequirementsFieldProps {
|
||||
requirements: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskRequirementsField: React.FC<TaskRequirementsFieldProps> = ({ requirements, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label htmlFor="requirements" className="text-right pt-2">
|
||||
Требования
|
||||
</Label>
|
||||
<Textarea
|
||||
id="requirements"
|
||||
value={requirements}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Введите требования к решению (необязательно)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRequirementsField;
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem
|
||||
} from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskSolutionTypeSelectorProps {
|
||||
solutionType: 'input' | 'file' | 'code';
|
||||
onChange: (value: 'input' | 'file' | 'code') => void;
|
||||
}
|
||||
|
||||
const TaskSolutionTypeSelector: React.FC<TaskSolutionTypeSelectorProps> = ({ solutionType, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right pt-2">
|
||||
Тип решения
|
||||
</Label>
|
||||
<RadioGroup
|
||||
className="col-span-3"
|
||||
value={solutionType}
|
||||
onValueChange={(value) => onChange(value as 'input' | 'file' | 'code')}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="input" id="input" />
|
||||
<Label htmlFor="input">Ввод ответа</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="file" id="file" />
|
||||
<Label htmlFor="file">Загрузка файла</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="code" id="code" />
|
||||
<Label htmlFor="code">Программный код</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSolutionTypeSelector;
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Task } from "@/shared/types";
|
||||
import TaskNumberField from './components/TaskNumberField';
|
||||
import TaskDescriptionField from './components/TaskDescriptionField';
|
||||
import TaskRequirementsField from './components/TaskRequirementsField';
|
||||
import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector';
|
||||
import TaskFileAttachments from './components/TaskFileAttachments';
|
||||
|
||||
interface TaskCreationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateTask: (task: Partial<Task>) => void;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
const TaskCreationModal: React.FC<TaskCreationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateTask,
|
||||
taskCount
|
||||
}) => {
|
||||
const [number, setNumber] = useState(`${taskCount + 1}`);
|
||||
const [description, setDescription] = useState('');
|
||||
const [requirements, setRequirements] = useState('');
|
||||
const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input');
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newTask: Partial<Task> = {
|
||||
number,
|
||||
description,
|
||||
requirements: requirements || undefined,
|
||||
solutionType,
|
||||
attachments: attachedFiles.map(file => file.name)
|
||||
};
|
||||
|
||||
onCreateTask(newTask);
|
||||
|
||||
setNumber(`${taskCount + 1}`);
|
||||
setDescription('');
|
||||
setRequirements('');
|
||||
setSolutionType('input');
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] font-hse-sans">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Создание новой задачи</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<TaskNumberField
|
||||
number={number}
|
||||
onChange={setNumber}
|
||||
/>
|
||||
|
||||
<TaskDescriptionField
|
||||
description={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
|
||||
<TaskRequirementsField
|
||||
requirements={requirements}
|
||||
onChange={setRequirements}
|
||||
/>
|
||||
|
||||
<TaskSolutionTypeSelector
|
||||
solutionType={solutionType}
|
||||
onChange={setSolutionType}
|
||||
/>
|
||||
|
||||
<TaskFileAttachments
|
||||
files={attachedFiles}
|
||||
onChange={setAttachedFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit}>
|
||||
Создать задачу
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCreationModal;
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Task } from "@/shared/types";
|
||||
import { getTaskBgColor, getTaskTextColor } from '../../utils/utils';
|
||||
import { Task } from '@/shared/types/task';
|
||||
|
||||
interface CompetitionHeaderProps {
|
||||
title: string;
|
||||
@@ -28,12 +27,12 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/competition/${competitionId}/tasks/${task.id}`}
|
||||
className={`${getTaskBgColor(task.status)} ${getTaskTextColor(task.status)}
|
||||
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
|
||||
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:brightness-95 flex-shrink-0
|
||||
`}
|
||||
>
|
||||
{task.number}
|
||||
{task.in_competition_position}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,53 +3,78 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { Task } from "@/shared/types";
|
||||
import { Task } from '@/shared/types/task';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTaskAttachments } from '@/shared/api/session';
|
||||
import { FileIcon, Loader2 } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
interface TaskContentProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
|
||||
const markdownContent = `
|
||||
## Задача на числовую последовательность
|
||||
const { id: competitionId } = useParams<{ id: string }>();
|
||||
|
||||
Рассмотрим последовательность чисел:
|
||||
\`2, 3, 5, 9, 17, 33, 65, 129, ...\`
|
||||
const attachmentsQuery = useQuery({
|
||||
queryKey: ['taskAttachments', competitionId, task.id],
|
||||
queryFn: () => getTaskAttachments(competitionId || '', task.id),
|
||||
enabled: !!(competitionId && task.id),
|
||||
});
|
||||
|
||||
Каждый член этой последовательности, **начиная с третьего**, равен сумме двух предыдущих членов:
|
||||
- $a_1 = 2$
|
||||
- $a_2 = 3$
|
||||
- $a_n = a_{n-1} + a_{n-2}$ для всех $n ≥ 3$
|
||||
|
||||
### Задание:
|
||||
Найдите сумму первых 15 членов этой последовательности.
|
||||
|
||||
*Примечание:* Для решения задачи вам может быть полезно записать несколько первых членов последовательности:
|
||||
1. $a_1 = 2$
|
||||
2. $a_2 = 3$
|
||||
3. $a_3 = 3 + 2 = 5$
|
||||
4. $a_4 = 5 + 3 = 8$
|
||||
5. $a_5 = 8 + 5 = 13$
|
||||
|
||||
**В ответе укажите целое число.**
|
||||
`;
|
||||
const attachments = attachmentsQuery.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white rounded-lg p-6">
|
||||
<h2 className="text-3xl font-semibold mb-6 font-hse-sans">
|
||||
Задача {task.number}
|
||||
Задача {task.in_competition_position}
|
||||
</h2>
|
||||
|
||||
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans">
|
||||
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{markdownContent}
|
||||
{task.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{attachmentsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
|
||||
<span className="text-gray-500 font-hse-sans">Загрузка файлов...</span>
|
||||
</div>
|
||||
) : attachments.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3 font-hse-sans">Прикрепленные файлы</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.file}
|
||||
download
|
||||
className="flex items-center p-3 border rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileIcon size={18} className="text-blue-500 mr-2" />
|
||||
<span className="font-hse-sans">
|
||||
{getFileNameFromUrl(attachment.file)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFileNameFromUrl = (url: string): string => {
|
||||
try {
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1];
|
||||
} catch (e) {
|
||||
return 'Файл';
|
||||
}
|
||||
};
|
||||
|
||||
export default TaskContent;
|
||||
@@ -1,45 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams, Navigate } from "react-router-dom";
|
||||
import { Task, TaskStatus } from "@/shared/types";
|
||||
import { mockSolutions } from "@/shared/mocks/mocks"; // Keep mocks for solutions for now
|
||||
import CompetitionHeader from "./components/CompetitionHeader";
|
||||
import TaskContent from "./components/TaskContent";
|
||||
import TaskSolution from "./modules/TaskSolution";
|
||||
import { getCompetitionTasks } from "@/shared/api/session";
|
||||
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const CompetitionSession = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const competitionId = id || "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedTasks = await getCompetitionTasks(competitionId);
|
||||
setTasks(fetchedTasks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch tasks:", err);
|
||||
setError("Не удалось загрузить задания. Пожалуйста, попробуйте позже.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["competitionTasks", competitionId],
|
||||
queryFn: () => getCompetitionTasks(competitionId),
|
||||
enabled: !!competitionId,
|
||||
});
|
||||
|
||||
if (competitionId) {
|
||||
fetchTasks();
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['submissionHistory', competitionId, taskId]
|
||||
});
|
||||
setAnswer("");
|
||||
}
|
||||
}, [competitionId]);
|
||||
});
|
||||
|
||||
const tasks = tasksQuery.data || [];
|
||||
const isLoading = tasksQuery.isLoading;
|
||||
const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null;
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === taskId) || null;
|
||||
|
||||
if (!taskId && tasks.length > 0 && !loading) {
|
||||
if (!taskId && tasks.length > 0 && !isLoading) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
|
||||
@@ -48,14 +44,10 @@ const CompetitionSession = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentTask || !competitionId) return;
|
||||
|
||||
try {
|
||||
console.log("Solution submitted successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to submit solution:", err);
|
||||
}
|
||||
const handleSubmit = () => {
|
||||
console.log(currentTask, competitionId, answer)
|
||||
if (!currentTask || !competitionId || !answer.trim()) return;
|
||||
submitMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -68,7 +60,7 @@ const CompetitionSession = () => {
|
||||
|
||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
|
||||
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
|
||||
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
|
||||
@@ -82,7 +74,7 @@ const CompetitionSession = () => {
|
||||
<TaskContent task={currentTask} />
|
||||
<TaskSolution
|
||||
task={currentTask}
|
||||
solutions={mockSolutions} // Still using mock solutions
|
||||
solutions={[]}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
+4
-21
@@ -1,31 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SolutionHistorySheet from '../SolutionHistorySheet';
|
||||
import { Solution } from "@/shared/types";
|
||||
import { mockSolutions } from '@/shared/mocks/mocks';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onSubmit: () => void;
|
||||
solutionHistory?: Solution[];
|
||||
onHistoryClick: () => void;
|
||||
}
|
||||
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onSubmit,
|
||||
solutionHistory = mockSolutions
|
||||
onHistoryClick
|
||||
}) => {
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
|
||||
const handleHistoryClick = () => {
|
||||
setIsHistoryOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="font-hse-sans bg-white hover:bg-gray-100"
|
||||
onClick={handleHistoryClick}
|
||||
onClick={onHistoryClick}
|
||||
>
|
||||
История
|
||||
</Button>
|
||||
@@ -36,13 +26,6 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
Отправить решение
|
||||
</Button>
|
||||
</div>
|
||||
{/* чуть-чуть рак */}
|
||||
<SolutionHistorySheet
|
||||
isOpen={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
solutions={solutionHistory}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+5
-3
@@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import SolutionStatus from '../SolutionStatus';
|
||||
import { Solution } from "@/shared/types";
|
||||
import { Solution } from '@/shared/types/task';
|
||||
|
||||
interface SolutionHistorySheetProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
solutions: Solution[];
|
||||
maxPoints: number
|
||||
}
|
||||
|
||||
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
solutions
|
||||
solutions,
|
||||
maxPoints
|
||||
}) => {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
|
||||
{solutions.length > 0 ? (
|
||||
solutions.map((solution, index) => (
|
||||
<div key={index} className="w-full">
|
||||
<SolutionStatus solution={solution} />
|
||||
<SolutionStatus solution={solution} maxPoints={maxPoints} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
+10
-25
@@ -1,41 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Solution, TaskStatus } from "@/shared/types";
|
||||
import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils';
|
||||
import { Solution } from '@/shared/types/task';
|
||||
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
|
||||
|
||||
interface SolutionStatusProps {
|
||||
solution: Solution;
|
||||
maxPoints: number;
|
||||
}
|
||||
|
||||
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution }) => {
|
||||
const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => {
|
||||
switch (status) {
|
||||
case TaskStatus.Checking:
|
||||
return 'На проверке';
|
||||
case TaskStatus.Wrong:
|
||||
return 'Неверный ответ';
|
||||
case TaskStatus.Correct:
|
||||
return `Зачтено ${maxScore}/${maxScore} баллов`;
|
||||
case TaskStatus.Partial:
|
||||
return `Зачтено ${score}/${maxScore} баллов`;
|
||||
case TaskStatus.Uncleared:
|
||||
return 'Не решено';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
|
||||
|
||||
return (
|
||||
<div className={`${getTaskBgColor(solution.status)} rounded-lg p-4 relative`}>
|
||||
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
|
||||
<div className="flex flex-col">
|
||||
<span className={`${getTaskTextColor(solution.status)} font-medium`}>
|
||||
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
|
||||
Решение {solution.id}
|
||||
</span>
|
||||
<span className={`${getTaskTextColor(solution.status)} mt-1`}>
|
||||
{getStatusText(solution.status, solution.score, solution.maxScore)}
|
||||
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
|
||||
{getStatusText(solution.status, solution.earned_points, maxPoints)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}>
|
||||
{solution.date}
|
||||
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
|
||||
{solution.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Solution, Task } from "@/shared/types";
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Task, TaskType, Solution } from '@/shared/types/task';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTaskSolutionHistory } from '@/shared/api/session';
|
||||
import SolutionStatus from './components/SolutionStatus';
|
||||
import InputSolution from './components/InputSolution';
|
||||
import FileSolution from './components/FileSolution';
|
||||
import CodeSolution from './components/CodeSolution';
|
||||
import ActionButtons from './components/ActionButtons';
|
||||
import SolutionHistorySheet from './components/SolutionHistorySheet';
|
||||
|
||||
interface TaskSolutionProps {
|
||||
task: Task;
|
||||
@@ -12,28 +16,49 @@ interface TaskSolutionProps {
|
||||
answer: string;
|
||||
setAnswer: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
|
||||
}
|
||||
|
||||
const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
task,
|
||||
solutions,
|
||||
solutions = [],
|
||||
answer,
|
||||
setAnswer,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const { id: competitionId } = useParams<{ id: string }>();
|
||||
|
||||
const solutionsQuery = useQuery({
|
||||
queryKey: ['solutionHistory', competitionId, task.id],
|
||||
queryFn: () => getTaskSolutionHistory(competitionId || '', task.id),
|
||||
enabled: !!(competitionId && task.id),
|
||||
});
|
||||
|
||||
const solutionHistory = solutionsQuery.data || [];
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
setIsHistoryOpen(true);
|
||||
};
|
||||
|
||||
const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null;
|
||||
|
||||
return (
|
||||
<div className="md:w-[500px] flex flex-col gap-4">
|
||||
<SolutionStatus solution={solutions[0]} />
|
||||
{latestSolution ? (
|
||||
<SolutionStatus solution={latestSolution} maxPoints={task.points}/>
|
||||
) : (
|
||||
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
|
||||
Решение еще не отправлено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.solutionType === 'input' && (
|
||||
{task.type === TaskType.INPUT && (
|
||||
<InputSolution answer={answer} setAnswer={setAnswer} />
|
||||
)}
|
||||
|
||||
{task.solutionType === 'file' && (
|
||||
{task.type === TaskType.FILE && (
|
||||
<FileSolution
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
@@ -41,11 +66,21 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{task.solutionType === 'code' && (
|
||||
{task.type === TaskType.CODE && (
|
||||
<CodeSolution answer={answer} setAnswer={setAnswer} />
|
||||
)}
|
||||
|
||||
<ActionButtons onSubmit={onSubmit} />
|
||||
<ActionButtons
|
||||
onSubmit={onSubmit}
|
||||
onHistoryClick={handleOpenHistory}
|
||||
/>
|
||||
|
||||
<SolutionHistorySheet
|
||||
isOpen={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
solutions={solutionHistory}
|
||||
maxPoints={task.points}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskStatus } from "@/shared/types";
|
||||
import { SolutionStatus } from "@/shared/types/task";
|
||||
const getTaskBgColor = (status: TaskStatus): string => {
|
||||
switch (status) {
|
||||
case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]";
|
||||
@@ -19,4 +20,39 @@ const getTaskTextColor = (status: TaskStatus): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export {getTaskBgColor, getTaskTextColor}
|
||||
const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]";
|
||||
case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "text-[var(--color-task-wrong)]";
|
||||
else if (earned_points === maxPoints) "text-[var(--color-task-correct)]";
|
||||
return "text-[var(--color-task-partial)]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]";
|
||||
case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "text-[var(--color-task-text-wrong)]";
|
||||
else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]";
|
||||
return "text-[var(--color-task-text-partial)]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
|
||||
switch (status) {
|
||||
case SolutionStatus.SENT: return "Решение отправлено";
|
||||
case SolutionStatus.CHECKING: return "Решение проверяется";
|
||||
case SolutionStatus.CHECKED: {
|
||||
if (earned_points === 0) return "Неверный ответ";
|
||||
else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`;
|
||||
return `Зачтено ${earned_points}/${maxPoints} баллов`;
|
||||
}
|
||||
}
|
||||
}
|
||||
export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText}
|
||||
@@ -20,13 +20,11 @@ export function CompetitionCard({
|
||||
className={cn("aspect-square h-full w-auto overflow-hidden", className)}
|
||||
>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{competition.image_url && (
|
||||
<img
|
||||
src={competition.image_url}
|
||||
src={competition.image_url ? competition.image_url : '/DANO.png'}
|
||||
alt={competition.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
|
||||
@@ -112,4 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
||||
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
||||
};
|
||||
|
||||
|
||||
export default CompetitionsPage;
|
||||
@@ -12,3 +12,9 @@ export const getCompetitions = async (participating?: boolean) => {
|
||||
export const getCompetition = async (id: string) => {
|
||||
return await userFetch<Competition>(`/competition/${id}`);
|
||||
};
|
||||
|
||||
export const startCompetition = async (competitionId: string) => {
|
||||
return await userFetch(`/competitions/${competitionId}/start`, {
|
||||
method: 'POST'
|
||||
});
|
||||
};
|
||||
@@ -1,82 +1,37 @@
|
||||
import { apiFetch } from './index';
|
||||
import { Task, TaskStatus } from '@/shared/types';
|
||||
import { userFetch } from ".";
|
||||
import { Task, Solution, TaskAttachment } from "../types/task";
|
||||
|
||||
interface ApiTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'input' | 'file' | 'code';
|
||||
in_competition_position: number;
|
||||
points: number;
|
||||
status?: TaskStatus;
|
||||
}
|
||||
export const getCompetitionTasks = async (competitionId: string) => {
|
||||
return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches tasks for a specific competition
|
||||
* @param competitionId - The ID of the competition
|
||||
* @returns Promise with an array of tasks in the application's format
|
||||
*/
|
||||
export const getCompetitionTasks = async (competitionId: string): Promise<Task[]> => {
|
||||
try {
|
||||
const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`);
|
||||
export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
|
||||
return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
|
||||
};
|
||||
|
||||
// Transform API tasks to application Task format
|
||||
return apiTasks.map(apiTask => transformApiTask(apiTask));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch competition tasks:', error);
|
||||
throw error;
|
||||
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
|
||||
return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
|
||||
};
|
||||
|
||||
|
||||
export const submitTaskSolution = async (
|
||||
competitionId: string,
|
||||
taskId: string,
|
||||
solution: string | File
|
||||
) => {
|
||||
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||
if (typeof solution === 'string') {
|
||||
return await userFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: { content: solution }
|
||||
});
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('file', solution);
|
||||
|
||||
return await userFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms an API task to the application's Task format
|
||||
*/
|
||||
const transformApiTask = (apiTask: ApiTask): Task => {
|
||||
return {
|
||||
id: apiTask.id,
|
||||
number: String(apiTask.in_competition_position),
|
||||
status: apiTask.status || TaskStatus.Uncleared,
|
||||
solutionType: apiTask.type,
|
||||
description: apiTask.description,
|
||||
maxScore: apiTask.points
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// export const submitTaskSolution = async (
|
||||
// competitionId: string,
|
||||
// taskId: string,
|
||||
// solution: string | File
|
||||
// ): Promise<void> => {
|
||||
// const endpoint = `/api/v1/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||
|
||||
// // Handle different solution types
|
||||
// if (typeof solution === 'string') {
|
||||
// // Text or code solution
|
||||
// await apiFetch(endpoint, {
|
||||
// method: 'POST',
|
||||
// body: { answer: solution }
|
||||
// });
|
||||
// } else {
|
||||
// // File solution
|
||||
// const formData = new FormData();
|
||||
// formData.append('file', solution);
|
||||
|
||||
// await apiFetch(endpoint, {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
/**
|
||||
* Gets the status of a task submission
|
||||
* This would be used to poll for updates after submission
|
||||
*/
|
||||
// export const getTaskSubmissionStatus = async (
|
||||
// competitionId: string,
|
||||
// taskId: string
|
||||
// ): Promise<TaskStatus> => {
|
||||
// const response = await apiFetch(`/api/v1/competitions/${competitionId}/tasks/${taskId}/status`);
|
||||
// return response.status;
|
||||
// };
|
||||
@@ -12,6 +12,11 @@ enum TaskStatus {
|
||||
Wrong = "wrong"
|
||||
}
|
||||
|
||||
enum ParticipationType {
|
||||
Solo = "solo",
|
||||
Team = "team"
|
||||
}
|
||||
|
||||
interface Competition {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -19,6 +24,9 @@ interface Competition {
|
||||
isOlympics: boolean;
|
||||
status: CompetitionStatus;
|
||||
description?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
participationType: ParticipationType
|
||||
}
|
||||
|
||||
type SolutionType = "input" | "file" | "code";
|
||||
@@ -42,5 +50,5 @@ interface Task {
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export { CompetitionStatus, TaskStatus };
|
||||
export { CompetitionStatus, TaskStatus, ParticipationType };
|
||||
export type { Solution, Competition, Task };
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
in_competition_position: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface TaskAttachment {
|
||||
id: string;
|
||||
file: string;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
enum TaskType {
|
||||
INPUT = "input",
|
||||
FILE = "checker",
|
||||
CODE = "review",
|
||||
}
|
||||
|
||||
enum SolutionStatus {
|
||||
SENT = "sent",
|
||||
CHECKING = "checking",
|
||||
CHECKED = "checked",
|
||||
}
|
||||
|
||||
interface Solution {
|
||||
id: string,
|
||||
status: SolutionStatus,
|
||||
timestamp: string,
|
||||
earned_points: number
|
||||
}
|
||||
|
||||
export type {Task, Solution}
|
||||
export {TaskType, SolutionStatus}
|
||||
Reference in New Issue
Block a user