From 077074d424754a1e4391a70fd897864c1f3db00a Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 09:04:51 +0300 Subject: [PATCH 01/10] add basic openapi scheme for competitions tasks --- services/backend/api/v1/router.py | 5 ++ services/backend/api/v1/task/__init__.py | 0 services/backend/api/v1/task/schemas.py | 10 ++++ services/backend/api/v1/task/views.py | 64 ++++++++++++++++++++++++ services/backend/apps/task/models.py | 7 ++- services/backend/config/settings.py | 1 + 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 services/backend/api/v1/task/__init__.py create mode 100644 services/backend/api/v1/task/schemas.py create mode 100644 services/backend/api/v1/task/views.py diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 241af1e..2c24b2b 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -6,6 +6,7 @@ from api.v1 import handlers from api.v1.auth import BearerAuth from api.v1.competition.views import router as competition_router from api.v1.ping.views import router as ping_router +from api.v1.task.views import router as task_router from api.v1.user.views import router as user_router router = NinjaAPI( @@ -29,6 +30,10 @@ router.add_router( "", competition_router, ) +router.add_router( + "", + task_router, +) for exception, handler in handlers.exception_handlers: diff --git a/services/backend/api/v1/task/__init__.py b/services/backend/api/v1/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py new file mode 100644 index 0000000..4a3ec2e --- /dev/null +++ b/services/backend/api/v1/task/schemas.py @@ -0,0 +1,10 @@ +from ninja import ModelSchema, Schema + +from apps.competition.models import State +from apps.task.models import CompetitionTask + + +class TaskOutSchema(ModelSchema): + class Meta: + model = CompetitionTask + fields = ["id", "competition", "title", "description", "type"] diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py new file mode 100644 index 0000000..81301c8 --- /dev/null +++ b/services/backend/api/v1/task/views.py @@ -0,0 +1,64 @@ +from http import HTTPStatus as status + +from ninja import Router + +from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError +from api.v1.ping.schemas import PingOut +from api.v1.task.schemas import TaskOutSchema + +router = Router(tags=["competition"]) + + +@router.post( + "competitions/{competition_id}/start", + description="Start a competition completing (open access to tasks)", + response={ + status.OK: PingOut, + status.UNAUTHORIZED: UnauthorizedError, + status.NOT_FOUND: NotFoundError, + }, +) +def start_competition(request, competition_id: str) -> PingOut: + ... + + +@router.get( + "competitions/{competition_id}/tasks", + description="Get all tasks of competition (works only if user started competition)", + response={ + status.OK: list[TaskOutSchema], + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: + ... + + +@router.get( + "competitions/{competition_id}/tasks/{task_id}", + description="Get a task of competition task", + response={ + status.OK: TaskOutSchema, + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: + ... + + +@router.post( + "competitions/{competition_id}/tasks/{task_id}/submit", + description="Submit task solution", + response={ + status.OK: PingOut, # todo maybe I should write an other schema for this + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def submit_task(request, competition_id: str, task_id: str) -> PingOut: + ... diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index eee9f4f..3ff7ac4 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,11 +1,10 @@ from uuid import uuid4 -from competition.models import Competition -from core.models import BaseModel from django.db import models from apps.task.validators import ContestTaskCriteriesValidator - +from apps.competition.models import Competition +from apps.core.models import BaseModel class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): @@ -19,7 +18,7 @@ class CompetitionTask(BaseModel): competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.TextField(verbose_name="заголовок", max_length=50) description = models.TextField(verbose_name="описание", max_length=300) - type = models.CharField(choices=CompetitionTaskType) + type = models.CharField(choices=CompetitionTaskType, max_length=8) # only when "input" or "checker" type correct_answer_file = models.FileField(upload_to=answer_file_upload_to) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index c3a64da..ffae770 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -445,6 +445,7 @@ INSTALLED_APPS = [ "apps.core", "apps.user", "apps.competition", + "apps.task", ] # GUID From 7eef7a76109dc7dafed6b5e52ded23c33d62e511 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:00:55 +0300 Subject: [PATCH 02/10] add not found status to get competition id --- services/backend/api/v1/competition/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 095549c..5891d9d 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -18,6 +18,7 @@ router = Router(tags=["competition"]) status.OK: schemas.CompetitionOut, status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, + status.NOT_FOUND: global_schemas.NotFoundError, }, ) def get_competition( From 0e65fc5fdda13626eea360b7ba0e89c6c712d24f Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:11:22 +0300 Subject: [PATCH 03/10] add callback to start competition endpoint --- services/backend/api/v1/task/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 81301c8..043ad10 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,10 +1,12 @@ from http import HTTPStatus as status +from django.shortcuts import get_object_or_404 from ninja import Router from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut from api.v1.task.schemas import TaskOutSchema +from apps.competition.models import Competition, State router = Router(tags=["competition"]) @@ -19,7 +21,11 @@ router = Router(tags=["competition"]) }, ) def start_competition(request, competition_id: str) -> PingOut: - ... + competition = get_object_or_404(Competition, pk=competition_id) + state_obj, _ = State.objects.update_or_create( + user=request.auth, competition=competition, state="started" + ) + return status.OK, PingOut() @router.get( From d5bf9f3acce417136bf6169bf47eb6b224348a20 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:11:34 +0300 Subject: [PATCH 04/10] add created_at field to state model --- .../migrations/0004_state_changed_at.py | 19 +++++++++++++++++++ services/backend/apps/competition/models.py | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 services/backend/apps/competition/migrations/0004_state_changed_at.py diff --git a/services/backend/apps/competition/migrations/0004_state_changed_at.py b/services/backend/apps/competition/migrations/0004_state_changed_at.py new file mode 100644 index 0000000..365f995 --- /dev/null +++ b/services/backend/apps/competition/migrations/0004_state_changed_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:10 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0003_state'), + ] + + operations = [ + migrations.AddField( + model_name='state', + name='changed_at', + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 644f733..589ce91 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.db import models from apps.core.models import BaseModel @@ -49,3 +51,4 @@ class State(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) state = models.CharField(choices=StateChoices.choices, max_length=11) + changed_at = models.DateTimeField(default=datetime.now) From 156c4d036be51912a28eec25fb865d70f8a253d3 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:00:15 +0300 Subject: [PATCH 05/10] add callback to get competition tasks endpoint --- services/backend/api/v1/task/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 043ad10..5899492 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -7,6 +7,7 @@ from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut from api.v1.task.schemas import TaskOutSchema from apps.competition.models import Competition, State +from apps.task.models import CompetitionTask router = Router(tags=["competition"]) @@ -39,7 +40,14 @@ def start_competition(request, competition_id: str) -> PingOut: } ) def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: - ... + competition = get_object_or_404(Competition, pk=competition_id) + state = State.objects.filter( + user=request.auth, competition=competition, state="started" + ).first() + if not state: + return 403, ForbiddenError() + + return status.OK, CompetitionTask.objects.filter(competition=competition).all() @router.get( From 52c19d2f80166c2f8eef514acece66c957232d45 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:24:47 +0300 Subject: [PATCH 06/10] change str to uuid in path params --- services/backend/api/v1/task/views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 5899492..07b729f 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,4 +1,5 @@ from http import HTTPStatus as status +from uuid import UUID from django.shortcuts import get_object_or_404 from ninja import Router @@ -21,7 +22,7 @@ router = Router(tags=["competition"]) status.NOT_FOUND: NotFoundError, }, ) -def start_competition(request, competition_id: str) -> PingOut: +def start_competition(request, competition_id: UUID) -> PingOut: competition = get_object_or_404(Competition, pk=competition_id) state_obj, _ = State.objects.update_or_create( user=request.auth, competition=competition, state="started" @@ -39,7 +40,7 @@ def start_competition(request, competition_id: str) -> PingOut: status.NOT_FOUND: NotFoundError, } ) -def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: +def get_competition_tasks(request, competition_id: UUID) -> list[TaskOutSchema]: competition = get_object_or_404(Competition, pk=competition_id) state = State.objects.filter( user=request.auth, competition=competition, state="started" @@ -60,7 +61,7 @@ def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: status.NOT_FOUND: NotFoundError, } ) -def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: +def get_task(request, competition_id: UUID, task_id: UUID) -> TaskOutSchema: ... @@ -74,5 +75,5 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: status.NOT_FOUND: NotFoundError, } ) -def submit_task(request, competition_id: str, task_id: str) -> PingOut: +def submit_task(request, competition_id: UUID, task_id: UUID) -> PingOut: ... From d062e96bbc978b7b7f88ad0f542d55761d0fdaa4 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:28:27 +0300 Subject: [PATCH 07/10] add partipication type and competition type to get all competitions --- services/backend/api/v1/competition/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 5891d9d..1f7eb77 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -31,14 +31,14 @@ def get_competition( @router.get( "competitions", response={ - status.OK: list[schemas.CompetitionListInstanceOut], + status.OK: list[schemas.CompetitionOut], status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, ) def list_competitions( request: HttpRequest, is_participating: bool -) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: +) -> tuple[status, list[schemas.CompetitionOut]]: user = request.auth if is_participating: competitions = Competition.objects.filter(participants=user) From 37fb9097bbd9c452d4a9fc7cb967e35e68b1fe69 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:29:07 +0300 Subject: [PATCH 08/10] remove competition from task schema --- services/backend/api/v1/task/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 4a3ec2e..1f9f264 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -7,4 +7,4 @@ from apps.task.models import CompetitionTask class TaskOutSchema(ModelSchema): class Meta: model = CompetitionTask - fields = ["id", "competition", "title", "description", "type"] + fields = ["id", "title", "description", "type"] From 688862ca786879525f60a1656170b77cc4d4f5e0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 1 Mar 2025 12:40:10 +0300 Subject: [PATCH 09/10] super aboba --- services/backend/apps/task/models.py | 38 +++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 3ff7ac4..1c4a3ae 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,3 +1,4 @@ +from random import choice from uuid import uuid4 from django.db import models @@ -5,6 +6,9 @@ from django.db import models from apps.task.validators import ContestTaskCriteriesValidator from apps.competition.models import Competition from apps.core.models import BaseModel +from apps.user.models import User +from apps.task.models import CompetitionTask + class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): @@ -13,7 +17,7 @@ class CompetitionTask(BaseModel): REVIEW = "review" def answer_file_upload_to(instance, filename) -> str: - return f"/tasks/{instance.id}/answer/{uuid4}" + return f"/tasks/{instance.id}/answer/{uuid4()}/filename" competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.TextField(verbose_name="заголовок", max_length=50) @@ -21,13 +25,41 @@ class CompetitionTask(BaseModel): type = models.CharField(choices=CompetitionTaskType, max_length=8) # only when "input" or "checker" type - correct_answer_file = models.FileField(upload_to=answer_file_upload_to) + correct_answer_file = models.FileField( + upload_to=answer_file_upload_to, null=True, blank=True + ) + points = models.IntegerField(null=True, blank=True) # only when "checker" type - answer_file_path = models.TextField() + answer_file_path = models.TextField(null=True, blank=True) # only when "review" type criteries = models.JSONField(blank=True, null=True) def clean(self): ContestTaskCriteriesValidator()(self) + + +class CompetetionTaskSumbission(BaseModel): + class StatusChoices(models.TextChoices): + SENT = "sent" + CHECKING = "checking" + CHECKED = "checked" + + def submission_content_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/content" + + def submission_stdout_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/stdout" + + status = models.CharField( + choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=2 + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + content = models.FileField(upload_to=submission_content_upload_to) + stdout = models.FileField( + upload_to=submission_stdout_upload_to, null=True, blank=True + ) + result = models.JSONField(default=None, null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) From 07dc5210a0a807f9f02b1e62d68566fca9282969 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 1 Mar 2025 13:06:30 +0300 Subject: [PATCH 10/10] (scope): [body] [footer(s)] --- services/backend/api/v1/task/schemas.py | 11 ++ services/backend/api/v1/task/views.py | 63 +++++++++--- services/backend/apps/task/models.py | 23 ++++- services/backend/apps/task/tasks.py | 128 ++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 services/backend/apps/task/tasks.py diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 4a3ec2e..ecc98f3 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -1,3 +1,5 @@ +from typing import Literal +from uuid import UUID from ninja import ModelSchema, Schema from apps.competition.models import State @@ -8,3 +10,12 @@ class TaskOutSchema(ModelSchema): class Meta: model = CompetitionTask fields = ["id", "competition", "title", "description", "type"] + + +class TaskSubmissionIn(Schema): + type: Literal["input", "file", "code"] + content: str + + +class TaskSubmissionOut(Schema): + submission_id: UUID diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 81301c8..b805998 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,10 +1,20 @@ from http import HTTPStatus as status from ninja import Router +from django.shortcuts import get_object_or_404 from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut -from api.v1.task.schemas import TaskOutSchema +from api.v1.task.schemas import ( + TaskOutSchema, + TaskSubmissionOut, + TaskSubmissionIn, +) +from apps.task.models import ( + Competition, + CompetitionTask, + CompetetionTaskSumbission, +) router = Router(tags=["competition"]) @@ -18,8 +28,7 @@ router = Router(tags=["competition"]) status.NOT_FOUND: NotFoundError, }, ) -def start_competition(request, competition_id: str) -> PingOut: - ... +def start_competition(request, competition_id: str) -> PingOut: ... @router.get( @@ -30,10 +39,11 @@ def start_competition(request, competition_id: str) -> PingOut: status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: - ... +def get_competition_tasks( + request, competition_id: str +) -> list[TaskOutSchema]: ... @router.get( @@ -44,21 +54,48 @@ def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: - ... +def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ... @router.post( "competitions/{competition_id}/tasks/{task_id}/submit", description="Submit task solution", response={ - status.OK: PingOut, # todo maybe I should write an other schema for this + status.OK: TaskSubmissionOut, status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def submit_task(request, competition_id: str, task_id: str) -> PingOut: - ... +def submit_task( + request, competition_id: str, task_id: str, submission: TaskSubmissionIn +) -> PingOut: + user = request.auth + competetion = get_object_or_404(Competition, id=competition_id) + task = get_object_or_404( + CompetitionTask, competetion=competetion, id=task_id + ) + + if task.type == CompetitionTask.CompetitionTaskType.INPUT: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.CHECKED, + result={"correct": submission.content == task.answer_file_path}, + ) + if task.type == CompetitionTask.CompetitionTaskType.REVIEW: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.SENT, + ) + if task.type == CompetitionTask.CompetitionTaskType.CHECKER: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.CHECKING, + ) + + return TaskSubmissionOut(id=CompetetionTaskSumbission.id) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 1c4a3ae..c76d925 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,4 +1,3 @@ -from random import choice from uuid import uuid4 from django.db import models @@ -7,7 +6,6 @@ from apps.task.validators import ContestTaskCriteriesValidator from apps.competition.models import Competition from apps.core.models import BaseModel from apps.user.models import User -from apps.task.models import CompetitionTask class CompetitionTask(BaseModel): @@ -52,14 +50,29 @@ class CompetetionTaskSumbission(BaseModel): def submission_stdout_upload_to(instance, filename) -> str: return f"/submissions/{instance.id}/stdout" - status = models.CharField( - choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=2 - ) user = models.ForeignKey(User, on_delete=models.CASCADE) task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + + status = models.CharField( + choices=StatusChoices.choices, + default=StatusChoices.SENT, + max_length=8, + ) + + # code or text or file content = models.FileField(upload_to=submission_content_upload_to) + + # only if task type is checker stdout = models.FileField( upload_to=submission_stdout_upload_to, null=True, blank=True ) + + # 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) + # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria + earned_points = models.IntegerField() + timestamp = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py new file mode 100644 index 0000000..b2f0286 --- /dev/null +++ b/services/backend/apps/task/tasks.py @@ -0,0 +1,128 @@ +import tempfile +import os +import sys +import ast +from io import StringIO +import hashlib +from config.celery import app + +ALLOWED_MODULES = { + "pandas", + "numpy", + "matplotlib", + "seaborn", + "scipy", + "sklearn", + "datetime", + "json", + "csv", + "math", + "statistics", +} + + +class SecurityException(Exception): + pass + + +def validate_code(code_str): + try: + tree = ast.parse(code_str) + except SyntaxError as e: + raise SecurityException(f"Syntax error: {str(e)}") + + 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}") + + 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 as e: + raise + except Exception as e: + raise SecurityException(f"Security check failed: {str(e)}") + + +def secure_exec(code_str, result_path): + original_dir = os.getcwd() + original_stdout = sys.stdout + sys.stdout = captured_stdout = StringIO() + result_content = None + + with tempfile.TemporaryDirectory() as temp_dir: + try: + os.chdir(temp_dir) + restricted_globals = { + "__builtins__": { + "open": lambda f, *a, **kw: open(f, *a, **kw), + "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: {str(e)}") + 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_bytes): + try: + validate_code(code_str) + + result_content = secure_exec(code_str, result_path) + + result_hash = hashlib.sha256(result_content).hexdigest() + expected_hash = hashlib.sha256(expected_bytes).hexdigest() + + return { + "success": True, + "match": result_hash == expected_hash, + "result_hash": result_hash, + "expected_hash": expected_hash, + } + + except SecurityException as e: + return {"success": False, "error": f"Security violation: {str(e)}"} + except RuntimeError as e: + return {"success": False, "error": f"Execution error: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"Unexpected error: {str(e)}"}