Merge remote-tracking branch 'origin/master'

This commit is contained in:
Андрей Сумин
2025-03-04 05:46:48 +03:00
27 changed files with 296 additions and 120 deletions
+79
View File
@@ -0,0 +1,79 @@
# Креды для DATARUSH
Найдите номер набора на вашей карточке(слева вверху)
#### Администратор
- Логин: `admin`
- Пароль: `prooooooood`
- Ссылка: https://clck.ru/3GkMoo
### Набор 1
#### Участник
- Почта: germanivanov1984@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjrtg
### Набор 2
#### Участник
- Почта: dreamonovich@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjs9y
### Набор 3
#### Участник
- Почта: alisa.kuznetsova@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjsDq
### Набор 4
#### Участник
- Почта: ivan.petrov@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjsK3
### Набор 5
#### Участник
- Почта: olga.sidorova@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjrtg
### Набор 6
#### Участник
- Почта: karim@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjsa3
### Набор 7
#### Участник
- Почта: noble@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjsq2
### Набор 8
#### Участник
- Почта: koller@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3Gjt7n
### Набор 9
#### Участник
- Почта: gold_checker@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjtBp
### Набор 10
#### Участник
- Почта: looka@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjtLv
### Набор 11
#### Участник
- Почта: danil_malikov@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjtMh
### Набор 12
#### Участник
- Почта: marina-looks@gmail.com
- Пароль: `password123!`
#### Ревьювер
Ссылка: https://clck.ru/3GjtNP
+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 тестами бекенда данного приложения
+2 -1
View File
@@ -33,7 +33,7 @@ class TaskOutSchema(ModelSchema):
"description", "description",
"in_competition_position", "in_competition_position",
"points", "points",
"max_attempts" "max_attempts",
] ]
@@ -68,3 +68,4 @@ class TaskStatusSchema(Schema):
task_name: str task_name: str
result: int result: int
max_points: int max_points: int
position: int
+17 -11
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 = (
CompetitionTaskSubmission.objects.filter(
user=request.auth, task=task user=request.auth, task=task
).filter(status="checked").order_by("-earned_points").all() )
.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,10 +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(
TaskStatusSchema(
task_name=task.title, task_name=task.title,
result=result, result=result,
max_points=task.points, 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
@@ -23,7 +23,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,
@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-03 23:02
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task', '0003_alter_competitiontaskattachment_task'),
]
operations = [
migrations.AlterField(
model_name='competitiontaskattachment',
name='bind_at',
field=models.CharField(max_length=255, validators=[django.core.validators.RegexValidator('^(?:[a-zA-Z]:\\\\(?:[^<>:\\"\\/\\\\|?*]*\\\\)*|/(?:[^<>:\\"\\/\\\\|?*]+/?)*)$', message='Введите абсолютный путь до папки')], verbose_name='путь сохранения'),
),
]
+13 -10
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": "Проверьте тип - вы ввели ревьюверов, но задание не является ручным"
+11 -8
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
@@ -35,23 +34,27 @@ def analyze_data_task(self, submission_id):
"code": base64.b64encode(code).decode("utf-8"), "code": base64.b64encode(code).decode("utf-8"),
"answer_file_path": submission.task.answer_file_path, "answer_file_path": submission.task.answer_file_path,
"expected_hash": hashlib.sha256( "expected_hash": hashlib.sha256(
submission.task.correct_answer_file.read().decode("utf-8") submission.task.correct_answer_file.read()
).hexdigest(), ).hexdigest(),
}, },
timeout=30, timeout=30,
) )
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
print(result, response.request) print(result)
submission.stdout.save("output.txt", ContentFile(result["output"])) # submission.stdout = ContentFile(
# bytes(result["output"]),
# "output.txt",
# )
# submission.stdout.save()
submission.result = { submission.result = {
"correct": result["correct"], "correct": result["hash_match"],
"hash_match": result["hash_match"], "hash_match": result["hash_match"],
"error": result.get("error"), "error": result.get("error"),
} }
submission.earned_points = ( submission.earned_points = (
submission.task.points if result["correct"] else 0 submission.task.points if result["hash_match"] else 0
) )
submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
+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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -30,13 +30,14 @@ export const Header = () => {
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a
href="/docs/" <Link
to="/docs" target="_blank"
className="hidden md:flex text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors items-center gap-1" className="hidden md:flex text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors items-center gap-1"
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Материалы Материалы
</a> </Link>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -53,7 +54,7 @@ export const Header = () => {
</Link> </Link>
<div className="md:hidden"> <div className="md:hidden">
<Link to="/docs"> <Link to="/docs" target="_blank">
<DropdownMenuItem> <DropdownMenuItem>
Материалы Материалы
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,4 +1,3 @@
// src/components/competition/CompetitionResultsModal.tsx
import React from 'react'; import React from 'react';
import { import {
Dialog, Dialog,
@@ -12,7 +11,8 @@ import { Loader2 } from 'lucide-react';
export interface CompetitionResult { export interface CompetitionResult {
task_name: string; task_name: string;
result: number; result: number;
max_points: number max_points: number;
position: number;
} }
interface CompetitionResultsModalProps { interface CompetitionResultsModalProps {
@@ -111,7 +111,9 @@ export const CompetitionResultsModal: React.FC<CompetitionResultsModalProps> = (
Произошла ошибка при загрузке результатов Произошла ошибка при загрузке результатов
</div> </div>
) : results && results.length > 0 ? ( ) : results && results.length > 0 ? (
results.map((result, index) => ( [...results]
.sort((a, b) => a.position - b.position)
.map((result, index) => (
<div <div
key={index} key={index}
className="flex flex-col md:flex-row justify-between items-start md:items-center p-4 bg-gray-50 rounded-lg border" className="flex flex-col md:flex-row justify-between items-start md:items-center p-4 bg-gray-50 rounded-lg border"
@@ -74,7 +74,11 @@ const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
const getFileNameFromUrl = (url: string): string => { const getFileNameFromUrl = (url: string): string => {
try { try {
const parts = url.split('/'); const parts = url.split('/');
return parts[parts.length - 1]; const fullFileName = parts[parts.length - 1]
const fileName = fullFileName.length > 20
? fullFileName.substring(0, 20) + '...'
: fullFileName;
return fileName;
} catch (e) { } catch (e) {
return 'Файл'; return 'Файл';
} }
@@ -151,8 +151,8 @@ const CompetitionSession = () => {
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
/> />
{isReloading && ( {isReloading && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> <div className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm bg-white/30">
<div className="bg-white p-6 rounded-lg shadow-xl text-center"> <div className="bg-white p-6 rounded-lg shadow-xl text-center max-w-xs lg:max-w-md mx-4 w-full">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" /> <Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="font-hse-sans text-gray-700"> <p className="font-hse-sans text-gray-700">
Решение отправлено! Пожалуйста, подождите... Решение отправлено! Пожалуйста, подождите...
@@ -116,29 +116,29 @@ const CodeSolution: React.FC<CodeSolutionProps> = ({
<div className="mt-4 space-y-6"> <div className="mt-4 space-y-6">
<div> <div>
<h3 className="text-lg font-semibold mb-3 text-indigo-700 border-b pb-2">Ограничения</h3> <h3 className="text-lg font-semibold mb-3 border-b pb-2">Ограничение ресурсов</h3>
<ul className="space-y-3 text-gray-700"> <ul className="space-y-3 text-gray-700">
<li className="flex items-start"> <li className="flex items-start">
<div className="bg-indigo-100 p-1.5 rounded-full mr-3 mt-0.5"> <div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-indigo-500 rounded-full"></div> <div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div> </div>
Максимум 1 посылка в 10 секунд Максимум 1 посылка в 10 секунд
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<div className="bg-indigo-100 p-1.5 rounded-full mr-3 mt-0.5"> <div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-indigo-500 rounded-full"></div> <div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div> </div>
Максимальный размер решения 4MB Максимальный размер решения 4MB
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<div className="bg-indigo-100 p-1.5 rounded-full mr-3 mt-0.5"> <div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-indigo-500 rounded-full"></div> <div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div> </div>
Максимальное время работы программы 1 минута Максимальное время работы программы 1 минута
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<div className="bg-indigo-100 p-1.5 rounded-full mr-3 mt-0.5"> <div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-indigo-500 rounded-full"></div> <div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div> </div>
Выделяется 512MB на решение Выделяется 512MB на решение
</li> </li>
@@ -146,35 +146,35 @@ const CodeSolution: React.FC<CodeSolutionProps> = ({
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold mb-3 text-indigo-700 border-b pb-2">Доступные библиотеки</h3> <h3 className="text-lg font-semibold mb-3 border-b pb-2">Доступные библиотеки</h3>
<div className="bg-gray-50 p-4 rounded-md font-mono text-sm"> <div className="bg-gray-50 p-4 rounded-md font-mono text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">pandas</span> <span className="text-yellow-600 font-semibold">pandas</span>
<span className="text-gray-500 ml-2">2.2.3</span> <span className="text-gray-500 ml-2">2.2.3</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">numpy</span> <span className="text-yellow-600 font-semibold">numpy</span>
<span className="text-gray-500 ml-2">2.2.3</span> <span className="text-gray-500 ml-2">2.2.3</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">matplotlib</span> <span className="text-yellow-600 font-semibold">matplotlib</span>
<span className="text-gray-500 ml-2">3.10.1</span> <span className="text-gray-500 ml-2">3.10.1</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">scipy</span> <span className="text-yellow-600 font-semibold">scipy</span>
<span className="text-gray-500 ml-2">1.15.2</span> <span className="text-gray-500 ml-2">1.15.2</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">scikit-learn</span> <span className="text-yellow-600 font-semibold">scikit-learn</span>
<span className="text-gray-500 ml-2">1.6.1</span> <span className="text-gray-500 ml-2">1.6.1</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">seaborn</span> <span className="text-yellow-600 font-semibold">seaborn</span>
<span className="text-gray-500 ml-2">0.13.2</span> <span className="text-gray-500 ml-2">0.13.2</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-indigo-600 font-semibold">statsmodels</span> <span className="text-yellow-600 font-semibold">statsmodels</span>
<span className="text-gray-500 ml-2">0.14.4</span> <span className="text-gray-500 ml-2">0.14.4</span>
</div> </div>
</div> </div>
@@ -60,12 +60,16 @@ const FileSolution: React.FC<FileSolutionProps> = ({
}; };
const fileName = selectedFile const fullFileName = selectedFile
? selectedFile.name ? selectedFile.name
: existingFileUrl : existingFileUrl
? existingFileUrl.split('/').pop() || 'file' ? existingFileUrl.split('/').pop() || 'file'
: ''; : '';
const fileName = fullFileName.length > 20
? fullFileName.substring(0, 20) + '...'
: fullFileName;
const hasFile = !!selectedFile || !!existingFileUrl; const hasFile = !!selectedFile || !!existingFileUrl;
return ( return (
@@ -42,7 +42,11 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
enabled: !!(competitionId && task.id), enabled: !!(competitionId && task.id),
}); });
const solutionHistory = solutionsQuery.data || []; const solutionHistory = [...(solutionsQuery.data || [])].sort((a, b) => {
const dateA = new Date(a.timestamp);
const dateB = new Date(b.timestamp);
return dateA.getTime() - dateB.getTime();
});
let lastSolutionPoints = 0; let lastSolutionPoints = 0;
if (solutionHistory.length > 0) { if (solutionHistory.length > 0) {
@@ -51,7 +55,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
const maxAttempts = task.max_attempts || -1; const maxAttempts = task.max_attempts || -1;
const submissionsUsed = solutionHistory.length; const submissionsUsed = solutionHistory.length;
const submissionsLeft = Math.max(0, maxAttempts - submissionsUsed); const submissionsLeft = Math.max(0, maxAttempts - submissionsUsed);
const hasSubmissionsLeft = submissionsLeft > 0; const hasSubmissionsLeft = submissionsLeft > 0 || maxAttempts === -1;
useEffect(() => { useEffect(() => {
if (solutionHistory.length > 0 && !displayedSolution) { if (solutionHistory.length > 0 && !displayedSolution) {
@@ -155,10 +159,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
? 'bg-blue-50 text-blue-700' ? 'bg-blue-50 text-blue-700'
: 'bg-red-50 text-red-700'}`} : 'bg-red-50 text-red-700'}`}
> >
{hasSubmissionsLeft ? ( {maxAttempts === -1 || hasSubmissionsLeft ? (
<> <>
<span className="font-medium"> <span className="font-medium">
Осталось посылок: {submissionsLeft === Infinity ? '∞' : submissionsLeft} Осталось посылок: {maxAttempts === -1 ? '∞' : submissionsLeft}
</span> </span>
{maxAttempts !== -1 && ( {maxAttempts !== -1 && (
<span className="text-blue-500 ml-1"> <span className="text-blue-500 ml-1">
@@ -3,15 +3,14 @@ import { User } from "@/shared/types/user";
export const UserInfo = ({ user }: { user: User }) => { export const UserInfo = ({ user }: { user: User }) => {
return ( return (
<section className="flex flex-1 flex-col items-center gap-6 text-center md:max-w-[420px] md:items-start md:text-left md:text-ellipsis"> <section className="flex flex-1 flex-col items-center gap-6 text-center md:max-w-[420px] md:items-start md:text-left md:text-ellipsis">
{user.avatar && ( <div className="bg-card aspect-square h-auto w-full max-w-[300px] overflow-hidden rounded-full border">
<div className="aspect-square h-auto w-full max-w-[300px] overflow-hidden rounded-full border">
<img <img
src={user.avatar} src={user.avatar ?? "/lottie.png"}
alt={user.username} alt={user.username}
className="h-full w-full object-cover object-center" className="h-full w-full object-cover object-center"
/> />
</div> </div>
)}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h1 className="text-4xl font-semibold">{user.username}</h1> <h1 className="text-4xl font-semibold">{user.username}</h1>
<p className="text-muted-foreground">{user.email}</p> <p className="text-muted-foreground">{user.email}</p>
@@ -148,7 +148,12 @@ const ReviewDescription = ({ review }: { review: Review }) => {
const ReviewContent = ({ review }: { review: Review }) => { const ReviewContent = ({ review }: { review: Review }) => {
const extension = review.content.split(".").at(-1); const extension = review.content.split(".").at(-1);
const filename = review.content.split("/").at(-1); const fullFilename = review.content.split("/").at(-1);
const filename = fullFilename ?
(fullFilename.length > 20 ? fullFilename.substring(0, 20) + '...' : fullFilename)
: '';
const { data: content, isLoading } = useQuery({ const { data: content, isLoading } = useQuery({
queryKey: ["review-file", review.id], queryKey: ["review-file", review.id],