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

This commit is contained in:
ITQ
2025-03-01 18:19:59 +03:00
33 changed files with 740 additions and 67 deletions
+1
View File
@@ -0,0 +1 @@
.idea
+6 -6
View File
@@ -4,8 +4,8 @@ from uuid import UUID
from django.http import HttpRequest from django.http import HttpRequest
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from apps.review.models import Reviewer from apps.review.models import Reviewer, Review
from apps.task.models import CompetetionTaskSumbission from apps.task.models import CompetitionTaskSubmission
class PingOut(Schema): class PingOut(Schema):
@@ -25,13 +25,13 @@ class SubmissionOut(ModelSchema):
status: Literal["sent", "checking", "checked"] status: Literal["sent", "checking", "checked"]
class Meta: class Meta:
model = CompetetionTaskSumbission model = CompetitionTaskSubmission
exclude = ("user",) exclude = ("user",)
class SubmissionsOut(Schema): class SubmissionsOut(Schema):
submissions: list[SubmissionOut] = [] submissions: list = None
@staticmethod @staticmethod
def resolve_submissions(self, context: HttpRequest) -> list[SubmissionOut]: def resolve_submissions(self, context) -> list[SubmissionOut]:
return list(CompetetionTaskSumbission.objects.all()) return list(Review.objects.filter(reviewer=context.get("request").auth))
+16 -1
View File
@@ -1,10 +1,15 @@
import logging
from http import HTTPStatus as status from http import HTTPStatus as status
from uuid import UUID
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Router from ninja import Router
from api.v1 import schemas as global_schemas from api.v1 import schemas as global_schemas
from api.v1.review import schemas from api.v1.review import schemas
from api.v1.task.schemas import TaskSubmissionIn
from apps.task.models import CompetitionTaskSubmission
router = Router(tags=["review"]) router = Router(tags=["review"])
@@ -16,7 +21,7 @@ router = Router(tags=["review"])
}, },
description="Список отправок, на проверку которых назначен ревьюер" description="Список отправок, на проверку которых назначен ревьюер"
) )
def get_submissions(request: HttpRequest, token) -> tuple[status, schemas.SubmissionsOut]: def get_submissions(request: HttpRequest, token: str) -> tuple[status, schemas.SubmissionsOut]:
return status.OK, schemas.SubmissionsOut() return status.OK, schemas.SubmissionsOut()
@@ -30,3 +35,13 @@ def get_submissions(request: HttpRequest, token) -> tuple[status, schemas.Submis
) )
def get_reviewer_profile(request: HttpRequest, token: str): def get_reviewer_profile(request: HttpRequest, token: str):
return status.OK, request.auth return status.OK, request.auth
@router.get(
"{token}/submissions/{submition_id}",
response={
status.OK: schemas.SubmissionOut,
},
)
def get_submission(request: HttpRequest, token: str, submition_id: UUID) -> tuple[status, schemas.SubmissionsOut]:
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
return status.OK, submission
+8 -8
View File
@@ -13,7 +13,7 @@ from api.v1.task.schemas import (
) )
from apps.competition.models import State from apps.competition.models import State
from apps.task.models import ( from apps.task.models import (
CompetetionTaskSumbission, CompetitionTaskSubmission,
Competition, Competition,
CompetitionTask, CompetitionTask,
) )
@@ -96,23 +96,23 @@ def submit_task(
) )
if task.type == CompetitionTask.CompetitionTaskType.INPUT: if task.type == CompetitionTask.CompetitionTaskType.INPUT:
CompetetionTaskSumbission.objects.create( CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetetionTaskSumbission.StatusChoices.CHECKED, status=CompetitionTaskSubmission.StatusChoices.CHECKED,
result={"correct": submission.content == task.answer_file_path}, result={"correct": submission.content == task.answer_file_path},
) )
if task.type == CompetitionTask.CompetitionTaskType.REVIEW: if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
CompetetionTaskSumbission.objects.create( CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetetionTaskSumbission.StatusChoices.SENT, status=CompetitionTaskSubmission.StatusChoices.SENT,
) )
if task.type == CompetitionTask.CompetitionTaskType.CHECKER: if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
CompetetionTaskSumbission.objects.create( CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetetionTaskSumbission.StatusChoices.CHECKING, status=CompetitionTaskSubmission.StatusChoices.CHECKING,
) )
return TaskSubmissionOut(id=CompetetionTaskSumbission.id) return TaskSubmissionOut(id=CompetitionTaskSubmission.id)
+1 -1
View File
@@ -46,7 +46,7 @@ def sign_in(request, data: LoginSchema):
user = User.objects.filter(email=data.email).first() user = User.objects.filter(email=data.email).first()
if not user: if not user:
raise AuthenticationError raise AuthenticationError
if user.password != data.password: if not user.check_password(data.password):
raise AuthenticationError raise AuthenticationError
token = BearerAuth.generate_jwt(user) token = BearerAuth.generate_jwt(user)
@@ -0,0 +1,49 @@
# Generated by Django 5.1.6 on 2025-03-01 14:46
import tinymce.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0003_remove_competition_tasks'),
]
operations = [
migrations.AlterField(
model_name='competition',
name='description',
field=tinymce.models.HTMLField(verbose_name='описание'),
),
migrations.AlterField(
model_name='competition',
name='end_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
),
migrations.AlterField(
model_name='competition',
name='image_url',
field=models.FileField(blank=True, null=True, upload_to='', verbose_name='изображение соревнования'),
),
migrations.AlterField(
model_name='competition',
name='participation_type',
field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования'),
),
migrations.AlterField(
model_name='competition',
name='start_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
),
migrations.AlterField(
model_name='competition',
name='title',
field=models.CharField(max_length=100, verbose_name='аазвание'),
),
migrations.AlterField(
model_name='competition',
name='type',
field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия'),
),
]
+10 -7
View File
@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from django.db import models from django.db import models
from tinymce.models import HTMLField
from apps.core.models import BaseModel from apps.core.models import BaseModel
from apps.user.models import User from apps.user.models import User
@@ -17,29 +18,29 @@ class Competition(BaseModel):
def image_url_upload_to(instance, filename): def image_url_upload_to(instance, filename):
return f"/competitions/{instance.id}/image" return f"/competitions/{instance.id}/image"
title = models.CharField(max_length=100, verbose_name="Название") title = models.CharField(max_length=100, verbose_name="название")
description = models.TextField(verbose_name="Описание") description = models.TextField(verbose_name="описание")
image_url = models.FileField( image_url = models.FileField(
verbose_name="Изображение соревнования", verbose_name="изображение соревнования",
null=True, null=True,
blank=True, blank=True,
upload_to=image_url_upload_to, upload_to=image_url_upload_to,
) )
end_date = models.DateTimeField( end_date = models.DateTimeField(
verbose_name="Дедлайн участия", null=True, blank=True verbose_name="дедлайн участия", null=True, blank=True
) )
start_date = models.DateTimeField( start_date = models.DateTimeField(
verbose_name="Дедлайн участия", null=True, blank=True verbose_name="дедлайн участия", null=True, blank=True
) )
type = models.CharField( type = models.CharField(
max_length=10, max_length=10,
choices=CompetitionType.choices, choices=CompetitionType.choices,
verbose_name="Тип участия", verbose_name="тип участия",
) )
participation_type = models.CharField( participation_type = models.CharField(
max_length=11, max_length=11,
choices=CompetitionParticipationType.choices, choices=CompetitionParticipationType.choices,
verbose_name="Тип соревнования", verbose_name="тип соревнования",
) )
participants = models.ManyToManyField( participants = models.ManyToManyField(
User, related_name="participants", blank=True, editable=False User, related_name="participants", blank=True, editable=False
@@ -48,6 +49,8 @@ class Competition(BaseModel):
def __str__(self): def __str__(self):
return self.title return self.title
class Meta: class Meta:
verbose_name = "соревнование" verbose_name = "соревнование"
verbose_name_plural = "соревнования" verbose_name_plural = "соревнования"
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from apps.competition.models import Competition, State from apps.competition.models import Competition, State
from apps.task.models import CompetetionTaskSumbission, CompetitionTask from apps.task.models import CompetitionTaskSubmission, CompetitionTask
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -105,7 +105,7 @@ class Command(BaseCommand):
b"Submission content", b"Submission content",
name=f"submission_{uuid.uuid4().hex}.txt", name=f"submission_{uuid.uuid4().hex}.txt",
) )
submission = CompetetionTaskSumbission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
earned_points=random.randint( earned_points=random.randint(
@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], max_length=11)),
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')),
],
options={
'abstract': False,
},
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0002_review'),
('task', '0005_alter_competitiontask_description_and_more'),
]
operations = [
migrations.AddField(
model_name='review',
name='submission',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontasksubmission'),
),
]
+12
View File
@@ -1,6 +1,7 @@
from django.db import models from django.db import models
from apps.core.models import BaseModel from apps.core.models import BaseModel
from apps.task.models import CompetitionTaskSubmission
class Reviewer(BaseModel): class Reviewer(BaseModel):
@@ -8,3 +9,14 @@ class Reviewer(BaseModel):
surname = models.CharField(max_length=100) surname = models.CharField(max_length=100)
token = models.CharField(max_length=100) token = models.CharField(max_length=100)
class Review(BaseModel):
class ReviewStatusChoices(models.TextChoices):
NOT_CHECKED = "not_checked"
CHECKING = "checking"
CHECKED = "checked"
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE)
submission = models.ForeignKey(CompetitionTaskSubmission, on_delete=models.CASCADE)
state = models.CharField(choices=ReviewStatusChoices.choices, max_length=11)
@@ -38,8 +38,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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)), ('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)), ('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to)),
('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)), ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)),
('result', models.JSONField(blank=True, default=None, null=True)), ('result', models.JSONField(blank=True, default=None, null=True)),
('earned_points', models.IntegerField()), ('earned_points', models.IntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)), ('timestamp', models.DateTimeField(auto_now_add=True)),
@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-01 12:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0001_initial'),
('task', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='competetiontasksumbission',
name='reviewers',
field=models.ManyToManyField(blank=True, related_name='reviewers', to='review.reviewer'),
),
]
@@ -0,0 +1,14 @@
# Generated by Django 5.1.6 on 2025-03-01 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task', '0002_competetiontasksumbission_reviewers'),
('task', '0003_competitiontask_max_attemps_and_more'),
]
operations = [
]
@@ -0,0 +1,48 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import apps.task.models
import django.db.models.deletion
import tinymce.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task', '0004_merge_20250301_1739'),
('user', '0002_alter_user_email_alter_user_password_and_more'),
]
operations = [
migrations.AlterField(
model_name='competitiontask',
name='description',
field=tinymce.models.HTMLField(max_length=300, verbose_name='описание'),
),
migrations.AlterField(
model_name='competitiontask',
name='max_attemps',
field=models.PositiveSmallIntegerField(),
),
migrations.CreateModel(
name='CompetitionTaskSubmission',
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.CompetitionTaskSubmission.submission_content_upload_to)),
('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)),
('result', models.JSONField(blank=True, default=None, null=True)),
('earned_points', models.IntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='CompetetionTaskSumbission',
),
]
+4 -3
View File
@@ -1,6 +1,7 @@
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from tinymce.models import HTMLField
from apps.competition.models import Competition from apps.competition.models import Competition
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -19,8 +20,8 @@ class CompetitionTask(BaseModel):
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50) title = models.CharField(verbose_name="заголовок", max_length=50)
description = models.TextField(verbose_name="описание", max_length=300) description = HTMLField(verbose_name="описание", max_length=300)
max_attemps = models.PositiveSmallIntegerField(default=0) max_attemps = models.PositiveSmallIntegerField()
type = models.CharField( type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
) )
@@ -63,7 +64,7 @@ class CompetitionTask(BaseModel):
verbose_name_plural = "задания" verbose_name_plural = "задания"
class CompetetionTaskSumbission(BaseModel): class CompetitionTaskSubmission(BaseModel):
class StatusChoices(models.TextChoices): class StatusChoices(models.TextChoices):
SENT = "sent" SENT = "sent"
CHECKING = "checking" CHECKING = "checking"
+9
View File
@@ -0,0 +1,9 @@
from django.contrib import admin
from apps.user.models import User
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ("email", "username")
search_fields = ("id", "email", "username")
+1
View File
@@ -5,3 +5,4 @@ class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.user" name = "apps.user"
label = "user" label = "user"
verbose_name = "Пользователи"
@@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-03-01 14:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True, verbose_name='почта'),
),
migrations.AlterField(
model_name='user',
name='password',
field=models.TextField(verbose_name='пароль'),
),
migrations.AlterField(
model_name='user',
name='username',
field=models.SlugField(unique=True, verbose_name='юзернейм'),
),
]
+10 -3
View File
@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.contrib.auth.hashers import check_password, make_password
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -9,9 +10,15 @@ class UserRole(models.Choices):
class User(BaseModel): class User(BaseModel):
email = models.EmailField(unique=True, verbose_name="Почта") email = models.EmailField(unique=True, verbose_name="почта")
username = models.SlugField(unique=True, verbose_name="Юзернейм") username = models.SlugField(unique=True, verbose_name="юзернейм")
password = models.TextField(verbose_name="Пароль") password = models.TextField(verbose_name="пароль")
def make_password(self):
return make_password(self.password)
def check_password(self, password):
return check_password(self.password, password)
status = models.CharField( status = models.CharField(
max_length=10, choices=UserRole, default="student" max_length=10, choices=UserRole, default="student"
+21
View File
@@ -441,6 +441,7 @@ INSTALLED_APPS = [
"django_guid", "django_guid",
"ninja", "ninja",
"minio_storage", "minio_storage",
"tinymce",
# Internal apps # Internal apps
"apps.core", "apps.core",
"apps.user", "apps.user",
@@ -449,6 +450,22 @@ INSTALLED_APPS = [
"apps.task", "apps.task",
] ]
# tinymce
TINYMCE_DEFAULT_CONFIG = {
"theme": "silver",
"height": 500,
"menubar": False,
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
"code,help,wordcount",
"toolbar": "undo redo | formatselect | "
"bold italic backcolor | alignleft aligncenter "
"alignright alignjustify | bullist numlist outdent indent | "
"removeformat | help",
"skin": "oxide-dark",
"content_css": "dark"
}
# GUID # GUID
DJANGO_GUID = { DJANGO_GUID = {
@@ -466,6 +483,10 @@ DJANGO_GUID = {
LANGUAGE_COOKIE_AGE = 31449600 LANGUAGE_COOKIE_AGE = 31449600
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
]
LANGUAGE_COOKIE_DOMAIN = None LANGUAGE_COOKIE_DOMAIN = None
LANGUAGE_COOKIE_HTTPONLY = False LANGUAGE_COOKIE_HTTPONLY = False
+2
View File
@@ -12,6 +12,8 @@ admin.site.index_title = "DataRush"
urlpatterns = [ urlpatterns = [
# tinymce
path('tinymce/', include('tinymce.urls')),
# Admin urls # Admin urls
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# API urls # API urls
+3
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<3.12" requires-python = ">=3.10,<3.12"
dependencies = [ dependencies = [
"argon2-cffi>=23.1.0",
"celery>=5.4.0", "celery>=5.4.0",
"colorlog>=6.9.0", "colorlog>=6.9.0",
"django-cors-headers>=4.6.0", "django-cors-headers>=4.6.0",
@@ -13,7 +14,9 @@ dependencies = [
"django-health-check>=3.18.3", "django-health-check>=3.18.3",
"django-minio-storage>=0.5.7", "django-minio-storage>=0.5.7",
"django-ninja>=1.3.0", "django-ninja>=1.3.0",
"django-pagedown>=2.2.1",
"django-stubs-ext>=5.1.3", "django-stubs-ext>=5.1.3",
"django-tinymce>=4.1.0",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"pillow>=11.1.0", "pillow>=11.1.0",
@@ -0,0 +1,130 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/shared/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,134 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/shared/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{/* Removed the default close button that was causing duplication */}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)} // Kept original padding
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, Navigate } from "react-router-dom"; import { useParams, Navigate } from "react-router-dom";
import { Task } from "@/shared/types"; import { Task } from "@/shared/types";
import { mockTasks } from "@/shared/mocks/mocks"; import { mockSolutions, mockTasks } from "@/shared/mocks/mocks";
import CompetitionHeader from "./components/CompetitionHeader"; import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent"; import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution"; import TaskSolution from "./modules/TaskSolution";
@@ -40,6 +40,7 @@ const CompetitionSessionPage = () => {
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={mockSolutions}
answer={answer} answer={answer}
setAnswer={setAnswer} setAnswer={setAnswer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -1,28 +1,51 @@
import React from 'react'; import React, { useState } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import SolutionHistorySheet from '../SolutionHistorySheet';
import { Solution } from "@/shared/types";
import { mockSolutions } from '@/shared/mocks/mocks';
interface ActionButtonsProps { interface ActionButtonsProps {
onHistoryClick: () => void; onHistoryClick: () => void;
onSubmit: () => void; onSubmit: () => void;
solutionHistory?: Solution[];
} }
const ActionButtons: React.FC<ActionButtonsProps> = ({ onHistoryClick, onSubmit }) => { const ActionButtons: React.FC<ActionButtonsProps> = ({
onHistoryClick,
onSubmit,
solutionHistory = mockSolutions
}) => {
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const handleHistoryClick = () => {
setIsHistoryOpen(true);
onHistoryClick();
};
return ( return (
<div className="flex gap-3 justify-between"> <>
<Button <div className="flex gap-8">
variant="ghost" <Button
className="font-hse-sans bg-white hover:bg-gray-100" variant="ghost"
onClick={onHistoryClick} className="font-hse-sans bg-white hover:bg-gray-100"
> onClick={handleHistoryClick}
История >
</Button> История
<Button </Button>
onClick={onSubmit} <Button
className="font-hse-sans" onClick={onSubmit}
> className="font-hse-sans flex-grow"
Отправить решение >
</Button> Отправить решение
</div> </Button>
</div>
{/* чуть-чуть рак */}
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
/>
</>
); );
}; };
@@ -0,0 +1,51 @@
import React from 'react';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import SolutionStatus from '../SolutionStatus';
import { Solution } from "@/shared/types";
interface SolutionHistorySheetProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
solutions: Solution[];
}
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
isOpen,
onOpenChange,
solutions
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="w-[350px] sm:w-[450px] p-0">
<SheetHeader className="border-b py-3 px-4">
<div className="flex justify-between items-center">
<SheetTitle className="text-lg font-medium">История решений</SheetTitle>
<SheetClose asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</SheetClose>
</div>
</SheetHeader>
<div className="flex flex-col mt-3 space-y-2.5 overflow-y-auto max-h-[calc(100vh-80px)] px-4">
{solutions.length > 0 ? (
solutions.map((solution, index) => (
<div key={index} className="w-full">
<SolutionStatus solution={solution} />
</div>
))
) : (
<div className="text-center py-8 text-gray-500">
У вас пока нет истории решений для этой задачи
</div>
)}
</div>
</SheetContent>
</Sheet>
);
};
export default SolutionHistorySheet;
@@ -1,24 +1,41 @@
import React from 'react'; import React from 'react';
import { Task } from "@/shared/types"; import { Solution, TaskStatus } from "@/shared/types";
import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils';
interface SolutionStatusProps { interface SolutionStatusProps {
task: Task; solution: Solution;
} }
const SolutionStatus: React.FC<SolutionStatusProps> = ({ task }) => { const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution }) => {
const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => {
switch (status) {
case 'checking':
return 'На проверке';
case 'wrong':
return 'Неверный ответ';
case 'correct':
return `Зачтено ${maxScore}/${maxScore} баллов`;
case 'partial':
return `Зачтено ${score}/${maxScore} баллов`;
case 'uncleared':
return 'Не решено';
default:
return '';
}
};
return ( return (
<div className={`${getTaskBgColor(task.status)} rounded-lg p-4 relative`}> <div className={`${getTaskBgColor(solution.status)} rounded-lg p-4 relative`}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`${getTaskTextColor(task.status)} font-medium`}> <span className={`${getTaskTextColor(solution.status)} font-medium`}>
Решение 12345 Решение {solution.id}
</span> </span>
<span className={`${getTaskTextColor(task.status)} mt-1`}> <span className={`${getTaskTextColor(solution.status)} mt-1`}>
Зачтено 5/10 баллов {getStatusText(solution.status, solution.score, solution.maxScore)}
</span> </span>
</div> </div>
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(task.status)}`}> <div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}>
1 марта, 08:41 {solution.date}
</div> </div>
</div> </div>
); );
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Task } from "@/shared/types"; import { Solution, Task } from "@/shared/types";
import SolutionStatus from './components/SolutionStatus'; import SolutionStatus from './components/SolutionStatus';
import InputSolution from './components/InputSolution'; import InputSolution from './components/InputSolution';
import FileSolution from './components/FileSolution'; import FileSolution from './components/FileSolution';
@@ -8,6 +8,7 @@ import ActionButtons from './components/ActionButtons';
interface TaskSolutionProps { interface TaskSolutionProps {
task: Task; task: Task;
solutions: Solution[];
answer: string; answer: string;
setAnswer: (value: string) => void; setAnswer: (value: string) => void;
onSubmit: () => void; onSubmit: () => void;
@@ -16,6 +17,7 @@ interface TaskSolutionProps {
const TaskSolution: React.FC<TaskSolutionProps> = ({ const TaskSolution: React.FC<TaskSolutionProps> = ({
task, task,
solutions,
answer, answer,
setAnswer, setAnswer,
onSubmit, onSubmit,
@@ -26,7 +28,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
return ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <div className="md:w-[500px] flex flex-col gap-4">
<SolutionStatus task={task} /> <SolutionStatus solution={solutions[0]} />
{task.solutionType === 'input' && ( {task.solutionType === 'input' && (
<InputSolution answer={answer} setAnswer={setAnswer} /> <InputSolution answer={answer} setAnswer={setAnswer} />
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Competition, CompetitionStatus } from "@/shared/types"; import { Competition, CompetitionStatus } from "@/shared/types";
import { CompetitionGrid } from "./modules/CompetitionGrid"; import { CompetitionGrid } from "./modules/CompetitionGrid";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+31 -2
View File
@@ -1,4 +1,4 @@
import { Competition, CompetitionStatus, Task } from "../types"; import { Competition, CompetitionStatus, Solution, Task } from "../types";
const mockCompetitions: Competition[] = [ const mockCompetitions: Competition[] = [
{ {
@@ -104,4 +104,33 @@ const mockTasks: Task[] = [
]; ];
export { mockCompetitions, mockTasks }; const mockSolutions: Solution[] = [
{
id: '1',
status: 'wrong',
date: '1 марта, 08:41',
},
{
id: '2',
status: 'partial',
score: 5,
maxScore: 10,
date: '28 февраля, 15:22',
},
{
id: '3',
status: 'correct',
score: 0,
maxScore: 10,
date: '27 февраля, 12:10',
},
{
id: '4',
status: 'checking',
date: '1 марта, 08:41',
},
];
export { mockCompetitions, mockTasks, mockSolutions };
+8 -1
View File
@@ -16,6 +16,13 @@ interface Competition {
type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong"; type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong";
type SolutionType = "input" | "file" | "code"; type SolutionType = "input" | "file" | "code";
interface Solution {
id: string,
status: TaskStatus,
date: string,
score?: number,
maxScore?: number,
}
interface Task { interface Task {
id: string; id: string;
number: string; number: string;
@@ -24,4 +31,4 @@ interface Task {
} }
export { CompetitionStatus }; export { CompetitionStatus };
export type { Competition, TaskStatus, Task }; export type { Solution, Competition, TaskStatus, Task };