Merge branch 'feature/tasks'

This commit is contained in:
ITQ
2025-03-01 13:16:31 +03:00
10 changed files with 345 additions and 8 deletions
+3 -2
View File
@@ -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(
@@ -30,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)
+5
View File
@@ -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(
@@ -30,6 +31,10 @@ router.add_router(
competition_router,
auth=BearerAuth(),
)
router.add_router(
"",
task_router,
)
for exception, handler in handlers.exception_handlers:
+21
View File
@@ -0,0 +1,21 @@
from typing import Literal
from uuid import UUID
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"]
class TaskSubmissionIn(Schema):
type: Literal["input", "file", "code"]
content: str
class TaskSubmissionOut(Schema):
submission_id: UUID
+115
View File
@@ -0,0 +1,115 @@
from http import HTTPStatus as status
from uuid import UUID
from django.shortcuts import get_object_or_404
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,
TaskSubmissionOut,
TaskSubmissionIn,
)
from apps.task.models import (
Competition,
CompetitionTask,
CompetetionTaskSumbission,
)
from apps.competition.models import State
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: 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"
)
return status.OK, 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: UUID) -> 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(
"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: TaskSubmissionOut,
status.UNAUTHORIZED: UnauthorizedError,
status.FORBIDDEN: ForbiddenError,
status.NOT_FOUND: NotFoundError,
},
)
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)
@@ -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),
),
]
@@ -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)
+50 -6
View File
@@ -1,10 +1,11 @@
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
from apps.user.models import User
class CompetitionTask(BaseModel):
@@ -14,21 +15,64 @@ 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)
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)
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"
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)
+128
View File
@@ -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)}"}
+1
View File
@@ -445,6 +445,7 @@ INSTALLED_APPS = [
"apps.core",
"apps.user",
"apps.competition",
"apps.task",
]
# GUID