Настройки олимпиады
-- Здесь будет форма настроек олимпиады -
-- {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} -
-- Здесь будет форма редактирования задачи -
-diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a734261..fde8bb7 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -54,6 +54,24 @@ build_backend-staticfiles:
DOCKERFILE_PATH: "Dockerfile.staticfiles"
IMAGE_NAME: "$CI_REGISTRY_IMAGE/backend-staticfiles"
+build_checker:
+ <<: *build-template
+ rules:
+ - if: '$CI_COMMIT_REF_NAME == "master"'
+ variables:
+ CONTEXT: "${CI_PROJECT_DIR}/services/checker"
+ DOCKERFILE_PATH: "Dockerfile"
+ IMAGE_NAME: "$CI_REGISTRY_IMAGE/checker"
+
+build_custom-python:
+ <<: *build-template
+ rules:
+ - if: '$CI_COMMIT_REF_NAME == "master"'
+ variables:
+ CONTEXT: "${CI_PROJECT_DIR}/services/checker"
+ DOCKERFILE_PATH: "Dockerfile.checker"
+ IMAGE_NAME: "$CI_REGISTRY_IMAGE/custom-python"
+
build_docs:
<<: *build-template
rules:
diff --git a/compose.yaml b/compose.yaml
index 7a28eac..e4401a3 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -370,6 +370,23 @@ services:
restart: unless-stopped
shm_size: 4mb
+ checker:
+ image: gitlab.prodcontest.ru:5050/team-15/project/checker:latest
+ build:
+ context: ./services/checker
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - name: web
+ target: 8000
+ published: 8009
+ host_ip: 0.0.0.0
+ protocol: tcp
+ volumes:
+ - type: bind
+ source: /var/run/docker.sock
+ target: /var/run/docker.sock
+
proxy:
image: docker.io/nginx:1.27-alpine3.21
configs:
diff --git a/infrastructure/backend/.env.template b/infrastructure/backend/.env.template
index 40ead7f..c72f06a 100644
--- a/infrastructure/backend/.env.template
+++ b/infrastructure/backend/.env.template
@@ -18,3 +18,5 @@ MINIO_ENDPOINT=minio:9000
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=password
+
+CHECKER_API_ENDPOINT=http://checker:8000
diff --git a/services/backend/Dockerfile.staticfiles b/services/backend/Dockerfile.staticfiles
index 5150bf5..21ac3a0 100644
--- a/services/backend/Dockerfile.staticfiles
+++ b/services/backend/Dockerfile.staticfiles
@@ -24,4 +24,6 @@ FROM docker.io/nginx:latest
COPY --from=builder /app/static /usr/share/nginx/html
+COPY ../checker/checker_requirements.txt .
+
CMD ["nginx", "-g", "daemon off;"]
diff --git a/services/frontend/src/pages/CompetitionConstructor/modules/index.tsx b/services/backend/api/v1/achievement/__init__.py
similarity index 100%
rename from services/frontend/src/pages/CompetitionConstructor/modules/index.tsx
rename to services/backend/api/v1/achievement/__init__.py
diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py
new file mode 100644
index 0000000..ecc1c5d
--- /dev/null
+++ b/services/backend/api/v1/achievement/schemas.py
@@ -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",
+ )
diff --git a/services/backend/api/v1/achievement/views.py b/services/backend/api/v1/achievement/views.py
new file mode 100644
index 0000000..804348a
--- /dev/null
+++ b/services/backend/api/v1/achievement/views.py
@@ -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()
diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py
index 1b79004..97a9fd1 100644
--- a/services/backend/api/v1/review/schemas.py
+++ b/services/backend/api/v1/review/schemas.py
@@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema):
competition: UUID = Field(..., alias="task.competition.id")
competition_name: str = Field(..., alias="task.competition.title")
task_position: int = Field(..., alias="task.in_competition_position")
+ task_title: str = Field(..., alias="task.title")
@staticmethod
def resolve_criteries(self, context) -> list[CriteriaOut] | None:
diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py
index 281358b..05c2e3a 100644
--- a/services/backend/api/v1/review/views.py
+++ b/services/backend/api/v1/review/views.py
@@ -1,5 +1,6 @@
from datetime import datetime
from http import HTTPStatus as status
+from statistics import median
from uuid import UUID
from django.http import HttpRequest
@@ -84,15 +85,22 @@ def evaluate_submission(
review.evaluation = evaluation
review.state = ReviewStatusChoices.CHECKED.value
review.submission.checked_at = datetime.now()
-
- points = 0
- for criterea in evaluation:
- points += criterea["mark"]
- review.submission.earned_points = (
- points # TODO: оценка не от последнего проверяющего а средняя по всем
- )
review.save()
+ submission_evaluations = Review.objects.filter(
+ submission=submission
+ ).values_list("evaluation", flat=True)
+
+ marks = []
+ for evaluation in submission_evaluations:
+ mark = 0
+ for criterea in evaluation:
+ mark += criterea["mark"]
+ marks.append(mark)
+ earned_points = median(marks)
+
+ review.submission.earned_points = earned_points
+
all_checked = not submission.reviews.exclude(
state=ReviewStatusChoices.CHECKED
).exists()
@@ -100,5 +108,6 @@ def evaluate_submission(
review.submission.status = (
CompetitionTaskSubmission.StatusChoices.CHECKED.value
)
- review.submission.save()
+ review.submission.save()
+
return status.OK, review.submission
diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py
index 836643d..9e73c37 100644
--- a/services/backend/api/v1/task/schemas.py
+++ b/services/backend/api/v1/task/schemas.py
@@ -3,10 +3,27 @@ from uuid import UUID
from ninja import ModelSchema, Schema
-from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
+from apps.task.models import (
+ CompetitionTask,
+ CompetitionTaskAttachment,
+ CompetitionTaskSubmission,
+)
class TaskOutSchema(ModelSchema):
+ status: Literal["sent", "checked", "checking", "not_submitted"] = None
+ type: Literal["input", "checker", "review"] = None
+
+ @staticmethod
+ def resolve_status(
+ self, context
+ ) -> Literal["sent", "checked", "checking", "not_submitted"]:
+ if submission := CompetitionTaskSubmission.objects.filter(
+ task=self, user=context.get("request").auth
+ ).first():
+ return submission.status
+ return "not_submitted"
+
class Meta:
model = CompetitionTask
fields = [
@@ -14,7 +31,6 @@ class TaskOutSchema(ModelSchema):
"competition",
"title",
"description",
- "type",
"in_competition_position",
"points",
]
@@ -29,10 +45,19 @@ class HistorySubmissionOut(ModelSchema):
class Meta:
model = CompetitionTaskSubmission
- fields = ("id", "earned_points", "timestamp", "content",)
+ fields = (
+ "id",
+ "earned_points",
+ "timestamp",
+ "content",
+ )
class TaskAttachmentSchema(ModelSchema):
class Meta:
model = CompetitionTaskAttachment
- fields = ("id", "file", "public",)
+ fields = (
+ "id",
+ "file",
+ "public",
+ )
diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py
index f918526..91f7477 100644
--- a/services/backend/api/v1/task/views.py
+++ b/services/backend/api/v1/task/views.py
@@ -155,5 +155,5 @@ def get_submissions_history(request, competition_id: UUID, task_id: UUID):
def get_task_attachments(request, competition_id: UUID, task_id: UUID):
task = get_object_or_404(CompetitionTask, id=task_id)
return status.OK, CompetitionTaskAttachment.objects.filter(
- competition_id=competition_id, task=task, user=request.auth
- )
+ task=task
+ ).all()
diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py
index 3e03423..832d91f 100644
--- a/services/backend/api/v1/user/schemas.py
+++ b/services/backend/api/v1/user/schemas.py
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
class UserSchema(ModelSchema):
class Meta:
model = User
- fields = ["id", "email", "username", "created_at",]
+ fields = ["id", "email", "username", "created_at", "achievements"]
diff --git a/services/backend/apps/achievement/__init__.py b/services/backend/apps/achievement/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/backend/apps/achievement/admin.py b/services/backend/apps/achievement/admin.py
new file mode 100644
index 0000000..9d7822d
--- /dev/null
+++ b/services/backend/apps/achievement/admin.py
@@ -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",
+ )
diff --git a/services/backend/apps/achievement/apps.py b/services/backend/apps/achievement/apps.py
new file mode 100644
index 0000000..4c9ddeb
--- /dev/null
+++ b/services/backend/apps/achievement/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class AchievementConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.achievement"
+ verbose_name = "Ачивки"
diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py
new file mode 100644
index 0000000..b20fb21
--- /dev/null
+++ b/services/backend/apps/achievement/migrations/0001_initial.py
@@ -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': 'ачивки',
+ },
+ ),
+ ]
diff --git a/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py
new file mode 100644
index 0000000..e16f3b6
--- /dev/null
+++ b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py
@@ -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='тип'),
+ ),
+ ]
diff --git a/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py
new file mode 100644
index 0000000..682a718
--- /dev/null
+++ b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py
@@ -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='слаг'),
+ ),
+ ]
diff --git a/services/backend/apps/achievement/migrations/__init__.py b/services/backend/apps/achievement/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py
new file mode 100644
index 0000000..2c7724f
--- /dev/null
+++ b/services/backend/apps/achievement/models.py
@@ -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 = "ачивки"
diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py
index 18212f4..5d9880f 100644
--- a/services/backend/apps/competition/models.py
+++ b/services/backend/apps/competition/models.py
@@ -15,7 +15,7 @@ class Competition(BaseModel):
SOLO = "solo", "Индивидуальный"
def image_url_upload_to(instance, filename):
- return f"/competitions/{instance.id}/image"
+ return f"competitions/{instance.id}/image/{filename}"
title = models.CharField(max_length=100, verbose_name="название")
description = models.TextField(verbose_name="описание")
diff --git a/services/backend/apps/core/admin.py b/services/backend/apps/core/admin.py
index 3bc8edf..4027d38 100644
--- a/services/backend/apps/core/admin.py
+++ b/services/backend/apps/core/admin.py
@@ -1,4 +1,4 @@
from django.contrib import admin
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
admin.site.unregister(Group)
diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py
index 0b8b31c..d78d0a5 100644
--- a/services/backend/apps/core/management/commands/generate_data.py
+++ b/services/backend/apps/core/management/commands/generate_data.py
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.competition.models import Competition, State
-from apps.review.models import Review, Reviewer
+from apps.review.models import Reviewer
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
from apps.user.models import User, UserRole
@@ -99,11 +99,13 @@ class Command(BaseCommand):
title = f"Task {i} for {comp.title}"
description = f"Task description for task {i} in {comp.title}"
task = CompetitionTask.objects.create(
+ in_competition_position=i,
competition=comp,
title=title,
description=description,
type=task_type,
points=random.randint(1, 10),
+ submission_reviewers_count=random.randint(2, 10),
max_attempts=random.randint(1, 10),
)
tasks.append(task)
diff --git a/services/backend/apps/review/admin.py b/services/backend/apps/review/admin.py
index c173af7..653d999 100644
--- a/services/backend/apps/review/admin.py
+++ b/services/backend/apps/review/admin.py
@@ -1,9 +1,15 @@
from django.contrib import admin
-from apps.review.models import Review, Reviewer
+from apps.review.models import Reviewer
@admin.register(Reviewer)
class ReviewersAdmin(admin.ModelAdmin):
- list_display = ("name", "surname",)
- search_fields = ("name", "surname",)
+ list_display = (
+ "name",
+ "surname",
+ )
+ search_fields = (
+ "name",
+ "surname",
+ )
diff --git a/services/backend/apps/review/apps.py b/services/backend/apps/review/apps.py
index 27080a3..138bf7f 100644
--- a/services/backend/apps/review/apps.py
+++ b/services/backend/apps/review/apps.py
@@ -5,3 +5,6 @@ class CoreConfig(AppConfig):
name = "apps.review"
label = "review"
verbose_name = "Проверка"
+
+ def ready(self):
+ import apps.review.signals
diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py
index 5fd4fb9..3c1201a 100644
--- a/services/backend/apps/review/models.py
+++ b/services/backend/apps/review/models.py
@@ -24,22 +24,24 @@ class ReviewStatusChoices(models.TextChoices):
class Review(BaseModel):
- reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
- verbose_name="проверяющий")
+ reviewer = models.ForeignKey(
+ Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий"
+ )
submission = models.ForeignKey(
"task.CompetitionTaskSubmission",
on_delete=models.CASCADE,
related_name="reviews",
- verbose_name="посылка"
+ verbose_name="посылка",
)
- evaluation = models.JSONField(default=list, null=True, blank=True,
- verbose_name="выполнение")
+ evaluation = models.JSONField(
+ default=list, null=True, blank=True, verbose_name="выполнение"
+ )
state = models.CharField(
choices=ReviewStatusChoices.choices,
default=ReviewStatusChoices.NOT_CHECKED.value,
max_length=11,
- verbose_name="состояние"
+ verbose_name="состояние",
)
def __str__(self):
diff --git a/services/backend/apps/review/signals.py b/services/backend/apps/review/signals.py
new file mode 100644
index 0000000..44da6c2
--- /dev/null
+++ b/services/backend/apps/review/signals.py
@@ -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()
\ No newline at end of file
diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py
index a09f852..1cf4361 100644
--- a/services/backend/apps/task/admin.py
+++ b/services/backend/apps/task/admin.py
@@ -1,7 +1,10 @@
from django.contrib import admin
-from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \
- CompetitionTaskSubmission
+from apps.task.models import (
+ CompetitionTask,
+ CompetitionTaskAttachment,
+ CompetitionTaskSubmission,
+)
class CompletionAttachmentInline(admin.StackedInline):
@@ -12,15 +15,22 @@ class CompletionAttachmentInline(admin.StackedInline):
@admin.register(CompetitionTask)
class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points")
- filter_horizontal = (
- "reviewers",
- )
+ filter_horizontal = ("reviewers",)
@admin.register(CompetitionTaskSubmission)
class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
- list_display = ("task", "user", "status",)
- search_fields = ("task__id", "task__title", "user__username", "user__email")
+ list_display = (
+ "task",
+ "user",
+ "status",
+ )
+ search_fields = (
+ "task__id",
+ "task__title",
+ "user__username",
+ "user__email",
+ )
filter = ("plagiarism_checked",)
ordering = ["-timestamp"]
diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py
new file mode 100644
index 0000000..9cc1672
--- /dev/null
+++ b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py
@@ -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='пользователь'),
+ ),
+ ]
diff --git a/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py
new file mode 100644
index 0000000..2dfa914
--- /dev/null
+++ b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py
@@ -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='описание'),
+ ),
+ ]
diff --git a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py
new file mode 100644
index 0000000..400255c
--- /dev/null
+++ b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py
@@ -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='описание'),
+ ),
+ ]
diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py
index 60e54c9..dc917ef 100644
--- a/services/backend/apps/task/models.py
+++ b/services/backend/apps/task/models.py
@@ -2,11 +2,11 @@ from uuid import uuid4
from django.db import models
from django.db.models import Count, Q
-from tinymce.models import HTMLField
+from martor.models import MartorField
from apps.competition.models import Competition
from apps.core.models import BaseModel
-from apps.review.models import Review, ReviewStatusChoices, Reviewer
+from apps.review.models import Review, Reviewer, ReviewStatusChoices
from apps.user.models import User
@@ -17,14 +17,14 @@ class CompetitionTask(BaseModel):
REVIEW = "review", "Ручная"
def answer_file_upload_to(instance, filename) -> str:
- return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
+ return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
in_competition_position = models.PositiveSmallIntegerField(
null=True, blank=True
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50)
- description = HTMLField(verbose_name="описание", max_length=300)
+ description = MartorField(verbose_name="описание")
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
@@ -55,7 +55,10 @@ class CompetitionTask(BaseModel):
Reviewer,
blank=True,
verbose_name="ревьюверы",
- help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему"
+ help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему",
+ )
+ submission_reviewers_count = models.PositiveSmallIntegerField(
+ default=1, null=True, blank=True
)
def __str__(self):
@@ -79,12 +82,12 @@ class CompetitionTaskCriteria(BaseModel):
class CompetitionTaskAttachment(BaseModel):
def file_upload_at(instance, filename):
- return f"/attachment/{instance.id}/file"
+ return f"attachment/{instance.id}/file/{filename}"
- task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
- verbose_name="задание")
- file = models.FileField(upload_to=file_upload_at,
- verbose_name="файл")
+ task = models.ForeignKey(
+ CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
+ )
+ file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
bind_at = models.FilePathField(verbose_name="путь сохранения")
public = models.BooleanField(default=False, verbose_name="публичный")
@@ -96,50 +99,61 @@ class CompetitionTaskSubmission(BaseModel):
CHECKED = "checked"
def submission_content_upload_to(instance, filename) -> str:
- return f"submissions/{instance.id}/content"
+ return f"submissions/{instance.id}/content/{filename}"
def submission_stdout_upload_to(instance, filename) -> str:
- return f"/submissions/{instance.id}/stdout"
+ return f"submissions/{instance.id}/stdout/{filename}"
- user = models.ForeignKey(User, on_delete=models.CASCADE,
- verbose_name="пользователь")
- task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
- verbose_name="задание")
+ user = models.ForeignKey(
+ User, on_delete=models.CASCADE, verbose_name="пользователь"
+ )
+ task = models.ForeignKey(
+ CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
+ )
status = models.CharField(
choices=StatusChoices.choices,
default=StatusChoices.SENT,
max_length=8,
- verbose_name="статус"
+ verbose_name="статус",
)
# code or text or file
- content = models.FileField(upload_to=submission_content_upload_to,
- verbose_name="содержание посылки")
+ content = models.FileField(
+ upload_to=submission_content_upload_to,
+ verbose_name="содержание посылки",
+ )
# only if task type is checker
stdout = models.FileField(
- upload_to=submission_stdout_upload_to, null=True, blank=True,
+ upload_to=submission_stdout_upload_to,
+ null=True,
+ blank=True,
verbose_name="вывод программы",
- help_text="Используется только при проверке чекером"
+ help_text="Используется только при проверке чекером",
)
# depends on task type:
# - input: {"correct": boolean}
# - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]}
# - code: {"correct": boolean}
- result = models.JSONField(default=None, null=True, blank=True,
- verbose_name="результат проверки")
+ result = models.JSONField(
+ default=None, null=True, blank=True, verbose_name="результат проверки"
+ )
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
- earned_points = models.IntegerField(null=True, blank=True,
- verbose_name="баллы за задание")
+ earned_points = models.IntegerField(
+ null=True, blank=True, verbose_name="баллы за задание"
+ )
- checked_at = models.DateTimeField(null=True, blank=True,
- verbose_name="дата проверки")
- plagiarism_checked = models.BooleanField(default=False,
- verbose_name="проверено на плагиат")
- timestamp = models.DateTimeField(auto_now_add=True,
- verbose_name="дата отправки")
+ checked_at = models.DateTimeField(
+ null=True, blank=True, verbose_name="дата проверки"
+ )
+ plagiarism_checked = models.BooleanField(
+ default=False, verbose_name="проверено на плагиат"
+ )
+ timestamp = models.DateTimeField(
+ auto_now_add=True, verbose_name="дата отправки"
+ )
class Meta:
verbose_name = "посылка"
@@ -152,22 +166,23 @@ class CompetitionTaskSubmission(BaseModel):
if not self.task.reviewers.exists():
return
- reviewer = (
- self.task.reviewers.annotate(
- pending_count=Count(
- "review",
- filter=Q(
- review__state__in=[
- ReviewStatusChoices.NOT_CHECKED,
- ReviewStatusChoices.CHECKING,
- ]
- ),
- )
+ reviewers_count = self.task.submission_reviewers_count
+ reviewers = self.task.reviewers.annotate(
+ pending_count=Count(
+ "review",
+ filter=Q(
+ review__state__in=[
+ ReviewStatusChoices.NOT_CHECKED,
+ 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,
- )
diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py
index a4a8eee..0c0a6a9 100644
--- a/services/backend/apps/task/tasks.py
+++ b/services/backend/apps/task/tasks.py
@@ -1,148 +1,50 @@
-import ast
-import contextlib
-import hashlib
-import os
-import sys
-import tempfile
-from io import StringIO
+import requests
+from celery import shared_task
+from django.core.files.base import ContentFile
-from config.celery import app
-from apps.task.models import CompetitionTaskSubmission
-
-ALLOWED_MODULES = {
- "pandas",
- "numpy",
- "matplotlib",
- "seaborn",
- "scipy",
- "sklearn",
- "datetime",
- "json",
- "csv",
- "math",
- "statistics",
- "statsmodels",
-}
+from django.conf import settings
-class SecurityException(Exception):
- pass
+@shared_task(bind=True, max_retries=3)
+def analyze_data_task(self, submission_id):
+ from .models import CompetitionTaskSubmission
-
-def validate_code(code_str):
+ submission = CompetitionTaskSubmission.objects.get(id=submission_id)
try:
- tree = ast.parse(code_str)
- except SyntaxError as e:
- raise SecurityException(f"Syntax error: {e!s}")
+ code = submission.content.read().decode()
+ files = [
+ (f.name, f.file.open("rb"))
+ for f in submission.task.attachments.filter(public=True)
+ ]
- class ImportVisitor(ast.NodeVisitor):
- def visit_Import(self, node):
- for alias in node.names:
- module = alias.name.split(".")[0]
- if module not in ALLOWED_MODULES:
- raise SecurityException(f"Disallowed import: {module}")
+ response = requests.post(
+ f"{settings.CHECKER_API_ENDPOINT}/execute",
+ files=[("files", (f.name, f)) for f in files]
+ + [
+ ("code", code),
+ ("expected_hash", submission.task.correct_answer_hash),
+ ],
+ timeout=30,
+ )
+ response.raise_for_status()
+ result = response.json()
- def visit_ImportFrom(self, node):
- if node.module:
- module = node.module.split(".")[0]
- if module not in ALLOWED_MODULES:
- raise SecurityException(
- f"Disallowed import from: {module}"
- )
-
- class SecurityVisitor(ast.NodeVisitor):
- def generic_visit(self, node):
- if isinstance(node, (ast.Call, ast.Attribute)):
- if "system" in getattr(node, "attr", ""):
- raise SecurityException("Dangerous system call detected")
- super().generic_visit(node)
-
- try:
- ImportVisitor().visit(tree)
- SecurityVisitor().visit(tree)
- except SecurityException:
- raise
- except Exception as e:
- raise SecurityException(f"Security check failed: {e!s}")
-
-
-def secure_exec(code_str, result_path, input_files=None):
- original_dir = os.getcwd()
- original_stdout = sys.stdout
- sys.stdout = captured_stdout = StringIO()
- result_content = None
-
- if input_files is None:
- input_files = []
-
- with tempfile.TemporaryDirectory() as temp_dir:
- try:
- os.chdir(temp_dir)
-
- for file in input_files:
- file_path = os.path.join(temp_dir, file["bind_at"])
- os.makedirs(os.path.dirname(file_path), exist_ok=True)
- with open(file_path, "wb") as f:
- f.write(file["content"])
-
- restricted_globals = {
- "__builtins__": {
- "open": open,
- "print": print,
- "str": str,
- "int": int,
- "float": float,
- "bool": bool,
- "list": list,
- "dict": dict,
- "tuple": tuple,
- "set": set,
- }
- }
-
- exec(code_str, restricted_globals)
-
- if result_path == "stdout":
- result_content = captured_stdout.getvalue().encode("utf-8")
- else:
- with open(result_path, "rb") as f:
- result_content = f.read()
-
- except Exception as e:
- raise RuntimeError(f"Execution error: {e!s}")
- finally:
- os.chdir(original_dir)
- sys.stdout = original_stdout
-
- return result_content
-
-
-@app.task(bind=True)
-def analyze_data_task(
- self, code_str, result_path, expected_file_link, submission_id, input_files=[]
-):
- try:
- validate_code(code_str)
-
- result_content = secure_exec(code_str, result_path, input_files)
-
- result_hash = hashlib.sha256(result_content).hexdigest()
- expected_hash = hashlib.sha256(expected_bytes).hexdigest()
-
- with contextlib.suppress(CompetitionTaskSubmission.DoesNotExist):
- submission = CompetitionTaskSubmission.objects.get(id=submission_id)
- submission.result = {"correct": True}
-
- return {
- "success": True,
- "match": result_hash == expected_hash,
- "result_hash": result_hash,
- "expected_hash": expected_hash,
+ submission.stdout.save("output.txt", ContentFile(result["output"]))
+ submission.result = {
+ "correct": result["hash_match"],
+ "result_hash": result["result_hash"],
+ "error": result.get("error"),
}
+ submission.earned_points = (
+ submission.task.points if result["hash_match"] else 0
+ )
+ submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
- except SecurityException as e:
- return {"success": False, "error": f"Security violation: {e!s}"}
- except RuntimeError as e:
- return {"success": False, "error": f"Execution error: {e!s}"}
+ except requests.exceptions.RequestException as e:
+ self.retry(countdown=2**self.request.retries)
except Exception as e:
- return {"success": False, "error": f"Unexpected error: {e!s}"}
+ submission.result = {"error": str(e)}
+ submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
+ submission.earned_points = 0
+ finally:
+ submission.save()
diff --git a/services/backend/apps/task/tests/test_tasks.py b/services/backend/apps/task/tests/test_tasks.py
index 4209d70..b61bb11 100644
--- a/services/backend/apps/task/tests/test_tasks.py
+++ b/services/backend/apps/task/tests/test_tasks.py
@@ -28,5 +28,3 @@ with open("file.txt") as f:
print(result)
self.assertTrue(result["success"])
self.assertTrue(result["match"])
-
-
diff --git a/services/backend/apps/user/admin.py b/services/backend/apps/user/admin.py
index 89dca07..d13c779 100644
--- a/services/backend/apps/user/admin.py
+++ b/services/backend/apps/user/admin.py
@@ -7,3 +7,4 @@ from apps.user.models import User
class UserAdmin(admin.ModelAdmin):
list_display = ("email", "username")
search_fields = ("id", "email", "username")
+ filter_horizontal = ("achievements",)
diff --git a/services/backend/apps/user/migrations/0002_user_achievements.py b/services/backend/apps/user/migrations/0002_user_achievements.py
new file mode 100644
index 0000000..33adefa
--- /dev/null
+++ b/services/backend/apps/user/migrations/0002_user_achievements.py
@@ -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='ачивки пользователя'),
+ ),
+ ]
diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py
index 2f2d69a..aaa0ec0 100644
--- a/services/backend/apps/user/models.py
+++ b/services/backend/apps/user/models.py
@@ -1,6 +1,7 @@
from django.contrib.auth.hashers import check_password, make_password
from django.db import models
+from apps.achievement.models import Achievement
from apps.core.models import BaseModel
@@ -16,6 +17,10 @@ class User(BaseModel):
created_at = models.DateTimeField(auto_now=True)
+ achievements = models.ManyToManyField(
+ Achievement, blank=True, verbose_name="ачивки пользователя"
+ )
+
@staticmethod
def make_password(password: str):
return make_password(password)
diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py
index 6bf014b..243d5ee 100644
--- a/services/backend/config/settings.py
+++ b/services/backend/config/settings.py
@@ -7,7 +7,9 @@ from pathlib import Path
import django_stubs_ext
import environ
+from health_check.plugins import plugin_dir
from django.utils.translation import gettext_lazy as _
+from integrations.checker.healthcheck import CheckerHealthCheck
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -30,18 +32,12 @@ ALLOWED_HOSTS = env(
# Integrations
-YANDEX_CLOUD_FOLDER_ID = env("YANDEX_CLOUD_FOLDER_ID", default=None)
-
-YANDEX_CLOUD_API_KEY = env("YANDEX_CLOUD_API_KEY", default=None)
-
-YANDEX_CLOUD_INTEGRATION_ENABLED = (
- YANDEX_CLOUD_FOLDER_ID and YANDEX_CLOUD_API_KEY
-)
+CHECKER_API_ENDPOINT = env("CHECKER_API_ENDPOINT", default=None)
# Register healthchecks
-# plugin_dir.register(SomeHealthCheckClass)
+plugin_dir.register(CheckerHealthCheck)
# Caching
@@ -442,6 +438,7 @@ INSTALLED_APPS = [
"ninja",
"minio_storage",
"tinymce",
+ "martor",
# Internal apps
"apps.core",
"apps.user",
@@ -449,6 +446,7 @@ INSTALLED_APPS = [
"apps.review",
"apps.task",
"apps.team",
+ "apps.achievement",
]
# tinymce
@@ -458,15 +456,58 @@ TINYMCE_DEFAULT_CONFIG = {
"menubar": False,
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
- "code,help,wordcount",
+ "code,help,wordcount,markdown",
"toolbar": "undo redo | formatselect | "
"bold italic backcolor | alignleft aligncenter "
"alignright alignjustify | bullist numlist outdent indent | "
"removeformat | help",
"skin": "oxide-dark",
"content_css": "dark",
+ "textpattern_patterns": [
+ {"start": "*", "end": "*", "format": "italic"},
+ {"start": "**", "end": "**", "format": "bold"},
+ {"start": "#", "format": "h1"},
+ {"start": "##", "format": "h2"},
+ {"start": "###", "format": "h3"},
+ {"start": "####", "format": "h4"},
+ {"start": "#####", "format": "h5"},
+ {"start": "######", "format": "h6"},
+ {"start": "1. ", "cmd": "InsertOrderedList"},
+ {"start": "* ", "cmd": "InsertUnorderedList"},
+ {"start": "- ", "cmd": "InsertUnorderedList"},
+ ],
}
+# martor
+
+MARTOR_THEME = "bootstrap"
+
+MARTOR_ENABLE_CONFIGS = {
+ "emoji": "true", # to enable/disable emoji icons.
+ "imgur": "true", # to enable/disable imgur/custom uploader.
+ "mention": "false", # to enable/disable mention
+ "jquery": "true", # to include/revoke jquery (require for admin default django)
+ "living": "false", # to enable/disable live updates in preview
+ "spellcheck": "false", # to enable/disable spellcheck in form textareas
+ "hljs": "true", # to enable/disable hljs highlighting in preview
+}
+
+MARTOR_TOOLBAR_BUTTONS = [
+ "bold",
+ "italic",
+ "horizontal",
+ "heading",
+ "pre-code",
+ "blockquote",
+ "unordered-list",
+ "ordered-list",
+ "link",
+ "emoji",
+ "direct-mention",
+ "toggle-maximize",
+ "help",
+]
+
# GUID
DJANGO_GUID = {
diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py
index 27b279a..6fe96c2 100644
--- a/services/backend/config/urls.py
+++ b/services/backend/config/urls.py
@@ -14,6 +14,8 @@ admin.site.index_title = "DataRush"
urlpatterns = [
# tinymce
path("tinymce/", include("tinymce.urls")),
+ # martor
+ path("martor/", include("martor.urls")),
# Admin urls
path("admin/", admin.site.urls),
# API urls
diff --git a/services/backend/integrations/__init__.py b/services/backend/integrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/backend/integrations/checker/__init__.py b/services/backend/integrations/checker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/backend/integrations/checker/healthcheck.py b/services/backend/integrations/checker/healthcheck.py
new file mode 100644
index 0000000..1ab32f5
--- /dev/null
+++ b/services/backend/integrations/checker/healthcheck.py
@@ -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__
diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml
index 20b218e..d3b2371 100644
--- a/services/backend/pyproject.toml
+++ b/services/backend/pyproject.toml
@@ -19,6 +19,7 @@ dependencies = [
"django-tinymce>=4.1.0",
"gunicorn>=23.0.0",
"httpx>=0.28.1",
+ "martor>=1.6.45",
"pillow>=11.1.0",
"psycopg2-binary>=2.9.10",
"pydantic>=2.10.5",
diff --git a/services/checker/.gitignore b/services/checker/.gitignore
new file mode 100644
index 0000000..b96e392
--- /dev/null
+++ b/services/checker/.gitignore
@@ -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
diff --git a/services/checker/Dockerfile b/services/checker/Dockerfile
new file mode 100644
index 0000000..9f0295f
--- /dev/null
+++ b/services/checker/Dockerfile
@@ -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
diff --git a/services/checker/Dockerfile.checker b/services/checker/Dockerfile.checker
new file mode 100644
index 0000000..e8c7573
--- /dev/null
+++ b/services/checker/Dockerfile.checker
@@ -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"]
diff --git a/services/checker/README.md b/services/checker/README.md
new file mode 100644
index 0000000..927c0e7
--- /dev/null
+++ b/services/checker/README.md
@@ -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 .
+```
diff --git a/services/checker/checker_requirements.txt b/services/checker/checker_requirements.txt
new file mode 100644
index 0000000..7cc32d3
--- /dev/null
+++ b/services/checker/checker_requirements.txt
@@ -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
diff --git a/services/checker/main.py b/services/checker/main.py
new file mode 100644
index 0000000..4b01fac
--- /dev/null
+++ b/services/checker/main.py
@@ -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)
+ )
diff --git a/services/checker/pyproject.toml b/services/checker/pyproject.toml
new file mode 100644
index 0000000..2e47424
--- /dev/null
+++ b/services/checker/pyproject.toml
@@ -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",
+]
diff --git a/services/checker/scripts/check b/services/checker/scripts/check
new file mode 100755
index 0000000..6230cab
--- /dev/null
+++ b/services/checker/scripts/check
@@ -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"
diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx
index 7534997..554e52c 100644
--- a/services/frontend/src/App.tsx
+++ b/services/frontend/src/App.tsx
@@ -10,7 +10,6 @@ import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review";
-import CompetitionConstructor from "./pages/CompetitionConstructor";
import UserProfile from "./pages/UserProfile";
const queryClient = new QueryClient();
@@ -32,15 +31,6 @@ const App = () => {
element={
- Здесь будет форма настроек олимпиады -
-- Здесь будет форма редактирования задачи -
-Загрузка заданий...
@@ -82,7 +74,7 @@ const CompetitionSession = () => {