Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
moolcoov
2025-03-04 02:27:46 +03:00
13 changed files with 254 additions and 99 deletions
+7 -5
View File
@@ -19,15 +19,17 @@ docker compose up
* `/admin/grafana` - графана * `/admin/grafana` - графана
* `/docs` - обучающие материалы по анализу данных * `/docs` - обучающие материалы по анализу данных
После запуска по методу выше создается пользователь в админке (`/admin/`) с данными ниже:`admin` После запуска по методу выше создается пользователь в админке (`/admin/`) с данными ниже:
- `admin` - логин
- `proooooood` - пароль
* `admin` - логин
* `proooooood` - пароль
## Тесты ## Тесты
Написаны unit-тесты (на базе Django TestCase) и E2E (Postman коллекция) Написаны unit-тесты (на базе Django TestCase) и E2E (Postman коллекция). Они покрывают flow регистрации, просмотра и участия в соревновании.
![Postman data](img/postman.gif) ![Postman data](img/postman.gif)
![django test]() Ниже можно увидеть Coverage тестами бекенда данного приложения
![django test](img/superduperdjangotests.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+1 -1
View File
@@ -24,6 +24,6 @@ FROM docker.io/nginx:latest
COPY --from=builder /app/static /usr/share/nginx/html COPY --from=builder /app/static /usr/share/nginx/html
COPY ../checker/checker_requirements.txt . COPY ../checker/checker_requirements.txt /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+1
View File
@@ -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
+1
View File
@@ -210,6 +210,7 @@ def get_competition_results(request, competition_id: UUID):
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
@@ -14,7 +14,8 @@ from apps.review.models import Reviewer
from apps.task.models import ( from apps.task.models import (
CompetitionTask, CompetitionTask,
CompetitionTaskCriteria, CompetitionTaskCriteria,
CompetitionTaskSubmission, CompetitionTaskAttachment, CompetitionTaskSubmission,
CompetitionTaskAttachment,
) )
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -44,6 +45,15 @@ dataset2 = ContentFile(
name=f"dataset-{uuid.uuid4().hex}.csv", name=f"dataset-{uuid.uuid4().hex}.csv",
) )
correct_answer_file = ContentFile(
b"42",
name=f"answer.txt",
)
correct2_answer_file = ContentFile(
b"it is a dataset",
name=f"answer.txt",
)
now = timezone.now() now = timezone.now()
image_dir = f"{settings.BASE_DIR}/apps/core/contents/images" image_dir = f"{settings.BASE_DIR}/apps/core/contents/images"
@@ -103,8 +113,9 @@ E — коэффициент чувствительности количеств
{ {
"obj": None, "obj": None,
"title": "Задача 2", "title": "Задача 2",
"description": "Найдите максимальную зарплату программиста из датасета на питоне", "description": "Найдите максимальную зарплату программиста из датасета на питоне. Программа должна вывести содержимое файла по пути /dataset",
"type": CompetitionTask.CompetitionTaskType.CHECKER.value, "type": CompetitionTask.CompetitionTaskType.CHECKER.value,
"correct_answer_file": correct2_answer_file,
"attachment": dataset, "attachment": dataset,
"attachment_path": "/dataset", "attachment_path": "/dataset",
"points": 25, "points": 25,
@@ -317,9 +328,10 @@ B — пользователи, которым доступен только о
и его характеристики. Однако оплатить товар можно и без захода на карточку и его характеристики. Однако оплатить товар можно и без захода на карточку
товара. товара.
Задача: сравните группы по каждой метрике и сделайте вывод о том, стоит ли Задача: сравните группы по каждой метрике и сделайте вывод о том, стоит ли
продолжить внедрение обновленного магазина или нужно вернуть старый продолжить внедрение обновленного магазина или нужно вернуть старый. Ответ 42.
""".strip(), """.strip(),
"type": CompetitionTask.CompetitionTaskType.CHECKER.value, "type": CompetitionTask.CompetitionTaskType.CHECKER.value,
"correct_answer_file": correct_answer_file,
"points": 30, "points": 30,
"submission_reviewers_count": 2, "submission_reviewers_count": 2,
}, },
@@ -338,7 +350,7 @@ B — пользователи, которым доступен только о
"tasks": [ "tasks": [
{ {
"obj": None, "obj": None,
"title": "Анализ трендов", # TODO сюда добавить бд "title": "Анализ трендов", # TODO сюда добавить бд
"description": """ "description": """
Скачайте базу данных со специальной страницы (https://dano.hse.ru/data), изучите ее более Скачайте базу данных со специальной страницы (https://dano.hse.ru/data), изучите ее более
внимательно: посмотрите на переменные, посчитайте описательные статистики, постройте внимательно: посмотрите на переменные, посчитайте описательные статистики, постройте
@@ -412,9 +424,8 @@ B — пользователи, которым доступен только о
""".strip(), """.strip(),
"type": CompetitionTask.CompetitionTaskType.INPUT.value, "type": CompetitionTask.CompetitionTaskType.INPUT.value,
"points": 15, "points": 15,
"submission_reviewers_count": 2,
"max_attempts": 50, "max_attempts": 50,
"correct_answer_file": ans3 "correct_answer_file": ans3,
}, },
{ {
"obj": None, "obj": None,
@@ -422,7 +433,6 @@ B — пользователи, которым доступен только о
"description": "Сколько будет 6 * 7?", "description": "Сколько будет 6 * 7?",
"type": CompetitionTask.CompetitionTaskType.INPUT.value, "type": CompetitionTask.CompetitionTaskType.INPUT.value,
"points": 5, "points": 5,
"submission_reviewers_count": 2,
"max_attempts": 10, "max_attempts": 10,
"correct_answer_file": ans3, "correct_answer_file": ans3,
}, },
@@ -510,8 +520,8 @@ users = [
"role": UserRole.STUDENT.value, "role": UserRole.STUDENT.value,
}, },
{ {
"email": "oleg-tinkov@gmail.com", "email": "s.bliznyuk@tbank.ru",
"username": "oleg-tinkov", "username": "s_bliznyuk",
"password": "password123!", "password": "password123!",
"role": UserRole.STUDENT.value, "role": UserRole.STUDENT.value,
}, },
@@ -601,6 +611,7 @@ reviewers = [
}, },
] ]
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."
@@ -619,9 +630,9 @@ class Command(BaseCommand):
def create_reviewers(self, count): def create_reviewers(self, count):
reviewers_objs = [] reviewers_objs = []
for reviewer in reviewers: for reviewer in reviewers:
name = reviewer['name'] name = reviewer["name"]
surname = reviewer['surname'] surname = reviewer["surname"]
token = reviewer['token'] token = reviewer["token"]
reviewer_obj = Reviewer(name=name, surname=surname, token=token) reviewer_obj = Reviewer(name=name, surname=surname, token=token)
reviewer_obj.save() reviewer_obj.save()
reviewers_objs.append(reviewer_obj) reviewers_objs.append(reviewer_obj)
@@ -631,11 +642,11 @@ class Command(BaseCommand):
users_objs = [] users_objs = []
for user in users: for user in users:
user_obj, created = User.objects.get_or_create( user_obj, created = User.objects.get_or_create(
email=user['email'], email=user["email"],
defaults={ defaults={
"username": user['username'], "username": user["username"],
"password": make_password(user['password']), "password": make_password(user["password"]),
"status": user['role'], "status": user["role"],
}, },
) )
users_objs.append(user_obj) users_objs.append(user_obj)
@@ -646,20 +657,23 @@ class Command(BaseCommand):
competitions_objs = [] competitions_objs = []
for i, competition in enumerate(competitions): for i, competition in enumerate(competitions):
competition_obj = Competition.objects.create( try:
title=competition['title'], competition_obj = Competition.objects.create(
description=competition['description'], title=competition["title"],
start_date=competition['start_date'], description=competition["description"],
end_date=competition['end_date'], start_date=competition["start_date"],
type=competition['type'], end_date=competition["end_date"],
participation_type=competition['participation_type'], type=competition["type"],
) participation_type=competition["participation_type"],
)
except Exception as e:
print(competition)
if competition.get("image"): if competition.get("image"):
competition_obj.image_url = competition['image'] competition_obj.image_url = competition["image"]
competition_obj.save() competition_obj.save()
competitions[i]['obj'] = competition_obj competitions[i]["obj"] = competition_obj
competition_obj.participants.add(*users) competition_obj.participants.add(*users)
competitions_objs.append(competition_obj) competitions_objs.append(competition_obj)
self.stdout.write(f"Created competition: {competition['title']}") self.stdout.write(f"Created competition: {competition['title']}")
@@ -673,45 +687,53 @@ class Command(BaseCommand):
CompetitionTask.CompetitionTaskType.INPUT.value, CompetitionTask.CompetitionTaskType.INPUT.value,
] ]
for i, competition in enumerate(competitions): for i, competition in enumerate(competitions):
for j, task in enumerate(competition['tasks']): for j, task in enumerate(competition["tasks"]):
task_obj = CompetitionTask.objects.create( task_obj = CompetitionTask.objects.create(
in_competition_position=j+1, in_competition_position=j + 1,
competition=competition['obj'], competition=competition["obj"],
title=task['title'], title=task["title"],
description=task['description'], description=task["description"],
type=task['type'], type=task["type"],
points=task['points'], points=task["points"],
submission_reviewers_count=task['submission_reviewers_count'], submission_reviewers_count=task[
max_attempts=task.get('max_attempts'), "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,
max_attempts=task.get("max_attempts"),
) )
competitions[i]['tasks'][j]['obj'] = task_obj competitions[i]["tasks"][j]["obj"] = task_obj
if task['type'] == CompetitionTask.CompetitionTaskType.INPUT.value:
task_obj.correct_answer_file = task['correct_answer_file']
if task.get("attachment"): if task.get("attachment"):
CompetitionTaskAttachment.objects.create( CompetitionTaskAttachment.objects.create(
task=task_obj, task=task_obj,
file=task['attachment'], file=task["attachment"],
bind_at=task['attachment_path'], bind_at=task["attachment_path"],
public=True public=True,
) )
if ( if (
task['type'] task["type"]
== CompetitionTask.CompetitionTaskType.REVIEW.value == CompetitionTask.CompetitionTaskType.REVIEW.value
): ):
for k, criteria in enumerate(task['criteries']): for k, criteria in enumerate(task["criteries"]):
criteria_obj = CompetitionTaskCriteria.objects.create( criteria_obj = CompetitionTaskCriteria.objects.create(
task=task_obj, task=task_obj,
name=criteria['name'], name=criteria["name"],
slug=criteria['slug'], slug=criteria["slug"],
description=criteria['description'], description=criteria["description"],
max_value=criteria['max_value'], max_value=criteria["max_value"],
)
competitions[i]["tasks"][j]["criteries"][k]["obj"] = (
criteria_obj
)
self.stdout.write(
f"Created criteria: {criteria['slug']}"
) )
competitions[i]['tasks'][j]['criteries'][k]['obj'] = criteria_obj
self.stdout.write(f"Created criteria: {criteria['slug']}")
tasks_objs.append(task_obj) tasks_objs.append(task_obj)
self.stdout.write(f"Created task: {task['title']} (type: {task['type']})") self.stdout.write(
f"Created task: {task['title']} (type: {task['type']})"
)
self.add_reviewers_to_task(tasks_objs) self.add_reviewers_to_task(tasks_objs)
return tasks_objs return tasks_objs
@@ -723,22 +745,29 @@ class Command(BaseCommand):
def create_incorrect_submissions(self, tasks, users): def create_incorrect_submissions(self, tasks, users):
for user in users: for user in users:
for task in tasks: for task in tasks:
if task.type == CompetitionTask.CompetitionTaskType.REVIEW.value: if (
task.type
== CompetitionTask.CompetitionTaskType.REVIEW.value
):
num_submissions = random.randint(1, 3) num_submissions = random.randint(1, 3)
for m in range(num_submissions): for m in range(num_submissions):
dummy_content_txt = ContentFile( dummy_content_txt = ContentFile(
b"otvet: 112 sto proc" , b"otvet: 112 sto proc",
name=f"submission_{uuid.uuid4().hex}.txt", name=f"submission_{uuid.uuid4().hex}.txt",
) )
content_dir = f"{settings.BASE_DIR}/apps/core/contents" content_dir = f"{settings.BASE_DIR}/apps/core/contents"
with open(f"{content_dir}/presentation.pptx", "rb") as f: with open(
f"{content_dir}/presentation.pptx", "rb"
) as f:
pptx = File(f, name="presentation.pptx") pptx = File(f, name="presentation.pptx")
files = [pptx, pptx, dummy_content_txt] files = [pptx, pptx, dummy_content_txt]
submission = CompetitionTaskSubmission.objects.create( submission = (
user=user, CompetitionTaskSubmission.objects.create(
task=task, user=user,
content=random.choice(files), task=task,
content=random.choice(files),
)
) )
submission.save() submission.save()
submission.send_on_review() submission.send_on_review()
@@ -754,9 +783,10 @@ class Command(BaseCommand):
competition=comp, competition=comp,
defaults={ defaults={
"state": "started", "state": "started",
"changed_at": timezone.now() - timedelta(days=random.randint(1, 30)), "changed_at": timezone.now()
- timedelta(days=random.randint(1, 30)),
}, },
) )
self.stdout.write( self.stdout.write(
f"Created state '{state_obj.state}' for user '{user.username}' in competition '{comp.title}'" f"Created state '{state_obj.state}' for user '{user.username}' in competition '{comp.title}'"
) )
@@ -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='путь сохранения'),
),
]
+12 -6
View File
@@ -91,7 +91,7 @@ class CompetitionTask(BaseModel):
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": "Укажите другой тип задания: этот не совместим с путем правильного ответа"
@@ -100,7 +100,7 @@ class CompetitionTask(BaseModel):
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": "Загрузите ревьюверов - кто будет проверять задания, если не они?"
@@ -110,7 +110,6 @@ class CompetitionTask(BaseModel):
# "type": "Проверьте тип - вы ввели ревьюверов, но задание не является ручным" # "type": "Проверьте тип - вы ввели ревьюверов, но задание не является ручным"
# }) # })
def __str__(self): def __str__(self):
return self.title return self.title
@@ -150,9 +149,16 @@ class CompetitionTaskAttachment(BaseModel):
related_name="attachments", related_name="attachments",
) )
file = models.FileField(upload_to=file_upload_at, verbose_name="файл") file = models.FileField(upload_to=file_upload_at, verbose_name="файл")
bind_at = models.CharField(verbose_name="путь сохранения", max_length=255, bind_at = models.CharField(
validators=[RegexValidator(r"^(?:[a-zA-Z]:\\(?:[^<>:\"\/\\|?*]*\\)*|/(?:[^<>:\"\/\\|?*]+/?)*)$", verbose_name="путь сохранения",
message="Введите абсолютный путь до папки")]) max_length=255,
validators=[
RegexValidator(
r"^(?:[a-zA-Z]:\\(?:[^<>:\"\/\\|?*]*\\)*|/(?:[^<>:\"\/\\|?*]+/?)*)$",
message="Введите абсолютный путь до папки",
)
],
)
public = models.BooleanField(default=False, verbose_name="публичный") public = models.BooleanField(default=False, verbose_name="публичный")
class Meta: class Meta:
+2 -2
View File
@@ -46,12 +46,12 @@ def analyze_data_task(self, submission_id):
submission.stdout.save("output.txt", ContentFile(result["output"])) submission.stdout.save("output.txt", ContentFile(result["output"]))
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
@@ -10,4 +10,4 @@ if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then
python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true
fi fi
python manage.py init_achievments python manage.py init_achievments
@@ -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,17 +111,19 @@ export const CompetitionResultsModal: React.FC<CompetitionResultsModalProps> = (
Произошла ошибка при загрузке результатов Произошла ошибка при загрузке результатов
</div> </div>
) : results && results.length > 0 ? ( ) : results && results.length > 0 ? (
results.map((result, index) => ( [...results]
<div .sort((a, b) => a.position - b.position)
key={index} .map((result, index) => (
className="flex flex-col md:flex-row justify-between items-start md:items-center p-4 bg-gray-50 rounded-lg border" <div
> key={index}
<div className="font-medium mb-2 md:mb-0">{result.task_name}</div> className="flex flex-col md:flex-row justify-between items-start md:items-center p-4 bg-gray-50 rounded-lg border"
<div className="text-right"> >
{renderResultValue(result.result, result.max_points)} <div className="font-medium mb-2 md:mb-0">{result.task_name}</div>
<div className="text-right">
{renderResultValue(result.result, result.max_points)}
</div>
</div> </div>
</div> ))
))
) : ( ) : (
<div className="text-center py-6 text-gray-500"> <div className="text-center py-6 text-gray-500">
Нет доступных результатов Нет доступных результатов
@@ -1,6 +1,13 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import { Copy, Check } from 'lucide-react'; import { Copy, Check, Info } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface CodeSolutionProps { interface CodeSolutionProps {
answer: string; answer: string;
@@ -92,16 +99,103 @@ const CodeSolution: React.FC<CodeSolutionProps> = ({
<div className="bg-white rounded-lg overflow-hidden border border-gray-200"> <div className="bg-white rounded-lg overflow-hidden border border-gray-200">
<div className="flex items-center justify-between bg-gray-50 px-4 py-2 border-b border-gray-200"> <div className="flex items-center justify-between bg-gray-50 px-4 py-2 border-b border-gray-200">
<div className="text-sm font-medium text-gray-600">{languageDisplay}</div> <div className="text-sm font-medium text-gray-600">{languageDisplay}</div>
<button <div className="flex items-center space-x-3">
onClick={copyToClipboard} <Dialog>
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors" <DialogTrigger asChild>
> <button
{copied ? ( className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors"
<Check className="w-4 h-4 mr-1" /> title="Информация о среде выполнения"
) : ( >
<Copy className="w-4 h-4 mr-1" /> <Info className="w-4 h-4" />
)} </button>
</button> </DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">Информация о среде выполнения</DialogTitle>
</DialogHeader>
<div className="mt-4 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3 border-b pb-2">Ограничение ресурсов</h3>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимум 1 посылка в 10 секунд
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимальный размер решения 4MB
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Максимальное время работы программы 1 минута
</li>
<li className="flex items-start">
<div className="bg-yellow-100 p-1.5 rounded-full mr-3 mt-0.5">
<div className="w-1.5 h-1.5 bg-yellow-500 rounded-full"></div>
</div>
Выделяется 512MB на решение
</li>
</ul>
</div>
<div>
<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="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">pandas</span>
<span className="text-gray-500 ml-2">2.2.3</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">numpy</span>
<span className="text-gray-500 ml-2">2.2.3</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">matplotlib</span>
<span className="text-gray-500 ml-2">3.10.1</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">scipy</span>
<span className="text-gray-500 ml-2">1.15.2</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">scikit-learn</span>
<span className="text-gray-500 ml-2">1.6.1</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">seaborn</span>
<span className="text-gray-500 ml-2">0.13.2</span>
</div>
<div className="flex items-center">
<span className="text-yellow-600 font-semibold">statsmodels</span>
<span className="text-gray-500 ml-2">0.14.4</span>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<button
onClick={copyToClipboard}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors"
title="Копировать код"
>
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div> </div>
<div className="p-4"> <div className="p-4">
@@ -155,10 +155,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">