diff --git a/services/backend/api/v1/achievement/__init__.py b/services/backend/api/v1/achievement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py new file mode 100644 index 0000000..ecc1c5d --- /dev/null +++ b/services/backend/api/v1/achievement/schemas.py @@ -0,0 +1,14 @@ +from ninja import ModelSchema + +from apps.achievement.models import Achievement + + +class AchievementSchema(ModelSchema): + class Meta: + model = Achievement + fields = ( + "id", + "name", + "description", + "icon", + ) diff --git a/services/backend/api/v1/achievement/views.py b/services/backend/api/v1/achievement/views.py new file mode 100644 index 0000000..804348a --- /dev/null +++ b/services/backend/api/v1/achievement/views.py @@ -0,0 +1,20 @@ +from http import HTTPStatus as status + +from ninja import Router + +from api.v1.achievement.schemas import AchievementSchema +from api.v1.schemas import UnauthorizedError +from apps.achievement.models import Achievement + +router = Router() + + +@router.get( + "", + response={ + status.OK: list[AchievementSchema], + status.UNAUTHORIZED: UnauthorizedError, + }, +) +def get_all_achievements(request): + return Achievement.objects.all() diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 3e47f62..97a9fd1 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -37,6 +37,8 @@ class SubmissionOut(ModelSchema): submitted_at: datetime = Field(..., alias="timestamp") competition: UUID = Field(..., alias="task.competition.id") competition_name: str = Field(..., alias="task.competition.title") + task_position: int = Field(..., alias="task.in_competition_position") + task_title: str = Field(..., alias="task.title") @staticmethod def resolve_criteries(self, context) -> list[CriteriaOut] | None: diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py index 281358b..05c2e3a 100644 --- a/services/backend/api/v1/review/views.py +++ b/services/backend/api/v1/review/views.py @@ -1,5 +1,6 @@ from datetime import datetime from http import HTTPStatus as status +from statistics import median from uuid import UUID from django.http import HttpRequest @@ -84,15 +85,22 @@ def evaluate_submission( review.evaluation = evaluation review.state = ReviewStatusChoices.CHECKED.value review.submission.checked_at = datetime.now() - - points = 0 - for criterea in evaluation: - points += criterea["mark"] - review.submission.earned_points = ( - points # TODO: оценка не от последнего проверяющего а средняя по всем - ) review.save() + submission_evaluations = Review.objects.filter( + submission=submission + ).values_list("evaluation", flat=True) + + marks = [] + for evaluation in submission_evaluations: + mark = 0 + for criterea in evaluation: + mark += criterea["mark"] + marks.append(mark) + earned_points = median(marks) + + review.submission.earned_points = earned_points + all_checked = not submission.reviews.exclude( state=ReviewStatusChoices.CHECKED ).exists() @@ -100,5 +108,6 @@ def evaluate_submission( review.submission.status = ( CompetitionTaskSubmission.StatusChoices.CHECKED.value ) - review.submission.save() + review.submission.save() + return status.OK, review.submission diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 836643d..9e73c37 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -3,10 +3,27 @@ from uuid import UUID from ninja import ModelSchema, Schema -from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment +from apps.task.models import ( + CompetitionTask, + CompetitionTaskAttachment, + CompetitionTaskSubmission, +) class TaskOutSchema(ModelSchema): + status: Literal["sent", "checked", "checking", "not_submitted"] = None + type: Literal["input", "checker", "review"] = None + + @staticmethod + def resolve_status( + self, context + ) -> Literal["sent", "checked", "checking", "not_submitted"]: + if submission := CompetitionTaskSubmission.objects.filter( + task=self, user=context.get("request").auth + ).first(): + return submission.status + return "not_submitted" + class Meta: model = CompetitionTask fields = [ @@ -14,7 +31,6 @@ class TaskOutSchema(ModelSchema): "competition", "title", "description", - "type", "in_competition_position", "points", ] @@ -29,10 +45,19 @@ class HistorySubmissionOut(ModelSchema): class Meta: model = CompetitionTaskSubmission - fields = ("id", "earned_points", "timestamp", "content",) + fields = ( + "id", + "earned_points", + "timestamp", + "content", + ) class TaskAttachmentSchema(ModelSchema): class Meta: model = CompetitionTaskAttachment - fields = ("id", "file", "public",) + fields = ( + "id", + "file", + "public", + ) diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index 6baa542..832d91f 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -22,4 +22,4 @@ class LoginSchema(ModelSchema): class UserSchema(ModelSchema): class Meta: model = User - fields = ["id", "email", "username"] + fields = ["id", "email", "username", "created_at", "achievements"] diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 756de02..c9fad87 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -1,3 +1,4 @@ +from datetime import datetime from http import HTTPStatus as status from django.contrib.auth.hashers import check_password, make_password @@ -35,6 +36,7 @@ router = Router(tags=["user"]) def sign_up(request, data: RegisterSchema): user = User(**data.dict(exclude={"password"})) user.password = make_password(data.password) + user.created_at = datetime.now() user.save() token = BearerAuth.generate_jwt(user) diff --git a/services/backend/apps/achievement/__init__.py b/services/backend/apps/achievement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/achievement/admin.py b/services/backend/apps/achievement/admin.py new file mode 100644 index 0000000..9d7822d --- /dev/null +++ b/services/backend/apps/achievement/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from apps.achievement.models import Achievement + + +@admin.register(Achievement) +class AchievementAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + ) + search_fields = ( + "name", + "description", + ) diff --git a/services/backend/apps/achievement/apps.py b/services/backend/apps/achievement/apps.py new file mode 100644 index 0000000..4c9ddeb --- /dev/null +++ b/services/backend/apps/achievement/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AchievementConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.achievement" + verbose_name = "Ачивки" diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py new file mode 100644 index 0000000..b20fb21 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:09 + +import apps.achievement.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Achievement', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=30, unique=True, verbose_name='название')), + ('description', models.TextField(verbose_name='описание')), + ('icon', models.FileField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')), + ], + options={ + 'verbose_name': 'ачивка', + 'verbose_name_plural': 'ачивки', + }, + ), + ] diff --git a/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py new file mode 100644 index 0000000..e16f3b6 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievement', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='achievement', + name='need_count', + field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'), + ), + migrations.AddField( + model_name='achievement', + name='type', + field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'), + ), + ] diff --git a/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py new file mode 100644 index 0000000..682a718 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-03-02 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')] + + dependencies = [ + ('achievement', '0002_achievement_need_count_achievement_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='achievement', + name='need_count', + ), + migrations.RemoveField( + model_name='achievement', + name='type', + ), + migrations.AddField( + model_name='achievement', + name='slug', + field=models.SlugField(unique=True, verbose_name='слаг'), + ), + ] diff --git a/services/backend/apps/achievement/migrations/__init__.py b/services/backend/apps/achievement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py new file mode 100644 index 0000000..2c7724f --- /dev/null +++ b/services/backend/apps/achievement/models.py @@ -0,0 +1,29 @@ +from django.db import models + +from apps.core.models import BaseModel + + +class Achievement(BaseModel): + class AchievementType(models.TextChoices): + CORRECT_TASKS = "correct_tasks", "Выполненные задания" + + def image_url_upload_to(instance, filename): + return f"achievements/{instance.id}/icon/{filename}" + + name = models.CharField( + max_length=30, verbose_name="название", unique=True + ) + description = models.TextField(verbose_name="описание") + icon = models.FileField( + verbose_name="иконка достижения", + upload_to=image_url_upload_to, + ) + + slug = models.SlugField(verbose_name="слаг", unique=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "ачивка" + verbose_name_plural = "ачивки" diff --git a/services/backend/apps/competition/admin.py b/services/backend/apps/competition/admin.py index 6c78551..a28901d 100644 --- a/services/backend/apps/competition/admin.py +++ b/services/backend/apps/competition/admin.py @@ -7,6 +7,7 @@ from apps.task.admin import CompetitionTaskInline @admin.register(Competition) class CompetitionAdmin(admin.ModelAdmin): list_display = ( + "id", "title", "end_date", "type", diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index 223f6b8..1ada8da 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 06:13 +# Generated by Django 5.1.6 on 2025-03-02 10:28 import apps.competition.models import datetime diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 18212f4..5d9880f 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -15,7 +15,7 @@ class Competition(BaseModel): SOLO = "solo", "Индивидуальный" def image_url_upload_to(instance, filename): - return f"/competitions/{instance.id}/image" + return f"competitions/{instance.id}/image/{filename}" title = models.CharField(max_length=100, verbose_name="название") description = models.TextField(verbose_name="описание") diff --git a/services/backend/apps/core/admin.py b/services/backend/apps/core/admin.py index 3bc8edf..4027d38 100644 --- a/services/backend/apps/core/admin.py +++ b/services/backend/apps/core/admin.py @@ -1,4 +1,4 @@ from django.contrib import admin -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group admin.site.unregister(Group) diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index bc3b9de..d78d0a5 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.review.models import Review, Reviewer +from apps.review.models import Reviewer from apps.task.models import CompetitionTask, CompetitionTaskSubmission from apps.user.models import User, UserRole @@ -20,8 +20,8 @@ class Command(BaseCommand): self.stdout.write("Starting data generation...") users = self.create_users(5) competitions = self.create_competitions(2, users) + self.reviewers = self.create_reviewers(2) tasks = self.create_tasks(competitions) - self.reviewers = self.create_reviewers(1) self.create_submissions(tasks, users) self.create_states(competitions, users) self.stdout.write("Data generation completed.") @@ -99,17 +99,25 @@ class Command(BaseCommand): title = f"Task {i} for {comp.title}" description = f"Task description for task {i} in {comp.title}" task = CompetitionTask.objects.create( + in_competition_position=i, competition=comp, title=title, description=description, type=task_type, points=random.randint(1, 10), + submission_reviewers_count=random.randint(2, 10), max_attempts=random.randint(1, 10), ) tasks.append(task) self.stdout.write(f"Created task: {title} (type: {task_type})") + self.add_reviewers_to_task(tasks) return tasks + def add_reviewers_to_task(self, tasks): + for task in tasks: + task.reviewers.set(self.reviewers) + task.save() + def create_submissions(self, tasks, users): for task in tasks: # Each task will get between 1 and 3 submissions @@ -133,15 +141,6 @@ class Command(BaseCommand): self.stdout.write( f"Created submission for task '{task.title}' by user '{user.username}'" ) - self.add_reviewers(submission) - - def add_reviewers(self, submission): - for reviewer in self.reviewers: - if random.choice([True, False]): - Review.objects.create( - submission=submission, - reviewer=reviewer, - ) def create_states(self, competitions, users): # For each competition, create a State for some of its participants diff --git a/services/backend/apps/review/admin.py b/services/backend/apps/review/admin.py index c35598b..653d999 100644 --- a/services/backend/apps/review/admin.py +++ b/services/backend/apps/review/admin.py @@ -1,17 +1,15 @@ from django.contrib import admin -from apps.review.models import Review, Reviewer +from apps.review.models import Reviewer @admin.register(Reviewer) -class ReviewAdmin(admin.ModelAdmin): - list_display = ("name", "surname",) - search_fields = ("name", "surname",) - - -@admin.register(Review) -class ReviewAdmin(admin.ModelAdmin): - list_display = ("id", "reviewer", "submission",) - search_fields = ("id", "reviewer__id", "reviewer__name", "reviewer__surname", - "submission__id", "submission__content") - list_filter = ("submission__plagiarism_checked", "submission__status",) +class ReviewersAdmin(admin.ModelAdmin): + list_display = ( + "name", + "surname", + ) + search_fields = ( + "name", + "surname", + ) diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index 70108e7..1d0ac7b 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 5.1.6 on 2025-03-02 09:31 +# Generated by Django 5.1.6 on 2025-03-02 10:28 -import django.db.models.deletion import uuid from django.db import migrations, models @@ -10,10 +9,21 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('task', '0003_remove_competitiontask_attachments'), ] operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('evaluation', models.JSONField(blank=True, default=list, null=True, verbose_name='выполнение')), + ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11, verbose_name='состояние')), + ], + options={ + 'verbose_name': 'проверка', + 'verbose_name_plural': 'проверки', + }, + ), migrations.CreateModel( name='Reviewer', fields=[ @@ -27,18 +37,4 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'проверяющие', }, ), - migrations.CreateModel( - name='Review', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('evaluation', models.JSONField(blank=True, default=list, null=True, verbose_name='выполнение')), - ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11, verbose_name='состояние')), - ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission', verbose_name='посылка')), - ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer', verbose_name='проверяющий')), - ], - options={ - 'verbose_name': 'проверка', - 'verbose_name_plural': 'проверки', - }, - ), ] diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py new file mode 100644 index 0000000..2d6ee5c --- /dev/null +++ b/services/backend/apps/review/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-02 10:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('review', '0001_initial'), + ('task', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='review', + name='submission', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission', verbose_name='посылка'), + ), + migrations.AddField( + model_name='review', + name='reviewer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer', verbose_name='проверяющий'), + ), + ] diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py index 9dd8d7f..3c1201a 100644 --- a/services/backend/apps/review/models.py +++ b/services/backend/apps/review/models.py @@ -1,7 +1,6 @@ from django.db import models from apps.core.models import BaseModel -from apps.task.models import CompetitionTaskSubmission class Reviewer(BaseModel): @@ -25,22 +24,24 @@ class ReviewStatusChoices(models.TextChoices): class Review(BaseModel): - reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE, - verbose_name="проверяющий") + reviewer = models.ForeignKey( + Reviewer, on_delete=models.CASCADE, verbose_name="проверяющий" + ) submission = models.ForeignKey( - CompetitionTaskSubmission, + "task.CompetitionTaskSubmission", on_delete=models.CASCADE, related_name="reviews", - verbose_name="посылка" + verbose_name="посылка", ) - evaluation = models.JSONField(default=list, null=True, blank=True, - verbose_name="выполнение") + evaluation = models.JSONField( + default=list, null=True, blank=True, verbose_name="выполнение" + ) state = models.CharField( choices=ReviewStatusChoices.choices, default=ReviewStatusChoices.NOT_CHECKED.value, max_length=11, - verbose_name="состояние" + verbose_name="состояние", ) def __str__(self): diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index 3766cdf..1cf4361 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -1,6 +1,10 @@ from django.contrib import admin -from apps.task.models import CompetitionTask, CompetitionTaskAttachment +from apps.task.models import ( + CompetitionTask, + CompetitionTaskAttachment, + CompetitionTaskSubmission, +) class CompletionAttachmentInline(admin.StackedInline): @@ -11,6 +15,30 @@ class CompletionAttachmentInline(admin.StackedInline): @admin.register(CompetitionTask) class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") + filter_horizontal = ("reviewers",) + + +@admin.register(CompetitionTaskSubmission) +class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): + list_display = ( + "task", + "user", + "status", + ) + search_fields = ( + "task__id", + "task__title", + "user__username", + "user__email", + ) + filter = ("plagiarism_checked",) + ordering = ["-timestamp"] + + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False class CompetitionTaskInline(admin.StackedInline): diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index cf4fbdc..c2cbaa8 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 06:13 +# Generated by Django 5.1.6 on 2025-03-02 10:28 import apps.task.models import django.db.models.deletion @@ -13,6 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ('competition', '0001_initial'), + ('review', '0001_initial'), ('user', '0001_initial'), ] @@ -30,6 +31,7 @@ class Migration(migrations.Migration): ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), ('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), + ('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')), ], options={ 'verbose_name': 'задание', @@ -40,10 +42,10 @@ class Migration(migrations.Migration): name='CompetitionTaskAttachment', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at)), - ('bind_at', models.FilePathField()), - ('public', models.BooleanField(default=False)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), + ('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл')), + ('bind_at', models.FilePathField(verbose_name='путь сохранения')), + ('public', models.BooleanField(default=False, verbose_name='публичный')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')), ], options={ 'abstract': False, @@ -67,7 +69,7 @@ class Migration(migrations.Migration): 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)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8, verbose_name='статус')), ('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)), diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py new file mode 100644 index 0000000..9cc1672 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:09 + +import apps.task.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('review', '0002_initial'), + ('task', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='competitiontasksubmission', + options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'}, + ), + migrations.AlterField( + model_name='competitiontask', + name='reviewers', + field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='checked_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='content', + field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='earned_points', + field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='plagiarism_checked', + field=models.BooleanField(default=False, verbose_name='проверено на плагиат'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='result', + field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='stdout', + field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'), + ), + ] diff --git a/services/backend/apps/task/migrations/0002_competitiontask_attachments_and_more.py b/services/backend/apps/task/migrations/0002_competitiontask_attachments_and_more.py deleted file mode 100644 index 9f88f60..0000000 --- a/services/backend/apps/task/migrations/0002_competitiontask_attachments_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 08:50 - -import apps.task.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='competitiontask', - name='attachments', - field=models.ManyToManyField(blank=True, related_name='tasks_attachments', to='task.competitiontaskattachment'), - ), - migrations.AlterField( - model_name='competitiontaskattachment', - name='bind_at', - field=models.FilePathField(verbose_name='путь сохранения'), - ), - migrations.AlterField( - model_name='competitiontaskattachment', - name='file', - field=models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл'), - ), - migrations.AlterField( - model_name='competitiontaskattachment', - name='public', - field=models.BooleanField(default=False, verbose_name='публичный'), - ), - migrations.AlterField( - model_name='competitiontaskattachment', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'), - ), - ] diff --git a/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py new file mode 100644 index 0000000..2dfa914 --- /dev/null +++ b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:23 + +import tinymce.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0002_alter_competitiontasksubmission_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='competitiontask', + name='description', + field=tinymce.models.HTMLField(verbose_name='описание'), + ), + ] diff --git a/services/backend/apps/task/migrations/0003_remove_competitiontask_attachments.py b/services/backend/apps/task/migrations/0003_remove_competitiontask_attachments.py deleted file mode 100644 index 0e5d430..0000000 --- a/services/backend/apps/task/migrations/0003_remove_competitiontask_attachments.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 09:31 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0002_competitiontask_attachments_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='competitiontask', - name='attachments', - ), - ] diff --git a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py new file mode 100644 index 0000000..400255c --- /dev/null +++ b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:49 + +import martor.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0003_alter_competitiontask_description'), + ] + + operations = [ + migrations.AddField( + model_name='competitiontask', + name='submission_reviewers_count', + field=models.PositiveSmallIntegerField(blank=True, default=1, null=True), + ), + migrations.AlterField( + model_name='competitiontask', + name='description', + field=martor.models.MartorField(verbose_name='описание'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 471197c..17232b8 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,10 +1,12 @@ from uuid import uuid4 from django.db import models -from tinymce.models import HTMLField +from django.db.models import Count, Q +from martor.models import MartorField from apps.competition.models import Competition from apps.core.models import BaseModel +from apps.review.models import Review, Reviewer, ReviewStatusChoices from apps.user.models import User @@ -15,14 +17,14 @@ class CompetitionTask(BaseModel): REVIEW = "review", "Ручная" def answer_file_upload_to(instance, filename) -> str: - return f"/tasks/{instance.id}/answer/{uuid4()}/filename" + return f"tasks/{instance.id}/answer/{uuid4()}/{filename}" in_competition_position = models.PositiveSmallIntegerField( null=True, blank=True ) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) - description = HTMLField(verbose_name="описание", max_length=300) + description = MartorField(verbose_name="описание") max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" @@ -48,6 +50,17 @@ class CompetitionTask(BaseModel): default="stdout", ) + # only when "review" type + reviewers = models.ManyToManyField( + Reviewer, + blank=True, + verbose_name="ревьюверы", + help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему", + ) + submission_reviewers_count = models.PositiveSmallIntegerField( + default=1, null=True, blank=True + ) + def __str__(self): return self.title @@ -69,12 +82,12 @@ class CompetitionTaskCriteria(BaseModel): class CompetitionTaskAttachment(BaseModel): def file_upload_at(instance, filename): - return f"/attachment/{instance.id}/file" + return f"attachment/{instance.id}/file/{filename}" - task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE, - verbose_name="задание") - file = models.FileField(upload_to=file_upload_at, - verbose_name="файл") + task = models.ForeignKey( + CompetitionTask, on_delete=models.CASCADE, verbose_name="задание" + ) + file = models.FileField(upload_to=file_upload_at, verbose_name="файл") bind_at = models.FilePathField(verbose_name="путь сохранения") public = models.BooleanField(default=False, verbose_name="публичный") @@ -86,60 +99,90 @@ class CompetitionTaskSubmission(BaseModel): CHECKED = "checked" def submission_content_upload_to(instance, filename) -> str: - return f"submissions/{instance.id}/content" + return f"submissions/{instance.id}/content/{filename}" def submission_stdout_upload_to(instance, filename) -> str: - return f"/submissions/{instance.id}/stdout" + return f"submissions/{instance.id}/stdout/{filename}" - user = models.ForeignKey(User, on_delete=models.CASCADE) - task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + user = models.ForeignKey( + User, on_delete=models.CASCADE, verbose_name="пользователь" + ) + task = models.ForeignKey( + CompetitionTask, on_delete=models.CASCADE, verbose_name="задание" + ) status = models.CharField( choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=8, + verbose_name="статус", ) # code or text or file - content = models.FileField(upload_to=submission_content_upload_to) + content = models.FileField( + upload_to=submission_content_upload_to, + verbose_name="содержание посылки", + ) # only if task type is checker stdout = models.FileField( - upload_to=submission_stdout_upload_to, null=True, blank=True + upload_to=submission_stdout_upload_to, + null=True, + blank=True, + verbose_name="вывод программы", + help_text="Используется только при проверке чекером", ) # depends on task type: # - input: {"correct": boolean} # - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]} # - code: {"correct": boolean} - result = models.JSONField(default=None, null=True, blank=True) + result = models.JSONField( + default=None, null=True, blank=True, verbose_name="результат проверки" + ) # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria - earned_points = models.IntegerField(null=True, blank=True) + earned_points = models.IntegerField( + null=True, blank=True, verbose_name="баллы за задание" + ) - checked_at = models.DateTimeField(null=True, blank=True) - plagiarism_checked = models.BooleanField(default=False) - timestamp = models.DateTimeField(auto_now_add=True) + checked_at = models.DateTimeField( + null=True, blank=True, verbose_name="дата проверки" + ) + plagiarism_checked = models.BooleanField( + default=False, verbose_name="проверено на плагиат" + ) + timestamp = models.DateTimeField( + auto_now_add=True, verbose_name="дата отправки" + ) + + class Meta: + verbose_name = "посылка" + verbose_name_plural = "посылки" + + def __str__(self): + return str(self.id) def send_on_review(self): if not self.task.reviewers.exists(): return - reviewer = ( - self.task.reviewers.annotate( - pending_count=Count( - "review", - filter=Q( - review__state__in=[ - ReviewStatusChoices.NOT_CHECKED, - ReviewStatusChoices.CHECKING, - ] - ), - ) + reviewers_count = self.task.submission_reviewers_count + reviewers = self.task.reviewers.annotate( + pending_count=Count( + "review", + filter=Q( + review__state__in=[ + ReviewStatusChoices.NOT_CHECKED, + ReviewStatusChoices.CHECKING, + ] + ), + ) + ).order_by("pending_count")[ + :reviewers_count + ] # да это медленно работает и чо + + for reviewer in reviewers: + Review.objects.create( + reviewer=reviewer, + submission=self, ) - .order_by("pending_count") - .first() - ) - review = Review.objects.create( - reviewer=reviewer, - submission=self, - ) diff --git a/services/backend/apps/task/tests/test_tasks.py b/services/backend/apps/task/tests/test_tasks.py index 4209d70..b61bb11 100644 --- a/services/backend/apps/task/tests/test_tasks.py +++ b/services/backend/apps/task/tests/test_tasks.py @@ -28,5 +28,3 @@ with open("file.txt") as f: print(result) self.assertTrue(result["success"]) self.assertTrue(result["match"]) - - diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index 27317a3..7e4fca4 100644 --- a/services/backend/apps/team/migrations/0001_initial.py +++ b/services/backend/apps/team/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 06:13 +# Generated by Django 5.1.6 on 2025-03-02 10:28 import django.db.models.deletion import uuid diff --git a/services/backend/apps/user/admin.py b/services/backend/apps/user/admin.py index 89dca07..d13c779 100644 --- a/services/backend/apps/user/admin.py +++ b/services/backend/apps/user/admin.py @@ -7,3 +7,4 @@ from apps.user.models import User class UserAdmin(admin.ModelAdmin): list_display = ("email", "username") search_fields = ("id", "email", "username") + filter_horizontal = ("achievements",) diff --git a/services/backend/apps/user/apps.py b/services/backend/apps/user/apps.py index dd71f2d..e38650e 100644 --- a/services/backend/apps/user/apps.py +++ b/services/backend/apps/user/apps.py @@ -5,4 +5,4 @@ class UsersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.user" label = "user" - verbose_name = "Пользователи" + verbose_name = "Пользователи (веб)" diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index 12a0407..fe09ceb 100644 --- a/services/backend/apps/user/migrations/0001_initial.py +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 00:16 +# Generated by Django 5.1.6 on 2025-03-02 10:28 import uuid from django.db import migrations, models @@ -19,6 +19,7 @@ class Migration(migrations.Migration): ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')), ('password', models.TextField(verbose_name='пароль')), + ('created_at', models.DateTimeField(auto_now=True)), ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ], options={ diff --git a/services/backend/apps/user/migrations/0002_user_achievements.py b/services/backend/apps/user/migrations/0002_user_achievements.py new file mode 100644 index 0000000..33adefa --- /dev/null +++ b/services/backend/apps/user/migrations/0002_user_achievements.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievement', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='achievements', + field=models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя'), + ), + ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index f525c29..aaa0ec0 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.hashers import check_password, make_password from django.db import models +from apps.achievement.models import Achievement from apps.core.models import BaseModel @@ -14,6 +15,12 @@ class User(BaseModel): username = models.SlugField(unique=True, verbose_name="юзернейм") password = models.TextField(verbose_name="пароль") + created_at = models.DateTimeField(auto_now=True) + + achievements = models.ManyToManyField( + Achievement, blank=True, verbose_name="ачивки пользователя" + ) + @staticmethod def make_password(password: str): return make_password(password) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index c5cbcbd..243d5ee 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -438,6 +438,7 @@ INSTALLED_APPS = [ "ninja", "minio_storage", "tinymce", + "martor", # Internal apps "apps.core", "apps.user", @@ -445,6 +446,7 @@ INSTALLED_APPS = [ "apps.review", "apps.task", "apps.team", + "apps.achievement", ] # tinymce @@ -454,15 +456,58 @@ TINYMCE_DEFAULT_CONFIG = { "menubar": False, "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,help,wordcount", + "code,help,wordcount,markdown", "toolbar": "undo redo | formatselect | " "bold italic backcolor | alignleft aligncenter " "alignright alignjustify | bullist numlist outdent indent | " "removeformat | help", "skin": "oxide-dark", "content_css": "dark", + "textpattern_patterns": [ + {"start": "*", "end": "*", "format": "italic"}, + {"start": "**", "end": "**", "format": "bold"}, + {"start": "#", "format": "h1"}, + {"start": "##", "format": "h2"}, + {"start": "###", "format": "h3"}, + {"start": "####", "format": "h4"}, + {"start": "#####", "format": "h5"}, + {"start": "######", "format": "h6"}, + {"start": "1. ", "cmd": "InsertOrderedList"}, + {"start": "* ", "cmd": "InsertUnorderedList"}, + {"start": "- ", "cmd": "InsertUnorderedList"}, + ], } +# martor + +MARTOR_THEME = "bootstrap" + +MARTOR_ENABLE_CONFIGS = { + "emoji": "true", # to enable/disable emoji icons. + "imgur": "true", # to enable/disable imgur/custom uploader. + "mention": "false", # to enable/disable mention + "jquery": "true", # to include/revoke jquery (require for admin default django) + "living": "false", # to enable/disable live updates in preview + "spellcheck": "false", # to enable/disable spellcheck in form textareas + "hljs": "true", # to enable/disable hljs highlighting in preview +} + +MARTOR_TOOLBAR_BUTTONS = [ + "bold", + "italic", + "horizontal", + "heading", + "pre-code", + "blockquote", + "unordered-list", + "ordered-list", + "link", + "emoji", + "direct-mention", + "toggle-maximize", + "help", +] + # GUID DJANGO_GUID = { diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 27b279a..6fe96c2 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -14,6 +14,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ # tinymce path("tinymce/", include("tinymce.urls")), + # martor + path("martor/", include("martor.urls")), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index 20b218e..d3b2371 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "django-tinymce>=4.1.0", "gunicorn>=23.0.0", "httpx>=0.28.1", + "martor>=1.6.45", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", "pydantic>=2.10.5", diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 96bf77a..ecd048c 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -6,9 +6,12 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "@tanstack/react-query": "^5.66.11", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -157,12 +160,16 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], @@ -177,6 +184,10 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.9", "", { "os": "android", "cpu": "arm" }, "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.9", "", { "os": "android", "cpu": "arm64" }, "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg=="], @@ -269,6 +280,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.0.9", "", { "dependencies": { "@tailwindcss/node": "4.0.9", "@tailwindcss/oxide": "4.0.9", "lightningcss": "^1.29.1", "tailwindcss": "4.0.9" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g=="], + "@tanstack/query-core": ["@tanstack/query-core@5.66.11", "", {}, "sha512-ZEYxgHUcohj3sHkbRaw0gYwFxjY5O6M3IXOYXEun7E1rqNhsP8fOtqjJTKPZpVHcdIdrmX4lzZctT4+pts0OgA=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.66.11", "", { "dependencies": { "@tanstack/query-core": "5.66.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uPDiQbZScWkAeihmZ9gAm3wOBA1TmLB1KCB1fJ1hIiEKq3dTT+ja/aYM7wGUD+XiEsY4sDSE7p8VIz/21L2Dow=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 85a85ec..9b45c78 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,9 +12,12 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@tailwindcss/vite": "^4.0.9", + "@tanstack/react-query": "^5.66.11", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index de38f23..86735e1 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Routes, Route } from "react-router"; import "./styles/globals.css"; +import { Routes, Route } from "react-router"; import { NavbarLayout } from "./widgets/navbar-layout"; @@ -8,24 +8,35 @@ import Competition from "./pages/Competition"; import CompetitionSession from "./pages/CompetitionSession"; import LoginPage from "./pages/Login"; import { AuthLayout } from "./widgets/auth-layout"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import ReviewPage from "./pages/Review"; +import UserProfile from "./pages/UserProfile"; + +const queryClient = new QueryClient(); const App = () => { return ( - - } /> + + + } /> - }> - }> - } /> - } /> + }> + }> + } /> + } /> + + + } + /> + + } /> + + } /> - - } - /> - - + + ); }; diff --git a/services/frontend/src/components/ui/button.tsx b/services/frontend/src/components/ui/button.tsx index 6a5e720..11b8b06 100644 --- a/services/frontend/src/components/ui/button.tsx +++ b/services/frontend/src/components/ui/button.tsx @@ -14,15 +14,14 @@ const buttonVariants = cva( "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + secondary: "bg-card text-secondary-foreground hover:bg-card/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-11 px-4 text-base font-semibold rounded-xl", lg: "h-12 px-5 py-3 has-[>svg]:px-3 text-lg font-semibold", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + sm: "h-10 rounded-xl gap-1.5 px-5 has-[>svg]:px-2.5", icon: "size-9", }, }, diff --git a/services/frontend/src/components/ui/dialog.tsx b/services/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b8b9407 --- /dev/null +++ b/services/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/services/frontend/src/components/ui/icons/datarush-review.tsx b/services/frontend/src/components/ui/icons/datarush-review.tsx new file mode 100644 index 0000000..97edab8 --- /dev/null +++ b/services/frontend/src/components/ui/icons/datarush-review.tsx @@ -0,0 +1,31 @@ +export const DataRushReview = ({ + size = 50, + className, +}: { + size?: number; + className?: string; +}) => { + return ( + + + + + + + ); +}; diff --git a/services/frontend/src/components/ui/icons/datarush.tsx b/services/frontend/src/components/ui/icons/datarush.tsx index 0cebbe9..4a0f8f4 100644 --- a/services/frontend/src/components/ui/icons/datarush.tsx +++ b/services/frontend/src/components/ui/icons/datarush.tsx @@ -1,5 +1,5 @@ const DataRush = ({ - size = 52, + size = 50, className, }: { size?: number; @@ -8,18 +8,18 @@ const DataRush = ({ return ( - + diff --git a/services/frontend/src/components/ui/label.tsx b/services/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..73ec5bf --- /dev/null +++ b/services/frontend/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/shared/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/services/frontend/src/components/ui/loading.tsx b/services/frontend/src/components/ui/loading.tsx new file mode 100644 index 0000000..7cb1272 --- /dev/null +++ b/services/frontend/src/components/ui/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "./spinner"; + +export const Loading = () => { + return ( +
+ +
+ ); +}; diff --git a/services/frontend/src/components/ui/radio-group.tsx b/services/frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..89a0f27 --- /dev/null +++ b/services/frontend/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { CircleIcon } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index 080ee46..2c87809 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -1,29 +1,58 @@ -import { useState } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import ReactMarkdown from "react-markdown"; -import { Competition } from "@/shared/types"; -import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { getCompetition, startCompetition } from "@/shared/api/competitions"; +import { getCompetitionTasks } from "@/shared/api/session"; +import { Loading } from "@/components/ui/loading"; const CompetitionPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [competition] = useState( - mockCompetitions.find((comp) => comp.id === id)!, - ); + const competitionId = id || ""; - const handleContinue = () => { - if (competition?.id) { - if (mockTasks && mockTasks.length > 0) { - const firstTaskId = mockTasks[0].id; - navigate(`/competition/${competition.id}/tasks/${firstTaskId}`); - } else { - navigate(`/competition/${competition.id}/tasks`); + const competitionQuery = useQuery({ + queryKey: ["competition", competitionId], + queryFn: () => getCompetition(competitionId), + enabled: !!competitionId, + }); + + const startMutation = useMutation({ + mutationFn: () => startCompetition(competitionId), + onSuccess: async () => { + try { + const tasks = await getCompetitionTasks(competitionId); + + if (tasks && tasks.length > 0) { + navigate(`/competition/${competitionId}/tasks/${tasks[0].id}`); + } else { + navigate(`/competition/${competitionId}/tasks`); + } + } catch (error) { + console.error("Failed to fetch tasks:", error); + navigate(`/competition/${competitionId}/tasks`); } + }, + onError: (error) => { + console.error("Failed to start competition:", error); } + }); + + const handleStart = () => { + startMutation.mutate(); }; + if (competitionQuery.isLoading) { + return ; + } + + if (!competitionId || !competitionQuery.data) { + return <>; + } + + const competition = competitionQuery.data; + return (
{
{competition.name}
@@ -46,15 +75,19 @@ const CompetitionPage = () => {

- {competition.name} + {competition.title}

{competition.description || ""}
-
@@ -63,4 +96,4 @@ const CompetitionPage = () => { ); }; -export default CompetitionPage; +export default CompetitionPage; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index 7357b52..405d5c0 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Task } from "@/shared/types"; -import { getTaskBgColor, getTaskTextColor } from '../../utils/utils'; +import { Task } from '@/shared/types/task'; interface CompetitionHeaderProps { title: string; @@ -15,7 +14,7 @@ const CompetitionHeader: React.FC = ({ competitionId }) => { return ( -
+

@@ -28,12 +27,12 @@ const CompetitionHeader: React.FC = ({ - {task.number} + {task.in_competition_position} ))}

diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx index af3e4f4..0c49ca3 100644 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx @@ -3,53 +3,78 @@ import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; -import { Task } from "@/shared/types"; +import { Task } from '@/shared/types/task'; +import { useQuery } from '@tanstack/react-query'; +import { getTaskAttachments } from '@/shared/api/session'; +import { FileIcon, Loader2 } from 'lucide-react'; +import { useParams } from 'react-router-dom'; interface TaskContentProps { task: Task; } const TaskContent: React.FC = ({ task }) => { - const markdownContent = ` -## Задача на числовую последовательность + const { id: competitionId } = useParams<{ id: string }>(); -Рассмотрим последовательность чисел: -\`2, 3, 5, 9, 17, 33, 65, 129, ...\` + const attachmentsQuery = useQuery({ + queryKey: ['taskAttachments', competitionId, task.id], + queryFn: () => getTaskAttachments(competitionId || '', task.id), + enabled: !!(competitionId && task.id), + }); -Каждый член этой последовательности, **начиная с третьего**, равен сумме двух предыдущих членов: -- $a_1 = 2$ -- $a_2 = 3$ -- $a_n = a_{n-1} + a_{n-2}$ для всех $n ≥ 3$ - -### Задание: -Найдите сумму первых 15 членов этой последовательности. - -*Примечание:* Для решения задачи вам может быть полезно записать несколько первых членов последовательности: -1. $a_1 = 2$ -2. $a_2 = 3$ -3. $a_3 = 3 + 2 = 5$ -4. $a_4 = 5 + 3 = 8$ -5. $a_5 = 8 + 5 = 13$ - -**В ответе укажите целое число.** - `; + const attachments = attachmentsQuery.data || []; return (

- Задача {task.number} + Задача {task.in_competition_position}

-
+
- {markdownContent} + {task.description}
+ + {attachmentsQuery.isLoading ? ( +
+ + Загрузка файлов... +
+ ) : attachments.length > 0 ? ( +
+

Прикрепленные файлы

+
+ {attachments.map((attachment) => ( + + + + {getFileNameFromUrl(attachment.file)} + + + ))} +
+
+ ) : null}
); }; -export default TaskContent; +const getFileNameFromUrl = (url: string): string => { + try { + const parts = url.split('/'); + return parts[parts.length - 1]; + } catch (e) { + return 'Файл'; + } +}; + +export default TaskContent; \ 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 2d2b09b..451968e 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,53 +1,88 @@ import { useState } from "react"; import { useParams, Navigate } from "react-router-dom"; -import { Task } from "@/shared/types"; -import { mockSolutions, mockTasks } from "@/shared/mocks/mocks"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; +import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; +import { Loader2 } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); - const [tasks] = useState(mockTasks); const [answer, setAnswer] = useState(""); + const competitionId = id || ""; + const queryClient = useQueryClient(); - const currentTask = tasks.find(t => t.id === taskId) || null; + const tasksQuery = useQuery({ + queryKey: ["competitionTasks", competitionId], + queryFn: () => getCompetitionTasks(competitionId), + enabled: !!competitionId, + }); - if (!taskId && tasks.length > 0) { - return ; + const submitMutation = useMutation({ + mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['submissionHistory', competitionId, taskId] + }); + setAnswer(""); + } + }); + + const tasks = tasksQuery.data || []; + const isLoading = tasksQuery.isLoading; + const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null; + + const currentTask = tasks.find((t) => t.id === taskId) || null; + + if (!taskId && tasks.length > 0 && !isLoading) { + return ( + + ); } const handleSubmit = () => { - console.log("Submitting answer:", answer); + console.log(currentTask, competitionId, answer) + if (!currentTask || !competitionId || !answer.trim()) return; + submitMutation.mutate(); }; - return ( -
- + - +
-
- {currentTask ? ( -
+
+ {isLoading ? ( +
+ +

Загрузка заданий...

+
+ ) : error ? ( +
+

{error}

+
+ ) : currentTask ? ( +
-
) : ( -
-

- Загрузка задания... -

+
+

Задание не найдено

)}
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx index 4aa7c62..ca1953e 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx @@ -1,48 +1,31 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Button } from "@/components/ui/button"; -import SolutionHistorySheet from '../SolutionHistorySheet'; -import { Solution } from "@/shared/types"; -import { mockSolutions } from '@/shared/mocks/mocks'; interface ActionButtonsProps { onSubmit: () => void; - solutionHistory?: Solution[]; + onHistoryClick: () => void; } const ActionButtons: React.FC = ({ onSubmit, - solutionHistory = mockSolutions + onHistoryClick }) => { - const [isHistoryOpen, setIsHistoryOpen] = useState(false); - - const handleHistoryClick = () => { - setIsHistoryOpen(true); - }; - 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 index dcaaa82..dc8fe95 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx @@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp import { Button } from "@/components/ui/button"; import { X } from "lucide-react"; import SolutionStatus from '../SolutionStatus'; -import { Solution } from "@/shared/types"; +import { Solution } from '@/shared/types/task'; interface SolutionHistorySheetProps { isOpen: boolean; onOpenChange: (open: boolean) => void; solutions: Solution[]; + maxPoints: number } const SolutionHistorySheet: React.FC = ({ isOpen, onOpenChange, - solutions + solutions, + maxPoints }) => { return ( @@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC = ({ {solutions.length > 0 ? ( solutions.map((solution, index) => (
- +
)) ) : ( 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 dbe77e9..33a412a 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,41 +1,26 @@ import React from 'react'; -import { Solution, TaskStatus } from "@/shared/types"; -import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; +import { Solution } from '@/shared/types/task'; +import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils'; interface SolutionStatusProps { solution: Solution; + maxPoints: number; } -const SolutionStatus: React.FC = ({ solution }) => { - const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => { - switch (status) { - case TaskStatus.Checking: - return 'На проверке'; - case TaskStatus.Wrong: - return 'Неверный ответ'; - case TaskStatus.Correct: - return `Зачтено ${maxScore}/${maxScore} баллов`; - case TaskStatus.Partial: - return `Зачтено ${score}/${maxScore} баллов`; - case TaskStatus.Uncleared: - return 'Не решено'; - default: - return ''; - } - }; - +const SolutionStatus: React.FC = ({ solution, maxPoints }) => { + return ( -
+
- + Решение {solution.id} - - {getStatusText(solution.status, solution.score, solution.maxScore)} + + {getStatusText(solution.status, solution.earned_points, maxPoints)}
-
- {solution.date} +
+ {solution.timestamp}
); diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 9c25bf8..d4b9c2c 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,10 +1,14 @@ import React, { useState, useRef } from 'react'; -import { Solution, Task } from "@/shared/types"; +import { useParams } from 'react-router-dom'; +import { Task, TaskType, Solution } from '@/shared/types/task'; +import { useQuery } from '@tanstack/react-query'; +import { getTaskSolutionHistory } from '@/shared/api/session'; import SolutionStatus from './components/SolutionStatus'; import InputSolution from './components/InputSolution'; import FileSolution from './components/FileSolution'; import CodeSolution from './components/CodeSolution'; import ActionButtons from './components/ActionButtons'; +import SolutionHistorySheet from './components/SolutionHistorySheet'; interface TaskSolutionProps { task: Task; @@ -12,28 +16,49 @@ interface TaskSolutionProps { answer: string; setAnswer: (value: string) => void; onSubmit: () => void; - } const TaskSolution: React.FC = ({ task, - solutions, + solutions = [], answer, setAnswer, onSubmit, }) => { const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const { id: competitionId } = useParams<{ id: string }>(); + + const solutionsQuery = useQuery({ + queryKey: ['solutionHistory', competitionId, task.id], + queryFn: () => getTaskSolutionHistory(competitionId || '', task.id), + enabled: !!(competitionId && task.id), + }); + + const solutionHistory = solutionsQuery.data || []; + + const handleOpenHistory = () => { + setIsHistoryOpen(true); + }; + + const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null; return (
- + {latestSolution ? ( + + ) : ( +
+ Решение еще не отправлено +
+ )} - {task.solutionType === 'input' && ( + {task.type === TaskType.INPUT && ( )} - {task.solutionType === 'file' && ( + {task.type === TaskType.FILE && ( = ({ /> )} - {task.solutionType === 'code' && ( + {task.type === TaskType.CODE && ( )} - + + +
); }; diff --git a/services/frontend/src/pages/CompetitionSession/utils/utils.ts b/services/frontend/src/pages/CompetitionSession/utils/utils.ts index 9ba336e..fa21f4c 100644 --- a/services/frontend/src/pages/CompetitionSession/utils/utils.ts +++ b/services/frontend/src/pages/CompetitionSession/utils/utils.ts @@ -1,4 +1,5 @@ import { TaskStatus } from "@/shared/types"; +import { SolutionStatus } from "@/shared/types/task"; const getTaskBgColor = (status: TaskStatus): string => { switch (status) { case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]"; @@ -19,4 +20,39 @@ const getTaskTextColor = (status: TaskStatus): string => { } }; -export {getTaskBgColor, getTaskTextColor} \ No newline at end of file +const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]"; + case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "text-[var(--color-task-wrong)]"; + else if (earned_points === maxPoints) "text-[var(--color-task-correct)]"; + return "text-[var(--color-task-partial)]"; + } + } +} + +const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]"; + case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "text-[var(--color-task-text-wrong)]"; + else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]"; + return "text-[var(--color-task-text-partial)]"; + } + } +} + +const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "Решение отправлено"; + case SolutionStatus.CHECKING: return "Решение проверяется"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "Неверный ответ"; + else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`; + return `Зачтено ${earned_points}/${maxPoints} баллов`; + } + } +} +export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText} \ No newline at end of file diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx index ad5edb0..67e8950 100644 --- a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx +++ b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx @@ -1,6 +1,10 @@ -import { Competition, CompetitionStatus } from "@/shared/types"; import { cn } from "@/shared/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; +import { + Competition, + CompetitionState, + CompetitionType, +} from "@/shared/types/competition"; interface CompetitionCardProps { competition: Competition; @@ -17,8 +21,8 @@ export function CompetitionCard({ >
{competition.name}
@@ -26,18 +30,24 @@ export function CompetitionCard({
- {competition.isOlympics ? "Олимпиада" : "Тренировка"} - {competition.status != CompetitionStatus.NotParticipating && ( + + {competition.type === CompetitionType.COMPETITIVE + ? "Соревнование" + : "Тренировка"} + + {competition.state != CompetitionState.NOT_STARTED && ( <> - {competition.status} + {competition.state === CompetitionState.STARTED + ? "В прогрессе" + : "Завершено"} )}

- {competition.name} + {competition.title}

diff --git a/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx deleted file mode 100644 index 445688f..0000000 --- a/services/frontend/src/pages/Competitions/components/CompetitionTag/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { cn } from "@/shared/lib/utils"; -import { Badge } from "@/components/ui/badge"; - -interface CompetitionTagProps { - label: string; - variant: 'olympics' | 'status'; - className?: string; -} - -const CompetitionTag = ({ label, variant, className }: CompetitionTagProps) => { - return ( - - {label} - - ); -} - -export default CompetitionTag \ No newline at end of file diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index cd09103..e818fa2 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -1,49 +1,96 @@ -import { useState } from "react"; -import { Competition, CompetitionStatus } from "@/shared/types"; -import { CompetitionGrid } from "./modules/CompetitionGrid"; +import React, { useState } from "react"; +import { CompetitionGrid } from "./modules/CompetitionsGrid"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { mockCompetitions } from "@/shared/mocks/mocks"; +import { useQuery } from "@tanstack/react-query"; +import { getCompetitions } from "@/shared/api/competitions"; +import { NoCompetitions } from "./modules/NoCompetitions"; +import { TabsContent } from "@radix-ui/react-tabs"; +import { Loading } from "@/components/ui/loading"; +import { CompetitionState } from "@/shared/types/competition"; + +enum CompetitionTab { + ONGOING = "ongoing", + COMPLETED = "completed", +} const CompetitionsPage = () => { - const [competitions] = useState(mockCompetitions); - const [activeTab, setActiveTab] = useState("ongoing"); + const [activeTab, setActiveTab] = useState(CompetitionTab.ONGOING); - const myCompetitions = competitions.filter( - (comp) => - comp.status === CompetitionStatus.InProgress || - comp.status === CompetitionStatus.Completed, + const activeCompetitionsQuery = useQuery({ + queryKey: ["active-competitions"], + queryFn: async () => getCompetitions(true), + retry: 1, + }); + + const inactiveCompetitionsQuery = useQuery({ + queryKey: ["inactive-competitions"], + queryFn: async () => getCompetitions(false), + retry: 1, + }); + + const startedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.STARTED, + ), + [activeCompetitionsQuery.data], ); - const filteredMyCompetitions = myCompetitions.filter((comp) => - activeTab === "ongoing" - ? comp.status === CompetitionStatus.InProgress - : comp.status === CompetitionStatus.Completed, + const finishedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.FINISHED, + ), + [activeCompetitionsQuery.data], ); - const availableCompetitions = competitions.filter( - (comp) => comp.status === "Не участвую", - ); + if ( + activeCompetitionsQuery.isLoading || + inactiveCompetitionsQuery.isLoading + ) { + return ; + } return (
-
- - Мои события + {(activeCompetitionsQuery.data ?? []).length > 0 && ( +
- - В процессе - Завершенные - + + Мои события + + + + В процессе + + + Завершенные + + + + + + + + + + + - - -
+
+ )}
События - + {(inactiveCompetitionsQuery.data ?? []).length > 0 ? ( + + ) : ( + + )}
); @@ -65,4 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => { return

{children}

; }; -export default CompetitionsPage; + +export default CompetitionsPage; \ No newline at end of file diff --git a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx similarity index 80% rename from services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx rename to services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx index 11d6289..60ac1fb 100644 --- a/services/frontend/src/pages/Competitions/modules/CompetitionGrid/index.tsx +++ b/services/frontend/src/pages/Competitions/modules/CompetitionsGrid.tsx @@ -1,5 +1,5 @@ -import { Competition } from "@/shared/types"; -import { CompetitionCard } from "../../components/CompetitionCard"; +import { Competition } from "@/shared/types/competition"; +import { CompetitionCard } from "../components/CompetitionCard"; import { Link } from "react-router"; interface CompetitionGridProps { diff --git a/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx new file mode 100644 index 0000000..8b71193 --- /dev/null +++ b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx @@ -0,0 +1,15 @@ +import { Ban } from "lucide-react"; + +export const NoCompetitions = () => { + return ( +
+ +
+

Событий нет

+

+ Увы, очередная победа.рф +

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Login/index.tsx b/services/frontend/src/pages/Login/index.tsx index 508b4ea..d7c4d88 100644 --- a/services/frontend/src/pages/Login/index.tsx +++ b/services/frontend/src/pages/Login/index.tsx @@ -18,7 +18,7 @@ const LoginPage = () => { return (
- +

Добро пожаловать! diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index e69de29..233b3c0 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -0,0 +1,51 @@ +import { Loading } from "@/components/ui/loading"; +import { getReviewer, getReviewerSubmissions } from "@/shared/api/review"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { ReviewHeader } from "./modules/review-header"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const ReviewPage = () => { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + + const reviewerQuery = useQuery({ + queryKey: ["reviewer", token], + queryFn: async () => getReviewer(token || ""), + retry: 0, + }); + const submissionsQuery = useQuery({ + queryKey: ["submissions", token], + queryFn: async () => getReviewerSubmissions(token || ""), + retry: 0, + }); + + if (reviewerQuery.isLoading || submissionsQuery.isLoading) { + return ; + } + + if (!token || !reviewerQuery.data || !submissionsQuery.data) { + navigate("/"); + return; + } + + return ( +
+
+ + + +
+

Посылки

+ + Доступные + Проверенные + +
+
+
+
+ ); +}; + +export default ReviewPage; diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/modules/review-header.tsx new file mode 100644 index 0000000..d27e9e8 --- /dev/null +++ b/services/frontend/src/pages/Review/modules/review-header.tsx @@ -0,0 +1,27 @@ +import { buttonVariants } from "@/components/ui/button"; +import { DataRushReview } from "@/components/ui/icons/datarush-review"; +import { Reviewer } from "@/shared/types/review"; +import { Link } from "react-router"; + +interface ReviewHeaderProps { + reviewer: Reviewer; +} + +export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => { + return ( +
+ +
+

+ {reviewer.name} {reviewer.surname} +

+ + Выйти + +
+
+ ); +}; diff --git a/services/frontend/src/pages/UserProfile/index.tsx b/services/frontend/src/pages/UserProfile/index.tsx new file mode 100644 index 0000000..ec9d1d1 --- /dev/null +++ b/services/frontend/src/pages/UserProfile/index.tsx @@ -0,0 +1,398 @@ +import React from "react"; +import { User } from "lucide-react"; +import { useUserStore } from "@/shared/stores/user"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const UserProfile = () => { + const user = useUserStore((state) => state.user); + + return ( +
+
+
+ {user?.avatar ? ( + {user.username} + ) : ( + + )} +
+
+

{user?.username}

+

+ {user?.role || "Участник"} • На платформе с{" "} + {new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", { + year: "numeric", + month: "long", + })} +

+
+
+ + + + + Информация + + + Статистика + + + Достижения + + + + + + + + + + + + + + + +
+ ); +}; + +const UserInfo = () => { + const user = useUserStore((state) => state.user); + + return ( + + + Личная информация + + +
+
+

+ Полное имя +

+

+ {user?.fullName || "Не указано"} +

+
+ +
+

+ Email +

+

{user?.email || "Не указано"}

+
+ +
+

+ Учебное заведение +

+

+ {user?.university || "Не указано"} +

+
+ +
+

+ Специализация +

+

+ {user?.specialization || "Не указано"} +

+
+
+ +
+

+ О себе +

+

+ {user?.bio || "Пользователь пока не добавил информацию о себе."} +

+
+
+
+ ); +}; + +const UserStatistics = () => { + // Mock statistics data + const statistics = { + totalCompetitions: 12, + completedCompetitions: 8, + totalScore: 756, + averageScore: 94.5, + bestResult: { + competition: "Олимпиада DANO 2024", + place: 3, + score: 97, + }, + totalTasks: 86, + solvedTasks: 72, + tasksByStatus: { + correct: 58, + partial: 14, + wrong: 9, + unattempted: 5, + }, + }; + + return ( +
+
+ + + + +
+ +
+ + + Лучший результат + + +
+

+ {statistics.bestResult.competition} +

+
+ Место + + {statistics.bestResult.place} + +
+
+ Баллы + + {statistics.bestResult.score} + +
+
+
+
+ + + + Решение задач + + +
+
+ Всего задач + + {statistics.totalTasks} + +
+
+ Решено задач + + {statistics.solvedTasks} + +
+
+ +
+

+ Статусы решений +

+
+
+
+
+
+
+
+
+
+
+
+ + Верно ({statistics.tasksByStatus.correct}) + +
+
+
+ + Частично ({statistics.tasksByStatus.partial}) + +
+
+
+ + Неверно ({statistics.tasksByStatus.wrong}) + +
+
+
+
+
+
+
+ ); +}; + + +const StatCard = ({ title, value }: { title: string; value: number | string }) => ( + + +

{title}

+

{value}

+
+
+); + +const UserAchievements = () => { + const achievements = [ + { + id: 1, + name: "Первые шаги", + description: "Участие в первом соревновании", + imageUrl: "/achievements/first-steps.png", + unlocked: true, + }, + { + id: 2, + name: "Восходящая звезда", + description: "Победа в соревновании", + imageUrl: "/achievements/rising-star.png", + unlocked: true, + }, + { + id: 3, + name: "Мастер кода", + description: "Решите 50 задач на программирование", + imageUrl: "/achievements/code-master.png", + unlocked: true, + }, + { + id: 4, + name: "Бронзовый призер", + description: "Займите 3 место в соревновании", + imageUrl: "/achievements/bronze.png", + unlocked: true, + }, + { + id: 5, + name: "Серебряный призер", + description: "Займите 2 место в соревновании", + imageUrl: "/achievements/silver.png", + unlocked: false, + }, + { + id: 6, + name: "Золотой призер", + description: "Займите 1 место в соревновании", + imageUrl: "/achievements/gold.png", + unlocked: false, + }, + { + id: 7, + name: "Марафонец", + description: "Участвуйте в 10 соревнованиях", + imageUrl: "/achievements/marathon.png", + unlocked: false, + }, + { + id: 8, + name: "Идеальное решение", + description: "Получите максимальные баллы за все задачи в соревновании", + imageUrl: "/achievements/perfect.png", + unlocked: false, + }, + ]; + + return ( +
+

+ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} +

+ +
+ {achievements.map((achievement) => ( +
+
+ {achievement.imageUrl ? ( +
+
+
+ ) : ( +
+ + {achievement.name.substring(0, 1)} + +
+ )} +
+

+ {achievement.name} +

+

+ {achievement.description} +

+
+ ))} +
+
+ ); +}; + +export default UserProfile; \ No newline at end of file diff --git a/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx new file mode 100644 index 0000000..a713aa0 --- /dev/null +++ b/services/frontend/src/pages/UserProfile/modules/UserAchievements/index.tsx @@ -0,0 +1,45 @@ +const UserAchievements = () => { + return ( +
+

+ Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length} +

+ +
+ {achievements.map((achievement) => ( +
+
+ {achievement.imageUrl ? ( +
+
+
+ ) : ( +
+ + {achievement.name.substring(0, 1)} + +
+ )} +
+

+ {achievement.name} +

+

+ {achievement.description} +

+
+ ))} +
+
+ ); +}; + +export default UserAchievements \ No newline at end of file diff --git a/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx b/services/frontend/src/pages/UserProfile/modules/UserStatistics/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/services/frontend/src/shared/api/auth.ts b/services/frontend/src/shared/api/auth.ts index 901a4e1..58e5c77 100644 --- a/services/frontend/src/shared/api/auth.ts +++ b/services/frontend/src/shared/api/auth.ts @@ -1,4 +1,4 @@ -import { authFetch } from "."; +import { apiFetch } from "."; interface AuthResponse { token: string; @@ -9,14 +9,14 @@ export const signup = async (body: { username: string; password: string; }) => { - return await authFetch("/sign-up", { + return await apiFetch("/sign-up", { method: "POST", body, }); }; export const login = async (body: { email: string; password: string }) => { - return await authFetch("/sign-in", { + return await apiFetch("/sign-in", { method: "POST", body, }); diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts new file mode 100644 index 0000000..8c47564 --- /dev/null +++ b/services/frontend/src/shared/api/competitions.ts @@ -0,0 +1,20 @@ +import { userFetch } from "."; +import { Competition } from "../types/competition"; + +export const getCompetitions = async (participating?: boolean) => { + return await userFetch("/competitions", { + params: { + is_participating: participating, + }, + }); +}; + +export const getCompetition = async (id: string) => { + return await userFetch(`/competition/${id}`); +}; + +export const startCompetition = async (competitionId: string) => { + return await userFetch(`/competitions/${competitionId}/start`, { + method: 'POST' + }); +}; \ No newline at end of file diff --git a/services/frontend/src/shared/api/index.ts b/services/frontend/src/shared/api/index.ts index 8772105..0616a21 100644 --- a/services/frontend/src/shared/api/index.ts +++ b/services/frontend/src/shared/api/index.ts @@ -14,17 +14,16 @@ export class ApiError extends Error { } } -export const authFetch = ofetch.create({ +export const apiFetch = ofetch.create({ baseURL: BASE_URL, async onResponseError({ response }) { throw new ApiError(response); }, }); -export const apiFetch = ofetch.create({ +export const userFetch = ofetch.create({ baseURL: BASE_URL, async onRequest({ options }) { - console.log(import.meta.env.VITE_API_ENDPOINT); options.headers.set("Authorization", "Bearer " + getToken()); }, async onResponseError({ response }) { diff --git a/services/frontend/src/shared/api/review.ts b/services/frontend/src/shared/api/review.ts new file mode 100644 index 0000000..887999b --- /dev/null +++ b/services/frontend/src/shared/api/review.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "."; +import { Reviewer } from "../types/review"; + +export const getReviewer = async (token: string) => { + return await apiFetch(`/review/${token}`); +}; + +export const getReviewerSubmissions = async (token: string) => { + return await apiFetch(`/review/${token}/submissions`); +}; diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts new file mode 100644 index 0000000..05b0c01 --- /dev/null +++ b/services/frontend/src/shared/api/session.ts @@ -0,0 +1,38 @@ +import { userFetch } from "."; +import { Task, Solution, TaskAttachment } from "../types/task"; + +export const getCompetitionTasks = async (competitionId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks`); +}; + +export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/history`); +}; + +export const getTaskAttachments = async (competitionId: string, taskId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/attachments`); +}; + + +export const submitTaskSolution = async ( + competitionId: string, + taskId: string, + solution: string | File +) => { + const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`; + console.log("SUBMIT ", taskId, competitionId, solution) + if (typeof solution === 'string') { + return await userFetch(endpoint, { + method: 'POST', + body: { answer: solution } + }); + } else { + const formData = new FormData(); + formData.append('file', solution); + + return await userFetch(endpoint, { + method: 'POST', + body: formData + }); + } +}; \ No newline at end of file diff --git a/services/frontend/src/shared/api/user.ts b/services/frontend/src/shared/api/user.ts index b71c15f..84b000d 100644 --- a/services/frontend/src/shared/api/user.ts +++ b/services/frontend/src/shared/api/user.ts @@ -1,6 +1,6 @@ -import { apiFetch } from "."; +import { userFetch } from "."; import { User } from "../types/user"; export const getCurrentUser = async () => { - return await apiFetch("/me"); + return await userFetch("/me"); }; diff --git a/services/frontend/src/shared/mocks/mocks.ts b/services/frontend/src/shared/mocks/mocks.ts index b7e2525..d1f24aa 100644 --- a/services/frontend/src/shared/mocks/mocks.ts +++ b/services/frontend/src/shared/mocks/mocks.ts @@ -57,49 +57,70 @@ const mockTasks: Task[] = [ id: "1", number: "1.1", status: TaskStatus.Uncleared, - solutionType: "input" + solutionType: "input", + description: "123", + maxScore: 10, }, { id: "2", number: "1.2", status: TaskStatus.Checking, - solutionType: "file" + solutionType: "file", + description: "123", + maxScore: 20, }, { id: "3", number: "1.3", status: TaskStatus.Correct, - solutionType: "code" + solutionType: "code", + description: "123", + maxScore: 20, }, { id: "4", number: "2.1", status: TaskStatus.Partial, - solutionType: "input" + solutionType: "input", + description: "123", + maxScore: 20, + }, { id: "5", number: "2.2", status: TaskStatus.Wrong, - solutionType: "file" + solutionType: "file", + description: "123", + maxScore: 20, + }, { id: "6", number: "2.3", status: TaskStatus.Uncleared, - solutionType: "code" + solutionType: "code", + description: "123", + maxScore: 20, + }, { id: "7", number: "3.1", status: TaskStatus.Checking, - solutionType: "file" + solutionType: "file", + description: "123", + maxScore: 20, + }, { id: "8", number: "3.2", status: TaskStatus.Correct, - solutionType: "input" + solutionType: "input", + description: "123", + maxScore: 20, + }, ]; @@ -132,5 +153,84 @@ const mockSolutions: Solution[] = [ ]; +const mockAchievements = [ + { + id: 1, + name: "Первые шаги", + description: "Участие в первом соревновании", + imageUrl: "/achievements/first-steps.png", + unlocked: true, + }, + { + id: 2, + name: "Восходящая звезда", + description: "Победа в соревновании", + imageUrl: "/achievements/rising-star.png", + unlocked: true, + }, + { + id: 3, + name: "Мастер кода", + description: "Решите 50 задач на программирование", + imageUrl: "/achievements/code-master.png", + unlocked: true, + }, + { + id: 4, + name: "Бронзовый призер", + description: "Займите 3 место в соревновании", + imageUrl: "/achievements/bronze.png", + unlocked: true, + }, + { + id: 5, + name: "Серебряный призер", + description: "Займите 2 место в соревновании", + imageUrl: "/achievements/silver.png", + unlocked: false, + }, + { + id: 6, + name: "Золотой призер", + description: "Займите 1 место в соревновании", + imageUrl: "/achievements/gold.png", + unlocked: false, + }, + { + id: 7, + name: "Марафонец", + description: "Участвуйте в 10 соревнованиях", + imageUrl: "/achievements/marathon.png", + unlocked: false, + }, + { + id: 8, + name: "Идеальное решение", + description: "Получите максимальные баллы за все задачи в соревновании", + imageUrl: "/achievements/perfect.png", + unlocked: false, + }, +]; -export { mockCompetitions, mockTasks, mockSolutions }; + +const mockStatistics = { + totalCompetitions: 12, + completedCompetitions: 8, + totalScore: 756, + averageScore: 94.5, + bestResult: { + competition: "Олимпиада DANO 2024", + place: 3, + score: 97, + }, + totalTasks: 86, + solvedTasks: 72, + tasksByStatus: { + correct: 58, + partial: 14, + wrong: 9, + unattempted: 5, + }, +}; + +export { mockCompetitions, mockTasks, mockSolutions, mockAchievements, mockStatistics }; diff --git a/services/frontend/src/shared/types.ts b/services/frontend/src/shared/types.ts index 6645a59..083614d 100644 --- a/services/frontend/src/shared/types.ts +++ b/services/frontend/src/shared/types.ts @@ -12,6 +12,11 @@ enum TaskStatus { Wrong = "wrong" } +enum ParticipationType { + Solo = "solo", + Team = "team" +} + interface Competition { id: string; name: string; @@ -19,6 +24,9 @@ interface Competition { isOlympics: boolean; status: CompetitionStatus; description?: string; + startDate: Date; + endDate: Date; + participationType: ParticipationType } type SolutionType = "input" | "file" | "code"; @@ -30,12 +38,17 @@ interface Solution { score?: number, maxScore?: number, } + interface Task { id: string; number: string; + description: string; + maxScore: number; status: TaskStatus; solutionType: SolutionType; + requirements?: string; + attachments?: string[]; } -export { CompetitionStatus, TaskStatus }; +export { CompetitionStatus, TaskStatus, ParticipationType }; export type { Solution, Competition, Task }; diff --git a/services/frontend/src/shared/types/competition.ts b/services/frontend/src/shared/types/competition.ts new file mode 100644 index 0000000..beea20e --- /dev/null +++ b/services/frontend/src/shared/types/competition.ts @@ -0,0 +1,26 @@ +export interface Competition { + id: string; + title: string; + description: string; + state: CompetitionState; + image_url?: string; + start_date?: Date; + end_date?: Date; + type: CompetitionType; + participation_type: CompetitionParticipationType; +} + +export enum CompetitionState { + NOT_STARTED = "not_started", + STARTED = "started", + FINISHED = "finished", +} + +export enum CompetitionType { + EDU = "edu", + COMPETITIVE = "competitive", +} + +export enum CompetitionParticipationType { + SOLO = "solo", +} diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts new file mode 100644 index 0000000..f3a9094 --- /dev/null +++ b/services/frontend/src/shared/types/review.ts @@ -0,0 +1,5 @@ +export interface Reviewer { + id: string; + name: string; + surname: string; +} diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts new file mode 100644 index 0000000..07c6fe1 --- /dev/null +++ b/services/frontend/src/shared/types/task.ts @@ -0,0 +1,36 @@ +interface Task { + id: string; + title: string; + description: string; + type: TaskType; + in_competition_position: number; + points: number; +} + +export interface TaskAttachment { + id: string; + file: string; + public: boolean; +} + +enum TaskType { + INPUT = "input", + FILE = "checker", + CODE = "review", +} + +enum SolutionStatus { + SENT = "sent", + CHECKING = "checking", + CHECKED = "checked", +} + +interface Solution { + id: string, + status: SolutionStatus, + timestamp: string, + earned_points: number +} + +export type {Task, Solution} +export {TaskType, SolutionStatus} \ No newline at end of file diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index 644cc81..860667b 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -88,7 +88,6 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); - } @theme inline { @@ -120,6 +119,7 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --radius-6: calc(var(--radius) + 6px); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary);