This commit is contained in:
rngsurrounded
2025-03-04 10:40:50 +09:00
13 changed files with 109 additions and 56 deletions
+39 -1
View File
@@ -2,7 +2,6 @@
Инновационный сервис для проведения соревнований по анализу данных Инновационный сервис для проведения соревнований по анализу данных
## Запуск ## Запуск
Склонируйте репозиторий и пропишите. Склонируйте репозиторий и пропишите.
@@ -24,10 +23,49 @@ docker compose up
* `admin` - логин * `admin` - логин
* `proooooood` - пароль * `proooooood` - пароль
## Как устроен проект
В проекте используются 3 основных модуля: backend, frontend и checker
1. `backend` представляет собой приложение, написанное на Django с применением [Django Ninja](https://django-ninja.dev/), который позволяет быстро и легко создавать Restful API.
Сам бекенд состоит из 2-х основных компонентов: приложений (где хранятся модели, тесты и настройки админ-панели) и основных колбеков (где хранятся схемы OpenAPI и сами ручки)
Решения на проверку отсылаются через `celery` для асинхронного взаимодействия, так как его ожидание может занять довольно длительное время.
2. `frontend` является React приложением, которое запускается через Vite. В нем также используется TypeScript для более строгой типизации. Структура приложения является стандартной для подобного вида проектов: есть отдельная папка для компонентов, страниц, стилей, работы с API.
3. `checker` - микросервис на FastAPI, созданный для безопасного асинхронного запуска посылок пользователей.
Данные в этот сервис отсылаются по специальной ручке `/execute`, которая является приватной (ее нет в документации)
Проверка заданий осуществляется через запуск кода пользователя через специально создаваемый Docker контейнер, на который выдаются следующие ресурсы:
* 1 ядро CPU
* 1 ГБ ОЗУ
Для приближения к условиям работы аналитиков в интерпритаторе Python есть следующие библиотеки:
```python
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
```
Контейнеру дается 1 минута на выполнение кода, потом - контейнер удаляется
## Тесты ## Тесты
Написаны unit-тесты (на базе Django TestCase) и E2E (Postman коллекция). Они покрывают flow регистрации, просмотра и участия в соревновании. Написаны unit-тесты (на базе Django TestCase) и E2E (Postman коллекция). Они покрывают flow регистрации, просмотра и участия в соревновании.
Unit-тесты находятся в соответствующих приложениях, которые располагаются по пути `services/backend/apps`
JSON коллекция, в которой хранятся E2E тесты, находится по пути `img/postman_e2e.json`. Ее можно импортировать в постман, нажав на соответсвующую кнопку в интерфейсе Postman
![Postman data](img/postman.gif) ![Postman data](img/postman.gif)
Ниже можно увидеть Coverage тестами бекенда данного приложения Ниже можно увидеть Coverage тестами бекенда данного приложения
+1 -1
View File
@@ -33,7 +33,7 @@ class TaskOutSchema(ModelSchema):
"description", "description",
"in_competition_position", "in_competition_position",
"points", "points",
"max_attempts" "max_attempts",
] ]
+21 -16
View File
@@ -1,7 +1,7 @@
from http import HTTPStatus as status from http import HTTPStatus as status
from uuid import UUID from uuid import UUID
from django.shortcuts import get_object_or_404, get_list_or_404 from django.shortcuts import get_list_or_404, get_object_or_404
from ninja import File, Router, UploadedFile from ninja import File, Router, UploadedFile
from api.v1.ping.schemas import PingOut from api.v1.ping.schemas import PingOut
@@ -10,8 +10,8 @@ from api.v1.task.schemas import (
HistorySubmissionOut, HistorySubmissionOut,
TaskAttachmentSchema, TaskAttachmentSchema,
TaskOutSchema, TaskOutSchema,
TaskSubmissionOut,
TaskStatusSchema, TaskStatusSchema,
TaskSubmissionOut,
) )
from apps.achievement.models import Achievement, UserAchievement from apps.achievement.models import Achievement, UserAchievement
from apps.competition.models import State from apps.competition.models import State
@@ -125,10 +125,8 @@ def submit_task(
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKED, status=CompetitionTaskSubmission.StatusChoices.CHECKED,
content=content, content=content,
result={ result={"correct": verdict},
"correct": verdict earned_points=task.points if verdict else 0,
},
earned_points=task.points if verdict else 0
) )
if task.type == CompetitionTask.CompetitionTaskType.REVIEW: if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
submission = CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
@@ -184,7 +182,7 @@ def get_task_attachments(request, competition_id: UUID, task_id: UUID):
"competitions/{competition_id}/results", "competitions/{competition_id}/results",
response={ response={
status.OK: list[TaskStatusSchema], status.OK: list[TaskStatusSchema],
status.UNAUTHORIZED: UnauthorizedError status.UNAUTHORIZED: UnauthorizedError,
}, },
) )
def get_competition_results(request, competition_id: UUID): def get_competition_results(request, competition_id: UUID):
@@ -193,9 +191,14 @@ def get_competition_results(request, competition_id: UUID):
data = [] data = []
for task in tasks: for task in tasks:
submissions = CompetitionTaskSubmission.objects.filter( submissions = (
user=request.auth, task=task CompetitionTaskSubmission.objects.filter(
).filter(status="checked").order_by("-earned_points").all() user=request.auth, task=task
)
.filter(status="checked")
.order_by("-earned_points")
.all()
)
if not submissions: if not submissions:
all_submissions_count = CompetitionTaskSubmission.objects.filter( all_submissions_count = CompetitionTaskSubmission.objects.filter(
user=request.auth, task=task user=request.auth, task=task
@@ -206,11 +209,13 @@ def get_competition_results(request, competition_id: UUID):
result = -1 result = -1
else: else:
result = submissions[0].earned_points result = submissions[0].earned_points
data.append(TaskStatusSchema( data.append(
task_name=task.title, TaskStatusSchema(
result=result, task_name=task.title,
max_points=task.points, result=result,
position=task.in_competition_position max_points=task.points,
)) position=task.in_competition_position,
)
)
return status.OK, data return status.OK, data
-1
View File
@@ -3,7 +3,6 @@ from uuid import UUID
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Router from ninja import Router
from api.v1.schemas import BadRequestError, NotFoundError, UnauthorizedError
from api.v1.team.schemas import CreateTeamSchema, TeamSchemaOut from api.v1.team.schemas import CreateTeamSchema, TeamSchemaOut
from apps.team.models import Team from apps.team.models import Team
+1 -1
View File
@@ -21,7 +21,7 @@ from api.v1.user.schemas import (
TokenSchema, TokenSchema,
UserSchema, UserSchema,
) )
from apps.task.models import CompetitionTaskSubmission, CompetitionTask from apps.task.models import CompetitionTask, CompetitionTaskSubmission
from apps.user.models import User from apps.user.models import User
router = Router(tags=["user"]) router = Router(tags=["user"])
+1 -1
View File
@@ -7,4 +7,4 @@ class CompetitionsConfig(AppConfig):
verbose_name = "Соревнование" verbose_name = "Соревнование"
def ready(self): def ready(self):
import apps.competition.signals pass
+12 -4
View File
@@ -3,15 +3,23 @@ from django.dispatch import receiver
from apps.achievement.models import Achievement, UserAchievement from apps.achievement.models import Achievement, UserAchievement
from apps.competition.models import State from apps.competition.models import State
from apps.user.models import User
@receiver(post_save, sender=State) @receiver(post_save, sender=State)
def assign_start_competition_achievement(sender, instance, created, **kwargs): def assign_start_competition_achievement(sender, instance, created, **kwargs):
if created: if created:
if State.objects.filter(user=instance.user, state=State.StateChoices.STARTED.value).count() == 1 \ if (
and not State.objects.filter(user=instance.user, state=State.StateChoices.FINISHED.value).exists(): State.objects.filter(
start_competition_achievement = Achievement.objects.get(slug="start_competition") user=instance.user, state=State.StateChoices.STARTED.value
).count()
== 1
and not State.objects.filter(
user=instance.user, state=State.StateChoices.FINISHED.value
).exists()
):
start_competition_achievement = Achievement.objects.get(
slug="start_competition"
)
UserAchievement.objects.create( UserAchievement.objects.create(
user=instance.user, achievement=start_competition_achievement user=instance.user, achievement=start_competition_achievement
) )
@@ -19,6 +19,7 @@ from apps.user.models import User, UserRole
faker = Faker("ru_RU") faker = Faker("ru_RU")
class Command(BaseCommand): class Command(BaseCommand):
help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States." help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States."
@@ -1,8 +1,7 @@
import random import random
import uuid import uuid
from datetime import timedelta, datetime from datetime import timedelta
from PIL.Image import Image
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.files.base import ContentFile, File from django.core.files.base import ContentFile, File
@@ -13,9 +12,9 @@ from apps.competition.models import Competition, State
from apps.review.models import Reviewer from apps.review.models import Reviewer
from apps.task.models import ( from apps.task.models import (
CompetitionTask, CompetitionTask,
CompetitionTaskAttachment,
CompetitionTaskCriteria, CompetitionTaskCriteria,
CompetitionTaskSubmission, CompetitionTaskSubmission,
CompetitionTaskAttachment,
) )
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -47,11 +46,11 @@ dataset2 = ContentFile(
correct_answer_file = ContentFile( correct_answer_file = ContentFile(
b"42", b"42",
name=f"answer.txt", name="answer.txt",
) )
correct2_answer_file = ContentFile( correct2_answer_file = ContentFile(
b"it is a dataset", b"it is a dataset",
name=f"answer.txt", name="answer.txt",
) )
now = timezone.now() now = timezone.now()
@@ -666,7 +665,7 @@ class Command(BaseCommand):
type=competition["type"], type=competition["type"],
participation_type=competition["participation_type"], participation_type=competition["participation_type"],
) )
except Exception as e: except Exception:
print(competition) print(competition)
if competition.get("image"): if competition.get("image"):
@@ -697,13 +696,18 @@ class Command(BaseCommand):
points=task["points"], points=task["points"],
submission_reviewers_count=task[ submission_reviewers_count=task[
"submission_reviewers_count" "submission_reviewers_count"
] if task["type"] == CompetitionTask.CompetitionTaskType.REVIEW.value else None, ]
correct_answer_file=task["correct_answer_file"] if task["type"] != CompetitionTask.CompetitionTaskType.REVIEW.value else None, if task["type"]
== CompetitionTask.CompetitionTaskType.REVIEW.value
else None,
correct_answer_file=task["correct_answer_file"]
if task["type"]
!= CompetitionTask.CompetitionTaskType.REVIEW.value
else None,
max_attempts=task.get("max_attempts"), max_attempts=task.get("max_attempts"),
) )
competitions[i]["tasks"][j]["obj"] = task_obj competitions[i]["tasks"][j]["obj"] = task_obj
if task.get("attachment"): if task.get("attachment"):
CompetitionTaskAttachment.objects.create( CompetitionTaskAttachment.objects.create(
task=task_obj, task=task_obj,
+15 -12
View File
@@ -1,10 +1,9 @@
from sys import stdout
from uuid import uuid4 from uuid import uuid4
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Count, Q from django.db.models import Count, Q
from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from mdeditor.fields import MDTextField from mdeditor.fields import MDTextField
from apps.competition.models import Competition from apps.competition.models import Competition
@@ -88,23 +87,27 @@ class CompetitionTask(BaseModel):
# "type": "Если загружен файл правильного ответа, то тип проверки не может быть ручным" # "type": "Если загружен файл правильного ответа, то тип проверки не может быть ручным"
# }) # })
if not self.correct_answer_file and self.type != "review": if not self.correct_answer_file and self.type != "review":
raise ValidationError({ raise ValidationError(
"correct_answer_file": "Загрузите правильный ответ" {"correct_answer_file": "Загрузите правильный ответ"}
}) )
# if self.answer_file_path and not self.type == "checker": # if self.answer_file_path and not self.type == "checker":
# raise ValidationError({ # raise ValidationError({
# "type": "Укажите другой тип задания: этот не совместим с путем правильного ответа" # "type": "Укажите другой тип задания: этот не совместим с путем правильного ответа"
# }) # })
if not self.answer_file_path and self.type == "checker": if not self.answer_file_path and self.type == "checker":
raise ValidationError({ raise ValidationError(
"answer_file_path": "Введите путь правильного ответа - это нужно для корректной работы чекера" {
}) "answer_file_path": "Введите путь правильного ответа - это нужно для корректной работы чекера"
}
)
if not self.reviewers and self.type == "review": if not self.reviewers and self.type == "review":
raise ValidationError({ raise ValidationError(
"reviewers": "Загрузите ревьюверов - кто будет проверять задания, если не они?" {
}) "reviewers": "Загрузите ревьюверов - кто будет проверять задания, если не они?"
}
)
# elif self.reviewers and not self.type == "review": # elif self.reviewers and not self.type == "review":
# raise ValidationError({ # raise ValidationError({
# "type": "Проверьте тип - вы ввели ревьюверов, но задание не является ручным" # "type": "Проверьте тип - вы ввели ревьюверов, но задание не является ручным"
+2 -3
View File
@@ -1,11 +1,10 @@
import base64
import hashlib import hashlib
from urllib.parse import urlparse
import httpx import httpx
from celery import shared_task from celery import shared_task
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile
from urllib.parse import urlparse
import base64
from apps.task.models import CompetitionTaskSubmission from apps.task.models import CompetitionTaskSubmission
+1 -1
View File
@@ -7,4 +7,4 @@ class UsersConfig(AppConfig):
verbose_name = "контестанты" verbose_name = "контестанты"
def ready(self): def ready(self):
import apps.user.signals pass
+2 -6
View File
@@ -88,9 +88,7 @@ async def download_file(
session: aiohttp.ClientSession, url: str, dest_path: str session: aiohttp.ClientSession, url: str, dest_path: str
) -> None: ) -> None:
try: try:
async with session.get( async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
url, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200: if resp.status != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -271,9 +269,7 @@ async def execute_code(request: ExecutionRequest) -> ExecutionResponse:
response = ExecutionResponse( response = ExecutionResponse(
success=success, success=success,
hash_match=( hash_match=(
result_hash == request.expected_hash result_hash == request.expected_hash if request.expected_hash else None
if request.expected_hash
else None
), ),
output=output[:5000], output=output[:5000],
result_hash=result_hash, result_hash=result_hash,