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/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 9546379..40750d0 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -16,6 +16,8 @@ class TaskOutSchema(ModelSchema): "description", "type", "in_competition_position", + "points", + "attachments", ] @@ -28,4 +30,4 @@ class HistorySubmissionOut(ModelSchema): class Meta: model = CompetitionTaskSubmission - fields = ("id", "earned_points", "timestamp") + fields = ("id", "earned_points", "timestamp", "content",) 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/competition/models.py b/services/backend/apps/competition/models.py index 3cb9b5b..18212f4 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -26,10 +26,10 @@ class Competition(BaseModel): upload_to=image_url_upload_to, ) end_date = models.DateTimeField( - verbose_name="дедлайн участия", null=True, blank=True + verbose_name="окончание соревнования", null=True, blank=True ) start_date = models.DateTimeField( - verbose_name="дедлайн участия", null=True, blank=True + verbose_name="начало соревнования", null=True, blank=True ) type = models.CharField( max_length=11, diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py index 37dfcc0..03f94c6 100644 --- a/services/backend/apps/competition/tests.py +++ b/services/backend/apps/competition/tests.py @@ -45,12 +45,10 @@ class CompetitionEndpointTests(TestCase): self.assertEqual(response.status_code, 200) data = response.json() - # Validate required fields self.assertEqual(data["id"], str(self.competition.id)) self.assertEqual(data["title"], "AI Challenge") self.assertEqual(data["type"], "edu") - # Validate optional null fields self.assertIsNone(data["image_url"]) self.assertIsNone(data["start_date"]) self.assertIsNone(data["end_date"]) @@ -85,8 +83,8 @@ class CompetitionEndpointTests(TestCase): def test_malformed_auth_header(self): cases = [ ("InvalidScheme valid_token_123", 401), - ("Bearer", 401), # Missing token - ("", 401), # No header + ("Bearer", 401), + ("", 401), ] for header, expected_status in cases: @@ -113,7 +111,6 @@ class CompetitionsEndpointTests(TestCase): ).json() token = resp["token"] - # Create test competitions now = datetime.now(tz=pytz.utc) self.competitions = [] for i in range(1, 6): @@ -157,8 +154,12 @@ class CompetitionsEndpointTests(TestCase): self.get_url("is_participating=true"), **self.valid_headers ) - for item in response.json(): - self.assertEqual(item["type"], "competitive") + for i in range(len(response.json())): + item = response.json()[i] + if (i + 1) % 2 == 0: + self.assertEqual(item["type"], "edu") + else: + self.assertEqual(item["type"], "competitive") def test_participation_type_values(self): response = self.client.get( diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index c12c510..da0497e 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# 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 from django.db import migrations, models @@ -10,10 +9,20 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('task', '0001_initial'), ] operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('evaluation', models.JSONField(blank=True, default=list, null=True)), + ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11)), + ], + options={ + 'abstract': False, + }, + ), migrations.CreateModel( name='Reviewer', fields=[ @@ -26,17 +35,4 @@ class Migration(migrations.Migration): 'abstract': False, }, ), - migrations.CreateModel( - name='Review', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('evaluation', models.JSONField(blank=True, default=list, null=True)), - ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11)), - ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission')), - ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')), - ], - options={ - 'abstract': False, - }, - ), ] diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py new file mode 100644 index 0000000..e2bc9d9 --- /dev/null +++ b/services/backend/apps/review/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-02 06:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('review', '0001_initial'), + ('task', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='review', + name='submission', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission'), + ), + migrations.AddField( + model_name='review', + name='reviewer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer'), + ), + ] diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index b1a0a2e..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 00:16 +# Generated by Django 5.1.6 on 2025-03-02 06:13 import apps.task.models import django.db.models.deletion @@ -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/migrations/0002_competitiontask_attachments_and_more.py b/services/backend/apps/task/migrations/0002_competitiontask_attachments_and_more.py new file mode 100644 index 0000000..9f88f60 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_competitiontask_attachments_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.6 on 2025-03-02 08:50 + +import apps.task.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='competitiontask', + name='attachments', + field=models.ManyToManyField(blank=True, related_name='tasks_attachments', to='task.competitiontaskattachment'), + ), + migrations.AlterField( + model_name='competitiontaskattachment', + name='bind_at', + field=models.FilePathField(verbose_name='путь сохранения'), + ), + migrations.AlterField( + model_name='competitiontaskattachment', + name='file', + field=models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл'), + ), + migrations.AlterField( + model_name='competitiontaskattachment', + name='public', + field=models.BooleanField(default=False, verbose_name='публичный'), + ), + migrations.AlterField( + model_name='competitiontaskattachment', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index da528fb..2a7bc49 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,12 +1,10 @@ from uuid import uuid4 from django.db import models -from django.db.models import Count, Q from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel -from apps.review.models import Review, Reviewer, ReviewStatusChoices from apps.task.validators import ContestTaskCriteriesValidator from apps.user.models import User @@ -14,7 +12,7 @@ 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: @@ -46,23 +44,13 @@ 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="критерии", - ) - - # only when "review" type - reviewers = models.ManyToManyField(Reviewer, blank=True) - - def clean(self): - ContestTaskCriteriesValidator()(self) + attachments = models.ManyToManyField("CompetitionTaskAttachment", blank=True, + related_name="tasks_attachments") def __str__(self): return self.title @@ -72,14 +60,27 @@ 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" - task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) - file = models.FileField(upload_to=file_upload_at) - bind_at = models.FilePathField() - public = models.BooleanField(default=False) + 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="публичный") class CompetitionTaskSubmission(BaseModel): @@ -119,7 +120,8 @@ 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) def send_on_review(self): 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