Merge branches 'master' and 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
ITQ
2025-03-02 17:08:05 +03:00
85 changed files with 2065 additions and 422 deletions
@@ -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",
)
@@ -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()
@@ -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:
+17 -8
View File
@@ -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
+29 -4
View File
@@ -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",
)
+1 -1
View File
@@ -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"]
+2
View File
@@ -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)
@@ -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",
)
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AchievementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.achievement"
verbose_name = "Ачивки"
@@ -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': 'ачивки',
},
),
]
@@ -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='тип'),
),
]
@@ -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='слаг'),
),
]
@@ -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 = "ачивки"
@@ -7,6 +7,7 @@ from apps.task.admin import CompetitionTaskInline
@admin.register(Competition)
class CompetitionAdmin(admin.ModelAdmin):
list_display = (
"id",
"title",
"end_date",
"type",
@@ -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
+1 -1
View File
@@ -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="описание")
+1 -1
View File
@@ -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)
@@ -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
+10 -12
View File
@@ -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",
)
@@ -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': 'проверки',
},
),
]
@@ -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='проверяющий'),
),
]
+9 -8
View File
@@ -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):
+29 -1
View File
@@ -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):
@@ -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)),
@@ -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='пользователь'),
),
]
@@ -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='задание'),
),
]
@@ -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='описание'),
),
]
@@ -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',
),
]
@@ -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='описание'),
),
]
+80 -37
View File
@@ -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,
)
@@ -28,5 +28,3 @@ with open("file.txt") as f:
print(result)
self.assertTrue(result["success"])
self.assertTrue(result["match"])
@@ -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
+1
View File
@@ -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",)
+1 -1
View File
@@ -5,4 +5,4 @@ class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.user"
label = "user"
verbose_name = "Пользователи"
verbose_name = "Пользователи (веб)"
@@ -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={
@@ -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='ачивки пользователя'),
),
]
+7
View File
@@ -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)
+46 -1
View File
@@ -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 = {
+2
View File
@@ -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
+1
View File
@@ -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",
+15
View File
@@ -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=="],
+3
View File
@@ -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",
+25 -14
View File
@@ -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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AuthLayout />}>
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<Competition />} />
<Route element={<AuthLayout />}>
<Route element={<NavbarLayout />}>
<Route path="/" element={<Competitions />} />
<Route path="/competition/:id" element={<Competition />} />
</Route>
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
<Route path="/profile" element={<UserProfile />} />
<Route path="/review/:token" element={<ReviewPage />} />
</Route>
<Route
path="/competition/:id/tasks/:taskId"
element={<CompetitionSession />}
/>
</Route>
</Routes>
</Routes>
</QueryClientProvider>
);
};
@@ -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",
},
},
@@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,31 @@
export const DataRushReview = ({
size = 50,
className,
}: {
size?: number;
className?: string;
}) => {
return (
<svg
height={size}
viewBox="0 0 296 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="179" height="52" fill="#333333" />
<path
d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D"
/>
<path
d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white"
/>
<path
d="M196.95 16H204.87C209.01 16 212.13 19.15 212.13 23.23C212.13 27.4 208.77 30.49 204.87 30.49H201.96V37H196.95V16ZM201.96 20.56V25.9H204.3C205.86 25.9 207.12 24.76 207.12 23.23C207.12 21.73 205.86 20.56 204.3 20.56H201.96ZM226.592 32.56V37H214.772V16H226.592V20.44H219.782V24.16H226.052V28.42H219.782V32.56H226.592ZM229.518 16H237.888C241.128 16 243.558 18.31 243.558 21.16C243.558 23.32 242.268 25.18 240.378 25.99C243.018 26.77 244.548 28.96 244.548 31.36C244.548 34.54 241.908 37 238.368 37H229.518V16ZM234.528 19.96V24.46H236.568C237.888 24.46 238.848 23.53 238.848 22.18C238.848 20.83 237.768 19.96 236.358 19.96H234.528ZM234.528 28.15V33.04H236.718C238.278 33.04 239.538 32.11 239.538 30.58C239.538 29.17 238.278 28.15 236.718 28.15H234.528ZM262.491 29.92C262.491 33.97 259.491 37 255.381 37H247.281V16H252.291V22.9L255.381 22.87C259.251 22.87 262.491 25.84 262.491 29.92ZM257.481 29.95C257.481 28.54 256.341 27.46 254.781 27.46H252.291V32.44H254.781C256.341 32.44 257.481 31.45 257.481 29.95ZM294.507 26.5C294.507 32.56 289.677 37.45 283.647 37.45C278.247 37.45 273.837 33.58 272.997 28.42H269.967V37H264.927V16H269.967V23.86H273.147C274.287 19.09 278.487 15.55 283.647 15.55C289.677 15.55 294.507 20.44 294.507 26.5ZM289.437 26.5C289.437 23.11 287.217 20.44 283.647 20.44C280.077 20.44 277.857 23.11 277.857 26.5C277.857 29.89 280.077 32.56 283.647 32.56C287.217 32.56 289.437 29.89 289.437 26.5Z"
fill="black"
/>
</svg>
);
};
@@ -1,5 +1,5 @@
const DataRush = ({
size = 52,
size = 50,
className,
}: {
size?: number;
@@ -8,18 +8,18 @@ const DataRush = ({
return (
<svg
height={size}
viewBox="0 0 149 52"
viewBox="0 0 179 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="149" height="52" fill="#333333" />
<rect width="179" height="52" fill="#333333" />
<path
d="M28.296 26.6C28.296 31.352 25.032 35 19.872 35H13.56V18.2H19.872C25.032 18.2 28.296 21.848 28.296 26.6ZM24.264 26.624C24.264 24.056 22.512 22.208 19.8 22.208H17.592V30.992H19.8C22.512 30.992 24.264 29.168 24.264 26.624ZM28.0838 35L34.2758 18.2H38.3318L44.4998 35H40.3958L38.9798 31.136H33.6038L32.1878 35H28.0838ZM34.7078 27.872H37.8518L36.2918 23.6L34.7078 27.872ZM56.4529 18.2V21.848H51.8209V35H47.8129V21.848H43.1809V18.2H56.4529ZM55.1072 35L61.2992 18.2H65.3552L71.5232 35H67.4192L66.0032 31.136H60.6272L59.2112 35H55.1072ZM61.7312 27.872H64.8752L63.3152 23.6L61.7312 27.872Z"
d="M32.37 26.5C32.37 32.44 28.29 37 21.84 37H13.95V16H21.84C28.29 16 32.37 20.56 32.37 26.5ZM27.33 26.53C27.33 23.32 25.14 21.01 21.75 21.01H18.99V31.99H21.75C25.14 31.99 27.33 29.71 27.33 26.53ZM31.8797 37L39.6197 16H44.6897L52.3997 37H47.2697L45.4997 32.17H38.7797L37.0097 37H31.8797ZM40.1597 28.09H44.0897L42.1397 22.75L40.1597 28.09ZM67.1161 16V20.56H61.3261V37H56.3161V20.56H50.5261V16H67.1161ZM65.209 37L72.949 16H78.019L85.729 37H80.599L78.829 32.17H72.109L70.339 37H65.209ZM73.489 28.09H77.419L75.469 22.75L73.489 28.09Z"
fill="#FFDD2D"
/>
<path
d="M73.3256 18.2H79.5656C82.8296 18.2 85.4216 20.792 85.4216 24.032C85.4216 26.336 84.0056 28.304 81.9656 29.144C83.0456 29.456 83.8376 31.52 85.3256 31.52C85.5896 31.52 85.8536 31.472 86.1656 31.352V35C85.4456 35.192 84.7976 35.288 84.2216 35.288C80.1656 35.288 79.5176 30.32 78.1976 29.744H77.3576V35H73.3256V18.2ZM77.3576 21.848V26.096H79.1576C80.3576 26.096 81.3896 25.232 81.3896 23.984C81.3896 22.76 80.3576 21.848 79.1576 21.848H77.3576ZM103.378 18.2V28.04C103.378 32.216 100.282 35.36 95.7216 35.36C91.1616 35.36 88.0416 32.216 88.0416 28.04V18.2H92.0736V28.232C92.0736 30.248 93.7296 31.616 95.7216 31.616C97.7136 31.616 99.3696 30.248 99.3696 28.232V18.2H103.378ZM105.556 32.576L107.668 30.128C108.964 31.472 110.452 32 111.628 32C113.044 32 114.004 31.184 114.004 30.056C114.004 27.368 106.18 29.312 106.18 22.736C106.18 20.048 108.268 17.84 111.964 17.84C114.412 17.84 115.828 18.68 117.724 20.336L115.612 22.808C114.292 21.608 113.404 21.032 111.94 21.032C110.74 21.032 109.996 21.536 109.996 22.616C109.996 25.376 117.844 23.576 117.844 30.056C117.844 32.816 115.9 35.36 111.628 35.36C109.372 35.36 107.476 34.544 105.556 32.576ZM120.13 18.2H124.138V24.488H131.386V18.2H135.394V35H131.386V28.136H124.138V35H120.13V18.2Z"
d="M87.757 16H95.557C99.637 16 102.877 19.24 102.877 23.29C102.877 26.17 101.107 28.63 98.557 29.68C99.907 30.07 100.897 32.65 102.757 32.65C103.087 32.65 103.417 32.59 103.807 32.44V37C102.907 37.24 102.097 37.36 101.377 37.36C96.307 37.36 95.497 31.15 93.847 30.43H92.797V37H87.757V16ZM92.797 20.56V25.87H95.047C96.547 25.87 97.837 24.79 97.837 23.23C97.837 21.7 96.547 20.56 95.047 20.56H92.797ZM125.097 16V28.3C125.097 33.52 121.227 37.45 115.527 37.45C109.827 37.45 105.927 33.52 105.927 28.3V16H110.967V28.54C110.967 31.06 113.037 32.77 115.527 32.77C118.017 32.77 120.087 31.06 120.087 28.54V16H125.097ZM127.595 33.97L130.235 30.91C131.855 32.59 133.715 33.25 135.185 33.25C136.955 33.25 138.155 32.23 138.155 30.82C138.155 27.46 128.375 29.89 128.375 21.67C128.375 18.31 130.985 15.55 135.605 15.55C138.665 15.55 140.435 16.6 142.805 18.67L140.165 21.76C138.515 20.26 137.405 19.54 135.575 19.54C134.075 19.54 133.145 20.17 133.145 21.52C133.145 24.97 142.955 22.72 142.955 30.82C142.955 34.27 140.525 37.45 135.185 37.45C132.365 37.45 129.995 36.43 127.595 33.97ZM145.588 16H150.598V23.86H159.658V16H164.668V37H159.658V28.42H150.598V37H145.588V16Z"
fill="white"
/>
</svg>
@@ -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<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,9 @@
import { Spinner } from "./spinner";
export const Loading = () => {
return (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner size={24} />
</div>
);
};
@@ -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<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
@@ -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<Competition>(
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 <Loading />;
}
if (!competitionId || !competitionQuery.data) {
return <></>;
}
const competition = competitionQuery.data;
return (
<div className="flex flex-col gap-4">
<Link
@@ -37,8 +66,8 @@ const CompetitionPage = () => {
<div className="flex flex-col gap-6">
<div className="aspect-2 h-auto w-full overflow-hidden rounded-xl">
<img
src={competition.imageUrl}
alt={competition.name}
src={competition.image_url ? competition.image_url : '/DANO.png'}
alt={competition.title}
className="h-full w-full object-cover object-center"
/>
</div>
@@ -46,15 +75,19 @@ const CompetitionPage = () => {
<div className="flex flex-col-reverse gap-8 md:flex-row">
<div className="flex flex-1 flex-col gap-5">
<h1 className="text-[34px] leading-11 font-semibold text-balance">
{competition.name}
{competition.title}
</h1>
<div className="prose prose-lg max-w-none text-xl leading-10 font-normal">
<ReactMarkdown>{competition.description || ""}</ReactMarkdown>
</div>
</div>
<div className="w-full *:w-full md:w-96">
<Button size={"lg"} onClick={handleContinue}>
Продолжить
<Button
size={"lg"}
onClick={handleStart}
disabled={startMutation.isPending}
>
{startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
</Button>
</div>
</div>
@@ -63,4 +96,4 @@ const CompetitionPage = () => {
);
};
export default CompetitionPage;
export default CompetitionPage;
@@ -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<CompetitionHeaderProps> = ({
competitionId
}) => {
return (
<header className="bg-white shadow-sm">
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4">
<div className="py-4 text-center">
<h1 className="font-hse-sans text-xl font-semibold">
@@ -28,12 +27,12 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
<Link
key={task.id}
to={`/competition/${competitionId}/tasks/${task.id}`}
className={`${getTaskBgColor(task.status)} ${getTaskTextColor(task.status)}
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:brightness-95 flex-shrink-0
`}
>
{task.number}
{task.in_competition_position}
</Link>
))}
</div>
@@ -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<TaskContentProps> = ({ 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 (
<div className="flex-1 bg-white rounded-lg p-6">
<h2 className="text-3xl font-semibold mb-6 font-hse-sans">
Задача {task.number}
Задача {task.in_competition_position}
</h2>
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans">
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{markdownContent}
{task.description}
</ReactMarkdown>
</div>
{attachmentsQuery.isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-gray-500 font-hse-sans">Загрузка файлов...</span>
</div>
) : attachments.length > 0 ? (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 font-hse-sans">Прикрепленные файлы</h3>
<div className="flex flex-col gap-2">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.file}
download
className="flex items-center p-3 border rounded-md hover:bg-gray-50 transition-colors"
>
<FileIcon size={18} className="text-blue-500 mr-2" />
<span className="font-hse-sans">
{getFileNameFromUrl(attachment.file)}
</span>
</a>
))}
</div>
</div>
) : null}
</div>
);
};
export default TaskContent;
const getFileNameFromUrl = (url: string): string => {
try {
const parts = url.split('/');
return parts[parts.length - 1];
} catch (e) {
return 'Файл';
}
};
export default TaskContent;
@@ -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<Task[]>(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 <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />;
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 (
<Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
replace
/>
);
}
const handleSubmit = () => {
console.log("Submitting answer:", answer);
console.log(currentTask, competitionId, answer)
if (!currentTask || !competitionId || !answer.trim()) return;
submitMutation.mutate();
};
return (
<div className="flex flex-col min-h-screen">
<CompetitionHeader
<div className="flex min-h-screen flex-col">
<CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап"
tasks={tasks}
competitionId={id || ""}
tasks={tasks}
competitionId={competitionId}
/>
<main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="max-w-6xl mx-auto px-4 py-6">
{currentTask ? (
<div className="flex flex-col md:flex-row gap-6 font-hse-sans">
<div className="mx-auto max-w-6xl px-4 py-6">
{isLoading ? (
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
</div>
) : error ? (
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-red-500">{error}</p>
</div>
) : currentTask ? (
<div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<TaskContent task={currentTask} />
<TaskSolution
<TaskSolution
task={currentTask}
solutions={mockSolutions}
solutions={[]}
answer={answer}
setAnswer={setAnswer}
onSubmit={handleSubmit}
/>
</div>
) : (
<div className="flex justify-center items-center h-40 bg-white rounded-lg">
<p className="font-hse-sans text-gray-500">
Загрузка задания...
</p>
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500">Задание не найдено</p>
</div>
)}
</div>
@@ -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<ActionButtonsProps> = ({
onSubmit,
solutionHistory = mockSolutions
onHistoryClick
}) => {
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const handleHistoryClick = () => {
setIsHistoryOpen(true);
};
return (
<>
<div className="flex gap-8">
<Button
variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100"
onClick={handleHistoryClick}
>
История
</Button>
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
>
Отправить решение
</Button>
</div>
{/* чуть-чуть рак */}
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
/>
</>
<div className="flex gap-8">
<Button
variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100"
onClick={onHistoryClick}
>
История
</Button>
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
>
Отправить решение
</Button>
</div>
);
};
@@ -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<SolutionHistorySheetProps> = ({
isOpen,
onOpenChange,
solutions
solutions,
maxPoints
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
{solutions.length > 0 ? (
solutions.map((solution, index) => (
<div key={index} className="w-full">
<SolutionStatus solution={solution} />
<SolutionStatus solution={solution} maxPoints={maxPoints} />
</div>
))
) : (
@@ -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<SolutionStatusProps> = ({ 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<SolutionStatusProps> = ({ solution, maxPoints }) => {
return (
<div className={`${getTaskBgColor(solution.status)} rounded-lg p-4 relative`}>
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
<div className="flex flex-col">
<span className={`${getTaskTextColor(solution.status)} font-medium`}>
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
Решение {solution.id}
</span>
<span className={`${getTaskTextColor(solution.status)} mt-1`}>
{getStatusText(solution.status, solution.score, solution.maxScore)}
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
{getStatusText(solution.status, solution.earned_points, maxPoints)}
</span>
</div>
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}>
{solution.date}
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
{solution.timestamp}
</div>
</div>
);
@@ -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<TaskSolutionProps> = ({
task,
solutions,
solutions = [],
answer,
setAnswer,
onSubmit,
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="md:w-[500px] flex flex-col gap-4">
<SolutionStatus solution={solutions[0]} />
{latestSolution ? (
<SolutionStatus solution={latestSolution} maxPoints={task.points}/>
) : (
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Решение еще не отправлено
</div>
)}
{task.solutionType === 'input' && (
{task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} />
)}
{task.solutionType === 'file' && (
{task.type === TaskType.FILE && (
<FileSolution
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
@@ -41,11 +66,21 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
/>
)}
{task.solutionType === 'code' && (
{task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} />
)}
<ActionButtons onSubmit={onSubmit} />
<ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
/>
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
maxPoints={task.points}
/>
</div>
);
};
@@ -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}
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}
@@ -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({
>
<div className="relative h-full overflow-hidden">
<img
src={competition.imageUrl}
alt={competition.name}
src={competition.image_url ? competition.image_url : '/DANO.png'}
alt={competition.title}
className="h-full w-full object-cover object-center"
/>
</div>
@@ -26,18 +30,24 @@ export function CompetitionCard({
<CardContent>
<div className="flex flex-col gap-2.5">
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span>
{competition.status != CompetitionStatus.NotParticipating && (
<span>
{competition.type === CompetitionType.COMPETITIVE
? "Соревнование"
: "Тренировка"}
</span>
{competition.state != CompetitionState.NOT_STARTED && (
<>
<span></span>
<span className="text-primary-foreground">
{competition.status}
{competition.state === CompetitionState.STARTED
? "В прогрессе"
: "Завершено"}
</span>
</>
)}
</div>
<h3 className="line-clamp-2 text-xl font-semibold">
{competition.name}
{competition.title}
</h3>
</div>
</CardContent>
@@ -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 (
<Badge
variant="secondary"
className={cn(
"text-xs font-medium",
variant === 'olympics' && "bg-yellow-400 text-yellow-800 hover:bg-yellow-500 font-hse-sans",
variant === 'status' && "bg-black text-white hover:bg-gray-800 font-hse-sans",
className
)}
>
{label}
</Badge>
);
}
export default CompetitionTag
@@ -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<Competition[]>(mockCompetitions);
const [activeTab, setActiveTab] = useState("ongoing");
const [activeTab, setActiveTab] = useState<string>(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 <Loading />;
}
return (
<div className="flex flex-col gap-6 sm:gap-8">
<Section>
<SectionHeader>
<SectionTitle>Мои события</SectionTitle>
{(activeCompetitionsQuery.data ?? []).length > 0 && (
<Section>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="ongoing">В процессе</TabsTrigger>
<TabsTrigger value="completed">Завершенные</TabsTrigger>
</TabsList>
<SectionHeader>
<SectionTitle>Мои события</SectionTitle>
<TabsList>
<TabsTrigger value={CompetitionTab.ONGOING}>
В процессе
</TabsTrigger>
<TabsTrigger value={CompetitionTab.COMPLETED}>
Завершенные
</TabsTrigger>
</TabsList>
</SectionHeader>
<TabsContent value={CompetitionTab.ONGOING} asChild>
<CompetitionGrid competitions={startedCompetitions} />
</TabsContent>
<TabsContent value={CompetitionTab.COMPLETED} asChild>
<CompetitionGrid competitions={finishedCompetitions} />
</TabsContent>
</Tabs>
</SectionHeader>
<CompetitionGrid competitions={filteredMyCompetitions} />
</Section>
</Section>
)}
<Section>
<SectionHeader>
<SectionTitle>События</SectionTitle>
</SectionHeader>
<CompetitionGrid competitions={availableCompetitions} />
{(inactiveCompetitionsQuery.data ?? []).length > 0 ? (
<CompetitionGrid
competitions={inactiveCompetitionsQuery.data ?? []}
/>
) : (
<NoCompetitions />
)}
</Section>
</div>
);
@@ -65,4 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => {
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
};
export default CompetitionsPage;
export default CompetitionsPage;
@@ -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 {
@@ -0,0 +1,15 @@
import { Ban } from "lucide-react";
export const NoCompetitions = () => {
return (
<div className="flex flex-col items-center gap-4">
<Ban size={32} />
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold">Событий нет</h2>
<p className="text-muted-foreground text-lg">
Увы, очередная победа.рф
</p>
</div>
</div>
);
};
+1 -1
View File
@@ -18,7 +18,7 @@ const LoginPage = () => {
return (
<div className="flex flex-col items-center gap-10 px-4 py-10 sm:gap-18 sm:py-18">
<DataRush size={52} className="min-h-[52px]" />
<DataRush size={50} className="min-h-[52px]" />
<div className="flex w-full max-w-96 flex-col items-center gap-7">
<h1 className="text-center text-4xl font-semibold">
Добро пожаловать!
@@ -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 <Loading />;
}
if (!token || !reviewerQuery.data || !submissionsQuery.data) {
navigate("/");
return;
}
return (
<div className="px-4">
<div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} />
<Tabs defaultValue="available" className="my-3">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Посылки</h1>
<TabsList>
<TabsTrigger value="available">Доступные</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
</div>
);
};
export default ReviewPage;
@@ -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 (
<header className="flex h-[90px] items-center justify-between gap-4">
<DataRushReview />
<div className="flex items-center gap-4">
<p className="text-right font-semibold">
{reviewer.name} {reviewer.surname}
</p>
<Link
to="/"
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
Выйти
</Link>
</div>
</header>
);
};
@@ -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 (
<div className="container mx-auto max-w-5xl px-4 py-8">
<div className="mb-8 flex items-center gap-6">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-blue-100">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="h-24 w-24 rounded-full object-cover"
/>
) : (
<User size={40} className="text-blue-500" />
)}
</div>
<div>
<h1 className="font-hse-sans text-3xl font-bold">{user?.username}</h1>
<p className="font-hse-sans text-gray-500">
{user?.role || "Участник"} На платформе с{" "}
{new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
})}
</p>
</div>
</div>
<Tabs defaultValue="info" className="w-full">
<TabsList className="mb-6 w-full justify-start">
<TabsTrigger value="info" className="font-hse-sans">
Информация
</TabsTrigger>
<TabsTrigger value="statistics" className="font-hse-sans">
Статистика
</TabsTrigger>
<TabsTrigger value="achievements" className="font-hse-sans">
Достижения
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<UserInfo />
</TabsContent>
<TabsContent value="statistics">
<UserStatistics />
</TabsContent>
<TabsContent value="achievements">
<UserAchievements />
</TabsContent>
</Tabs>
</div>
);
};
const UserInfo = () => {
const user = useUserStore((state) => state.user);
return (
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Личная информация</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Полное имя
</h3>
<p className="font-hse-sans mt-1">
{user?.fullName || "Не указано"}
</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Email
</h3>
<p className="font-hse-sans mt-1">{user?.email || "Не указано"}</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Учебное заведение
</h3>
<p className="font-hse-sans mt-1">
{user?.university || "Не указано"}
</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Специализация
</h3>
<p className="font-hse-sans mt-1">
{user?.specialization || "Не указано"}
</p>
</div>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
О себе
</h3>
<p className="font-hse-sans mt-1">
{user?.bio || "Пользователь пока не добавил информацию о себе."}
</p>
</div>
</CardContent>
</Card>
);
};
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 (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Всего соревнований"
value={statistics.totalCompetitions}
/>
<StatCard
title="Завершено соревнований"
value={statistics.completedCompetitions}
/>
<StatCard title="Всего баллов" value={statistics.totalScore} />
<StatCard
title="Средний балл"
value={statistics.averageScore.toFixed(1)}
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Лучший результат</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-hse-sans text-lg font-medium">
{statistics.bestResult.competition}
</p>
<div className="flex items-center justify-between">
<span className="font-hse-sans text-gray-500">Место</span>
<span className="font-hse-sans font-medium">
{statistics.bestResult.place}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-hse-sans text-gray-500">Баллы</span>
<span className="font-hse-sans font-medium">
{statistics.bestResult.score}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Решение задач</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-hse-sans">Всего задач</span>
<span className="font-hse-sans font-medium">
{statistics.totalTasks}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-hse-sans">Решено задач</span>
<span className="font-hse-sans font-medium">
{statistics.solvedTasks}
</span>
</div>
</div>
<div className="space-y-2">
<h4 className="font-hse-sans text-sm font-medium">
Статусы решений
</h4>
<div className="h-6 w-full overflow-hidden rounded-full bg-gray-200">
<div className="flex h-full">
<div
className="bg-green-500"
style={{
width: `${
(statistics.tasksByStatus.correct /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-yellow-500"
style={{
width: `${
(statistics.tasksByStatus.partial /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-red-500"
style={{
width: `${
(statistics.tasksByStatus.wrong /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-gray-300"
style={{
width: `${
(statistics.tasksByStatus.unattempted /
statistics.totalTasks) *
100
}%`,
}}
></div>
</div>
</div>
<div className="flex justify-between text-xs">
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-green-500"></div>
<span className="font-hse-sans">
Верно ({statistics.tasksByStatus.correct})
</span>
</div>
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-yellow-500"></div>
<span className="font-hse-sans">
Частично ({statistics.tasksByStatus.partial})
</span>
</div>
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-red-500"></div>
<span className="font-hse-sans">
Неверно ({statistics.tasksByStatus.wrong})
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
};
const StatCard = ({ title, value }: { title: string; value: number | string }) => (
<Card>
<CardContent className="pt-6">
<p className="font-hse-sans text-sm text-gray-500">{title}</p>
<p className="font-hse-sans mt-2 text-3xl font-bold">{value}</p>
</CardContent>
</Card>
);
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 (
<div className="space-y-6">
<h2 className="font-hse-sans text-xl font-semibold">
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{achievements.map((achievement) => (
<div
key={achievement.id}
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
achievement.unlocked ? "" : "opacity-40"
}`}
>
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
{achievement.imageUrl ? (
<div className="relative h-16 w-16 overflow-hidden rounded-full">
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
></div>
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
<span className="font-hse-sans text-xl font-bold">
{achievement.name.substring(0, 1)}
</span>
</div>
)}
</div>
<h3 className="font-hse-sans text-sm font-medium">
{achievement.name}
</h3>
<p className="font-hse-sans mt-1 text-xs text-gray-500">
{achievement.description}
</p>
</div>
))}
</div>
</div>
);
};
export default UserProfile;
@@ -0,0 +1,45 @@
const UserAchievements = () => {
return (
<div className="space-y-6">
<h2 className="font-hse-sans text-xl font-semibold">
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{achievements.map((achievement) => (
<div
key={achievement.id}
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
achievement.unlocked ? "" : "opacity-40"
}`}
>
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
{achievement.imageUrl ? (
<div className="relative h-16 w-16 overflow-hidden rounded-full">
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
></div>
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
<span className="font-hse-sans text-xl font-bold">
{achievement.name.substring(0, 1)}
</span>
</div>
)}
</div>
<h3 className="font-hse-sans text-sm font-medium">
{achievement.name}
</h3>
<p className="font-hse-sans mt-1 text-xs text-gray-500">
{achievement.description}
</p>
</div>
))}
</div>
</div>
);
};
export default UserAchievements
+3 -3
View File
@@ -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<AuthResponse>("/sign-up", {
return await apiFetch<AuthResponse>("/sign-up", {
method: "POST",
body,
});
};
export const login = async (body: { email: string; password: string }) => {
return await authFetch<AuthResponse>("/sign-in", {
return await apiFetch<AuthResponse>("/sign-in", {
method: "POST",
body,
});
@@ -0,0 +1,20 @@
import { userFetch } from ".";
import { Competition } from "../types/competition";
export const getCompetitions = async (participating?: boolean) => {
return await userFetch<Competition[]>("/competitions", {
params: {
is_participating: participating,
},
});
};
export const getCompetition = async (id: string) => {
return await userFetch<Competition>(`/competition/${id}`);
};
export const startCompetition = async (competitionId: string) => {
return await userFetch(`/competitions/${competitionId}/start`, {
method: 'POST'
});
};
+2 -3
View File
@@ -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 }) {
@@ -0,0 +1,10 @@
import { apiFetch } from ".";
import { Reviewer } from "../types/review";
export const getReviewer = async (token: string) => {
return await apiFetch<Reviewer>(`/review/${token}`);
};
export const getReviewerSubmissions = async (token: string) => {
return await apiFetch(`/review/${token}/submissions`);
};
@@ -0,0 +1,38 @@
import { userFetch } from ".";
import { Task, Solution, TaskAttachment } from "../types/task";
export const getCompetitionTasks = async (competitionId: string) => {
return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
};
export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
};
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
return await userFetch<TaskAttachment[]>(`/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
});
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { apiFetch } from ".";
import { userFetch } from ".";
import { User } from "../types/user";
export const getCurrentUser = async () => {
return await apiFetch<User>("/me");
return await userFetch<User>("/me");
};
+109 -9
View File
@@ -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 };
+14 -1
View File
@@ -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 };
@@ -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",
}
@@ -0,0 +1,5 @@
export interface Reviewer {
id: string;
name: string;
surname: string;
}
@@ -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}
+1 -1
View File
@@ -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);