mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 19:07:10 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
@@ -54,6 +54,24 @@ build_backend-staticfiles:
|
|||||||
DOCKERFILE_PATH: "Dockerfile.staticfiles"
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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': 'ачивки',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 12:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('achievement', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='need_count',
|
||||||
|
field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='type',
|
||||||
|
field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 13:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('achievement', '0002_achievement_need_count_achievement_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='need_count',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='type',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='achievement',
|
||||||
|
name='slug',
|
||||||
|
field=models.SlugField(unique=True, verbose_name='слаг'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Achievement(BaseModel):
|
||||||
|
class AchievementType(models.TextChoices):
|
||||||
|
CORRECT_TASKS = "correct_tasks", "Выполненные задания"
|
||||||
|
|
||||||
|
def image_url_upload_to(instance, filename):
|
||||||
|
return f"achievements/{instance.id}/icon/{filename}"
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=30, verbose_name="название", unique=True
|
||||||
|
)
|
||||||
|
description = models.TextField(verbose_name="описание")
|
||||||
|
icon = models.FileField(
|
||||||
|
verbose_name="иконка достижения",
|
||||||
|
upload_to=image_url_upload_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = models.SlugField(verbose_name="слаг", unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "ачивка"
|
||||||
|
verbose_name_plural = "ачивки"
|
||||||
@@ -15,7 +15,7 @@ class Competition(BaseModel):
|
|||||||
SOLO = "solo", "Индивидуальный"
|
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,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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# myapp/signals.py
|
||||||
|
from django.db.models.signals import m2m_changed
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from apps.review.models import Review
|
||||||
|
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=CompetitionTask.reviewers.through)
|
||||||
|
def print_reviewers(sender, instance, action, **kwargs):
|
||||||
|
if action in ['post_add', 'post_remove', 'post_clear']:
|
||||||
|
submissions = CompetitionTaskSubmission.objects.filter(task=instance)
|
||||||
|
for submission in submissions:
|
||||||
|
submission.send_on_review()
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
from django.contrib import admin
|
from 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"]
|
||||||
|
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 12:09
|
||||||
|
|
||||||
|
import apps.task.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('review', '0002_initial'),
|
||||||
|
('task', '0001_initial'),
|
||||||
|
('user', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='competitiontasksubmission',
|
||||||
|
options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontask',
|
||||||
|
name='reviewers',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='checked_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='content',
|
||||||
|
field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='earned_points',
|
||||||
|
field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='plagiarism_checked',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='проверено на плагиат'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='result',
|
||||||
|
field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='stdout',
|
||||||
|
field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='task',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='timestamp',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontasksubmission',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 12:23
|
||||||
|
|
||||||
|
import tinymce.models
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task', '0002_alter_competitiontasksubmission_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontask',
|
||||||
|
name='description',
|
||||||
|
field=tinymce.models.HTMLField(verbose_name='описание'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 12:49
|
||||||
|
|
||||||
|
import martor.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task', '0003_alter_competitiontask_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competitiontask',
|
||||||
|
name='submission_reviewers_count',
|
||||||
|
field=models.PositiveSmallIntegerField(blank=True, default=1, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competitiontask',
|
||||||
|
name='description',
|
||||||
|
field=martor.models.MartorField(verbose_name='описание'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,11 +2,11 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.db import models
|
from django.db 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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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='ачивки пользователя'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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__
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Ruff files
|
||||||
|
.ruff_cache
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Stage 1: Install dependencies
|
||||||
|
FROM docker.io/python:3.11-alpine3.20 AS builder
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONOPTIMIZE=2 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||||
|
|
||||||
|
COPY pyproject.toml .
|
||||||
|
|
||||||
|
RUN uv sync --no-dev --no-install-project --no-cache
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Start the application
|
||||||
|
FROM docker.io/python:3.11-alpine3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN adduser -D -g '' app && chown -R app:app ./
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONOPTIMIZE=2 \
|
||||||
|
PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/ping || exit 1
|
||||||
|
|
||||||
|
CMD uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM docker.io/python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY checker_requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r checker_requirements.txt
|
||||||
|
|
||||||
|
CMD ["python"]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# DataRush Checker
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure you have the following installed on your system:
|
||||||
|
|
||||||
|
- [Python](https://www.python.org/) (>=3.10,<3.12)
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
- [Docker](https://www.docker.com/) (for containerized setup)
|
||||||
|
|
||||||
|
## Basic setup
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Clone the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitlab.prodcontest.ru:team-15/project.git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Go to the project directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd project/services/checker
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install dependencies
|
||||||
|
|
||||||
|
##### For dev environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --all-extras
|
||||||
|
```
|
||||||
|
|
||||||
|
##### For prod environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --no-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Running
|
||||||
|
|
||||||
|
##### Apply migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Start celery worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
celery -A config worker -l INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Start server
|
||||||
|
|
||||||
|
In dev mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
In prod mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run gunicorn config.wsgi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Containerized setup
|
||||||
|
|
||||||
|
### Clone the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitlab.prodcontest.ru:team-15/project.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go to the project directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd project/services/checker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build docker image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t datarush-checker .
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pandas==2.2.3
|
||||||
|
numpy==2.2.3
|
||||||
|
matplotlib==3.10.1
|
||||||
|
scipy==1.15.2
|
||||||
|
scikit-learn==1.6.1
|
||||||
|
seaborn==0.13.2
|
||||||
|
statsmodels==0.14.4
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl, constr
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import docker
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import re
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
docker_client = docker.from_env()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
DOCKER_IMAGE = "gitlab.python:3-slim"
|
||||||
|
CONTAINER_TIMEOUT = 60
|
||||||
|
MAX_FILE_SIZE = 4 * 1024 * 1024
|
||||||
|
ALLOWED_FILENAME_CHARS = r"[^a-zA-Z0-9_\-.]"
|
||||||
|
|
||||||
|
|
||||||
|
class FileDetails(BaseModel):
|
||||||
|
url: HttpUrl = Field(
|
||||||
|
..., description="URL to download the file from (supports HTTP/HTTPS)"
|
||||||
|
)
|
||||||
|
bind_path: str = Field(
|
||||||
|
...,
|
||||||
|
description="Container path to bind the file (absolute)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionRequest(BaseModel):
|
||||||
|
code: str = Field(..., description="Base64 encoded Python code to execute")
|
||||||
|
answer_file_path: str = Field(
|
||||||
|
"stdout", description="Base64 encoded path to result file or 'stdout'"
|
||||||
|
)
|
||||||
|
expected_hash: str | None = Field(
|
||||||
|
None, description="Optional SHA-256 hash of expected output"
|
||||||
|
)
|
||||||
|
files: list[FileDetails] = Field(
|
||||||
|
[], description="List of files to mount in container"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionResponse(BaseModel):
|
||||||
|
success: bool = Field(..., description="Execution success status")
|
||||||
|
hash_match: bool | None = Field(
|
||||||
|
None, description="Output hash matches expected (if provided)"
|
||||||
|
)
|
||||||
|
output: str = Field(..., description="Captured stdout or file contents")
|
||||||
|
result_hash: str = Field(..., description="SHA-256 hash of output")
|
||||||
|
error: str = Field(..., description="Execution errors or stderr")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckResponse(BaseModel):
|
||||||
|
status: str = Field(..., description="Service health status")
|
||||||
|
docker: str = Field(..., description="Docker daemon status")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_base64(encoded_str: str, field_name: str) -> str:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(encoded_str).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Base64 decode failed for {field_name}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid Base64 in {field_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(url: str) -> str:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
base_name = os.path.basename(parsed.path)
|
||||||
|
|
||||||
|
if not base_name:
|
||||||
|
base_name = "file"
|
||||||
|
|
||||||
|
clean = re.sub(ALLOWED_FILENAME_CHARS, "", base_name)[:255]
|
||||||
|
return clean or "file"
|
||||||
|
|
||||||
|
|
||||||
|
async def download_file(
|
||||||
|
session: aiohttp.ClientSession, url: str, dest_path: str
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
url, timeout=aiohttp.ClientTimeout(total=30)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to download {url} - Status {resp.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = b""
|
||||||
|
async for chunk in resp.content.iter_chunked(8192):
|
||||||
|
content += chunk
|
||||||
|
if len(content) > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail="File size exceeds 4MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(dest_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
logger.info(f"Downloaded {url} to {dest_path}")
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Download error for {url}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Download failed: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_container_safely(
|
||||||
|
tmp_dir: str,
|
||||||
|
command: list[str],
|
||||||
|
bound_files: dict[str, str],
|
||||||
|
timeout: int = CONTAINER_TIMEOUT,
|
||||||
|
) -> dict:
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
volumes = {tmp_dir: {"bind": "/execution", "mode": "rw"}}
|
||||||
|
for host_path, container_path in bound_files.items():
|
||||||
|
volumes[host_path] = {"bind": container_path, "mode": "ro"}
|
||||||
|
|
||||||
|
container = docker_client.containers.run(
|
||||||
|
image=DOCKER_IMAGE,
|
||||||
|
command=command,
|
||||||
|
volumes=volumes,
|
||||||
|
working_dir="/execution",
|
||||||
|
stdout=True,
|
||||||
|
stderr=True,
|
||||||
|
detach=True,
|
||||||
|
mem_limit="100m",
|
||||||
|
network_mode="none",
|
||||||
|
cpu_period=100000,
|
||||||
|
cpu_quota=50000,
|
||||||
|
user="root",
|
||||||
|
security_opt=["no-new-privileges"],
|
||||||
|
)
|
||||||
|
|
||||||
|
exit_code = container.wait(timeout=timeout)["StatusCode"]
|
||||||
|
stdout = container.logs(stdout=True, stderr=False).decode().strip()
|
||||||
|
stderr = container.logs(stdout=False, stderr=True).decode().strip()
|
||||||
|
|
||||||
|
return {"stdout": stdout, "stderr": stderr, "status": exit_code}
|
||||||
|
|
||||||
|
except docker.errors.DockerException as e:
|
||||||
|
logger.error(f"Docker error: {str(e)}")
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"Container error: {str(e)}",
|
||||||
|
"status": -1,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if container:
|
||||||
|
try:
|
||||||
|
container.remove(force=True)
|
||||||
|
except docker.errors.DockerException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/execute", response_model=ExecutionResponse)
|
||||||
|
async def execute_code(request: ExecutionRequest) -> ExecutionResponse:
|
||||||
|
try:
|
||||||
|
code = decode_base64(request.code, "code")
|
||||||
|
answer_path = (
|
||||||
|
decode_base64(request.answer_file_path, "answer_file_path")
|
||||||
|
if request.answer_file_path != "stdout"
|
||||||
|
else "stdout"
|
||||||
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
return ExecutionResponse(
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
result_hash="",
|
||||||
|
error=e.detail,
|
||||||
|
hash_match=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if answer_path != "stdout":
|
||||||
|
if os.path.isabs(answer_path) or not validate_file_path(answer_path):
|
||||||
|
return ExecutionResponse(
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
result_hash="",
|
||||||
|
error="Invalid answer file path",
|
||||||
|
hash_match=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
bound_files = {}
|
||||||
|
if request.files:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
download_tasks = []
|
||||||
|
for file in request.files:
|
||||||
|
filename = sanitize_filename(str(file.url))
|
||||||
|
dest_path = os.path.join(tmp_dir, filename)
|
||||||
|
bound_files[dest_path] = file.bind_path
|
||||||
|
download_tasks.append(
|
||||||
|
download_file(session, str(file.url), dest_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*download_tasks)
|
||||||
|
except HTTPException as e:
|
||||||
|
return ExecutionResponse(
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
result_hash="",
|
||||||
|
error=e.detail,
|
||||||
|
hash_match=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
code_path = os.path.join(tmp_dir, "submission.py")
|
||||||
|
with open(code_path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
os.chmod(code_path, 0o444)
|
||||||
|
|
||||||
|
if answer_path == "stdout":
|
||||||
|
cmd = ["python", "submission.py"]
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"python submission.py && cat {answer_path} || echo 'EXECUTION_FAILED'",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
run_container_safely,
|
||||||
|
tmp_dir,
|
||||||
|
cmd,
|
||||||
|
bound_files,
|
||||||
|
CONTAINER_TIMEOUT,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Container execution failed: {str(e)}")
|
||||||
|
return ExecutionResponse(
|
||||||
|
success=False,
|
||||||
|
output="",
|
||||||
|
result_hash="",
|
||||||
|
error=f"Execution failed: {str(e)}",
|
||||||
|
hash_match=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = result["stdout"]
|
||||||
|
error = result["stderr"]
|
||||||
|
success = result["status"] == 0
|
||||||
|
|
||||||
|
if answer_path != "stdout" and not output:
|
||||||
|
error += "\nNo output captured - check answer file path"
|
||||||
|
|
||||||
|
result_hash = hashlib.sha256(output.encode()).hexdigest()
|
||||||
|
|
||||||
|
return ExecutionResponse(
|
||||||
|
success=success,
|
||||||
|
hash_match=(
|
||||||
|
result_hash == request.expected_hash
|
||||||
|
if request.expected_hash
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
output=output[:5000],
|
||||||
|
result_hash=result_hash,
|
||||||
|
error=error[:5000],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthCheckResponse)
|
||||||
|
async def health_check() -> HealthCheckResponse:
|
||||||
|
try:
|
||||||
|
docker_client.ping()
|
||||||
|
return HealthCheckResponse(status="healthy", docker="connected")
|
||||||
|
except docker.errors.DockerException:
|
||||||
|
return HealthCheckResponse(status="degraded", docker="unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_path(path: str) -> bool:
|
||||||
|
return (
|
||||||
|
not os.path.isabs(path)
|
||||||
|
and os.path.basename(path) == path
|
||||||
|
and all(c.isalnum() or c in {"_", "-", "."} for c in path)
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[project]
|
||||||
|
name = "checker"
|
||||||
|
version = "0.1.0"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"aiohttp>=3.11.13",
|
||||||
|
"docker>=7.1.0",
|
||||||
|
"fastapi>=0.115.11",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
|
"regex>=2024.11.6",
|
||||||
|
"uvicorn>=0.34.0",
|
||||||
|
]
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
GREEN='\033[1;32m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
uvx ruff format .
|
||||||
|
uvx ruff check . --fix
|
||||||
|
printf "${GREEN}Linters/formatters runned${NC}\n"
|
||||||
@@ -10,7 +10,6 @@ import LoginPage from "./pages/Login";
|
|||||||
import { AuthLayout } from "./widgets/auth-layout";
|
import { 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>
|
||||||
|
|||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Task } from "@/shared/types";
|
|
||||||
import { Settings, Plus } from 'lucide-react';
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface ConstructorHeaderProps {
|
|
||||||
title: string;
|
|
||||||
tasks: Task[];
|
|
||||||
competitionId: string;
|
|
||||||
onAddTaskClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({
|
|
||||||
title,
|
|
||||||
tasks,
|
|
||||||
competitionId,
|
|
||||||
onAddTaskClick
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
|
|
||||||
<div className="mx-auto max-w-6xl px-4">
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<h1 className="font-hse-sans text-xl font-semibold">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
|
|
||||||
<Link
|
|
||||||
to={`/constructor/${competitionId}/tasks/settings`}
|
|
||||||
className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
|
||||||
transition-all hover:bg-gray-200 flex-shrink-0 flex items-center"
|
|
||||||
>
|
|
||||||
<Settings size={16} className="mr-1" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<Link
|
|
||||||
key={task.id}
|
|
||||||
to={`/constructor/${competitionId}/tasks/${task.id}`}
|
|
||||||
className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
|
||||||
transition-all hover:bg-blue-200 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{task.number}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-lg flex items-center px-2 h-8"
|
|
||||||
onClick={onAddTaskClick}
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConstructorHeader;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useParams, Navigate, useNavigate } from "react-router-dom";
|
|
||||||
import { Task, TaskStatus } from "@/shared/types";
|
|
||||||
import ConstructorHeader from "./components/ConstructorHeader";
|
|
||||||
import TaskCreationModal from "./modules/TaskCreationModal";
|
|
||||||
|
|
||||||
const CompetitionConstructor = () => {
|
|
||||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада");
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const isSettings = taskId === "settings";
|
|
||||||
|
|
||||||
const handleOpenTaskModal = () => {
|
|
||||||
setIsTaskModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseTaskModal = () => {
|
|
||||||
setIsTaskModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateTask = (taskData: Partial<Task>) => {
|
|
||||||
const newTask: Task = {
|
|
||||||
id: `task-${Date.now()}`,
|
|
||||||
number: taskData.number || `${tasks.length + 1}`,
|
|
||||||
status: TaskStatus.Uncleared,
|
|
||||||
solutionType: taskData.solutionType || "input",
|
|
||||||
description: taskData.description || "",
|
|
||||||
requirements: taskData.requirements,
|
|
||||||
attachments: taskData.attachments || []
|
|
||||||
};
|
|
||||||
|
|
||||||
setTasks([...tasks, newTask]);
|
|
||||||
setIsTaskModalOpen(false);
|
|
||||||
navigate(`/constructor/${id}/tasks/${newTask.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!taskId) {
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />;
|
|
||||||
} else {
|
|
||||||
return <Navigate to={`/constructor/${id}/tasks/settings`} replace />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col min-h-screen">
|
|
||||||
<ConstructorHeader
|
|
||||||
title={competitionTitle}
|
|
||||||
tasks={tasks}
|
|
||||||
competitionId={id || ""}
|
|
||||||
onAddTaskClick={handleOpenTaskModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskCreationModal
|
|
||||||
isOpen={isTaskModalOpen}
|
|
||||||
onClose={handleCloseTaskModal}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
taskCount={tasks.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
|
||||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
|
||||||
{isSettings ? (
|
|
||||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
|
||||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2>
|
|
||||||
<p className="text-gray-500 font-hse-sans">
|
|
||||||
Здесь будет форма настроек олимпиады
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
|
||||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">
|
|
||||||
{`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 font-hse-sans">
|
|
||||||
Здесь будет форма редактирования задачи
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompetitionConstructor;
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface TaskDescriptionFieldProps {
|
|
||||||
description: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskDescriptionField: React.FC<TaskDescriptionFieldProps> = ({ description, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 items-start gap-4">
|
|
||||||
<Label htmlFor="description" className="text-right pt-2">
|
|
||||||
Описание
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="col-span-3 min-h-[100px]"
|
|
||||||
placeholder="Введите описание задачи"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskDescriptionField;
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FileIcon, X, Upload } from 'lucide-react';
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface TaskFileAttachmentsProps {
|
|
||||||
files: File[];
|
|
||||||
onChange: (files: File[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskFileAttachments: React.FC<TaskFileAttachmentsProps> = ({ files, onChange }) => {
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
const newFiles = Array.from(e.target.files);
|
|
||||||
onChange([...files, ...newFiles]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
onChange(files.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 items-start gap-4">
|
|
||||||
<Label className="text-right pt-2">
|
|
||||||
Файлы
|
|
||||||
</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{files.map((file, index) => (
|
|
||||||
<FileListItem
|
|
||||||
key={index}
|
|
||||||
file={file}
|
|
||||||
onRemove={() => removeFile(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<FileUploadButton onChange={handleFileChange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FileListItemProps {
|
|
||||||
file: File;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileListItem: React.FC<FileListItemProps> = ({ file, onRemove }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-2 border rounded-md">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FileIcon size={16} className="mr-2 text-gray-500" />
|
|
||||||
<span className="text-sm">{file.name}</span>
|
|
||||||
<span className="text-xs text-gray-500 ml-2">
|
|
||||||
({Math.round(file.size / 1024)} KB)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRemove}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FileUploadButtonProps {
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileUploadButton: React.FC<FileUploadButtonProps> = ({ onChange }) => {
|
|
||||||
return (
|
|
||||||
<label className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2 p-2 border border-dashed rounded-md hover:bg-gray-50 transition-colors">
|
|
||||||
<Upload size={16} className="text-gray-500" />
|
|
||||||
<span className="text-sm text-gray-700">Добавить файлы</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={onChange}
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskFileAttachments;
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface TaskNumberFieldProps {
|
|
||||||
number: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskNumberField: React.FC<TaskNumberFieldProps> = ({ number, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label htmlFor="task-number" className="text-right">
|
|
||||||
Номер задачи
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="task-number"
|
|
||||||
value={number}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="col-span-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskNumberField;
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface TaskRequirementsFieldProps {
|
|
||||||
requirements: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskRequirementsField: React.FC<TaskRequirementsFieldProps> = ({ requirements, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 items-start gap-4">
|
|
||||||
<Label htmlFor="requirements" className="text-right pt-2">
|
|
||||||
Требования
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="requirements"
|
|
||||||
value={requirements}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="col-span-3"
|
|
||||||
placeholder="Введите требования к решению (необязательно)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskRequirementsField;
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
RadioGroup,
|
|
||||||
RadioGroupItem
|
|
||||||
} from "@/components/ui/radio-group";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface TaskSolutionTypeSelectorProps {
|
|
||||||
solutionType: 'input' | 'file' | 'code';
|
|
||||||
onChange: (value: 'input' | 'file' | 'code') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskSolutionTypeSelector: React.FC<TaskSolutionTypeSelectorProps> = ({ solutionType, onChange }) => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 items-start gap-4">
|
|
||||||
<Label className="text-right pt-2">
|
|
||||||
Тип решения
|
|
||||||
</Label>
|
|
||||||
<RadioGroup
|
|
||||||
className="col-span-3"
|
|
||||||
value={solutionType}
|
|
||||||
onValueChange={(value) => onChange(value as 'input' | 'file' | 'code')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="input" id="input" />
|
|
||||||
<Label htmlFor="input">Ввод ответа</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="file" id="file" />
|
|
||||||
<Label htmlFor="file">Загрузка файла</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="code" id="code" />
|
|
||||||
<Label htmlFor="code">Программный код</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskSolutionTypeSelector;
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Task } from "@/shared/types";
|
|
||||||
import TaskNumberField from './components/TaskNumberField';
|
|
||||||
import TaskDescriptionField from './components/TaskDescriptionField';
|
|
||||||
import TaskRequirementsField from './components/TaskRequirementsField';
|
|
||||||
import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector';
|
|
||||||
import TaskFileAttachments from './components/TaskFileAttachments';
|
|
||||||
|
|
||||||
interface TaskCreationModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreateTask: (task: Partial<Task>) => void;
|
|
||||||
taskCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskCreationModal: React.FC<TaskCreationModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onCreateTask,
|
|
||||||
taskCount
|
|
||||||
}) => {
|
|
||||||
const [number, setNumber] = useState(`${taskCount + 1}`);
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [requirements, setRequirements] = useState('');
|
|
||||||
const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input');
|
|
||||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const newTask: Partial<Task> = {
|
|
||||||
number,
|
|
||||||
description,
|
|
||||||
requirements: requirements || undefined,
|
|
||||||
solutionType,
|
|
||||||
attachments: attachedFiles.map(file => file.name)
|
|
||||||
};
|
|
||||||
|
|
||||||
onCreateTask(newTask);
|
|
||||||
|
|
||||||
setNumber(`${taskCount + 1}`);
|
|
||||||
setDescription('');
|
|
||||||
setRequirements('');
|
|
||||||
setSolutionType('input');
|
|
||||||
setAttachedFiles([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="sm:max-w-[600px] font-hse-sans">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">Создание новой задачи</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<TaskNumberField
|
|
||||||
number={number}
|
|
||||||
onChange={setNumber}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskDescriptionField
|
|
||||||
description={description}
|
|
||||||
onChange={setDescription}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskRequirementsField
|
|
||||||
requirements={requirements}
|
|
||||||
onChange={setRequirements}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskSolutionTypeSelector
|
|
||||||
solutionType={solutionType}
|
|
||||||
onChange={setSolutionType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskFileAttachments
|
|
||||||
files={attachedFiles}
|
|
||||||
onChange={setAttachedFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={handleSubmit}>
|
|
||||||
Создать задачу
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskCreationModal;
|
|
||||||
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFileNameFromUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parts = url.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
} catch (e) {
|
||||||
|
return 'Файл';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default TaskContent;
|
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}
|
||||||
|
|||||||
+18
-35
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+10
-25
@@ -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'
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 getCompetitionTasks = async (competitionId: string): Promise<Task[]> => {
|
|
||||||
try {
|
|
||||||
const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`);
|
|
||||||
|
|
||||||
// Transform API tasks to application Task format
|
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
|
||||||
return apiTasks.map(apiTask => transformApiTask(apiTask));
|
return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
|
||||||
} catch (error) {
|
};
|
||||||
console.error('Failed to fetch competition tasks:', error);
|
|
||||||
throw error;
|
|
||||||
|
export const submitTaskSolution = async (
|
||||||
|
competitionId: string,
|
||||||
|
taskId: string,
|
||||||
|
solution: string | File
|
||||||
|
) => {
|
||||||
|
const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||||
|
if (typeof solution === 'string') {
|
||||||
|
return await userFetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { content: solution }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', solution);
|
||||||
|
|
||||||
|
return await userFetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms an API task to the application's Task format
|
|
||||||
*/
|
|
||||||
const transformApiTask = (apiTask: ApiTask): Task => {
|
|
||||||
return {
|
|
||||||
id: apiTask.id,
|
|
||||||
number: String(apiTask.in_competition_position),
|
|
||||||
status: apiTask.status || TaskStatus.Uncleared,
|
|
||||||
solutionType: apiTask.type,
|
|
||||||
description: apiTask.description,
|
|
||||||
maxScore: apiTask.points
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// export const submitTaskSolution = async (
|
|
||||||
// competitionId: string,
|
|
||||||
// taskId: string,
|
|
||||||
// solution: string | File
|
|
||||||
// ): Promise<void> => {
|
|
||||||
// const endpoint = `/api/v1/competitions/${competitionId}/tasks/${taskId}/submit`;
|
|
||||||
|
|
||||||
// // Handle different solution types
|
|
||||||
// if (typeof solution === 'string') {
|
|
||||||
// // Text or code solution
|
|
||||||
// await apiFetch(endpoint, {
|
|
||||||
// method: 'POST',
|
|
||||||
// body: { answer: solution }
|
|
||||||
// });
|
|
||||||
// } else {
|
|
||||||
// // File solution
|
|
||||||
// const formData = new FormData();
|
|
||||||
// formData.append('file', solution);
|
|
||||||
|
|
||||||
// await apiFetch(endpoint, {
|
|
||||||
// method: 'POST',
|
|
||||||
// body: formData
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the status of a task submission
|
|
||||||
* This would be used to poll for updates after submission
|
|
||||||
*/
|
|
||||||
// export const getTaskSubmissionStatus = async (
|
|
||||||
// competitionId: string,
|
|
||||||
// taskId: string
|
|
||||||
// ): Promise<TaskStatus> => {
|
|
||||||
// const response = await apiFetch(`/api/v1/competitions/${competitionId}/tasks/${taskId}/status`);
|
|
||||||
// return response.status;
|
|
||||||
// };
|
|
||||||
@@ -12,6 +12,11 @@ enum TaskStatus {
|
|||||||
Wrong = "wrong"
|
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}
|
||||||
Reference in New Issue
Block a user