diff --git a/compose.yaml b/compose.yaml index 5cfe9ac..9aac6d8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -88,7 +88,7 @@ services: image: gitlab.prodcontest.ru:5050/team-15/project/backend:latest build: context: ./services/backend - entrypoint: ["/app/scripts/celery-worker-entrypoint.sh"] + command: celery -A config worker -l INFO depends_on: redis: restart: false @@ -106,10 +106,6 @@ services: retries: 3 start_period: 10s start_interval: 2s - volumes: - - type: bind - source: ./infrastructure/backend/scripts - target: /app/scripts restart: unless-stopped celery-exporter: diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 1090f14..3e47f62 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -73,7 +73,7 @@ class SubmissionOut(ModelSchema): "stdout", "result", "earned_points", - "reviewed_at", + "checked_at", ) diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index 7a7cdfe..223f6b8 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 00:16 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import apps.competition.models import datetime @@ -23,8 +23,8 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=100, verbose_name='название')), ('description', models.TextField(verbose_name='описание')), ('image_url', models.ImageField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')), - ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), - ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='окончание соревнования')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='начало соревнования')), ('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')), ('participation_type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=11, verbose_name='тип соревнования')), ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')), diff --git a/services/backend/apps/competition/migrations/0002_alter_state_state.py b/services/backend/apps/competition/migrations/0002_alter_state_state.py deleted file mode 100644 index 9814e70..0000000 --- a/services/backend/apps/competition/migrations/0002_alter_state_state.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 00:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='state', - name='state', - field=models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], default='not_started', max_length=11), - ), - ] diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index 7c8ad53..da0497e 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 05:41 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import uuid from django.db import migrations, models diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py index 39fc31e..e2bc9d9 100644 --- a/services/backend/apps/review/migrations/0002_initial.py +++ b/services/backend/apps/review/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 05:41 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import django.db.models.deletion from django.db import migrations, models diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index c221558..cf4fbdc 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 05:41 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import apps.task.models import django.db.models.deletion @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('competition', '0002_alter_state_state'), + ('competition', '0001_initial'), ('user', '0001_initial'), ] @@ -25,11 +25,10 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=50, verbose_name='заголовок')), ('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')), ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)), - ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')), + ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')), ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')), ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), - ('answer_file_path', models.TextField(blank=True, default='stdout', null=True, verbose_name='куда сохранять решения')), - ('criteries', models.JSONField(blank=True, null=True, verbose_name='критерии')), + ('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ], options={ @@ -50,6 +49,20 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='CompetitionTaskCriteria', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField()), + ('slug', models.SlugField()), + ('description', models.TextField()), + ('max_value', models.PositiveSmallIntegerField()), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')), + ], + options={ + 'abstract': False, + }, + ), migrations.CreateModel( name='CompetitionTaskSubmission', fields=[ @@ -59,7 +72,8 @@ class Migration(migrations.Migration): ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)), ('result', models.JSONField(blank=True, default=None, null=True)), ('earned_points', models.IntegerField(blank=True, null=True)), - ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('checked_at', models.DateTimeField(blank=True, null=True)), + ('plagiarism_checked', models.BooleanField(default=False)), ('timestamp', models.DateTimeField(auto_now_add=True)), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 66d4ca3..dd618e1 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -5,14 +5,13 @@ from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel -from apps.task.validators import ContestTaskCriteriesValidator from apps.user.models import User class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): INPUT = "input", "Ввод правильного ответа" - CHECKER = "checker", "Вывод кода" + CHECKER = "checker", "Ввод кода" REVIEW = "review", "Ручная" def answer_file_upload_to(instance, filename) -> str: @@ -44,21 +43,11 @@ class CompetitionTask(BaseModel): answer_file_path = models.TextField( null=True, blank=True, - verbose_name="куда сохранять решения", + verbose_name="куда сделать вывод программы участнику", + help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt", default="stdout", ) - # only when "review" type - # TODO make it more humanize - criteries = models.JSONField( - blank=True, - null=True, - verbose_name="критерии", - ) - - def clean(self): - ContestTaskCriteriesValidator()(self) - def __str__(self): return self.title @@ -67,6 +56,17 @@ class CompetitionTask(BaseModel): verbose_name_plural = "задания" +class CompetitionTaskCriteria(BaseModel): + task = models.ForeignKey( + CompetitionTask, on_delete=models.CASCADE, related_name="criteries" + ) + + name = models.TextField() + slug = models.SlugField() + description = models.TextField() + max_value = models.PositiveSmallIntegerField() + + class CompetitionTaskAttachment(BaseModel): def file_upload_at(instance, filename): return f"/attachment/{instance.id}/file" @@ -114,5 +114,6 @@ class CompetitionTaskSubmission(BaseModel): # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria earned_points = models.IntegerField(null=True, blank=True) - reviewed_at = models.DateTimeField(null=True, blank=True) + checked_at = models.DateTimeField(null=True, blank=True) + plagiarism_checked = models.BooleanField(default=False) timestamp = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 2ac935e..a4a8eee 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -1,4 +1,5 @@ import ast +import contextlib import hashlib import os import sys @@ -6,6 +7,7 @@ import tempfile from io import StringIO from config.celery import app +from apps.task.models import CompetitionTaskSubmission ALLOWED_MODULES = { "pandas", @@ -117,7 +119,7 @@ def secure_exec(code_str, result_path, input_files=None): @app.task(bind=True) def analyze_data_task( - self, code_str, result_path, expected_bytes, input_files=[] + self, code_str, result_path, expected_file_link, submission_id, input_files=[] ): try: validate_code(code_str) @@ -127,6 +129,10 @@ def analyze_data_task( 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, diff --git a/services/backend/apps/task/tests/test_tasks.py b/services/backend/apps/task/tests/test_tasks.py new file mode 100644 index 0000000..4209d70 --- /dev/null +++ b/services/backend/apps/task/tests/test_tasks.py @@ -0,0 +1,32 @@ +import unittest + +from apps.task.tasks import analyze_data_task + + +class TestAnalyzeDataTask(unittest.TestCase): + def test_task_execution_basic(self): + code_str = 'print("Hello, World!")' + result_path = "stdout" + expected_bytes = b"Hello, World!\n" + result = analyze_data_task(code_str, result_path, expected_bytes) + self.assertTrue(result["success"]) + self.assertTrue(result["match"]) + + def test_task_execution_with_files(self): + code_str = """ +with open("file.txt") as f: + print(f.read()) + """ + result_path = "stdout" + expected_bytes = b"some_content\n" + result = analyze_data_task( + code_str, + result_path, + expected_bytes, + input_files=[{"bind_at": "file.txt", "content": b"some_content"}], + ) + print(result) + self.assertTrue(result["success"]) + self.assertTrue(result["match"]) + + diff --git a/services/backend/apps/task/validators.py b/services/backend/apps/task/validators.py deleted file mode 100644 index ec8024f..0000000 --- a/services/backend/apps/task/validators.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.core.exceptions import ValidationError -from pydantic import BaseModel -from pydantic import ValidationError as PydanticValidationError - - -class Criteria(BaseModel): - name: str - slug: str - max_value: int - min_value: int - - -class ContestTaskCriteriesValidator: - def __call__(self, instance): - if instance.criteries and not isinstance(instance.criteries, list): - err = "criteries must be a valid dictionary" - raise ValidationError(err) - - try: - for criteria in instance.criteries if instance.criteries else []: - Criteria(**criteria) - except PydanticValidationError: - err = "invalid criteries data" - raise ValidationError(err) diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index 81d686b..27317a3 100644 --- a/services/backend/apps/team/migrations/0001_initial.py +++ b/services/backend/apps/team/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 00:16 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import django.db.models.deletion import uuid