diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 3e47f62..1b79004 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -37,6 +37,7 @@ 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") @staticmethod def resolve_criteries(self, context) -> list[CriteriaOut] | None: 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/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index bc3b9de..0b8b31c 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -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.") @@ -108,8 +108,14 @@ class Command(BaseCommand): ) 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 +139,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..c173af7 100644 --- a/services/backend/apps/review/admin.py +++ b/services/backend/apps/review/admin.py @@ -4,14 +4,6 @@ from apps.review.models import Review, Reviewer @admin.register(Reviewer) -class ReviewAdmin(admin.ModelAdmin): +class ReviewersAdmin(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",) 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 a7ff21f..5fd4fb9 100644 --- a/services/backend/apps/review/models.py +++ b/services/backend/apps/review/models.py @@ -27,7 +27,7 @@ class Review(BaseModel): 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="посылка" diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index ce7d553..a09f852 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -12,6 +12,9 @@ class CompletionAttachmentInline(admin.StackedInline): @admin.register(CompetitionTask) class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") + filter_horizontal = ( + "reviewers", + ) @admin.register(CompetitionTaskSubmission) @@ -19,7 +22,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): list_display = ("task", "user", "status",) search_fields = ("task__id", "task__title", "user__username", "user__email") filter = ("plagiarism_checked",) - ordering = "-timestamp" + ordering = ["-timestamp"] def has_add_permission(self, request, obj=None): return False 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_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_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/models.py b/services/backend/apps/task/models.py index ef8e94c..60e54c9 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -6,7 +6,7 @@ from tinymce.models import HTMLField from apps.competition.models import Competition from apps.core.models import BaseModel -from apps.review.models import Review, ReviewStatusChoices +from apps.review.models import Review, ReviewStatusChoices, Reviewer from apps.user.models import User @@ -50,6 +50,14 @@ class CompetitionTask(BaseModel): default="stdout", ) + # only when "review" type + reviewers = models.ManyToManyField( + Reviewer, + blank=True, + verbose_name="ревьюверы", + help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему" + ) + def __str__(self): return self.title @@ -107,12 +115,13 @@ class CompetitionTaskSubmission(BaseModel): # code or text or file content = models.FileField(upload_to=submission_content_upload_to, - verbose_name="код/файл посылки") + verbose_name="содержание посылки") # only if task type is checker stdout = models.FileField( upload_to=submission_stdout_upload_to, null=True, blank=True, - verbose_name="вывод чекера" + verbose_name="вывод программы", + help_text="Используется только при проверке чекером" ) # depends on task type: @@ -123,22 +132,22 @@ class CompetitionTaskSubmission(BaseModel): 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, - verbose_name="получено баллов") + verbose_name="баллы за задание") checked_at = models.DateTimeField(null=True, blank=True, - verbose_name="дата и время проверки") + verbose_name="дата проверки") plagiarism_checked = models.BooleanField(default=False, verbose_name="проверено на плагиат") timestamp = models.DateTimeField(auto_now_add=True, verbose_name="дата отправки") - def __str__(self): - return str(self.id) - 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 @@ -158,7 +167,7 @@ class CompetitionTaskSubmission(BaseModel): .order_by("pending_count") .first() ) - Review.objects.create( + review = Review.objects.create( reviewer=reviewer, submission=self, ) 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/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_created_at.py b/services/backend/apps/user/migrations/0002_user_created_at.py deleted file mode 100644 index 83094ec..0000000 --- a/services/backend/apps/user/migrations/0002_user_created_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 09:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='created_at', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index fad1d98..ecd048c 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -11,6 +11,7 @@ "@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", @@ -279,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 2134a7d..9b45c78 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -17,6 +17,7 @@ "@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 a0aa08b..9b3a5ae 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -8,47 +8,45 @@ 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 CompetitionConstructor from "./pages/CompetitionConstructor"; 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/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/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/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index 080ee46..b434fa8 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -1,17 +1,28 @@ -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 { mockTasks } from "@/shared/mocks/mocks"; +import { useQuery } from "@tanstack/react-query"; +import { getCompetition } from "@/shared/api/competitions"; +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 { data: competition, isLoading } = useQuery({ + queryKey: ["competition", id], + queryFn: async () => getCompetition(id || ""), + }); + + if (isLoading) { + return ; + } + + if (!id || !competition) { + return <>; + } const handleContinue = () => { if (competition?.id) { @@ -35,18 +46,20 @@ const CompetitionPage = () => {
-
- {competition.name} -
+ {competition.image_url && ( +
+ {competition.title} +
+ )}

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

{competition.description || ""} diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 4072f07..f924000 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -37,17 +37,21 @@ const CompetitionSession = () => { } }, [competitionId]); - const currentTask = tasks.find(t => t.id === taskId) || null; + const currentTask = tasks.find((t) => t.id === taskId) || null; if (!taskId && tasks.length > 0 && !loading) { - return ; + return ( + + ); } const handleSubmit = async () => { if (!currentTask || !competitionId) return; - - try { + try { console.log("Solution submitted successfully"); } catch (err) { console.error("Failed to submit solution:", err); @@ -55,32 +59,28 @@ const CompetitionSession = () => { }; return ( -
- + - +
-
+
{loading ? ( -
- -

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

+
+ +

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

) : error ? ( -
-

- {error} -

+
+

{error}

) : currentTask ? ( -
+
- { />
) : ( -
-

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

+
+

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

)}
@@ -101,4 +99,4 @@ const CompetitionSession = () => { ); }; -export default CompetitionSession; \ No newline at end of file +export default CompetitionSession; diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx index ad5edb0..8ae52eb 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; @@ -16,28 +20,36 @@ export function CompetitionCard({ className={cn("aspect-square h-full w-auto overflow-hidden", className)} >
- {competition.name} + {competition.image_url && ( + {competition.title} + )}
- {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/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index 97aa23e..511ac15 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -1,86 +1,95 @@ -import { useState, useEffect } 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 { Loader2 } from "lucide-react"; -import { getAllCompetitions } from "@/shared/api/competitions"; +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 [myCompetitions, setMyCompetitions] = useState([]); - const [availableCompetitions, setAvailableCompetitions] = useState([]); - const [activeTab, setActiveTab] = useState("ongoing"); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState(CompetitionTab.ONGOING); - useEffect(() => { - const fetchCompetitions = async () => { - try { - setLoading(true); - const { participating, nonParticipating } = await getAllCompetitions(); - setMyCompetitions(participating); - setAvailableCompetitions(nonParticipating); - setError(null); - } catch (err) { - console.error("Failed to fetch competitions:", err); - setError("Не удалось загрузить события. Пожалуйста, попробуйте позже."); - } finally { - setLoading(false); - } - }; + const activeCompetitionsQuery = useQuery({ + queryKey: ["active-competitions"], + queryFn: async () => getCompetitions(true), + retry: 1, + }); - fetchCompetitions(); - }, []); + const inactiveCompetitionsQuery = useQuery({ + queryKey: ["inactive-competitions"], + queryFn: async () => getCompetitions(false), + retry: 1, + }); - const filteredMyCompetitions = myCompetitions.filter((comp) => - activeTab === "ongoing" - ? comp.status === CompetitionStatus.InProgress - : comp.status === CompetitionStatus.Completed, + const startedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.STARTED, + ), + [activeCompetitionsQuery.data], ); - if (loading) { - return ( -
- -

Загрузка событий...

-
- ); - } + const finishedCompetitions = React.useMemo( + () => + (activeCompetitionsQuery.data ?? []).filter( + (comp) => comp.state === CompetitionState.FINISHED, + ), + [activeCompetitionsQuery.data], + ); - if (error) { - return ( -
-

{error}

-
- ); + if ( + activeCompetitionsQuery.isLoading || + inactiveCompetitionsQuery.isLoading + ) { + return ; } return (
-
- - Мои события + {(activeCompetitionsQuery.data ?? []).length > 0 && ( +
- - В процессе - Завершенные - + + Мои события + + + + В процессе + + + Завершенные + + + + + + + + + + + - - {filteredMyCompetitions.length > 0 ? ( - - ) : ( - - )} -
+
+ )}
События - {availableCompetitions.length > 0 ? ( - + {(inactiveCompetitionsQuery.data ?? []).length > 0 ? ( + ) : ( - + )}
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..4d21663 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/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 index 6ba02ad..5a5eba0 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -1,83 +1,14 @@ -import { apiFetch } from '.'; -import { Competition, CompetitionStatus, ParticipationType } from '@/shared/types'; +import { userFetch } from "."; +import { Competition } from "../types/competition"; -interface ApiCompetition { - id: string; - state: 'started' | 'not_started' | 'finished'; - title: string; - description: string; - image_url: string | null; - end_date: string; - start_date: string; - type: string; - participation_type: ParticipationType; -} - -const mapStateToStatus = (state: string, isParticipating: boolean): CompetitionStatus => { - if (!isParticipating) { - return CompetitionStatus.NotParticipating; - } - - switch (state) { - case 'started': - return CompetitionStatus.InProgress; - case 'finished': - return CompetitionStatus.Completed; - case 'not_started': - return CompetitionStatus.InProgress; - default: - return CompetitionStatus.NotParticipating; - } +export const getCompetitions = async (participating?: boolean) => { + return await userFetch("/competitions", { + params: { + is_participating: participating, + }, + }); }; -const transformApiCompetition = (apiComp: ApiCompetition, isParticipating: boolean): Competition => { - return { - id: apiComp.id, - name: apiComp.title, - imageUrl: apiComp.image_url || '/DANO.png', - isOlympics: apiComp.type !== 'edu', - status: mapStateToStatus(apiComp.state, isParticipating), - description: apiComp.description, - startDate: new Date(apiComp.start_date), - endDate: new Date(apiComp.end_date), - participationType: apiComp.participation_type - }; +export const getCompetition = async (id: string) => { + return await userFetch(`/competition/${id}`); }; - -export const getParticipatingCompetitions = async (): Promise => { - try { - const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', { - query: { is_participating: true } - }); - - return apiCompetitions.map(comp => transformApiCompetition(comp, true)); - } catch (error) { - console.error('Failed to fetch participating competitions:', error); - throw error; - } -}; - -export const getNonParticipatingCompetitions = async (): Promise => { - try { - const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', { - query: { is_participating: false } - }); - - return apiCompetitions.map(comp => transformApiCompetition(comp, false)); - } catch (error) { - console.error('Failed to fetch non-participating competitions:', error); - throw error; - } -}; - -export const getAllCompetitions = async (): Promise<{ - participating: Competition[]; - nonParticipating: Competition[]; -}> => { - const [participating, nonParticipating] = await Promise.all([ - getParticipatingCompetitions(), - getNonParticipatingCompetitions() - ]); - - return { participating, nonParticipating }; -}; \ 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 a2d9be0..0616a21 100644 --- a/services/frontend/src/shared/api/index.ts +++ b/services/frontend/src/shared/api/index.ts @@ -14,14 +14,14 @@ 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 }) { options.headers.set("Authorization", "Bearer " + getToken()); 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/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/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/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);