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

This commit is contained in:
moolcoov
2025-03-02 17:53:28 +03:00
75 changed files with 1596 additions and 965 deletions
+18
View File
@@ -54,6 +54,24 @@ build_backend-staticfiles:
DOCKERFILE_PATH: "Dockerfile.staticfiles" DOCKERFILE_PATH: "Dockerfile.staticfiles"
IMAGE_NAME: "$CI_REGISTRY_IMAGE/backend-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_docs:
<<: *build-template <<: *build-template
rules: rules:
+17
View File
@@ -370,6 +370,23 @@ services:
restart: unless-stopped restart: unless-stopped
shm_size: 4mb 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: proxy:
image: docker.io/nginx:1.27-alpine3.21 image: docker.io/nginx:1.27-alpine3.21
configs: configs:
+2
View File
@@ -18,3 +18,5 @@ MINIO_ENDPOINT=minio:9000
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
MINIO_ACCESS_KEY=admin MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=password MINIO_SECRET_KEY=password
CHECKER_API_ENDPOINT=http://checker:8000
+2
View File
@@ -24,4 +24,6 @@ FROM docker.io/nginx:latest
COPY --from=builder /app/static /usr/share/nginx/html COPY --from=builder /app/static /usr/share/nginx/html
COPY ../checker/checker_requirements.txt .
CMD ["nginx", "-g", "daemon off;"] 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: UUID = Field(..., alias="task.competition.id")
competition_name: str = Field(..., alias="task.competition.title") competition_name: str = Field(..., alias="task.competition.title")
task_position: int = Field(..., alias="task.in_competition_position") task_position: int = Field(..., alias="task.in_competition_position")
task_title: str = Field(..., alias="task.title")
@staticmethod @staticmethod
def resolve_criteries(self, context) -> list[CriteriaOut] | None: def resolve_criteries(self, context) -> list[CriteriaOut] | None:
+17 -8
View File
@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from http import HTTPStatus as status from http import HTTPStatus as status
from statistics import median
from uuid import UUID from uuid import UUID
from django.http import HttpRequest from django.http import HttpRequest
@@ -84,15 +85,22 @@ def evaluate_submission(
review.evaluation = evaluation review.evaluation = evaluation
review.state = ReviewStatusChoices.CHECKED.value review.state = ReviewStatusChoices.CHECKED.value
review.submission.checked_at = datetime.now() review.submission.checked_at = datetime.now()
points = 0
for criterea in evaluation:
points += criterea["mark"]
review.submission.earned_points = (
points # TODO: оценка не от последнего проверяющего а средняя по всем
)
review.save() 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( all_checked = not submission.reviews.exclude(
state=ReviewStatusChoices.CHECKED state=ReviewStatusChoices.CHECKED
).exists() ).exists()
@@ -100,5 +108,6 @@ def evaluate_submission(
review.submission.status = ( review.submission.status = (
CompetitionTaskSubmission.StatusChoices.CHECKED.value CompetitionTaskSubmission.StatusChoices.CHECKED.value
) )
review.submission.save() review.submission.save()
return status.OK, review.submission return status.OK, review.submission
+29 -4
View File
@@ -3,10 +3,27 @@ from uuid import UUID
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment from apps.task.models import (
CompetitionTask,
CompetitionTaskAttachment,
CompetitionTaskSubmission,
)
class TaskOutSchema(ModelSchema): 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: class Meta:
model = CompetitionTask model = CompetitionTask
fields = [ fields = [
@@ -14,7 +31,6 @@ class TaskOutSchema(ModelSchema):
"competition", "competition",
"title", "title",
"description", "description",
"type",
"in_competition_position", "in_competition_position",
"points", "points",
] ]
@@ -29,10 +45,19 @@ class HistorySubmissionOut(ModelSchema):
class Meta: class Meta:
model = CompetitionTaskSubmission model = CompetitionTaskSubmission
fields = ("id", "earned_points", "timestamp", "content",) fields = (
"id",
"earned_points",
"timestamp",
"content",
)
class TaskAttachmentSchema(ModelSchema): class TaskAttachmentSchema(ModelSchema):
class Meta: class Meta:
model = CompetitionTaskAttachment model = CompetitionTaskAttachment
fields = ("id", "file", "public",) fields = (
"id",
"file",
"public",
)
+2 -2
View File
@@ -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): def get_task_attachments(request, competition_id: UUID, task_id: UUID):
task = get_object_or_404(CompetitionTask, id=task_id) task = get_object_or_404(CompetitionTask, id=task_id)
return status.OK, CompetitionTaskAttachment.objects.filter( return status.OK, CompetitionTaskAttachment.objects.filter(
competition_id=competition_id, task=task, user=request.auth task=task
) ).all()
+1 -1
View File
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
class UserSchema(ModelSchema): class UserSchema(ModelSchema):
class Meta: class Meta:
model = User 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': 'ачивки',
},
),
]
@@ -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='тип'),
),
]
@@ -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 = "ачивки"
+1 -1
View File
@@ -15,7 +15,7 @@ class Competition(BaseModel):
SOLO = "solo", "Индивидуальный" SOLO = "solo", "Индивидуальный"
def image_url_upload_to(instance, filename): 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="название") title = models.CharField(max_length=100, verbose_name="название")
description = models.TextField(verbose_name="описание") description = models.TextField(verbose_name="описание")
+1 -1
View File
@@ -1,4 +1,4 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group
admin.site.unregister(Group) admin.site.unregister(Group)
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from apps.competition.models import Competition, State 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.task.models import CompetitionTask, CompetitionTaskSubmission
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -99,11 +99,13 @@ class Command(BaseCommand):
title = f"Task {i} for {comp.title}" title = f"Task {i} for {comp.title}"
description = f"Task description for task {i} in {comp.title}" description = f"Task description for task {i} in {comp.title}"
task = CompetitionTask.objects.create( task = CompetitionTask.objects.create(
in_competition_position=i,
competition=comp, competition=comp,
title=title, title=title,
description=description, description=description,
type=task_type, type=task_type,
points=random.randint(1, 10), points=random.randint(1, 10),
submission_reviewers_count=random.randint(2, 10),
max_attempts=random.randint(1, 10), max_attempts=random.randint(1, 10),
) )
tasks.append(task) tasks.append(task)
+9 -3
View File
@@ -1,9 +1,15 @@
from django.contrib import admin from django.contrib import admin
from apps.review.models import Review, Reviewer from apps.review.models import Reviewer
@admin.register(Reviewer) @admin.register(Reviewer)
class ReviewersAdmin(admin.ModelAdmin): class ReviewersAdmin(admin.ModelAdmin):
list_display = ("name", "surname",) list_display = (
search_fields = ("name", "surname",) "name",
"surname",
)
search_fields = (
"name",
"surname",
)
+3
View File
@@ -5,3 +5,6 @@ class CoreConfig(AppConfig):
name = "apps.review" name = "apps.review"
label = "review" label = "review"
verbose_name = "Проверка" verbose_name = "Проверка"
def ready(self):
import apps.review.signals
+8 -6
View File
@@ -24,22 +24,24 @@ class ReviewStatusChoices(models.TextChoices):
class Review(BaseModel): class Review(BaseModel):
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE, reviewer = models.ForeignKey(
verbose_name="проверяющий") Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий"
)
submission = models.ForeignKey( submission = models.ForeignKey(
"task.CompetitionTaskSubmission", "task.CompetitionTaskSubmission",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="reviews", related_name="reviews",
verbose_name="посылка" verbose_name="посылка",
) )
evaluation = models.JSONField(default=list, null=True, blank=True, evaluation = models.JSONField(
verbose_name="выполнение") default=list, null=True, blank=True, verbose_name="выполнение"
)
state = models.CharField( state = models.CharField(
choices=ReviewStatusChoices.choices, choices=ReviewStatusChoices.choices,
default=ReviewStatusChoices.NOT_CHECKED.value, default=ReviewStatusChoices.NOT_CHECKED.value,
max_length=11, max_length=11,
verbose_name="состояние" verbose_name="состояние",
) )
def __str__(self): def __str__(self):
+14
View File
@@ -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()
+17 -7
View File
@@ -1,7 +1,10 @@
from django.contrib import admin from django.contrib import admin
from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \ from apps.task.models import (
CompetitionTaskSubmission CompetitionTask,
CompetitionTaskAttachment,
CompetitionTaskSubmission,
)
class CompletionAttachmentInline(admin.StackedInline): class CompletionAttachmentInline(admin.StackedInline):
@@ -12,15 +15,22 @@ class CompletionAttachmentInline(admin.StackedInline):
@admin.register(CompetitionTask) @admin.register(CompetitionTask)
class CompetitionTaskAdmin(admin.ModelAdmin): class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points") list_display = ("title", "type", "points")
filter_horizontal = ( filter_horizontal = ("reviewers",)
"reviewers",
)
@admin.register(CompetitionTaskSubmission) @admin.register(CompetitionTaskSubmission)
class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
list_display = ("task", "user", "status",) list_display = (
search_fields = ("task__id", "task__title", "user__username", "user__email") "task",
"user",
"status",
)
search_fields = (
"task__id",
"task__title",
"user__username",
"user__email",
)
filter = ("plagiarism_checked",) filter = ("plagiarism_checked",)
ordering = ["-timestamp"] ordering = ["-timestamp"]
@@ -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='описание'),
),
]
@@ -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='описание'),
),
]
+64 -49
View File
@@ -2,11 +2,11 @@ from uuid import uuid4
from django.db import models from django.db import models
from django.db.models import Count, Q 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.competition.models import Competition
from apps.core.models import BaseModel 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 from apps.user.models import User
@@ -17,14 +17,14 @@ class CompetitionTask(BaseModel):
REVIEW = "review", "Ручная" REVIEW = "review", "Ручная"
def answer_file_upload_to(instance, filename) -> str: def answer_file_upload_to(instance, filename) -> str:
return f"/tasks/{instance.id}/answer/{uuid4()}/filename" return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
in_competition_position = models.PositiveSmallIntegerField( in_competition_position = models.PositiveSmallIntegerField(
null=True, blank=True null=True, blank=True
) )
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50) 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) max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
type = models.CharField( type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
@@ -55,7 +55,10 @@ class CompetitionTask(BaseModel):
Reviewer, Reviewer,
blank=True, blank=True,
verbose_name="ревьюверы", verbose_name="ревьюверы",
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему" help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему",
)
submission_reviewers_count = models.PositiveSmallIntegerField(
default=1, null=True, blank=True
) )
def __str__(self): def __str__(self):
@@ -79,12 +82,12 @@ class CompetitionTaskCriteria(BaseModel):
class CompetitionTaskAttachment(BaseModel): class CompetitionTaskAttachment(BaseModel):
def file_upload_at(instance, filename): 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, task = models.ForeignKey(
verbose_name="задание") CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
file = models.FileField(upload_to=file_upload_at, )
verbose_name="файл") file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
bind_at = models.FilePathField(verbose_name="путь сохранения") bind_at = models.FilePathField(verbose_name="путь сохранения")
public = models.BooleanField(default=False, verbose_name="публичный") public = models.BooleanField(default=False, verbose_name="публичный")
@@ -96,50 +99,61 @@ class CompetitionTaskSubmission(BaseModel):
CHECKED = "checked" CHECKED = "checked"
def submission_content_upload_to(instance, filename) -> str: 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: 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, user = models.ForeignKey(
verbose_name="пользователь") User, on_delete=models.CASCADE, verbose_name="пользователь"
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE, )
verbose_name="задание") task = models.ForeignKey(
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
)
status = models.CharField( status = models.CharField(
choices=StatusChoices.choices, choices=StatusChoices.choices,
default=StatusChoices.SENT, default=StatusChoices.SENT,
max_length=8, max_length=8,
verbose_name="статус" verbose_name="статус",
) )
# code or text or file # code or text or file
content = models.FileField(upload_to=submission_content_upload_to, content = models.FileField(
verbose_name="содержание посылки") upload_to=submission_content_upload_to,
verbose_name="содержание посылки",
)
# only if task type is checker # only if task type is checker
stdout = models.FileField( 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="вывод программы", verbose_name="вывод программы",
help_text="Используется только при проверке чекером" help_text="Используется только при проверке чекером",
) )
# depends on task type: # depends on task type:
# - input: {"correct": boolean} # - input: {"correct": boolean}
# - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]} # - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]}
# - code: {"correct": boolean} # - code: {"correct": boolean}
result = models.JSONField(default=None, null=True, blank=True, result = models.JSONField(
verbose_name="результат проверки") default=None, null=True, blank=True, verbose_name="результат проверки"
)
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
earned_points = models.IntegerField(null=True, blank=True, earned_points = models.IntegerField(
verbose_name="баллы за задание") null=True, blank=True, verbose_name="баллы за задание"
)
checked_at = models.DateTimeField(null=True, blank=True, checked_at = models.DateTimeField(
verbose_name="дата проверки") null=True, blank=True, verbose_name="дата проверки"
plagiarism_checked = models.BooleanField(default=False, )
verbose_name="проверено на плагиат") plagiarism_checked = models.BooleanField(
timestamp = models.DateTimeField(auto_now_add=True, default=False, verbose_name="проверено на плагиат"
verbose_name="дата отправки") )
timestamp = models.DateTimeField(
auto_now_add=True, verbose_name="дата отправки"
)
class Meta: class Meta:
verbose_name = "посылка" verbose_name = "посылка"
@@ -152,22 +166,23 @@ class CompetitionTaskSubmission(BaseModel):
if not self.task.reviewers.exists(): if not self.task.reviewers.exists():
return return
reviewer = ( reviewers_count = self.task.submission_reviewers_count
self.task.reviewers.annotate( reviewers = self.task.reviewers.annotate(
pending_count=Count( pending_count=Count(
"review", "review",
filter=Q( filter=Q(
review__state__in=[ review__state__in=[
ReviewStatusChoices.NOT_CHECKED, ReviewStatusChoices.NOT_CHECKED,
ReviewStatusChoices.CHECKING, ReviewStatusChoices.CHECKING,
] ]
), ),
) )
).order_by("pending_count")[
:reviewers_count
] # да это медленно работает и чо
for reviewer in reviewers:
Review.objects.update_or_create(
reviewer=reviewer,
submission=self,
) )
.order_by("pending_count")
.first()
)
review = Review.objects.create(
reviewer=reviewer,
submission=self,
)
+40 -138
View File
@@ -1,148 +1,50 @@
import ast import requests
import contextlib from celery import shared_task
import hashlib from django.core.files.base import ContentFile
import os
import sys
import tempfile
from io import StringIO
from config.celery import app from django.conf import settings
from apps.task.models import CompetitionTaskSubmission
ALLOWED_MODULES = {
"pandas",
"numpy",
"matplotlib",
"seaborn",
"scipy",
"sklearn",
"datetime",
"json",
"csv",
"math",
"statistics",
"statsmodels",
}
class SecurityException(Exception): @shared_task(bind=True, max_retries=3)
pass def analyze_data_task(self, submission_id):
from .models import CompetitionTaskSubmission
submission = CompetitionTaskSubmission.objects.get(id=submission_id)
def validate_code(code_str):
try: try:
tree = ast.parse(code_str) code = submission.content.read().decode()
except SyntaxError as e: files = [
raise SecurityException(f"Syntax error: {e!s}") (f.name, f.file.open("rb"))
for f in submission.task.attachments.filter(public=True)
]
class ImportVisitor(ast.NodeVisitor): response = requests.post(
def visit_Import(self, node): f"{settings.CHECKER_API_ENDPOINT}/execute",
for alias in node.names: files=[("files", (f.name, f)) for f in files]
module = alias.name.split(".")[0] + [
if module not in ALLOWED_MODULES: ("code", code),
raise SecurityException(f"Disallowed import: {module}") ("expected_hash", submission.task.correct_answer_hash),
],
timeout=30,
)
response.raise_for_status()
result = response.json()
def visit_ImportFrom(self, node): submission.stdout.save("output.txt", ContentFile(result["output"]))
if node.module: submission.result = {
module = node.module.split(".")[0] "correct": result["hash_match"],
if module not in ALLOWED_MODULES: "result_hash": result["result_hash"],
raise SecurityException( "error": result.get("error"),
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}
return {
"success": True,
"match": result_hash == expected_hash,
"result_hash": result_hash,
"expected_hash": expected_hash,
} }
submission.earned_points = (
submission.task.points if result["hash_match"] else 0
)
submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
except SecurityException as e: except requests.exceptions.RequestException as e:
return {"success": False, "error": f"Security violation: {e!s}"} self.retry(countdown=2**self.request.retries)
except RuntimeError as e:
return {"success": False, "error": f"Execution error: {e!s}"}
except Exception as e: 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) print(result)
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertTrue(result["match"]) self.assertTrue(result["match"])
+1
View File
@@ -7,3 +7,4 @@ from apps.user.models import User
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ("email", "username") list_display = ("email", "username")
search_fields = ("id", "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='ачивки пользователя'),
),
]
+5
View File
@@ -1,6 +1,7 @@
from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.hashers import check_password, make_password
from django.db import models from django.db import models
from apps.achievement.models import Achievement
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -16,6 +17,10 @@ class User(BaseModel):
created_at = models.DateTimeField(auto_now=True) created_at = models.DateTimeField(auto_now=True)
achievements = models.ManyToManyField(
Achievement, blank=True, verbose_name="ачивки пользователя"
)
@staticmethod @staticmethod
def make_password(password: str): def make_password(password: str):
return make_password(password) return make_password(password)
+50 -9
View File
@@ -7,7 +7,9 @@ from pathlib import Path
import django_stubs_ext import django_stubs_ext
import environ import environ
from health_check.plugins import plugin_dir
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from integrations.checker.healthcheck import CheckerHealthCheck
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -30,18 +32,12 @@ ALLOWED_HOSTS = env(
# Integrations # Integrations
YANDEX_CLOUD_FOLDER_ID = env("YANDEX_CLOUD_FOLDER_ID", default=None) CHECKER_API_ENDPOINT = env("CHECKER_API_ENDPOINT", 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
)
# Register healthchecks # Register healthchecks
# plugin_dir.register(SomeHealthCheckClass) plugin_dir.register(CheckerHealthCheck)
# Caching # Caching
@@ -442,6 +438,7 @@ INSTALLED_APPS = [
"ninja", "ninja",
"minio_storage", "minio_storage",
"tinymce", "tinymce",
"martor",
# Internal apps # Internal apps
"apps.core", "apps.core",
"apps.user", "apps.user",
@@ -449,6 +446,7 @@ INSTALLED_APPS = [
"apps.review", "apps.review",
"apps.task", "apps.task",
"apps.team", "apps.team",
"apps.achievement",
] ]
# tinymce # tinymce
@@ -458,15 +456,58 @@ TINYMCE_DEFAULT_CONFIG = {
"menubar": False, "menubar": False,
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
"code,help,wordcount", "code,help,wordcount,markdown",
"toolbar": "undo redo | formatselect | " "toolbar": "undo redo | formatselect | "
"bold italic backcolor | alignleft aligncenter " "bold italic backcolor | alignleft aligncenter "
"alignright alignjustify | bullist numlist outdent indent | " "alignright alignjustify | bullist numlist outdent indent | "
"removeformat | help", "removeformat | help",
"skin": "oxide-dark", "skin": "oxide-dark",
"content_css": "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 # GUID
DJANGO_GUID = { DJANGO_GUID = {
+2
View File
@@ -14,6 +14,8 @@ admin.site.index_title = "DataRush"
urlpatterns = [ urlpatterns = [
# tinymce # tinymce
path("tinymce/", include("tinymce.urls")), path("tinymce/", include("tinymce.urls")),
# martor
path("martor/", include("martor.urls")),
# Admin urls # Admin urls
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# API 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__
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"django-tinymce>=4.1.0", "django-tinymce>=4.1.0",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"martor>=1.6.45",
"pillow>=11.1.0", "pillow>=11.1.0",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pydantic>=2.10.5", "pydantic>=2.10.5",
+170
View File
@@ -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
+42
View File
@@ -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
+14
View File
@@ -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"]
+87
View File
@@ -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
+289
View File
@@ -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)
)
+13
View File
@@ -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",
]
+8
View File
@@ -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
View File
@@ -10,7 +10,6 @@ import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout"; import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review"; import ReviewPage from "./pages/Review";
import CompetitionConstructor from "./pages/CompetitionConstructor";
import UserProfile from "./pages/UserProfile"; import UserProfile from "./pages/UserProfile";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -32,15 +31,6 @@ const App = () => {
element={<CompetitionSession />} 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 path="/profile" element={<UserProfile />} />
</Route> </Route>
@@ -2,38 +2,56 @@ import { useParams, Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { mockTasks } from "@/shared/mocks/mocks"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query"; import { getCompetition, startCompetition } from "@/shared/api/competitions";
import { getCompetition } from "@/shared/api/competitions"; import { getCompetitionTasks } from "@/shared/api/session";
import { Loading } from "@/components/ui/loading"; import { Loading } from "@/components/ui/loading";
const CompetitionPage = () => { const CompetitionPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const competitionId = id || "";
const { data: competition, isLoading } = useQuery({ const competitionQuery = useQuery({
queryKey: ["competition", id], queryKey: ["competition", competitionId],
queryFn: async () => getCompetition(id || ""), 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 />; return <Loading />;
} }
if (!id || !competition) { if (!competitionId || !competitionQuery.data) {
return <></>; return <></>;
} }
const handleContinue = () => { const competition = competitionQuery.data;
if (competition?.id) {
if (mockTasks && mockTasks.length > 0) {
const firstTaskId = mockTasks[0].id;
navigate(`/competition/${competition.id}/tasks/${firstTaskId}`);
} else {
navigate(`/competition/${competition.id}/tasks`);
}
}
};
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -46,15 +64,13 @@ const CompetitionPage = () => {
</Link> </Link>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{competition.image_url && ( <div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl"> <img
<img src={competition.image_url ? competition.image_url : '/DANO.png'}
src={competition.image_url} alt={competition.title}
alt={competition.title} className="h-full w-full object-cover object-center"
className="h-full w-full object-cover object-center" />
/> </div>
</div>
)}
<div className="flex flex-col-reverse gap-8 md:flex-row"> <div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
@@ -66,8 +82,12 @@ const CompetitionPage = () => {
</div> </div>
</div> </div>
<div className="w-full *:w-full md:w-96"> <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> </Button>
</div> </div>
</div> </div>
@@ -76,4 +96,4 @@ const CompetitionPage = () => {
); );
}; };
export default CompetitionPage; export default CompetitionPage;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Task } from "@/shared/types"; import { Task } from '@/shared/types/task';
import { getTaskBgColor, getTaskTextColor } from '../../utils/utils';
interface CompetitionHeaderProps { interface CompetitionHeaderProps {
title: string; title: string;
@@ -28,12 +27,12 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
<Link <Link
key={task.id} key={task.id}
to={`/competition/${competitionId}/tasks/${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 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:brightness-95 flex-shrink-0 transition-all hover:brightness-95 flex-shrink-0
`} `}
> >
{task.number} {task.in_competition_position}
</Link> </Link>
))} ))}
</div> </div>
@@ -3,53 +3,78 @@ import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css'; 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 { interface TaskContentProps {
task: Task; task: Task;
} }
const TaskContent: React.FC<TaskContentProps> = ({ task }) => { const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
const markdownContent = ` const { id: competitionId } = useParams<{ id: string }>();
## Задача на числовую последовательность
Рассмотрим последовательность чисел: const attachmentsQuery = useQuery({
\`2, 3, 5, 9, 17, 33, 65, 129, ...\` queryKey: ['taskAttachments', competitionId, task.id],
queryFn: () => getTaskAttachments(competitionId || '', task.id),
enabled: !!(competitionId && task.id),
});
Каждый член этой последовательности, **начиная с третьего**, равен сумме двух предыдущих членов: const attachments = attachmentsQuery.data || [];
- $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$
**В ответе укажите целое число.**
`;
return ( return (
<div className="flex-1 bg-white rounded-lg p-6"> <div className="flex-1 bg-white rounded-lg p-6">
<h2 className="text-3xl font-semibold mb-6 font-hse-sans"> <h2 className="text-3xl font-semibold mb-6 font-hse-sans">
Задача {task.number} Задача {task.in_competition_position}
</h2> </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 <ReactMarkdown
remarkPlugins={[remarkMath]} remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
> >
{markdownContent} {task.description}
</ReactMarkdown> </ReactMarkdown>
</div> </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> </div>
); );
}; };
export default TaskContent; 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 { 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 CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent"; import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution"; import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks } from "@/shared/api/session"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
const CompetitionSession = () => { const CompetitionSession = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const [tasks, setTasks] = useState<Task[]>([]);
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const competitionId = id || ""; const competitionId = id || "";
const queryClient = useQueryClient();
useEffect(() => { const tasksQuery = useQuery({
const fetchTasks = async () => { queryKey: ["competitionTasks", competitionId],
try { queryFn: () => getCompetitionTasks(competitionId),
setLoading(true); enabled: !!competitionId,
const fetchedTasks = await getCompetitionTasks(competitionId); });
setTasks(fetchedTasks);
setError(null);
} catch (err) {
console.error("Failed to fetch tasks:", err);
setError("Не удалось загрузить задания. Пожалуйста, попробуйте позже.");
} finally {
setLoading(false);
}
};
if (competitionId) { const submitMutation = useMutation({
fetchTasks(); 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; const currentTask = tasks.find((t) => t.id === taskId) || null;
if (!taskId && tasks.length > 0 && !loading) { if (!taskId && tasks.length > 0 && !isLoading) {
return ( return (
<Navigate <Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`} to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
@@ -48,14 +44,10 @@ const CompetitionSession = () => {
); );
} }
const handleSubmit = async () => { const handleSubmit = () => {
if (!currentTask || !competitionId) return; console.log(currentTask, competitionId, answer)
if (!currentTask || !competitionId || !answer.trim()) return;
try { submitMutation.mutate();
console.log("Solution submitted successfully");
} catch (err) {
console.error("Failed to submit solution:", err);
}
}; };
return ( return (
@@ -68,7 +60,7 @@ const CompetitionSession = () => {
<main className="flex-1 bg-[#F8F8F8] pb-8"> <main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="mx-auto max-w-6xl px-4 py-6"> <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"> <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" /> <Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p> <p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
@@ -82,7 +74,7 @@ const CompetitionSession = () => {
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={mockSolutions} // Still using mock solutions solutions={[]}
answer={answer} answer={answer}
setAnswer={setAnswer} setAnswer={setAnswer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -99,4 +91,4 @@ const CompetitionSession = () => {
); );
}; };
export default CompetitionSession; export default CompetitionSession;
@@ -1,48 +1,31 @@
import React, { useState } from 'react'; import React from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import SolutionHistorySheet from '../SolutionHistorySheet';
import { Solution } from "@/shared/types";
import { mockSolutions } from '@/shared/mocks/mocks';
interface ActionButtonsProps { interface ActionButtonsProps {
onSubmit: () => void; onSubmit: () => void;
solutionHistory?: Solution[]; onHistoryClick: () => void;
} }
const ActionButtons: React.FC<ActionButtonsProps> = ({ const ActionButtons: React.FC<ActionButtonsProps> = ({
onSubmit, onSubmit,
solutionHistory = mockSolutions onHistoryClick
}) => { }) => {
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const handleHistoryClick = () => {
setIsHistoryOpen(true);
};
return ( return (
<> <div className="flex gap-8">
<div className="flex gap-8"> <Button
<Button variant="ghost"
variant="ghost" className="font-hse-sans bg-white hover:bg-gray-100"
className="font-hse-sans bg-white hover:bg-gray-100" onClick={onHistoryClick}
onClick={handleHistoryClick} >
> История
История </Button>
</Button> <Button
<Button onClick={onSubmit}
onClick={onSubmit} className="font-hse-sans flex-grow"
className="font-hse-sans flex-grow" >
> Отправить решение
Отправить решение </Button>
</Button> </div>
</div>
{/* чуть-чуть рак */}
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
/>
</>
); );
}; };
@@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X } from "lucide-react";
import SolutionStatus from '../SolutionStatus'; import SolutionStatus from '../SolutionStatus';
import { Solution } from "@/shared/types"; import { Solution } from '@/shared/types/task';
interface SolutionHistorySheetProps { interface SolutionHistorySheetProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
solutions: Solution[]; solutions: Solution[];
maxPoints: number
} }
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
solutions solutions,
maxPoints
}) => { }) => {
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
{solutions.length > 0 ? ( {solutions.length > 0 ? (
solutions.map((solution, index) => ( solutions.map((solution, index) => (
<div key={index} className="w-full"> <div key={index} className="w-full">
<SolutionStatus solution={solution} /> <SolutionStatus solution={solution} maxPoints={maxPoints} />
</div> </div>
)) ))
) : ( ) : (
@@ -1,41 +1,26 @@
import React from 'react'; import React from 'react';
import { Solution, TaskStatus } from "@/shared/types"; import { Solution } from '@/shared/types/task';
import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
interface SolutionStatusProps { interface SolutionStatusProps {
solution: Solution; solution: Solution;
maxPoints: number;
} }
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution }) => { const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
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 '';
}
};
return ( 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"> <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} Решение {solution.id}
</span> </span>
<span className={`${getTaskTextColor(solution.status)} mt-1`}> <span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
{getStatusText(solution.status, solution.score, solution.maxScore)} {getStatusText(solution.status, solution.earned_points, maxPoints)}
</span> </span>
</div> </div>
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}> <div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
{solution.date} {solution.timestamp}
</div> </div>
</div> </div>
); );
@@ -1,10 +1,14 @@
import React, { useState, useRef } from 'react'; 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 SolutionStatus from './components/SolutionStatus';
import InputSolution from './components/InputSolution'; import InputSolution from './components/InputSolution';
import FileSolution from './components/FileSolution'; import FileSolution from './components/FileSolution';
import CodeSolution from './components/CodeSolution'; import CodeSolution from './components/CodeSolution';
import ActionButtons from './components/ActionButtons'; import ActionButtons from './components/ActionButtons';
import SolutionHistorySheet from './components/SolutionHistorySheet';
interface TaskSolutionProps { interface TaskSolutionProps {
task: Task; task: Task;
@@ -12,28 +16,49 @@ interface TaskSolutionProps {
answer: string; answer: string;
setAnswer: (value: string) => void; setAnswer: (value: string) => void;
onSubmit: () => void; onSubmit: () => void;
} }
const TaskSolution: React.FC<TaskSolutionProps> = ({ const TaskSolution: React.FC<TaskSolutionProps> = ({
task, task,
solutions, solutions = [],
answer, answer,
setAnswer, setAnswer,
onSubmit, onSubmit,
}) => { }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <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} /> <InputSolution answer={answer} setAnswer={setAnswer} />
)} )}
{task.solutionType === 'file' && ( {task.type === TaskType.FILE && (
<FileSolution <FileSolution
selectedFile={selectedFile} selectedFile={selectedFile}
setSelectedFile={setSelectedFile} setSelectedFile={setSelectedFile}
@@ -41,11 +66,21 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
/> />
)} )}
{task.solutionType === 'code' && ( {task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} /> <CodeSolution answer={answer} setAnswer={setAnswer} />
)} )}
<ActionButtons onSubmit={onSubmit} /> <ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
/>
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
maxPoints={task.points}
/>
</div> </div>
); );
}; };
@@ -1,4 +1,5 @@
import { TaskStatus } from "@/shared/types"; import { TaskStatus } from "@/shared/types";
import { SolutionStatus } from "@/shared/types/task";
const getTaskBgColor = (status: TaskStatus): string => { const getTaskBgColor = (status: TaskStatus): string => {
switch (status) { switch (status) {
case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]"; 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)} className={cn("aspect-square h-full w-auto overflow-hidden", className)}
> >
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
{competition.image_url && ( <img
<img src={competition.image_url ? competition.image_url : '/DANO.png'}
src={competition.image_url} alt={competition.title}
alt={competition.title} className="h-full w-full object-cover object-center"
className="h-full w-full object-cover object-center" />
/>
)}
</div> </div>
<CardContent> <CardContent>
@@ -112,4 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
return <h1 className="w-full text-3xl font-semibold">{children}</h1>; return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
}; };
export default CompetitionsPage;
export default CompetitionsPage;
@@ -12,3 +12,9 @@ export const getCompetitions = async (participating?: boolean) => {
export const getCompetition = async (id: string) => { export const getCompetition = async (id: string) => {
return await userFetch<Competition>(`/competition/${id}`); return await userFetch<Competition>(`/competition/${id}`);
}; };
export const startCompetition = async (competitionId: string) => {
return await userFetch(`/competitions/${competitionId}/start`, {
method: 'POST'
});
};
+33 -78
View File
@@ -1,82 +1,37 @@
import { apiFetch } from './index'; import { userFetch } from ".";
import { Task, TaskStatus } from '@/shared/types'; import { Task, Solution, TaskAttachment } from "../types/task";
interface ApiTask { export const getCompetitionTasks = async (competitionId: string) => {
id: string; return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
title: string; };
description: string;
type: 'input' | 'file' | 'code';
in_competition_position: number;
points: number;
status?: TaskStatus;
}
/** export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
* Fetches tasks for a specific competition return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
* @param competitionId - The ID of the competition };
* @returns Promise with an array of tasks in the application's format
*/ export const getTaskAttachments = async (competitionId: string, taskId: string) => {
export const getCompetitionTasks = async (competitionId: string): Promise<Task[]> => { return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
try { };
const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`);
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);
// Transform API tasks to application Task format return await userFetch(endpoint, {
return apiTasks.map(apiTask => transformApiTask(apiTask)); method: 'POST',
} catch (error) { body: formData
console.error('Failed to fetch competition tasks:', error); });
throw error;
} }
}; };
/**
* 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;
// };
+9 -1
View File
@@ -12,6 +12,11 @@ enum TaskStatus {
Wrong = "wrong" Wrong = "wrong"
} }
enum ParticipationType {
Solo = "solo",
Team = "team"
}
interface Competition { interface Competition {
id: string; id: string;
name: string; name: string;
@@ -19,6 +24,9 @@ interface Competition {
isOlympics: boolean; isOlympics: boolean;
status: CompetitionStatus; status: CompetitionStatus;
description?: string; description?: string;
startDate: Date;
endDate: Date;
participationType: ParticipationType
} }
type SolutionType = "input" | "file" | "code"; type SolutionType = "input" | "file" | "code";
@@ -42,5 +50,5 @@ interface Task {
attachments?: string[]; attachments?: string[];
} }
export { CompetitionStatus, TaskStatus }; export { CompetitionStatus, TaskStatus, ParticipationType };
export type { Solution, Competition, Task }; 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}