diff --git a/compose.yaml b/compose.yaml index b2f0751..87231d1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -273,7 +273,7 @@ services: - name: web target: 3000 published: 8004 - host_ip: 127.0.0.1 + host_ip: 0.0.0.0 protocol: tcp restart: unless-stopped shm_size: 4mb diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 095549c..1f7eb77 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( @@ -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) diff --git a/services/backend/api/v1/review/__init__.py b/services/backend/api/v1/review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/review/auth.py b/services/backend/api/v1/review/auth.py new file mode 100644 index 0000000..9fbd270 --- /dev/null +++ b/services/backend/api/v1/review/auth.py @@ -0,0 +1,26 @@ +from abc import ABC +from typing import Optional + +from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from django.urls import resolve +from ninja.errors import AuthenticationError +from ninja.security import APIKeyQuery +from ninja.security.apikey import APIKeyBase + +from apps.review.models import Reviewer + +class APIKeyPath(APIKeyBase, ABC): + openapi_in: str = "path" + + def _get_key(self, request: HttpRequest) -> Optional[str]: + func, args, kwargs = resolve(request.path) + return kwargs.get(self.param_name) + +class ReviewerAuth(APIKeyPath): + param_name = "token" + + def authenticate(self, request, token): + if not (reviewer := Reviewer.objects.filter(token=token).first()): + raise AuthenticationError + return reviewer \ No newline at end of file diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py new file mode 100644 index 0000000..824e62d --- /dev/null +++ b/services/backend/api/v1/review/schemas.py @@ -0,0 +1,37 @@ +from typing import List, Literal +from uuid import UUID + +from django.http import HttpRequest +from ninja import Schema, ModelSchema + +from apps.review.models import Reviewer +from apps.task.models import CompetetionTaskSumbission + + +class PingOut(Schema): + status: str = "ok" + +class ReviewerOut(ModelSchema): + id: UUID + + class Meta: + model = Reviewer + exclude = ("token",) + +class SubmissionOut(ModelSchema): + id: UUID + status: Literal["sent", "checking", "checked"] + + class Meta: + model = CompetetionTaskSumbission + exclude = ( + "user", + ) + +class SubmissionsOut(Schema): + submissions: list[SubmissionOut] = [] + + @staticmethod + def resolve_submissions(self, context: HttpRequest) -> List[SubmissionOut]: + print(CompetetionTaskSumbission.objects.all()) + return list(CompetetionTaskSumbission.objects.all()) \ No newline at end of file diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py new file mode 100644 index 0000000..b3e1bdf --- /dev/null +++ b/services/backend/api/v1/review/views.py @@ -0,0 +1,34 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest +from ninja import Router + +from api.v1.review import schemas +from api.v1 import schemas as global_schemas + +router = Router(tags=["review"]) + + +@router.get( + "{token}/tasks", + response={ + status.OK: schemas.SubmissionsOut, + }, +) +def ping(request: HttpRequest, token) -> tuple[status, schemas.SubmissionsOut]: + return status.OK, schemas.SubmissionsOut() + + +@router.get( + "{token}", + response={ + status.OK: schemas.ReviewerOut, + status.UNAUTHORIZED: global_schemas.UnauthorizedError + }, + description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query" +) +def get_reviewer( + request: HttpRequest, + token: str +): + return status.OK, request.auth \ No newline at end of file diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 1ad7924..e85570a 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -6,7 +6,9 @@ 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.review.auth import ReviewerAuth from api.v1.user.views import router as user_router +from api.v1.review.views import router as review_router router = NinjaAPI( title="DataRush API", @@ -30,6 +32,12 @@ router.add_router( competition_router, auth=BearerAuth(), ) +router.add_router( + "review", + review_router, + auth=ReviewerAuth(), +) + 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..ecc98f3 --- /dev/null +++ b/services/backend/api/v1/task/schemas.py @@ -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 diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py new file mode 100644 index 0000000..6710c95 --- /dev/null +++ b/services/backend/api/v1/task/views.py @@ -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) diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index dd16963..bb60448 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -1,5 +1,7 @@ -# Generated by Django 5.1.6 on 2025-02-28 21:27 +# Generated by Django 5.1.6 on 2025-03-01 10:26 +import datetime +import django.db.models.deletion import uuid from django.db import migrations, models @@ -9,6 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('user', '0001_initial'), ] operations = [ @@ -23,10 +26,24 @@ class Migration(migrations.Migration): ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), ('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), ('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), + ('participants', models.ManyToManyField(related_name='participants', to='user.user')), ], options={ 'verbose_name': 'соревнование', 'verbose_name_plural': 'соревнования', }, ), + migrations.CreateModel( + name='State', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)), + ('changed_at', models.DateTimeField(default=datetime.datetime.now)), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/services/backend/apps/competition/migrations/0002_competition_participants.py b/services/backend/apps/competition/migrations/0002_competition_participants.py deleted file mode 100644 index a15dafd..0000000 --- a/services/backend/apps/competition/migrations/0002_competition_participants.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0001_initial'), - ('user', '0002_user_status'), - ] - - operations = [ - migrations.AddField( - model_name='competition', - name='participants', - field=models.ManyToManyField(related_name='participants', to='user.user'), - ), - ] diff --git a/services/backend/apps/competition/migrations/0003_state.py b/services/backend/apps/competition/migrations/0003_state.py deleted file mode 100644 index 2212552..0000000 --- a/services/backend/apps/competition/migrations/0003_state.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 23:26 - -import django.db.models.deletion -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0002_competition_participants'), - ('user', '0003_alter_user_status'), - ] - - operations = [ - migrations.CreateModel( - name='State', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)), - ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), - ], - options={ - 'abstract': False, - }, - ), - ] 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) diff --git a/services/backend/apps/review/__init__.py b/services/backend/apps/review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/review/apps.py b/services/backend/apps/review/apps.py new file mode 100644 index 0000000..fc4d048 --- /dev/null +++ b/services/backend/apps/review/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "apps.review" + label = "review" diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py new file mode 100644 index 0000000..ceed39d --- /dev/null +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:47 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Reviewer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('surname', models.CharField(max_length=100)), + ('token', models.CharField(max_length=100)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/review/migrations/__init__.py b/services/backend/apps/review/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py new file mode 100644 index 0000000..58bd512 --- /dev/null +++ b/services/backend/apps/review/models.py @@ -0,0 +1,10 @@ +from django.db import models + +from apps.core.models import BaseModel + + +class Reviewer(BaseModel): + name = models.CharField(max_length=100) + surname = models.CharField(max_length=100) + + token = models.CharField(max_length=100) \ No newline at end of file diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py new file mode 100644 index 0000000..c3e32f9 --- /dev/null +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.1.6 on 2025-03-01 10:26 + +import apps.task.models +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competition', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CompetitionTask', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.TextField(max_length=50, verbose_name='заголовок')), + ('description', models.TextField(max_length=300, verbose_name='описание')), + ('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)), + ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)), + ('points', models.IntegerField(blank=True, null=True)), + ('answer_file_path', models.TextField(blank=True, null=True)), + ('criteries', models.JSONField(blank=True, null=True)), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CompetetionTaskSumbission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), + ('content', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_content_upload_to)), + ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)), + ('result', models.JSONField(blank=True, default=None, null=True)), + ('earned_points', models.IntegerField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/task/migrations/__init__.py b/services/backend/apps/task/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index eee9f4f..8b38b3a 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,11 +1,12 @@ +from random import choice 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): class CompetitionTaskType(models.TextChoices): @@ -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) 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)}"} diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index 41491e9..6fb8be0 100644 --- a/services/backend/apps/user/migrations/0001_initial.py +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-02-28 20:46 +# Generated by Django 5.1.6 on 2025-03-01 08:47 import uuid from django.db import migrations, models @@ -19,6 +19,7 @@ class Migration(migrations.Migration): ('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), ('username', models.SlugField(unique=True, verbose_name='Юзернейм')), ('password', models.TextField(verbose_name='Пароль')), + ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ], options={ 'verbose_name': 'пользователь', diff --git a/services/backend/apps/user/migrations/0002_user_status.py b/services/backend/apps/user/migrations/0002_user_status.py deleted file mode 100644 index 281d8fd..0000000 --- a/services/backend/apps/user/migrations/0002_user_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='status', - field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), - ), - ] diff --git a/services/backend/apps/user/migrations/0003_alter_user_status.py b/services/backend/apps/user/migrations/0003_alter_user_status.py deleted file mode 100644 index a7c766f..0000000 --- a/services/backend/apps/user/migrations/0003_alter_user_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0002_user_status'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='status', - field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), - ), - ] diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index f4f2ba2..af79c52 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -445,6 +445,8 @@ INSTALLED_APPS = [ "apps.core", "apps.user", "apps.competition", + "apps.review", + "apps.task" ] # GUID diff --git a/services/frontend/src/pages/CompetitionPreviewPage/index.tsx b/services/frontend/src/pages/CompetitionPreviewPage/index.tsx new file mode 100644 index 0000000..15bcdc8 --- /dev/null +++ b/services/frontend/src/pages/CompetitionPreviewPage/index.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import Navbar from "@/widgets/Navbar"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { Competition } from "@/shared/types"; +import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks"; + +const CompetitionPreview = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [competition, setCompetition] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchCompetition = async () => { + try { + setTimeout(() => { + const found = mockCompetitions.find((comp) => comp.id === id); + setCompetition(found || null); + setIsLoading(false); + }, 500); + } catch (error) { + console.error("Error fetching competition:", error); + setIsLoading(false); + } + }; + + fetchCompetition(); + }, [id]); + + const handleBack = () => { + navigate(-1); + }; + + 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`); + } + } + }; + + return ( + <> + +
+ + + {isLoading ? ( +
+

Загрузка...

+
+ ) : competition ? ( +
+
+ {competition.name} +
+ +
+
+

+ {competition.name} +

+ +
+ +
+

{competition.description}

+
+
+
+ ) : ( +
+

+ Соревнование не найдено +

+

+ Запрошенное соревнование не существует или было удалено. +

+
+ )} +
+ + ); +}; + +export default CompetitionPreview; diff --git a/services/frontend/src/pages/CompetitionRunnerPage/utils/utils.ts b/services/frontend/src/pages/CompetitionRunnerPage/utils/utils.ts new file mode 100644 index 0000000..736450a --- /dev/null +++ b/services/frontend/src/pages/CompetitionRunnerPage/utils/utils.ts @@ -0,0 +1,23 @@ +import { TaskStatus } from "@/shared/types/types"; + +const getTaskBgColor = (status: TaskStatus): string => { + switch (status) { + case "uncleared": return "bg-[var(--color-task-uncleared)]"; + case "checking": return "bg-[var(--color-task-checking)]"; + case "correct": return "bg-[var(--color-task-correct)]"; + case "partial": return "bg-[var(--color-task-partial)]"; + case "wrong": return "bg-[var(--color-task-wrong)]"; + } +}; + +const getTaskTextColor = (status: TaskStatus): string => { + switch (status) { + case "uncleared": return "text-[var(--color-task-text-uncleared)]"; + case "checking": return "text-[var(--color-task-text-checking)]"; + case "correct": return "text-[var(--color-task-text-correct)]"; + case "partial": return "text-[var(--color-task-text-partial)]"; + case "wrong": return "text-[var(--color-task-text-wrong)]"; + } +}; + +export {getTaskBgColor, getTaskTextColor} \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 8b3f3fd..27f1e8d 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,58 +1,47 @@ -import { useState } from "react"; -import { useParams } from "react-router-dom"; -import { Task, TaskStatus } from "@/shared/types"; - -const sampleTasks: Task[] = [ - { id: "1", number: "1.1", status: "uncleared" }, - { id: "2", number: "1.2", status: "checking" }, - { id: "3", number: "1.3", status: "correct" }, - { id: "4", number: "2.1", status: "partial" }, - { id: "5", number: "2.2", status: "wrong" }, - { id: "6", number: "2.3", status: "uncleared" }, - { id: "7", number: "3.1", status: "checking" }, - { id: "8", number: "3.2", status: "correct" }, -]; +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Task } from "@/shared/types"; +import { getTaskBgColor, getTaskTextColor } from "./utils/utils"; +import { mockTasks } from "@/shared/mocks/mocks"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "lucide-react"; const CompetitionRunnerPage = () => { - const { id } = useParams<{ id: string }>(); + const { id, taskId } = useParams<{ id: string; taskId?: string }>(); + const navigate = useNavigate(); const [competitionTitle, setCompetitionTitle] = useState( "Олимпиада DANO 2025. Индивидуальный этап", ); - const [tasks, setTasks] = useState(sampleTasks); - const [selectedTaskId, setSelectedTaskId] = useState(null); + const [tasks] = useState(mockTasks); + const [selectedTaskId, setSelectedTaskId] = useState( + taskId || null, + ); + const [answer, setAnswer] = useState(""); - const getTaskBgColor = (status: TaskStatus): string => { - switch (status) { - case "uncleared": - return "bg-[var(--color-task-uncleared)]"; - case "checking": - return "bg-[var(--color-task-checking)]"; - case "correct": - return "bg-[var(--color-task-correct)]"; - case "partial": - return "bg-[var(--color-task-partial)]"; - case "wrong": - return "bg-[var(--color-task-wrong)]"; + useEffect(() => { + if (taskId) { + setSelectedTaskId(taskId); + } else if (tasks.length > 0) { + navigate(`/competition/${id}/tasks/${tasks[0].id}`, { replace: true }); } - }; - - const getTaskTextColor = (status: TaskStatus): string => { - switch (status) { - case "uncleared": - return "text-gray-600"; - case "checking": - return "text-gray-800"; - case "correct": - return "text-green-800"; - case "partial": - return "text-green-700"; - case "wrong": - return "text-red-800"; - } - }; + }, [taskId, tasks, id, navigate]); const handleTaskClick = (taskId: string) => { - setSelectedTaskId(taskId); + if (selectedTaskId !== taskId) { + setSelectedTaskId(taskId); + navigate(`/competition/${id}/tasks/${taskId}`); + } + }; + + const currentTask = tasks.find((t) => t.id === selectedTaskId); + + const handleSubmit = () => { + console.log("Submitting answer:", answer); + // Submit logic here + }; + + const handleHistoryClick = () => { + console.log("View history"); }; return ( @@ -65,11 +54,11 @@ const CompetitionRunnerPage = () => { -
+
{tasks.map((task) => (
handleTaskClick(task.id)} > {task.number} @@ -79,21 +68,84 @@ const CompetitionRunnerPage = () => {
-
-
- {selectedTaskId ? ( -
-

- Задание {tasks.find((t) => t.id === selectedTaskId)?.number} -

-

- Содержание задания будет отображаться здесь. -

+
+
+ {currentTask ? ( +
+ {/* Left Container - Task Description */} +
+

+ Задача {currentTask.number} +

+ +
+

+ Рассмотрим последовательность чисел 2, 3, 5, 9, 17, 33, 65, + 129, ... Каждый член этой последовательности, начиная с + третьего, равен сумме двух предыдущих членов. +

+

+ Найдите сумму первых 15 членов этой последовательности. +

+

В ответе укажите целое число.

+
+
+ + {/* Right Container - Solution Area */} +
+ {/* Solution Status Card */} +
+
+ + Решение 12345 + + + Зачтено 5/10 баллов + +
+
+ 1 марта, 08:41 +
+
+ + {/* Answer Input */} +
+