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={} /> - } /> - - } /> - - } - /> - } /> diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index b434fa8..2c87809 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -2,38 +2,56 @@ import { useParams, Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import ReactMarkdown from "react-markdown"; -import { mockTasks } from "@/shared/mocks/mocks"; -import { useQuery } from "@tanstack/react-query"; -import { getCompetition } from "@/shared/api/competitions"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { getCompetition, startCompetition } from "@/shared/api/competitions"; +import { getCompetitionTasks } from "@/shared/api/session"; import { Loading } from "@/components/ui/loading"; const CompetitionPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const competitionId = id || ""; - const { data: competition, isLoading } = useQuery({ - queryKey: ["competition", id], - queryFn: async () => getCompetition(id || ""), + const competitionQuery = useQuery({ + queryKey: ["competition", competitionId], + queryFn: () => getCompetition(competitionId), + enabled: !!competitionId, }); - if (isLoading) { + const startMutation = useMutation({ + mutationFn: () => startCompetition(competitionId), + onSuccess: async () => { + try { + const tasks = await getCompetitionTasks(competitionId); + + if (tasks && tasks.length > 0) { + navigate(`/competition/${competitionId}/tasks/${tasks[0].id}`); + } else { + navigate(`/competition/${competitionId}/tasks`); + } + } catch (error) { + console.error("Failed to fetch tasks:", error); + navigate(`/competition/${competitionId}/tasks`); + } + }, + onError: (error) => { + console.error("Failed to start competition:", error); + } + }); + + const handleStart = () => { + startMutation.mutate(); + }; + + if (competitionQuery.isLoading) { return ; } - if (!id || !competition) { + if (!competitionId || !competitionQuery.data) { return <>; } - const handleContinue = () => { - if (competition?.id) { - if (mockTasks && mockTasks.length > 0) { - const firstTaskId = mockTasks[0].id; - navigate(`/competition/${competition.id}/tasks/${firstTaskId}`); - } else { - navigate(`/competition/${competition.id}/tasks`); - } - } - }; + const competition = competitionQuery.data; return (
@@ -46,15 +64,13 @@ const CompetitionPage = () => {
- {competition.image_url && ( -
- {competition.title} -
- )} +
+ {competition.title} +
@@ -66,8 +82,12 @@ const CompetitionPage = () => {
-
@@ -76,4 +96,4 @@ const CompetitionPage = () => { ); }; -export default CompetitionPage; +export default CompetitionPage; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx deleted file mode 100644 index 04442d1..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { Task } from "@/shared/types"; -import { Settings, Plus } from 'lucide-react'; -import { Button } from "@/components/ui/button"; - -interface ConstructorHeaderProps { - title: string; - tasks: Task[]; - competitionId: string; - onAddTaskClick: () => void; -} - -const ConstructorHeader: React.FC = ({ - title, - tasks, - competitionId, - onAddTaskClick -}) => { - return ( -
-
-
-

- {title} -

-
- -
- - - - - {tasks.map((task) => ( - - {task.number} - - ))} - - -
-
-
- ); -}; - -export default ConstructorHeader; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/index.tsx b/services/frontend/src/pages/CompetitionConstructor/index.tsx deleted file mode 100644 index 4f7f247..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useState } from "react"; -import { useParams, Navigate, useNavigate } from "react-router-dom"; -import { Task, TaskStatus } from "@/shared/types"; -import ConstructorHeader from "./components/ConstructorHeader"; -import TaskCreationModal from "./modules/TaskCreationModal"; - -const CompetitionConstructor = () => { - const { id, taskId } = useParams<{ id: string; taskId?: string }>(); - const navigate = useNavigate(); - const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); - const [tasks, setTasks] = useState([]); - const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); - - const isSettings = taskId === "settings"; - - const handleOpenTaskModal = () => { - setIsTaskModalOpen(true); - }; - - const handleCloseTaskModal = () => { - setIsTaskModalOpen(false); - }; - - const handleCreateTask = (taskData: Partial) => { - const newTask: Task = { - id: `task-${Date.now()}`, - number: taskData.number || `${tasks.length + 1}`, - status: TaskStatus.Uncleared, - solutionType: taskData.solutionType || "input", - description: taskData.description || "", - requirements: taskData.requirements, - attachments: taskData.attachments || [] - }; - - setTasks([...tasks, newTask]); - setIsTaskModalOpen(false); - navigate(`/constructor/${id}/tasks/${newTask.id}`); - }; - - if (!taskId) { - if (tasks.length > 0) { - return ; - } else { - return ; - } - } - - return ( -
- - - - -
-
- {isSettings ? ( -
-

Настройки олимпиады

-

- Здесь будет форма настроек олимпиады -

-
- ) : ( -
-

- {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} -

-

- Здесь будет форма редактирования задачи -

-
- )} -
-
-
- ); -}; - -export default CompetitionConstructor; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx deleted file mode 100644 index c5f6876..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; - -interface TaskDescriptionFieldProps { - description: string; - onChange: (value: string) => void; -} - -const TaskDescriptionField: React.FC = ({ description, onChange }) => { - return ( -
- -