mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 17:57:10 +00:00
fix: merge conflicts
This commit is contained in:
@@ -18,6 +18,7 @@ router = Router(tags=["competition"])
|
|||||||
status.OK: schemas.CompetitionOut,
|
status.OK: schemas.CompetitionOut,
|
||||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||||
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
||||||
|
status.NOT_FOUND: global_schemas.NotFoundError,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def get_competition(
|
def get_competition(
|
||||||
@@ -30,14 +31,14 @@ def get_competition(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"competitions",
|
"competitions",
|
||||||
response={
|
response={
|
||||||
status.OK: list[schemas.CompetitionListInstanceOut],
|
status.OK: list[schemas.CompetitionOut],
|
||||||
status.BAD_REQUEST: global_schemas.BadRequestError,
|
status.BAD_REQUEST: global_schemas.BadRequestError,
|
||||||
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def list_competitions(
|
def list_competitions(
|
||||||
request: HttpRequest, is_participating: bool
|
request: HttpRequest, is_participating: bool
|
||||||
) -> tuple[status, list[schemas.CompetitionListInstanceOut]]:
|
) -> tuple[status, list[schemas.CompetitionOut]]:
|
||||||
user = request.auth
|
user = request.auth
|
||||||
if is_participating:
|
if is_participating:
|
||||||
competitions = Competition.objects.filter(participants=user)
|
competitions = Competition.objects.filter(participants=user)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from abc import ABC
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import resolve
|
||||||
|
from ninja.errors import AuthenticationError
|
||||||
|
from ninja.security import APIKeyQuery
|
||||||
|
from ninja.security.apikey import APIKeyBase
|
||||||
|
|
||||||
|
from apps.review.models import Reviewer
|
||||||
|
|
||||||
|
class APIKeyPath(APIKeyBase, ABC):
|
||||||
|
openapi_in: str = "path"
|
||||||
|
|
||||||
|
def _get_key(self, request: HttpRequest) -> Optional[str]:
|
||||||
|
func, args, kwargs = resolve(request.path)
|
||||||
|
return kwargs.get(self.param_name)
|
||||||
|
|
||||||
|
class ReviewerAuth(APIKeyPath):
|
||||||
|
param_name = "token"
|
||||||
|
|
||||||
|
def authenticate(self, request, token):
|
||||||
|
if not (reviewer := Reviewer.objects.filter(token=token).first()):
|
||||||
|
raise AuthenticationError
|
||||||
|
return reviewer
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import List, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
|
||||||
|
from apps.review.models import Reviewer
|
||||||
|
from apps.task.models import CompetetionTaskSumbission
|
||||||
|
|
||||||
|
|
||||||
|
class PingOut(Schema):
|
||||||
|
status: str = "ok"
|
||||||
|
|
||||||
|
class ReviewerOut(ModelSchema):
|
||||||
|
id: UUID
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Reviewer
|
||||||
|
exclude = ("token",)
|
||||||
|
|
||||||
|
class SubmissionOut(ModelSchema):
|
||||||
|
id: UUID
|
||||||
|
status: Literal["sent", "checking", "checked"]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CompetetionTaskSumbission
|
||||||
|
exclude = (
|
||||||
|
"user",
|
||||||
|
)
|
||||||
|
|
||||||
|
class SubmissionsOut(Schema):
|
||||||
|
submissions: list[SubmissionOut] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_submissions(self, context: HttpRequest) -> List[SubmissionOut]:
|
||||||
|
print(CompetetionTaskSumbission.objects.all())
|
||||||
|
return list(CompetetionTaskSumbission.objects.all())
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from http import HTTPStatus as status
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from api.v1.review import schemas
|
||||||
|
from api.v1 import schemas as global_schemas
|
||||||
|
|
||||||
|
router = Router(tags=["review"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"{token}/tasks",
|
||||||
|
response={
|
||||||
|
status.OK: schemas.SubmissionsOut,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def ping(request: HttpRequest, token) -> tuple[status, schemas.SubmissionsOut]:
|
||||||
|
return status.OK, schemas.SubmissionsOut()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"{token}",
|
||||||
|
response={
|
||||||
|
status.OK: schemas.ReviewerOut,
|
||||||
|
status.UNAUTHORIZED: global_schemas.UnauthorizedError
|
||||||
|
},
|
||||||
|
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query"
|
||||||
|
)
|
||||||
|
def get_reviewer(
|
||||||
|
request: HttpRequest,
|
||||||
|
token: str
|
||||||
|
):
|
||||||
|
return status.OK, request.auth
|
||||||
@@ -6,7 +6,9 @@ from api.v1 import handlers
|
|||||||
from api.v1.auth import BearerAuth
|
from api.v1.auth import BearerAuth
|
||||||
from api.v1.competition.views import router as competition_router
|
from api.v1.competition.views import router as competition_router
|
||||||
from api.v1.ping.views import router as ping_router
|
from api.v1.ping.views import router as ping_router
|
||||||
|
from api.v1.review.auth import ReviewerAuth
|
||||||
from api.v1.user.views import router as user_router
|
from api.v1.user.views import router as user_router
|
||||||
|
from api.v1.review.views import router as review_router
|
||||||
|
|
||||||
router = NinjaAPI(
|
router = NinjaAPI(
|
||||||
title="DataRush API",
|
title="DataRush API",
|
||||||
@@ -30,6 +32,12 @@ router.add_router(
|
|||||||
competition_router,
|
competition_router,
|
||||||
auth=BearerAuth(),
|
auth=BearerAuth(),
|
||||||
)
|
)
|
||||||
|
router.add_router(
|
||||||
|
"review",
|
||||||
|
review_router,
|
||||||
|
auth=ReviewerAuth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for exception, handler in handlers.exception_handlers:
|
for exception, handler in handlers.exception_handlers:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
|
||||||
|
from apps.competition.models import State
|
||||||
|
from apps.task.models import CompetitionTask
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOutSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = CompetitionTask
|
||||||
|
fields = ["id", "competition", "title", "description", "type"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSubmissionIn(Schema):
|
||||||
|
type: Literal["input", "file", "code"]
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSubmissionOut(Schema):
|
||||||
|
submission_id: UUID
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from http import HTTPStatus as status
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from ninja import Router
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError
|
||||||
|
from api.v1.ping.schemas import PingOut
|
||||||
|
from api.v1.task.schemas import (
|
||||||
|
TaskOutSchema,
|
||||||
|
TaskSubmissionOut,
|
||||||
|
TaskSubmissionIn,
|
||||||
|
)
|
||||||
|
from apps.task.models import (
|
||||||
|
Competition,
|
||||||
|
CompetitionTask,
|
||||||
|
CompetetionTaskSumbission,
|
||||||
|
)
|
||||||
|
from apps.competition.models import State
|
||||||
|
|
||||||
|
router = Router(tags=["competition"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"competitions/{competition_id}/start",
|
||||||
|
description="Start a competition completing (open access to tasks)",
|
||||||
|
response={
|
||||||
|
status.OK: PingOut,
|
||||||
|
status.UNAUTHORIZED: UnauthorizedError,
|
||||||
|
status.NOT_FOUND: NotFoundError,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def start_competition(request, competition_id: UUID) -> PingOut:
|
||||||
|
competition = get_object_or_404(Competition, pk=competition_id)
|
||||||
|
state_obj, _ = State.objects.update_or_create(
|
||||||
|
user=request.auth, competition=competition, state="started"
|
||||||
|
)
|
||||||
|
return status.OK, PingOut()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"competitions/{competition_id}/tasks",
|
||||||
|
description="Get all tasks of competition (works only if user started competition)",
|
||||||
|
response={
|
||||||
|
status.OK: list[TaskOutSchema],
|
||||||
|
status.UNAUTHORIZED: UnauthorizedError,
|
||||||
|
status.FORBIDDEN: ForbiddenError,
|
||||||
|
status.NOT_FOUND: NotFoundError,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get_competition_tasks(request, competition_id: UUID) -> list[TaskOutSchema]:
|
||||||
|
competition = get_object_or_404(Competition, pk=competition_id)
|
||||||
|
state = State.objects.filter(
|
||||||
|
user=request.auth, competition=competition, state="started"
|
||||||
|
).first()
|
||||||
|
if not state:
|
||||||
|
return 403, ForbiddenError()
|
||||||
|
|
||||||
|
return status.OK, CompetitionTask.objects.filter(competition=competition).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"competitions/{competition_id}/tasks/{task_id}",
|
||||||
|
description="Get a task of competition task",
|
||||||
|
response={
|
||||||
|
status.OK: TaskOutSchema,
|
||||||
|
status.UNAUTHORIZED: UnauthorizedError,
|
||||||
|
status.FORBIDDEN: ForbiddenError,
|
||||||
|
status.NOT_FOUND: NotFoundError,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"competitions/{competition_id}/tasks/{task_id}/submit",
|
||||||
|
description="Submit task solution",
|
||||||
|
response={
|
||||||
|
status.OK: TaskSubmissionOut,
|
||||||
|
status.UNAUTHORIZED: UnauthorizedError,
|
||||||
|
status.FORBIDDEN: ForbiddenError,
|
||||||
|
status.NOT_FOUND: NotFoundError,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def submit_task(
|
||||||
|
request, competition_id: str, task_id: str, submission: TaskSubmissionIn
|
||||||
|
) -> PingOut:
|
||||||
|
user = request.auth
|
||||||
|
competetion = get_object_or_404(Competition, id=competition_id)
|
||||||
|
task = get_object_or_404(
|
||||||
|
CompetitionTask, competetion=competetion, id=task_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
|
||||||
|
CompetetionTaskSumbission.objects.create(
|
||||||
|
user=user,
|
||||||
|
task=task,
|
||||||
|
status=CompetetionTaskSumbission.StatusChoices.CHECKED,
|
||||||
|
result={"correct": submission.content == task.answer_file_path},
|
||||||
|
)
|
||||||
|
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
|
||||||
|
CompetetionTaskSumbission.objects.create(
|
||||||
|
user=user,
|
||||||
|
task=task,
|
||||||
|
status=CompetetionTaskSumbission.StatusChoices.SENT,
|
||||||
|
)
|
||||||
|
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
|
||||||
|
CompetetionTaskSumbission.objects.create(
|
||||||
|
user=user,
|
||||||
|
task=task,
|
||||||
|
status=CompetetionTaskSumbission.StatusChoices.CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskSubmissionOut(id=CompetetionTaskSumbission.id)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 21:27
|
# Generated by Django 5.1.6 on 2025-03-01 08:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
('user', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -23,10 +25,23 @@ class Migration(migrations.Migration):
|
|||||||
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')),
|
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')),
|
||||||
('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')),
|
('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')),
|
||||||
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')),
|
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')),
|
||||||
|
('participants', models.ManyToManyField(related_name='participants', to='user.user')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'соревнование',
|
'verbose_name': 'соревнование',
|
||||||
'verbose_name_plural': 'соревнования',
|
'verbose_name_plural': 'соревнования',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='State',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)),
|
||||||
|
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 22:40
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('competition', '0001_initial'),
|
|
||||||
('user', '0002_user_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='competition',
|
|
||||||
name='participants',
|
|
||||||
field=models.ManyToManyField(related_name='participants', to='user.user'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 23:26
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('competition', '0002_competition_participants'),
|
|
||||||
('user', '0003_alter_user_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='State',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)),
|
|
||||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-01 08:10
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0003_state'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='state',
|
||||||
|
name='changed_at',
|
||||||
|
field=models.DateTimeField(default=datetime.datetime.now),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from apps.core.models import BaseModel
|
from apps.core.models import BaseModel
|
||||||
@@ -49,3 +51,4 @@ class State(BaseModel):
|
|||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||||
state = models.CharField(choices=StateChoices.choices, max_length=11)
|
state = models.CharField(choices=StateChoices.choices, max_length=11)
|
||||||
|
changed_at = models.DateTimeField(default=datetime.now)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = "apps.review"
|
||||||
|
label = "review"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-01 08:47
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Reviewer',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('surname', models.CharField(max_length=100)),
|
||||||
|
('token', models.CharField(max_length=100)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Reviewer(BaseModel):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
surname = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
token = models.CharField(max_length=100)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-01 09:42
|
||||||
|
|
||||||
|
import apps.task.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0001_initial'),
|
||||||
|
('user', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CompetitionTask',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('title', models.TextField(max_length=50, verbose_name='заголовок')),
|
||||||
|
('description', models.TextField(max_length=300, verbose_name='описание')),
|
||||||
|
('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)),
|
||||||
|
('correct_answer_file', models.FileField(upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)),
|
||||||
|
('answer_file_path', models.TextField()),
|
||||||
|
('criteries', models.JSONField(blank=True, null=True)),
|
||||||
|
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CompetetionTaskSumbission',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)),
|
||||||
|
('content', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_content_upload_to)),
|
||||||
|
('stdout', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)),
|
||||||
|
('result', models.JSONField(default={})),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
|
||||||
|
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
|
from random import choice
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from competition.models import Competition
|
|
||||||
from core.models import BaseModel
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from apps.task.validators import ContestTaskCriteriesValidator
|
from apps.task.validators import ContestTaskCriteriesValidator
|
||||||
|
from apps.competition.models import Competition
|
||||||
|
from apps.core.models import BaseModel
|
||||||
|
from apps.user.models import User
|
||||||
|
|
||||||
class CompetitionTask(BaseModel):
|
class CompetitionTask(BaseModel):
|
||||||
class CompetitionTaskType(models.TextChoices):
|
class CompetitionTaskType(models.TextChoices):
|
||||||
@@ -14,21 +15,64 @@ class CompetitionTask(BaseModel):
|
|||||||
REVIEW = "review"
|
REVIEW = "review"
|
||||||
|
|
||||||
def answer_file_upload_to(instance, filename) -> str:
|
def answer_file_upload_to(instance, filename) -> str:
|
||||||
return f"/tasks/{instance.id}/answer/{uuid4}"
|
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
|
||||||
|
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||||
title = models.TextField(verbose_name="заголовок", max_length=50)
|
title = models.TextField(verbose_name="заголовок", max_length=50)
|
||||||
description = models.TextField(verbose_name="описание", max_length=300)
|
description = models.TextField(verbose_name="описание", max_length=300)
|
||||||
type = models.CharField(choices=CompetitionTaskType)
|
type = models.CharField(choices=CompetitionTaskType, max_length=8)
|
||||||
|
|
||||||
# only when "input" or "checker" type
|
# only when "input" or "checker" type
|
||||||
correct_answer_file = models.FileField(upload_to=answer_file_upload_to)
|
correct_answer_file = models.FileField(
|
||||||
|
upload_to=answer_file_upload_to, null=True, blank=True
|
||||||
|
)
|
||||||
|
points = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
# only when "checker" type
|
# only when "checker" type
|
||||||
answer_file_path = models.TextField()
|
answer_file_path = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
# only when "review" type
|
# only when "review" type
|
||||||
criteries = models.JSONField(blank=True, null=True)
|
criteries = models.JSONField(blank=True, null=True)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
ContestTaskCriteriesValidator()(self)
|
ContestTaskCriteriesValidator()(self)
|
||||||
|
|
||||||
|
|
||||||
|
class CompetetionTaskSumbission(BaseModel):
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
SENT = "sent"
|
||||||
|
CHECKING = "checking"
|
||||||
|
CHECKED = "checked"
|
||||||
|
|
||||||
|
def submission_content_upload_to(instance, filename) -> str:
|
||||||
|
return f"/submissions/{instance.id}/content"
|
||||||
|
|
||||||
|
def submission_stdout_upload_to(instance, filename) -> str:
|
||||||
|
return f"/submissions/{instance.id}/stdout"
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
choices=StatusChoices.choices,
|
||||||
|
default=StatusChoices.SENT,
|
||||||
|
max_length=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# code or text or file
|
||||||
|
content = models.FileField(upload_to=submission_content_upload_to)
|
||||||
|
|
||||||
|
# only if task type is checker
|
||||||
|
stdout = models.FileField(
|
||||||
|
upload_to=submission_stdout_upload_to, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
|
||||||
|
earned_points = models.IntegerField()
|
||||||
|
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import ast
|
||||||
|
from io import StringIO
|
||||||
|
import hashlib
|
||||||
|
from config.celery import app
|
||||||
|
|
||||||
|
ALLOWED_MODULES = {
|
||||||
|
"pandas",
|
||||||
|
"numpy",
|
||||||
|
"matplotlib",
|
||||||
|
"seaborn",
|
||||||
|
"scipy",
|
||||||
|
"sklearn",
|
||||||
|
"datetime",
|
||||||
|
"json",
|
||||||
|
"csv",
|
||||||
|
"math",
|
||||||
|
"statistics",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_code(code_str):
|
||||||
|
try:
|
||||||
|
tree = ast.parse(code_str)
|
||||||
|
except SyntaxError as e:
|
||||||
|
raise SecurityException(f"Syntax error: {str(e)}")
|
||||||
|
|
||||||
|
class ImportVisitor(ast.NodeVisitor):
|
||||||
|
def visit_Import(self, node):
|
||||||
|
for alias in node.names:
|
||||||
|
module = alias.name.split(".")[0]
|
||||||
|
if module not in ALLOWED_MODULES:
|
||||||
|
raise SecurityException(f"Disallowed import: {module}")
|
||||||
|
|
||||||
|
def visit_ImportFrom(self, node):
|
||||||
|
if node.module:
|
||||||
|
module = node.module.split(".")[0]
|
||||||
|
if module not in ALLOWED_MODULES:
|
||||||
|
raise SecurityException(
|
||||||
|
f"Disallowed import from: {module}"
|
||||||
|
)
|
||||||
|
|
||||||
|
class SecurityVisitor(ast.NodeVisitor):
|
||||||
|
def generic_visit(self, node):
|
||||||
|
if isinstance(node, (ast.Call, ast.Attribute)):
|
||||||
|
if "system" in getattr(node, "attr", ""):
|
||||||
|
raise SecurityException("Dangerous system call detected")
|
||||||
|
super().generic_visit(node)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ImportVisitor().visit(tree)
|
||||||
|
SecurityVisitor().visit(tree)
|
||||||
|
except SecurityException as e:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise SecurityException(f"Security check failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def secure_exec(code_str, result_path):
|
||||||
|
original_dir = os.getcwd()
|
||||||
|
original_stdout = sys.stdout
|
||||||
|
sys.stdout = captured_stdout = StringIO()
|
||||||
|
result_content = None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
os.chdir(temp_dir)
|
||||||
|
restricted_globals = {
|
||||||
|
"__builtins__": {
|
||||||
|
"open": lambda f, *a, **kw: open(f, *a, **kw),
|
||||||
|
"print": print,
|
||||||
|
"str": str,
|
||||||
|
"int": int,
|
||||||
|
"float": float,
|
||||||
|
"bool": bool,
|
||||||
|
"list": list,
|
||||||
|
"dict": dict,
|
||||||
|
"tuple": tuple,
|
||||||
|
"set": set,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(code_str, restricted_globals)
|
||||||
|
|
||||||
|
if result_path == "stdout":
|
||||||
|
result_content = captured_stdout.getvalue().encode("utf-8")
|
||||||
|
else:
|
||||||
|
with open(result_path, "rb") as f:
|
||||||
|
result_content = f.read()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Execution error: {str(e)}")
|
||||||
|
finally:
|
||||||
|
os.chdir(original_dir)
|
||||||
|
sys.stdout = original_stdout
|
||||||
|
|
||||||
|
return result_content
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True)
|
||||||
|
def analyze_data_task(self, code_str, result_path, expected_bytes):
|
||||||
|
try:
|
||||||
|
validate_code(code_str)
|
||||||
|
|
||||||
|
result_content = secure_exec(code_str, result_path)
|
||||||
|
|
||||||
|
result_hash = hashlib.sha256(result_content).hexdigest()
|
||||||
|
expected_hash = hashlib.sha256(expected_bytes).hexdigest()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"match": result_hash == expected_hash,
|
||||||
|
"result_hash": result_hash,
|
||||||
|
"expected_hash": expected_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
except SecurityException as e:
|
||||||
|
return {"success": False, "error": f"Security violation: {str(e)}"}
|
||||||
|
except RuntimeError as e:
|
||||||
|
return {"success": False, "error": f"Execution error: {str(e)}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"Unexpected error: {str(e)}"}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 20:46
|
# Generated by Django 5.1.6 on 2025-03-01 08:47
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
|
|||||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')),
|
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')),
|
||||||
('username', models.SlugField(unique=True, verbose_name='Юзернейм')),
|
('username', models.SlugField(unique=True, verbose_name='Юзернейм')),
|
||||||
('password', models.TextField(verbose_name='Пароль')),
|
('password', models.TextField(verbose_name='Пароль')),
|
||||||
|
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'пользователь',
|
'verbose_name': 'пользователь',
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 22:40
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('user', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-28 22:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('user', '0002_user_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='status',
|
|
||||||
field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -445,6 +445,8 @@ INSTALLED_APPS = [
|
|||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.user",
|
"apps.user",
|
||||||
"apps.competition",
|
"apps.competition",
|
||||||
|
"apps.review",
|
||||||
|
"apps.task"
|
||||||
]
|
]
|
||||||
|
|
||||||
# GUID
|
# GUID
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import "./styles/globals.css";
|
|||||||
import CompetitionsPage from "./pages/CompetitionsPage";
|
import CompetitionsPage from "./pages/CompetitionsPage";
|
||||||
import CompetitionPreviewPage from "./pages/CompetitionPreviewPage";
|
import CompetitionPreviewPage from "./pages/CompetitionPreviewPage";
|
||||||
import CompetitionRunnerPage from "./pages/CompetitionRunnerPage";
|
import CompetitionRunnerPage from "./pages/CompetitionRunnerPage";
|
||||||
|
import { NavbarLayout } from "./widgets/navbar-layout";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<CompetitionsPage/>} />
|
<Route element={<NavbarLayout />}>
|
||||||
|
<Route path="/" element={<CompetitionsPage />} />
|
||||||
|
</Route>
|
||||||
<Route path="/competition/:id" element={<CompetitionPreviewPage />} />
|
<Route path="/competition/:id" element={<CompetitionPreviewPage />} />
|
||||||
<Route path="/competition/:id/tasks/:taskId" element={<CompetitionRunnerPage />} />
|
<Route
|
||||||
|
path="/competition/:id/tasks/:taskId"
|
||||||
|
element={<CompetitionRunnerPage />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { DataRush } from "@/components/ui/icons/datarush";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<header className="bg-card sticky top-0 z-30 flex h-[72px] w-full items-center justify-center">
|
||||||
|
<div className="flex w-full max-w-5xl items-center justify-between">
|
||||||
|
<Link to="/">
|
||||||
|
<DataRush />
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-lg font-semibold">itqdev</span>
|
||||||
|
<ChevronDown size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Header };
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn("bg-card flex flex-col rounded-xl", className)}
|
||||||
"bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -22,7 +19,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -32,7 +29,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -42,17 +39,13 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="card-content" className={cn("p-5", className)} {...props} />
|
||||||
data-slot="card-content"
|
);
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -62,7 +55,14 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex items-center px-6", className)}
|
className={cn("flex items-center px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const DataRush = ({ size = 52 }: { size?: number }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 149 52"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect width="149" 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"
|
||||||
|
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"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DataRush };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({
|
||||||
className,
|
className,
|
||||||
@@ -10,10 +10,10 @@ function Tabs({
|
|||||||
return (
|
return (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
className={cn("flex flex-col", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsList({
|
function TabsList({
|
||||||
@@ -24,34 +24,28 @@ function TabsList({
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center gap-6",
|
"inline-flex w-fit items-center justify-center gap-6 rounded-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({
|
function TabsTrigger({
|
||||||
className,
|
className,
|
||||||
value,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & { value: string }) {
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
value={value}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative px-1 py-2 text-sm font-medium outline-none",
|
"data-[state=active]:text-foreground text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring data-[state=active]:border-primary-foreground inline-flex cursor-pointer items-center justify-center border-b-2 border-transparent pt-3 pb-[18px] text-base font-semibold whitespace-nowrap focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50",
|
||||||
"text-gray-500",
|
className,
|
||||||
"data-[state=active]:font-semibold after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5",
|
|
||||||
value === "ongoing" && "data-[state=active]:text-yellow-500 data-[state=active]:after:bg-yellow-500",
|
|
||||||
value === "completed" && "data-[state=active]:text-green-500 data-[state=active]:after:bg-green-500",
|
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
@@ -61,10 +55,10 @@ function TabsContent({
|
|||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
data-slot="tabs-content"
|
data-slot="tabs-content"
|
||||||
className={cn("mt-2 outline-none", className)}
|
className={cn("flex-1 outline-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import Navbar from "@/modules/Navbar";
|
import Navbar from "@/widgets/Navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Competition } from "@/shared/types/types";
|
import { Competition } from "@/shared/types";
|
||||||
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
|
import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks";
|
||||||
|
|
||||||
|
|
||||||
const CompetitionPreview = () => {
|
const CompetitionPreview = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,7 +16,7 @@ const CompetitionPreview = () => {
|
|||||||
const fetchCompetition = async () => {
|
const fetchCompetition = async () => {
|
||||||
try {
|
try {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const found = mockCompetitions.find(comp => comp.id === id);
|
const found = mockCompetitions.find((comp) => comp.id === id);
|
||||||
setCompetition(found || null);
|
setCompetition(found || null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -48,49 +47,55 @@ const CompetitionPreview = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="container mx-auto px-4 py-8 mt-16">
|
<div className="container mx-auto mt-16 px-4 py-8">
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="flex items-center text-gray-600 mb-8 font-hse-sans"
|
className="font-hse-sans mb-8 flex items-center text-gray-600"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} className="mr-2" />
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
Назад к соревнованиям
|
Назад к соревнованиям
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<p className="font-hse-sans text-gray-500">Загрузка...</p>
|
<p className="font-hse-sans text-gray-500">Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
) : competition ? (
|
) : competition ? (
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-lg overflow-hidden shadow-lg">
|
<div className="mx-auto max-w-5xl overflow-hidden rounded-lg bg-white shadow-lg">
|
||||||
<div className="w-full h-80 overflow-hidden">
|
<div className="h-80 w-full overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={competition.imageUrl}
|
src={competition.imageUrl}
|
||||||
alt={competition.name}
|
alt={competition.name}
|
||||||
className="w-full h-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-semibold font-hse-sans mr-6 flex-1">{competition.name}</h1>
|
<h1 className="font-hse-sans mr-6 flex-1 text-3xl font-semibold">
|
||||||
|
{competition.name}
|
||||||
|
</h1>
|
||||||
<Button
|
<Button
|
||||||
className="bg-yellow-400 hover:bg-yellow-500 text-black font-hse-sans text-base px-12 min-w-[180px]"
|
className="font-hse-sans min-w-[180px] bg-yellow-400 px-12 text-base text-black hover:bg-yellow-500"
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
>
|
>
|
||||||
Продолжить
|
Продолжить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-700 font-hse-sans text-lg leading-relaxed">
|
<div className="font-hse-sans text-lg leading-relaxed text-gray-700">
|
||||||
<p>{competition.description}</p>
|
<p>{competition.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<h2 className="text-2xl font-bold mb-2 font-hse-sans">Соревнование не найдено</h2>
|
<h2 className="font-hse-sans mb-2 text-2xl font-bold">
|
||||||
<p className="text-gray-600 font-hse-sans">Запрошенное соревнование не существует или было удалено.</p>
|
Соревнование не найдено
|
||||||
|
</h2>
|
||||||
|
<p className="font-hse-sans text-gray-600">
|
||||||
|
Запрошенное соревнование не существует или было удалено.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Task } from "@/shared/types/types";
|
import { Task } from "@/shared/types";
|
||||||
import { getTaskBgColor, getTaskTextColor } from "./utils/utils";
|
import { getTaskBgColor, getTaskTextColor } from "./utils/utils";
|
||||||
import { mockTasks } from "@/shared/mocks/mocks";
|
import { mockTasks } from "@/shared/mocks/mocks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -42,10 +42,11 @@ const CompetitionRunnerPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sticky top-0 z-10 bg-white">
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className="sticky top-16 z-10 bg-white border-b border-gray-200 shadow-sm">
|
||||||
<div className="py-3 text-center">
|
<div className="container mx-auto px-4">
|
||||||
<h1 className="text-lg font-semibold font-hse-sans">{competitionTitle}</h1>
|
<div className="py-4">
|
||||||
|
<h1 className="text-xl font-semibold font-hse-sans">{competitionTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 pb-3 overflow-x-auto no-scrollbar">
|
<div className="flex items-center justify-center gap-2 pb-3 overflow-x-auto no-scrollbar">
|
||||||
|
|||||||
@@ -1,54 +1,46 @@
|
|||||||
import { Competition } from "@/shared/types/types";
|
import { Competition, CompetitionStatus } from "@/shared/types";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
|
|
||||||
interface CompetitionCardProps {
|
interface CompetitionCardProps {
|
||||||
competition: Competition;
|
competition: Competition;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompetitionCard({ competition, className }: CompetitionCardProps) {
|
export function CompetitionCard({
|
||||||
const { id, name, imageUrl, isOlympics, status } = competition;
|
competition,
|
||||||
const navigate = useNavigate();
|
className,
|
||||||
|
}: CompetitionCardProps) {
|
||||||
const handleClick = () => {
|
|
||||||
navigate(`/competition/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn("overflow-hidden h-full", className)}
|
className={cn(
|
||||||
onClick={handleClick}
|
"aspect-square h-full max-h-80 w-auto overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative h-48 overflow-hidden">
|
<div className="relative h-full overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={competition.imageUrl}
|
||||||
alt={name}
|
alt={competition.name}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
|
className="h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardFooter className="p-4 pb-0 flex items-center text-xs font-medium font-hse-sans">
|
<CardContent>
|
||||||
<span className="text-gray-500">
|
<div className="flex flex-col gap-2.5">
|
||||||
{isOlympics ? "Олимпиада" : "Тренировка"}
|
<div className="text-muted-foreground flex items-center gap-2 *:text-sm *:font-semibold">
|
||||||
</span>
|
<span>{competition.isOlympics ? "Олимпиада" : "Тренировка"}</span>
|
||||||
<span className="mx-2 w-1.5 h-1.5 rounded-full bg-gray-300"></span>
|
{competition.status != CompetitionStatus.NotParticipating && (
|
||||||
<span className={cn(
|
<>
|
||||||
status === 'В процессе' && "text-yellow-500",
|
<span>•</span>
|
||||||
status === 'Завершено' && "text-green-500",
|
<span className="text-primary-foreground">
|
||||||
status === 'Не участвую' && "text-gray-500"
|
{competition.status}
|
||||||
)}>
|
</span>
|
||||||
{status.replace(/^\w/, c => c.toUpperCase())}
|
</>
|
||||||
</span>
|
)}
|
||||||
</CardFooter>
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">{competition.name}</h3>
|
||||||
<CardContent className="p-4 pt-2">
|
</div>
|
||||||
<h3 className="font-semibold text-lg line-clamp-2 font-hse-sans">{name}</h3>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,98 +1,122 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Competition, Status } from '@/shared/types/types';
|
import { Competition, CompetitionStatus } from "@/shared/types";
|
||||||
import { CompetitionGrid } from './modules/CompetitionGrid';
|
import { CompetitionGrid } from "./modules/CompetitionGrid";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import Navbar from '@/modules/Navbar';
|
|
||||||
import { mockCompetitions } from '@/shared/mocks/mocks';
|
const mockCompetitions: Competition[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: true,
|
||||||
|
status: CompetitionStatus.InProgress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: false,
|
||||||
|
status: CompetitionStatus.NotParticipating,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: false,
|
||||||
|
status: CompetitionStatus.InProgress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: true,
|
||||||
|
status: CompetitionStatus.Completed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: false,
|
||||||
|
status: CompetitionStatus.Completed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: true,
|
||||||
|
status: CompetitionStatus.NotParticipating,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: true,
|
||||||
|
status: CompetitionStatus.NotParticipating,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
|
imageUrl: "/DANO.png",
|
||||||
|
isOlympics: true,
|
||||||
|
status: CompetitionStatus.NotParticipating,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const CompetitionsPage = () => {
|
const CompetitionsPage = () => {
|
||||||
const [competitions, setCompetitions] = useState<Competition[]>([]);
|
const [competitions] = useState<Competition[]>(mockCompetitions);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState("ongoing");
|
const [activeTab, setActiveTab] = useState("ongoing");
|
||||||
|
|
||||||
useEffect(() => {
|
const myCompetitions = competitions.filter(
|
||||||
// ! симуляция фетча
|
(comp) =>
|
||||||
const fetchCompetitions = async () => {
|
comp.status === CompetitionStatus.InProgress ||
|
||||||
try {
|
comp.status === CompetitionStatus.Completed,
|
||||||
setTimeout(() => {
|
|
||||||
setCompetitions(mockCompetitions);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 800);
|
|
||||||
} catch (error) {
|
|
||||||
setError('Соревнования не найдены, пожалуйста, попробуйте позже');
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCompetitions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const myCompetitions = competitions.filter(comp =>
|
|
||||||
comp.status === Status.InProgress || comp.status === Status.Completed
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredMyCompetitions = myCompetitions.filter(comp =>
|
const filteredMyCompetitions = myCompetitions.filter((comp) =>
|
||||||
activeTab === "ongoing" ? comp.status === Status.InProgress : comp.status === Status.Completed
|
activeTab === "ongoing"
|
||||||
|
? comp.status === CompetitionStatus.InProgress
|
||||||
|
: comp.status === CompetitionStatus.Completed,
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableCompetitions = competitions.filter(comp =>
|
const availableCompetitions = competitions.filter(
|
||||||
comp.status === 'Не участвую'
|
(comp) => comp.status === "Не участвую",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-8">
|
||||||
<Navbar />
|
<Section>
|
||||||
<div className="container mx-auto px-4 py-8 mt-16">
|
<SectionHeader>
|
||||||
{error && (
|
<SectionTitle>Мои события</SectionTitle>
|
||||||
<Alert variant="destructive" className="mb-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<TabsList>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<TabsTrigger value="ongoing">В процессе</TabsTrigger>
|
||||||
</Alert>
|
<TabsTrigger value="completed">Завершенные</TabsTrigger>
|
||||||
)}
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</SectionHeader>
|
||||||
|
<CompetitionGrid competitions={filteredMyCompetitions} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<div className="mb-12">
|
<Section>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<SectionHeader>
|
||||||
<h2 className="text-2xl font-semibold font-hse-sans">Мои события</h2>
|
<SectionTitle>События</SectionTitle>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
</SectionHeader>
|
||||||
<TabsList>
|
<CompetitionGrid competitions={availableCompetitions} />
|
||||||
<TabsTrigger value="ongoing" className="font-hse-sans">Текущие</TabsTrigger>
|
</Section>
|
||||||
<TabsTrigger value="completed" className="font-hse-sans">Завершенные</TabsTrigger>
|
</div>
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<CompetitionGrid competitions={[]} isLoading={true} />
|
|
||||||
) : filteredMyCompetitions.length > 0 ? (
|
|
||||||
<CompetitionGrid competitions={filteredMyCompetitions} isLoading={false} />
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center items-center h-40 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-gray-500 font-hse-sans">
|
|
||||||
{activeTab === "ongoing" ? "У вас нет текущих соревнований" : "У вас нет завершенных соревнований"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Доступные события</h2>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<CompetitionGrid competitions={[]} isLoading={true} />
|
|
||||||
) : availableCompetitions.length > 0 ? (
|
|
||||||
<CompetitionGrid competitions={availableCompetitions} isLoading={false} />
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center items-center h-40 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-gray-500 font-hse-sans">Нет доступных соревнований</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const Section = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <div className="flex flex-col gap-8">{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionHeader = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <div className="flex h-[58px] items-center gap-2">{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionTitle = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <h1 className="w-full text-3xl font-semibold">{children}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
export default CompetitionsPage;
|
export default CompetitionsPage;
|
||||||
@@ -1,45 +1,15 @@
|
|||||||
import { Competition } from "@/shared/types/types";
|
import { Competition } from "@/shared/types";
|
||||||
import { CompetitionCard } from "../../components/CompetitionCard";
|
import { CompetitionCard } from "../../components/CompetitionCard";
|
||||||
import CompetitionSkeleton from "../../components/CompetitionSkeleton";
|
|
||||||
import { cn } from "@/shared/lib/utils";
|
|
||||||
|
|
||||||
interface CompetitionGridProps {
|
interface CompetitionGridProps {
|
||||||
competitions: Competition[];
|
competitions: Competition[];
|
||||||
isLoading?: boolean;
|
|
||||||
className?: string;
|
|
||||||
skeletonCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompetitionGrid({
|
export function CompetitionGrid({ competitions }: CompetitionGridProps) {
|
||||||
competitions,
|
|
||||||
isLoading = false,
|
|
||||||
className,
|
|
||||||
skeletonCount
|
|
||||||
}: CompetitionGridProps) {
|
|
||||||
const gridClasses = cn(
|
|
||||||
"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6",
|
|
||||||
className
|
|
||||||
);
|
|
||||||
|
|
||||||
const numberOfSkeletons = skeletonCount ?? (competitions.length > 0 ? competitions.length : 4);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={gridClasses}>
|
|
||||||
{Array.from({ length: numberOfSkeletons }).map((_, index) => (
|
|
||||||
<CompetitionSkeleton key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={gridClasses}>
|
<div className="grid grid-cols-3 gap-9">
|
||||||
{competitions.map((competition) => (
|
{competitions.map((competition) => (
|
||||||
<CompetitionCard
|
<CompetitionCard key={competition.id} competition={competition} />
|
||||||
key={competition.id}
|
|
||||||
competition={competition}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +1,52 @@
|
|||||||
import { Competition, Status, Task } from "../types/types";
|
import { Competition, CompetitionStatus, Task } from "../types";
|
||||||
|
|
||||||
const mockCompetitions: Competition[] = [
|
const mockCompetitions: Competition[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: "1",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: true,
|
isOlympics: true,
|
||||||
status: Status.InProgress,
|
status: CompetitionStatus.InProgress,
|
||||||
description: 'Проверка глубоких знаний и навыков в анализе данных. Будет несколько творческих заданий со свободным ответом. Задания выполняются индивидуально, вес тура в итоговом результате – 0,5. Этап пройдет онлайн в заданное время, с применением системы прокторинга. На работу дается 240 минут.'
|
description:
|
||||||
|
"Проверка глубоких знаний и навыков в анализе данных. Будет несколько творческих заданий со свободным ответом. Задания выполняются индивидуально, вес тура в итоговом результате – 0,5. Этап пройдет онлайн в заданное время, с применением системы прокторинга. На работу дается 240 минут.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: "2",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: false,
|
isOlympics: false,
|
||||||
status: Status.NotParticipating,
|
status: CompetitionStatus.NotParticipating,
|
||||||
description: 'Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.'
|
description:
|
||||||
|
"Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: "3",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: false,
|
isOlympics: false,
|
||||||
status: Status.InProgress
|
status: CompetitionStatus.InProgress,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: "4",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: true,
|
isOlympics: true,
|
||||||
status: Status.Completed
|
status: CompetitionStatus.Completed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: "5",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: false,
|
isOlympics: false,
|
||||||
status: Status.Completed
|
status: CompetitionStatus.Completed,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: "6",
|
||||||
name: 'Олимпиада DANO 2025. Индивидуальный этап',
|
name: "Олимпиада DANO 2025. Индивидуальный этап",
|
||||||
imageUrl: '/DANO.png',
|
imageUrl: "/DANO.png",
|
||||||
isOlympics: true,
|
isOlympics: true,
|
||||||
status: Status.NotParticipating
|
status: CompetitionStatus.NotParticipating,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockTasks: Task[] = [
|
const mockTasks: Task[] = [
|
||||||
@@ -58,4 +60,4 @@ const mockTasks: Task[] = [
|
|||||||
{ id: "8", number: "3.2", status: "correct" },
|
{ id: "8", number: "3.2", status: "correct" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export { mockCompetitions, mockTasks }
|
export { mockCompetitions, mockTasks };
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
enum CompetitionStatus {
|
||||||
|
InProgress = "В процессе",
|
||||||
|
NotParticipating = "Не участвую",
|
||||||
|
Completed = "Завершено",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Competition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
isOlympics: boolean;
|
||||||
|
status: CompetitionStatus;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CompetitionStatus };
|
||||||
|
export type { Competition, TaskStatus, Task };
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
enum Status {
|
|
||||||
InProgress = 'В процессе',
|
|
||||||
NotParticipating = 'Не участвую',
|
|
||||||
Completed = 'Завершено'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Competition {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
imageUrl: string;
|
|
||||||
isOlympics: boolean;
|
|
||||||
status: Status;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong";
|
|
||||||
|
|
||||||
interface Task {
|
|
||||||
id: string;
|
|
||||||
number: string;
|
|
||||||
status: TaskStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export {Status}
|
|
||||||
export type {Competition, TaskStatus, Task}
|
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.97 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(89.97% 0.1763 97.07);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(82.87% 0.1701 94.8);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-hse-sans: "HSE Sans", system-ui, sans-serif
|
--font-hse-sans: "HSE Sans", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50 font-hse-sans;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
const NavbarLayout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<div className="m-auto mt-6 w-full max-w-5xl">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NavbarLayout };
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
'hse-sans': ['"HSE Sans"', 'system-ui', 'sans-serif'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user