diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 457741d..929f711 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -109,7 +109,6 @@ deploy: cd ~/deploy docker system prune -a --force docker compose pull > deploy.log 2>&1 - docker compose down >> deploy.log 2>&1 docker compose up -d --remove-orphans --force-recreate >> deploy.log 2>&1 docker compose ps >> deploy.log 2>&1 EOF diff --git a/compose.yaml b/compose.yaml index 53771a3..a6f946f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -400,6 +400,9 @@ services: - type: bind source: /var/run/docker.sock target: /var/run/docker.sock + - type: bind + source: /tmp + target: /tmp proxy: image: docker.io/nginx:1.27-alpine3.21 @@ -410,7 +413,7 @@ services: test: ["CMD", "service", "nginx", "status", "||", " exit 1"] interval: 1m30s timeout: 5s - start_period: 5s + start_period: 15s start_interval: 2s retries: 5 ports: diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py index 0017a16..74feaf8 100644 --- a/services/backend/api/v1/achievement/schemas.py +++ b/services/backend/api/v1/achievement/schemas.py @@ -1,3 +1,5 @@ +from datetime import datetime + from ninja import ModelSchema, Schema from pydantic import Field @@ -19,6 +21,7 @@ class UserAchievementSchema(Schema): name: str = Field(..., alias="achievement.name") description: str = Field(..., alias="achievement.description") icon: str = Field(..., alias="achievement.icon") + received_at: datetime class Meta: model = UserAchievement diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index 2788913..2109f8e 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -32,7 +32,7 @@ class UserSchema(ModelSchema): class Meta: model = User - fields = ["id", "email", "username", "created_at"] + fields = ["id", "avatar", "email", "username", "created_at"] class StatSchema(Schema): diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py index 03300ea..82a5079 100644 --- a/services/backend/apps/achievement/migrations/0001_initial.py +++ b/services/backend/apps/achievement/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import apps.achievement.models import django.db.models.deletion diff --git a/services/backend/apps/achievement/migrations/0002_initial.py b/services/backend/apps/achievement/migrations/0002_initial.py index 1d03338..19f8cc3 100644 --- a/services/backend/apps/achievement/migrations/0002_initial.py +++ b/services/backend/apps/achievement/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import django.db.models.deletion from django.db import migrations, models diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index 428a78a..1d11125 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-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import apps.competition.models import datetime diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index afe281e..d4c9ece 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-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 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 5a5d3cc..61347c5 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-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import django.db.models.deletion from django.db import migrations, models diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index 10f3c67..ca097c0 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -3,8 +3,8 @@ from django.contrib import admin from apps.task.models import ( CompetitionTask, CompetitionTaskAttachment, + CompetitionTaskCriteria, CompetitionTaskSubmission, - CompetitionTaskCriteria ) @@ -23,7 +23,10 @@ class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") filter_horizontal = ("reviewers",) list_filter = ("type",) - inlines = (CompletionAttachmentInline, CompetitionCriteriaInline,) + inlines = ( + CompletionAttachmentInline, + CompetitionCriteriaInline, + ) @admin.register(CompetitionTaskSubmission) @@ -45,9 +48,6 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): def has_add_permission(self, request, obj=None): return False - def has_delete_permission(self, request, obj=None): - return False - class CompetitionTaskInline(admin.StackedInline): model = CompetitionTask diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index 9cfeeae..943b9ca 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,8 +1,8 @@ -# Generated by Django 5.1.6 on 2025-03-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import apps.task.models import django.db.models.deletion -import martor.models +import mdeditor.fields import uuid from django.db import migrations, models @@ -22,17 +22,17 @@ class Migration(migrations.Migration): name='CompetitionTask', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('in_competition_position', models.PositiveSmallIntegerField()), + ('in_competition_position', models.PositiveSmallIntegerField(verbose_name='позиция в соревновании')), ('title', models.CharField(max_length=50, verbose_name='заголовок')), - ('description', martor.models.MartorField(verbose_name='описание')), - ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)), + ('description', mdeditor.fields.MDTextField(verbose_name='описание')), + ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True, 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', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')), - ('submission_reviewers_count', models.PositiveSmallIntegerField(blank=True, default=1, null=True)), - ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), - ('reviewers', models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы')), + ('correct_answer_file', models.FileField(blank=True, help_text='Имеет смысл только при автоматической (ввод ответа или кода) проверке.', 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', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки.', null=True, verbose_name='куда сделать вывод программы участнику')), + ('submission_reviewers_count', models.PositiveSmallIntegerField(blank=True, default=1, null=True, verbose_name='кол-во проверяющих для зачета задачи')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='привязанное соревнование')), + ('reviewers', models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему. Имеет смысл только при ручном типе проверки.', to='review.reviewer', verbose_name='ревьюверы')), ], options={ 'verbose_name': 'задание', @@ -57,14 +57,15 @@ class Migration(migrations.Migration): 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()), + ('name', models.TextField(verbose_name='название')), + ('slug', models.SlugField(verbose_name='техническое название')), + ('description', models.TextField(verbose_name='описание критерии')), + ('max_value', models.PositiveSmallIntegerField(verbose_name='максимальное кол-во баллов')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')), ], options={ - 'abstract': False, + 'verbose_name': 'критерий', + 'verbose_name_plural': 'критерии', }, ), migrations.CreateModel( diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index ef0016d..8f6936e 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -22,12 +22,16 @@ class CompetitionTask(BaseModel): in_competition_position = models.PositiveSmallIntegerField( verbose_name="позиция в соревновании" ) - competition = models.ForeignKey(Competition, on_delete=models.CASCADE, - verbose_name="привязанное соревнование") + competition = models.ForeignKey( + Competition, + on_delete=models.CASCADE, + verbose_name="привязанное соревнование", + ) title = models.CharField(verbose_name="заголовок", max_length=50) description = MDTextField(verbose_name="описание") - max_attempts = models.PositiveSmallIntegerField(null=True, blank=True, - verbose_name="максимальное кол-во попыток") + max_attempts = models.PositiveSmallIntegerField( + null=True, blank=True, verbose_name="максимальное кол-во попыток" + ) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" ) @@ -38,9 +42,10 @@ class CompetitionTask(BaseModel): null=True, blank=True, verbose_name="файл с правильным ответом", + help_text="Имеет смысл только при автоматической (ввод ответа или кода) проверке.", ) points = models.IntegerField( - null=True, blank=True, verbose_name="баллы за задание" + null=True, blank=True, verbose_name="общий балл за задание" ) # only when "checker" type @@ -48,7 +53,10 @@ class CompetitionTask(BaseModel): null=True, blank=True, verbose_name="куда сделать вывод программы участнику", - help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt", + help_text=( + "Путь до файла в котором ожидается результат. " + "Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки." + ), default="stdout", ) @@ -57,10 +65,17 @@ 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, verbose_name="кол-во проверяющих для зачета задачи" + default=1, + null=True, + blank=True, + verbose_name="кол-во проверяющих для зачета задачи", ) def __str__(self): @@ -76,15 +91,9 @@ class CompetitionTaskCriteria(BaseModel): CompetitionTask, on_delete=models.CASCADE, related_name="criteries" ) - name = models.TextField( - verbose_name="название" - ) - slug = models.SlugField( - verbose_name="техническое название" - ) - description = models.TextField( - verbose_name="описание критерии" - ) + name = models.TextField(verbose_name="название") + slug = models.SlugField(verbose_name="техническое название") + description = models.TextField(verbose_name="описание критерии") max_value = models.PositiveSmallIntegerField( verbose_name="максимальное кол-во баллов" ) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 9001a6d..bb909c2 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -1,28 +1,40 @@ +import hashlib + import httpx from celery import shared_task from django.conf import settings from django.core.files.base import ContentFile +from apps.task.models import CompetitionTaskSubmission + @shared_task(bind=True, max_retries=3) def analyze_data_task(self, submission_id): - from .models import CompetitionTaskSubmission - submission = CompetitionTaskSubmission.objects.get(id=submission_id) try: - code = submission.content.read().decode() + code_url = ( + f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{submission.path}" + ) files = [ - (f.name, f.file.open("rb")) - for f in submission.task.attachments.filter(public=True) + { + "url": f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{attachment.path}", + "bind_path": attachment.bind_at, + } + for attachment in submission.task.attachments.filter( + bind_path__isnull=False + ) ] response = httpx.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), - ], + json={ + "files": files, + "code_url": code_url, + "answer_file_path": submission.task.answer_file_path, + "expected_hash": hashlib.sha256( + submission.task.correct_answer_file.read().encode() + ).hexdigest(), + }, timeout=30, ) response.raise_for_status() diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index 1924dc3..94cacb2 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-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import django.db.models.deletion import uuid diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index 4ade297..d6ee060 100644 --- a/services/backend/apps/user/migrations/0001_initial.py +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-03 07:20 +# Generated by Django 5.1.6 on 2025-03-03 09:41 import uuid from django.db import migrations, models @@ -17,11 +17,12 @@ class Migration(migrations.Migration): name='User', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='', verbose_name='аватар')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')), ('password', models.TextField(verbose_name='пароль')), - ('created_at', models.DateTimeField(auto_now=True)), - ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), + ('created_at', models.DateTimeField(auto_now=True, verbose_name='дата создания')), + ('status', models.CharField(choices=[('student', 'Участник соревнований'), ('metodist', 'Методист (составитель заданий)')], default='student', max_length=10, verbose_name='роль участника')), ('achievements', models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя')), ], options={ diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index 598abb6..6a7a0c5 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -11,11 +11,14 @@ class UserRole(models.TextChoices): class User(BaseModel): + avatar = models.ImageField(verbose_name="аватар", null=True, blank=True) email = models.EmailField(unique=True, verbose_name="почта") username = models.SlugField(unique=True, verbose_name="юзернейм") password = models.TextField(verbose_name="пароль") - created_at = models.DateTimeField(auto_now=True, verbose_name="дата создания") + created_at = models.DateTimeField( + auto_now=True, verbose_name="дата создания" + ) achievements = models.ManyToManyField( Achievement, blank=True, verbose_name="ачивки пользователя" @@ -29,8 +32,10 @@ class User(BaseModel): return check_password(self.password, password) status = models.CharField( - max_length=10, choices=UserRole.choices, default="student", - verbose_name="роль участника" + max_length=10, + choices=UserRole.choices, + default="student", + verbose_name="роль участника", ) def __str__(self) -> str: diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 0fb5044..73e9ec2 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -12,12 +12,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ - # tinymce - path("tinymce/", include("tinymce.urls")), - # martor - path("martor/", include("martor.urls")), # mdeditor - path(r'mdeditor/', include('mdeditor.urls')), + path(r"mdeditor/", include("mdeditor.urls")), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/checker/main.py b/services/checker/main.py index a1d05fa..557fdba 100644 --- a/services/checker/main.py +++ b/services/checker/main.py @@ -202,6 +202,7 @@ async def execute_code(request: ExecutionRequest) -> ExecutionResponse: ) with tempfile.TemporaryDirectory() as tmp_dir: + print(tmp_dir) bound_files = {} if request.files: async with aiohttp.ClientSession() as session: