diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 70bf9b3..831550b 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -4,8 +4,8 @@ from uuid import UUID from django.http import HttpRequest from ninja import ModelSchema, Schema -from apps.review.models import Reviewer -from apps.task.models import CompetetionTaskSumbission +from apps.review.models import Reviewer, Review +from apps.task.models import CompetitionTaskSubmission class PingOut(Schema): @@ -25,13 +25,13 @@ class SubmissionOut(ModelSchema): status: Literal["sent", "checking", "checked"] class Meta: - model = CompetetionTaskSumbission + model = CompetitionTaskSubmission exclude = ("user",) class SubmissionsOut(Schema): - submissions: list[SubmissionOut] = [] + submissions: list = None @staticmethod - def resolve_submissions(self, context: HttpRequest) -> list[SubmissionOut]: - return list(CompetetionTaskSumbission.objects.all()) + def resolve_submissions(self, context) -> list[SubmissionOut]: + return list(Review.objects.filter(reviewer=context.get("request").auth)) \ No newline at end of file diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py index d628faf..66f608e 100644 --- a/services/backend/api/v1/review/views.py +++ b/services/backend/api/v1/review/views.py @@ -1,10 +1,15 @@ +import logging from http import HTTPStatus as status +from uuid import UUID from django.http import HttpRequest +from django.shortcuts import get_object_or_404 from ninja import Router from api.v1 import schemas as global_schemas from api.v1.review import schemas +from api.v1.task.schemas import TaskSubmissionIn +from apps.task.models import CompetitionTaskSubmission router = Router(tags=["review"]) @@ -16,7 +21,7 @@ router = Router(tags=["review"]) }, 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() @@ -30,3 +35,13 @@ def get_submissions(request: HttpRequest, token) -> tuple[status, schemas.Submis ) def get_reviewer_profile(request: HttpRequest, token: str): 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 diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 208dd36..85d3cc1 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -13,7 +13,7 @@ from api.v1.task.schemas import ( ) from apps.competition.models import State from apps.task.models import ( - CompetetionTaskSumbission, + CompetitionTaskSubmission, Competition, CompetitionTask, ) @@ -96,23 +96,23 @@ def submit_task( ) if task.type == CompetitionTask.CompetitionTaskType.INPUT: - CompetetionTaskSumbission.objects.create( + CompetitionTaskSubmission.objects.create( user=user, task=task, - status=CompetetionTaskSumbission.StatusChoices.CHECKED, + status=CompetitionTaskSubmission.StatusChoices.CHECKED, result={"correct": submission.content == task.answer_file_path}, ) if task.type == CompetitionTask.CompetitionTaskType.REVIEW: - CompetetionTaskSumbission.objects.create( + CompetitionTaskSubmission.objects.create( user=user, task=task, - status=CompetetionTaskSumbission.StatusChoices.SENT, + status=CompetitionTaskSubmission.StatusChoices.SENT, ) if task.type == CompetitionTask.CompetitionTaskType.CHECKER: - CompetetionTaskSumbission.objects.create( + CompetitionTaskSubmission.objects.create( user=user, task=task, - status=CompetetionTaskSumbission.StatusChoices.CHECKING, + status=CompetitionTaskSubmission.StatusChoices.CHECKING, ) - return TaskSubmissionOut(id=CompetetionTaskSumbission.id) + return TaskSubmissionOut(id=CompetitionTaskSubmission.id) diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 2990f62..477752d 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -46,7 +46,7 @@ def sign_in(request, data: LoginSchema): user = User.objects.filter(email=data.email).first() if not user: raise AuthenticationError - if user.password != data.password: + if not user.check_password(data.password): raise AuthenticationError token = BearerAuth.generate_jwt(user) diff --git a/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py b/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py new file mode 100644 index 0000000..d5f462e --- /dev/null +++ b/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py @@ -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='тип участия'), + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index e7b7814..92bf05f 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,6 +1,7 @@ from datetime import datetime from django.db import models +from tinymce.models import HTMLField from apps.core.models import BaseModel from apps.user.models import User @@ -17,29 +18,29 @@ class Competition(BaseModel): def image_url_upload_to(instance, filename): return f"/competitions/{instance.id}/image" - title = models.CharField(max_length=100, verbose_name="Название") - description = models.TextField(verbose_name="Описание") + title = models.CharField(max_length=100, verbose_name="название") + description = models.TextField(verbose_name="описание") image_url = models.FileField( - verbose_name="Изображение соревнования", + verbose_name="изображение соревнования", null=True, blank=True, upload_to=image_url_upload_to, ) end_date = models.DateTimeField( - verbose_name="Дедлайн участия", null=True, blank=True + verbose_name="дедлайн участия", null=True, blank=True ) start_date = models.DateTimeField( - verbose_name="Дедлайн участия", null=True, blank=True + verbose_name="дедлайн участия", null=True, blank=True ) type = models.CharField( max_length=10, choices=CompetitionType.choices, - verbose_name="Тип участия", + verbose_name="тип участия", ) participation_type = models.CharField( max_length=11, choices=CompetitionParticipationType.choices, - verbose_name="Тип соревнования", + verbose_name="тип соревнования", ) participants = models.ManyToManyField( User, related_name="participants", blank=True, editable=False @@ -48,6 +49,8 @@ class Competition(BaseModel): def __str__(self): return self.title + + class Meta: verbose_name = "соревнование" verbose_name_plural = "соревнования" diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index c781811..479fd23 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand from django.utils import timezone 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 @@ -105,7 +105,7 @@ class Command(BaseCommand): b"Submission content", name=f"submission_{uuid.uuid4().hex}.txt", ) - submission = CompetetionTaskSumbission.objects.create( + submission = CompetitionTaskSubmission.objects.create( user=user, task=task, earned_points=random.randint( diff --git a/services/backend/apps/review/migrations/0002_review.py b/services/backend/apps/review/migrations/0002_review.py new file mode 100644 index 0000000..c9ded38 --- /dev/null +++ b/services/backend/apps/review/migrations/0002_review.py @@ -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, + }, + ), + ] diff --git a/services/backend/apps/review/migrations/0003_review_submission.py b/services/backend/apps/review/migrations/0003_review_submission.py new file mode 100644 index 0000000..fd976b0 --- /dev/null +++ b/services/backend/apps/review/migrations/0003_review_submission.py @@ -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'), + ), + ] diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py index 02b74af..60eb198 100644 --- a/services/backend/apps/review/models.py +++ b/services/backend/apps/review/models.py @@ -1,6 +1,7 @@ from django.db import models from apps.core.models import BaseModel +from apps.task.models import CompetitionTaskSubmission class Reviewer(BaseModel): @@ -8,3 +9,14 @@ class Reviewer(BaseModel): surname = 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) \ No newline at end of file diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index c3e32f9..e65c59e 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -38,8 +38,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), - ('content', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_content_upload_to)), - ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)), + ('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)), diff --git a/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py b/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py new file mode 100644 index 0000000..bda15a2 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py @@ -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'), + ), + ] diff --git a/services/backend/apps/task/migrations/0004_merge_20250301_1739.py b/services/backend/apps/task/migrations/0004_merge_20250301_1739.py new file mode 100644 index 0000000..8d06cf8 --- /dev/null +++ b/services/backend/apps/task/migrations/0004_merge_20250301_1739.py @@ -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 = [ + ] diff --git a/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py b/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py new file mode 100644 index 0000000..fb0d89d --- /dev/null +++ b/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py @@ -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', + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 72bb349..3373809 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,6 +1,7 @@ from uuid import uuid4 from django.db import models +from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel @@ -19,8 +20,8 @@ class CompetitionTask(BaseModel): competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) - description = models.TextField(verbose_name="описание", max_length=300) - max_attemps = models.PositiveSmallIntegerField(default=0) + description = HTMLField(verbose_name="описание", max_length=300) + max_attemps = models.PositiveSmallIntegerField() type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" ) @@ -63,7 +64,7 @@ class CompetitionTask(BaseModel): verbose_name_plural = "задания" -class CompetetionTaskSumbission(BaseModel): +class CompetitionTaskSubmission(BaseModel): class StatusChoices(models.TextChoices): SENT = "sent" CHECKING = "checking" diff --git a/services/backend/apps/user/admin.py b/services/backend/apps/user/admin.py new file mode 100644 index 0000000..89dca07 --- /dev/null +++ b/services/backend/apps/user/admin.py @@ -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") diff --git a/services/backend/apps/user/apps.py b/services/backend/apps/user/apps.py index 2f3daa6..dd71f2d 100644 --- a/services/backend/apps/user/apps.py +++ b/services/backend/apps/user/apps.py @@ -5,3 +5,4 @@ class UsersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.user" label = "user" + verbose_name = "Пользователи" diff --git a/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py b/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py new file mode 100644 index 0000000..a733466 --- /dev/null +++ b/services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py @@ -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='юзернейм'), + ), + ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index a5b19fc..f702b6f 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.contrib.auth.hashers import check_password, make_password from apps.core.models import BaseModel @@ -9,9 +10,15 @@ class UserRole(models.Choices): class User(BaseModel): - email = models.EmailField(unique=True, verbose_name="Почта") - username = models.SlugField(unique=True, verbose_name="Юзернейм") - password = models.TextField(verbose_name="Пароль") + email = models.EmailField(unique=True, verbose_name="почта") + username = models.SlugField(unique=True, 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( max_length=10, choices=UserRole, default="student" diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index e383e80..dbb0717 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -441,6 +441,7 @@ INSTALLED_APPS = [ "django_guid", "ninja", "minio_storage", + "tinymce", # Internal apps "apps.core", "apps.user", @@ -449,6 +450,22 @@ INSTALLED_APPS = [ "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 DJANGO_GUID = { @@ -466,6 +483,10 @@ DJANGO_GUID = { LANGUAGE_COOKIE_AGE = 31449600 +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", +] + LANGUAGE_COOKIE_DOMAIN = None LANGUAGE_COOKIE_HTTPONLY = False diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 264fcc1..f69ade8 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -12,6 +12,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ + # tinymce + path('tinymce/', include('tinymce.urls')), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index b6292e0..def2053 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" readme = "README.md" requires-python = ">=3.10,<3.12" dependencies = [ + "argon2-cffi>=23.1.0", "celery>=5.4.0", "colorlog>=6.9.0", "django-cors-headers>=4.6.0", @@ -13,7 +14,9 @@ dependencies = [ "django-health-check>=3.18.3", "django-minio-storage>=0.5.7", "django-ninja>=1.3.0", + "django-pagedown>=2.2.1", "django-stubs-ext>=5.1.3", + "django-tinymce>=4.1.0", "gunicorn>=23.0.0", "httpx>=0.28.1", "pillow>=11.1.0", diff --git a/services/frontend/src/components/ui/drawer.tsx b/services/frontend/src/components/ui/drawer.tsx new file mode 100644 index 0000000..bec5a19 --- /dev/null +++ b/services/frontend/src/components/ui/drawer.tsx @@ -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) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/services/frontend/src/components/ui/sheet.tsx b/services/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000..9adc293 --- /dev/null +++ b/services/frontend/src/components/ui/sheet.tsx @@ -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) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + {/* Removed the default close button that was causing duplication */} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 7fdafa0..f068751 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useParams, Navigate } from "react-router-dom"; import { Task } from "@/shared/types"; -import { mockTasks } from "@/shared/mocks/mocks"; +import { mockSolutions, mockTasks } from "@/shared/mocks/mocks"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; @@ -40,6 +40,7 @@ const CompetitionSessionPage = () => { void; onSubmit: () => void; + solutionHistory?: Solution[]; } -const ActionButtons: React.FC = ({ onHistoryClick, onSubmit }) => { +const ActionButtons: React.FC = ({ + onHistoryClick, + onSubmit, + solutionHistory = mockSolutions +}) => { + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + + const handleHistoryClick = () => { + setIsHistoryOpen(true); + onHistoryClick(); + }; + return ( -
- - -
+ <> +
+ + +
+ {/* чуть-чуть рак */} + + ); }; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx new file mode 100644 index 0000000..dcaaa82 --- /dev/null +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx @@ -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 = ({ + isOpen, + onOpenChange, + solutions +}) => { + return ( + + + +
+ История решений + + + +
+
+ +
+ {solutions.length > 0 ? ( + solutions.map((solution, index) => ( +
+ +
+ )) + ) : ( +
+ У вас пока нет истории решений для этой задачи +
+ )} +
+
+
+ ); +}; + +export default SolutionHistorySheet; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx index bbde29c..02b5c8d 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx @@ -1,24 +1,41 @@ import React from 'react'; -import { Task } from "@/shared/types"; +import { Solution, TaskStatus } from "@/shared/types"; import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; interface SolutionStatusProps { - task: Task; + solution: Solution; } -const SolutionStatus: React.FC = ({ task }) => { +const SolutionStatus: React.FC = ({ 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 ( -
+
- - Решение 12345 + + Решение {solution.id} - - Зачтено 5/10 баллов + + {getStatusText(solution.status, solution.score, solution.maxScore)}
-
- 1 марта, 08:41 +
+ {solution.date}
); diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index c2e9fc3..58db705 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { Task } from "@/shared/types"; +import { Solution, Task } from "@/shared/types"; import SolutionStatus from './components/SolutionStatus'; import InputSolution from './components/InputSolution'; import FileSolution from './components/FileSolution'; @@ -8,6 +8,7 @@ import ActionButtons from './components/ActionButtons'; interface TaskSolutionProps { task: Task; + solutions: Solution[]; answer: string; setAnswer: (value: string) => void; onSubmit: () => void; @@ -16,6 +17,7 @@ interface TaskSolutionProps { const TaskSolution: React.FC = ({ task, + solutions, answer, setAnswer, onSubmit, @@ -26,7 +28,7 @@ const TaskSolution: React.FC = ({ return (
- + {task.solutionType === 'input' && ( diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index 08c213d..0af28c2 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Competition, CompetitionStatus } from "@/shared/types"; import { CompetitionGrid } from "./modules/CompetitionGrid"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; diff --git a/services/frontend/src/shared/mocks/mocks.ts b/services/frontend/src/shared/mocks/mocks.ts index 58099e7..d7a543e 100644 --- a/services/frontend/src/shared/mocks/mocks.ts +++ b/services/frontend/src/shared/mocks/mocks.ts @@ -1,4 +1,4 @@ -import { Competition, CompetitionStatus, Task } from "../types"; +import { Competition, CompetitionStatus, Solution, Task } from "../types"; 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 }; diff --git a/services/frontend/src/shared/types.ts b/services/frontend/src/shared/types.ts index cb4089c..16741c4 100644 --- a/services/frontend/src/shared/types.ts +++ b/services/frontend/src/shared/types.ts @@ -16,6 +16,13 @@ interface Competition { type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong"; type SolutionType = "input" | "file" | "code"; +interface Solution { + id: string, + status: TaskStatus, + date: string, + score?: number, + maxScore?: number, +} interface Task { id: string; number: string; @@ -24,4 +31,4 @@ interface Task { } export { CompetitionStatus }; -export type { Competition, TaskStatus, Task }; +export type { Solution, Competition, TaskStatus, Task };