From 332ca694a89ed1f3391e9ef0ff45a0323041215c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 1 Mar 2025 17:20:45 +0300 Subject: [PATCH 1/9] feat: added review logic --- services/backend/api/v1/review/schemas.py | 12 ++++++------ services/backend/api/v1/review/views.py | 17 ++++++++++++++++- services/backend/api/v1/task/views.py | 16 ++++++++-------- services/backend/apps/competition/models.py | 2 ++ .../core/management/commands/generate_data.py | 4 ++-- services/backend/apps/review/models.py | 12 ++++++++++++ .../apps/task/migrations/0001_initial.py | 4 ++-- ...002_competetiontasksumbission_reviewers.py | 19 +++++++++++++++++++ services/backend/apps/task/models.py | 12 ++++++++++-- 9 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py 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/apps/competition/models.py b/services/backend/apps/competition/models.py index 0c6bd19..bd52ab7 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -41,6 +41,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/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/models.py b/services/backend/apps/task/models.py index 72bb349..f9b9a4c 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -20,7 +20,7 @@ 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) + max_attemps = models.PositiveSmallIntegerField() type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" ) @@ -50,6 +50,14 @@ class CompetitionTask(BaseModel): blank=True, null=True, verbose_name="критерии", + default=lambda: [ + { + "name": "CHANGE ME", + "slug": "CHANGE ME", + "max_value": 0, + "min_value": 0, + } + ], ) def clean(self): @@ -63,7 +71,7 @@ class CompetitionTask(BaseModel): verbose_name_plural = "задания" -class CompetetionTaskSumbission(BaseModel): +class CompetitionTaskSubmission(BaseModel): class StatusChoices(models.TextChoices): SENT = "sent" CHECKING = "checking" From c0d4c5673696db9d3016c2afe63c26ede3e1b407 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 17:23:12 +0300 Subject: [PATCH 2/9] add tinymce --- services/backend/apps/competition/models.py | 15 ++++++++------- services/backend/apps/task/models.py | 3 ++- services/backend/config/settings.py | 17 +++++++++++++++++ services/backend/config/urls.py | 2 ++ services/backend/pyproject.toml | 1 + 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index bd52ab7..f9d91ec 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 @@ -14,26 +15,26 @@ class Competition(BaseModel): EDU = "edu", "Образовательный" COMPETITIVE = "competitive", "Соревновательный" - title = models.CharField(max_length=100, verbose_name="Название") - description = models.TextField(verbose_name="Описание") + title = models.CharField(max_length=100, verbose_name="аазвание") + description = HTMLField(verbose_name="описание") image_url = models.FileField( - verbose_name="Изображение соревнования", null=True, blank=True + verbose_name="изображение соревнования", null=True, blank=True ) 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) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index f9b9a4c..13dd487 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,7 +20,7 @@ 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) + description = HTMLField(verbose_name="описание", max_length=300) max_attemps = models.PositiveSmallIntegerField() type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index e383e80..af06a21 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 = { 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..a3c2a81 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -13,6 +13,7 @@ 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", "gunicorn>=23.0.0", "httpx>=0.28.1", From 9a5a924333c4261d6139781bac3533ad2f74780e Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 17:23:37 +0300 Subject: [PATCH 3/9] make verbose names at user lowercased --- services/backend/apps/user/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index a5b19fc..bc7ce07 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -9,9 +9,9 @@ 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="пароль") status = models.CharField( max_length=10, choices=UserRole, default="student" From 656735649db681f26e7a8e4788ef0931cc61b3e1 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 17:40:08 +0300 Subject: [PATCH 4/9] add simple user admin and hash password --- services/backend/api/v1/user/views.py | 3 ++- services/backend/apps/user/admin.py | 9 +++++++++ services/backend/apps/user/apps.py | 1 + services/backend/apps/user/models.py | 9 ++++++++- services/backend/config/settings.py | 4 ++++ services/backend/pyproject.toml | 1 + 6 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 services/backend/apps/user/admin.py diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index ff49988..c4f8c15 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -27,6 +27,7 @@ router = Router(tags=["user"]) ) def sign_up(request, data: RegisterSchema): user = User(**data.dict()) + user.password = user.make_password() user.full_clean() user.save() @@ -47,7 +48,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/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/models.py b/services/backend/apps/user/models.py index bc7ce07..8fa8b44 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 @@ -11,7 +12,13 @@ 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="пароль") + password = models.TextField(verbose_name="пароль", editable=False) + + 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 af06a21..dbb0717 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -483,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/pyproject.toml b/services/backend/pyproject.toml index a3c2a81..200a90d 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", From 01e775605e2131e0dbae29efed9fcd1a8528e9fd Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sat, 1 Mar 2025 23:48:12 +0900 Subject: [PATCH 5/9] feat: history sheet --- .../frontend/src/components/ui/drawer.tsx | 130 +++++++++++++++++ services/frontend/src/components/ui/sheet.tsx | 134 ++++++++++++++++++ .../src/pages/CompetitionSession/index.tsx | 3 +- .../components/ActionButtons/index.tsx | 57 +++++--- .../components/SolutionHistorySheet/index.tsx | 51 +++++++ .../components/SolutionStatus/index.tsx | 37 +++-- .../modules/TaskSolution/index.tsx | 6 +- .../frontend/src/pages/Competitions/index.tsx | 2 +- services/frontend/src/shared/mocks/mocks.ts | 33 ++++- services/frontend/src/shared/types.ts | 9 +- 10 files changed, 428 insertions(+), 34 deletions(-) create mode 100644 services/frontend/src/components/ui/drawer.tsx create mode 100644 services/frontend/src/components/ui/sheet.tsx create mode 100644 services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx 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 }; From c4aecb26b5fe3ae17004f378d0a039622cf57f16 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 17:48:27 +0300 Subject: [PATCH 6/9] show password in admin and fix migrations generator --- services/backend/apps/task/models.py | 8 -------- services/backend/apps/user/models.py | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 13dd487..3373809 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -51,14 +51,6 @@ class CompetitionTask(BaseModel): blank=True, null=True, verbose_name="критерии", - default=lambda: [ - { - "name": "CHANGE ME", - "slug": "CHANGE ME", - "max_value": 0, - "min_value": 0, - } - ], ) def clean(self): diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index 8fa8b44..f702b6f 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -12,7 +12,7 @@ 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="пароль", editable=False) + password = models.TextField(verbose_name="пароль") def make_password(self): return make_password(self.password) From 58b419166b31589adbeac68cad2fbe17fac806ee Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 17:51:50 +0300 Subject: [PATCH 7/9] add tinymce to pyproject --- services/backend/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index 200a90d..def2053 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "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", From 5295f805306ad038fda08dc9cd57ee61c262a7ce Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 18:00:47 +0300 Subject: [PATCH 8/9] add missing migrations --- ..._alter_competition_description_and_more.py | 49 +++++++++++++++++++ .../apps/review/migrations/0002_review.py | 26 ++++++++++ .../migrations/0003_review_submission.py | 20 ++++++++ .../migrations/0004_merge_20250301_1739.py | 14 ++++++ ...er_competitiontask_description_and_more.py | 48 ++++++++++++++++++ ...user_email_alter_user_password_and_more.py | 28 +++++++++++ 6 files changed, 185 insertions(+) create mode 100644 services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py create mode 100644 services/backend/apps/review/migrations/0002_review.py create mode 100644 services/backend/apps/review/migrations/0003_review_submission.py create mode 100644 services/backend/apps/task/migrations/0004_merge_20250301_1739.py create mode 100644 services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py create mode 100644 services/backend/apps/user/migrations/0002_alter_user_email_alter_user_password_and_more.py 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/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/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/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='юзернейм'), + ), + ] From e312a400be34f7f45fc46959829252041fd32573 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 18:04:11 +0300 Subject: [PATCH 9/9] =?UTF-8?q?add=20.idea=20to=20gitignore=20in=20parent?= =?UTF-8?q?=20folder=20(jetbrains=20ide=20=F0=9F=A4=A1=F0=9F=A4=A1?= =?UTF-8?q?=F0=9F=A4=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea