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.image_url && (
+
+

+
+ )}
- {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}
-
+
) : 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.image_url && (
+

+ )}
- {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 (
-
- );
+ 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 (
+
+ );
+};
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);