mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-22 22:07:10 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
+1
-1
@@ -253,7 +253,7 @@ services:
|
||||
- name: web
|
||||
target: 80
|
||||
published: 8003
|
||||
host_ip: 127.0.0.1
|
||||
host_ip: 0.0.0.0
|
||||
protocol: tcp
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Начала!
|
||||
# Начало!
|
||||
|
||||
Выбирай интересующий раздел слева и просвещайся!
|
||||
|
||||
+66
-62
@@ -1,77 +1,81 @@
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
const config: Config = {
|
||||
title: 'DataRush',
|
||||
tagline: 'Изучите основы анализа данных здесь!',
|
||||
favicon: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
|
||||
title: "DataRush",
|
||||
tagline: "Изучите основы анализа данных здесь!",
|
||||
favicon: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
|
||||
|
||||
url: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru',
|
||||
baseUrl: '/docs/',
|
||||
url: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru",
|
||||
baseUrl: "/docs/",
|
||||
|
||||
organizationName: 'megazord',
|
||||
projectName: 'megazord',
|
||||
organizationName: "megazord",
|
||||
projectName: "megazord",
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'ru',
|
||||
locales: ['ru'],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {},
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
|
||||
navbar: {
|
||||
title: 'DataRush',
|
||||
logo: {
|
||||
alt: 'DataRush',
|
||||
src: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'defaultSidebar',
|
||||
position: 'left',
|
||||
label: 'Документация',
|
||||
},
|
||||
],
|
||||
i18n: {
|
||||
defaultLocale: "ru",
|
||||
locales: ["ru"],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Документация',
|
||||
items: [
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
{
|
||||
label: 'Начало',
|
||||
to: '/docs/docs/intro',
|
||||
docs: {
|
||||
routeBasePath: "/",
|
||||
},
|
||||
theme: {
|
||||
customCss: "./src/css/custom.css",
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
|
||||
navbar: {
|
||||
title: "DataRush",
|
||||
logo: {
|
||||
alt: "DataRush",
|
||||
src: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "defaultSidebar",
|
||||
position: "left",
|
||||
label: "Документация",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Создано для Megazord ♥`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
footer: {
|
||||
style: "dark",
|
||||
links: [
|
||||
{
|
||||
title: "Документация",
|
||||
items: [
|
||||
{
|
||||
label: "Начало",
|
||||
to: "/intro",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Создано для Megazord ♥`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
|
||||
staticDirectories: ["static"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -12,7 +12,7 @@ DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres
|
||||
DJANGO_CREATE_SUPERUSER=True
|
||||
DJANGO_SUPERUSER_USERNAME=admin
|
||||
DJANGO_SUPERUSER_EMAIL=admin@mail.com
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
DJANGO_SUPERUSER_PASSWORD=prooooooood
|
||||
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
|
||||
|
||||
@@ -1 +1 @@
|
||||
admin
|
||||
J2NofXLJa57mpHVQVdNFaltSmg9gjI
|
||||
@@ -8,6 +8,19 @@ from apps.competition.models import Competition, State
|
||||
|
||||
class CompetitionOut(ModelSchema):
|
||||
id: UUID
|
||||
state: Literal["not_started", "started", "finished"]
|
||||
|
||||
@staticmethod
|
||||
def resolve_state(
|
||||
self, context
|
||||
) -> Literal["not_started", "started", "finished"]:
|
||||
if not (
|
||||
state := State.objects.filter(
|
||||
user=context.get("request").auth, competition=self
|
||||
).first()
|
||||
):
|
||||
return "not_started"
|
||||
return state.state
|
||||
|
||||
class Meta:
|
||||
model = Competition
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field
|
||||
|
||||
from apps.review.models import Review, Reviewer
|
||||
from apps.review.models import Review, Reviewer, ReviewStatusChoices
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
|
||||
class PingOut(Schema):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class ReviewerOut(ModelSchema):
|
||||
id: UUID
|
||||
|
||||
@@ -19,20 +17,83 @@ class ReviewerOut(ModelSchema):
|
||||
exclude = ("token",)
|
||||
|
||||
|
||||
class CriteriaMarkOut(Schema):
|
||||
slug: str
|
||||
mark: float
|
||||
|
||||
|
||||
class CriteriaOut(Schema):
|
||||
name: str
|
||||
slug: str
|
||||
max_value: int
|
||||
min_value: int
|
||||
|
||||
|
||||
class SubmissionOut(ModelSchema):
|
||||
id: UUID
|
||||
status: Literal["sent", "checking", "checked"]
|
||||
review_status: Literal["not_checked", "checked", "checking"]
|
||||
evaluation: list[CriteriaMarkOut] | None = None
|
||||
criteries: list[CriteriaOut] | None = None
|
||||
submitted_at: datetime = Field(..., alias="timestamp")
|
||||
competition: UUID = Field(..., alias="task.competition.id")
|
||||
competition_name: str = Field(..., alias="task.competition.title")
|
||||
|
||||
@staticmethod
|
||||
def resolve_criteries(self, context) -> list[CriteriaOut] | None:
|
||||
criteries = self.task.criteries
|
||||
return criteries
|
||||
|
||||
@staticmethod
|
||||
def resolve_evaluation(self, context) -> list[CriteriaMarkOut] | None:
|
||||
if not (
|
||||
review := Review.objects.filter(
|
||||
reviewer=context.get("request").auth, submission=self
|
||||
).first()
|
||||
):
|
||||
return None
|
||||
return review.evaluation
|
||||
|
||||
@staticmethod
|
||||
def resolve_review_status(self, context):
|
||||
reviewer = context.get("request").auth
|
||||
if not (
|
||||
review := Review.objects.filter(
|
||||
reviewer=reviewer, submission=self
|
||||
).first()
|
||||
):
|
||||
return ReviewStatusChoices.NOT_CHECKED.value
|
||||
return review.state
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTaskSubmission
|
||||
exclude = ("user",)
|
||||
fields = (
|
||||
"id",
|
||||
"task",
|
||||
"content",
|
||||
"stdout",
|
||||
"result",
|
||||
"earned_points",
|
||||
"checked_at",
|
||||
)
|
||||
|
||||
|
||||
class CriteriaMarkIn(Schema):
|
||||
slug: str
|
||||
mark: float
|
||||
|
||||
|
||||
class EvaluationIn(Schema):
|
||||
evaluation: list[CriteriaMarkIn]
|
||||
|
||||
|
||||
class SubmissionsOut(Schema):
|
||||
submissions: list = None
|
||||
submissions: list[SubmissionOut | None] = []
|
||||
|
||||
@staticmethod
|
||||
def resolve_submissions(self, context) -> list[SubmissionOut]:
|
||||
return list(
|
||||
Review.objects.filter(reviewer=context.get("request").auth)
|
||||
def resolve_submissions(self, context) -> list[SubmissionOut | None]:
|
||||
submissions = list(
|
||||
CompetitionTaskSubmission.objects.filter(
|
||||
reviews__reviewer=context.get("request").auth
|
||||
)
|
||||
)
|
||||
return submissions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
@@ -7,31 +8,19 @@ from ninja import Router
|
||||
|
||||
from api.v1 import schemas as global_schemas
|
||||
from api.v1.review import schemas
|
||||
from apps.review.models import Review, ReviewStatusChoices
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
router = Router(tags=["review"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"{token}/submissions",
|
||||
response={
|
||||
status.OK: schemas.SubmissionsOut,
|
||||
},
|
||||
description="Список отправок, на проверку которых назначен ревьюер",
|
||||
)
|
||||
def get_submissions(
|
||||
request: HttpRequest, token: str
|
||||
) -> 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",
|
||||
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в path",
|
||||
)
|
||||
def get_reviewer_profile(request: HttpRequest, token: str):
|
||||
return status.OK, request.auth
|
||||
@@ -47,4 +36,69 @@ def get_submission(
|
||||
request: HttpRequest, token: str, submition_id: UUID
|
||||
) -> tuple[status, schemas.SubmissionsOut]:
|
||||
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
|
||||
reviewer = request.auth
|
||||
|
||||
review = Review.objects.get(reviewer=reviewer, submission=submission)
|
||||
if review.state == ReviewStatusChoices.NOT_CHECKED.value:
|
||||
review.state = ReviewStatusChoices.CHECKING.value
|
||||
review.submission.state = (
|
||||
CompetitionTaskSubmission.StatusChoices.CHECKING.value
|
||||
)
|
||||
review.save()
|
||||
review.submission.save()
|
||||
|
||||
return status.OK, submission
|
||||
|
||||
|
||||
@router.get(
|
||||
"{token}/submissions",
|
||||
response={
|
||||
status.OK: schemas.SubmissionsOut,
|
||||
},
|
||||
description="Список отправок, на проверку которых назначен ревьюер",
|
||||
)
|
||||
def get_submissions(
|
||||
request: HttpRequest, token: str
|
||||
) -> tuple[status, schemas.SubmissionsOut]:
|
||||
return status.OK, schemas.SubmissionsOut()
|
||||
|
||||
|
||||
@router.post(
|
||||
"{token}/submissions/{submition_id}/evaluate",
|
||||
response={
|
||||
status.OK: schemas.SubmissionOut,
|
||||
},
|
||||
description="Оценка посылки. В body отправляется список с slug критерия и оценкой по этому критерию",
|
||||
)
|
||||
def evaluate_submission(
|
||||
request: HttpRequest,
|
||||
token: str,
|
||||
submition_id: UUID,
|
||||
evaluation_info: schemas.EvaluationIn,
|
||||
) -> tuple[status, schemas.SubmissionsOut]:
|
||||
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
|
||||
reviewer = request.auth
|
||||
|
||||
review = Review.objects.get(reviewer=reviewer, submission=submission)
|
||||
evaluation = evaluation_info.dict()["evaluation"]
|
||||
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()
|
||||
|
||||
all_checked = not submission.reviews.exclude(
|
||||
state=ReviewStatusChoices.CHECKED
|
||||
).exists()
|
||||
if all_checked:
|
||||
review.submission.status = (
|
||||
CompetitionTaskSubmission.StatusChoices.CHECKED.value
|
||||
)
|
||||
review.submission.save()
|
||||
return status.OK, review.submission
|
||||
|
||||
@@ -8,6 +8,8 @@ from api.v1.competition.views import router as competition_router
|
||||
from api.v1.ping.views import router as ping_router
|
||||
from api.v1.review.auth import ReviewerAuth
|
||||
from api.v1.review.views import router as review_router
|
||||
from api.v1.task.views import router as task_router
|
||||
from api.v1.team.views import router as team_router
|
||||
from api.v1.user.views import router as user_router
|
||||
|
||||
router = NinjaAPI(
|
||||
@@ -37,6 +39,16 @@ router.add_router(
|
||||
review_router,
|
||||
auth=ReviewerAuth(),
|
||||
)
|
||||
router.add_router(
|
||||
"",
|
||||
task_router,
|
||||
auth=BearerAuth(),
|
||||
)
|
||||
router.add_router(
|
||||
"team",
|
||||
team_router,
|
||||
auth=BearerAuth(),
|
||||
)
|
||||
|
||||
|
||||
for exception, handler in handlers.exception_handlers:
|
||||
|
||||
@@ -3,19 +3,36 @@ from uuid import UUID
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.task.models import CompetitionTask
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
|
||||
|
||||
|
||||
class TaskOutSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = CompetitionTask
|
||||
fields = ["id", "competition", "title", "description", "type"]
|
||||
|
||||
|
||||
class TaskSubmissionIn(Schema):
|
||||
type: Literal["input", "file", "code"]
|
||||
content: str
|
||||
fields = [
|
||||
"id",
|
||||
"competition",
|
||||
"title",
|
||||
"description",
|
||||
"type",
|
||||
"in_competition_position",
|
||||
"points",
|
||||
]
|
||||
|
||||
|
||||
class TaskSubmissionOut(Schema):
|
||||
submission_id: UUID
|
||||
|
||||
|
||||
class HistorySubmissionOut(ModelSchema):
|
||||
status: Literal["sent", "checked", "checking"]
|
||||
|
||||
class Meta:
|
||||
model = CompetitionTaskSubmission
|
||||
fields = ("id", "earned_points", "timestamp", "content",)
|
||||
|
||||
|
||||
class TaskAttachmentSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = CompetitionTaskAttachment
|
||||
fields = ("id", "file", "public",)
|
||||
|
||||
@@ -2,19 +2,21 @@ from http import HTTPStatus as status
|
||||
from uuid import UUID
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
from ninja import File, Router, UploadedFile
|
||||
|
||||
from api.v1.ping.schemas import PingOut
|
||||
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
|
||||
from api.v1.task.schemas import (
|
||||
HistorySubmissionOut,
|
||||
TaskAttachmentSchema,
|
||||
TaskOutSchema,
|
||||
TaskSubmissionIn,
|
||||
TaskSubmissionOut,
|
||||
)
|
||||
from apps.competition.models import State
|
||||
from apps.task.models import (
|
||||
Competition,
|
||||
CompetitionTask,
|
||||
CompetitionTaskAttachment,
|
||||
CompetitionTaskSubmission,
|
||||
)
|
||||
|
||||
@@ -87,32 +89,71 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
|
||||
},
|
||||
)
|
||||
def submit_task(
|
||||
request, competition_id: str, task_id: str, submission: TaskSubmissionIn
|
||||
) -> PingOut:
|
||||
request,
|
||||
competition_id: str,
|
||||
task_id: str,
|
||||
content: UploadedFile = File(...), # TODO: вот это надо переделать
|
||||
) -> TaskSubmissionOut:
|
||||
user = request.auth
|
||||
competetion = get_object_or_404(Competition, id=competition_id)
|
||||
competition = get_object_or_404(Competition, id=competition_id)
|
||||
task = get_object_or_404(
|
||||
CompetitionTask, competetion=competetion, id=task_id
|
||||
CompetitionTask, competition=competition, id=task_id
|
||||
)
|
||||
|
||||
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
|
||||
CompetitionTaskSubmission.objects.create(
|
||||
submission = CompetitionTaskSubmission.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
status=CompetitionTaskSubmission.StatusChoices.CHECKED,
|
||||
result={"correct": submission.content == task.answer_file_path},
|
||||
result={"correct": content == task.answer_file_path},
|
||||
content=content,
|
||||
)
|
||||
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
|
||||
CompetitionTaskSubmission.objects.create(
|
||||
submission = CompetitionTaskSubmission.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
status=CompetitionTaskSubmission.StatusChoices.SENT,
|
||||
content=content,
|
||||
)
|
||||
submission.send_on_review()
|
||||
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
|
||||
CompetitionTaskSubmission.objects.create(
|
||||
submission = CompetitionTaskSubmission.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
status=CompetitionTaskSubmission.StatusChoices.CHECKING,
|
||||
content=content,
|
||||
)
|
||||
|
||||
return TaskSubmissionOut(id=CompetitionTaskSubmission.id)
|
||||
return TaskSubmissionOut(submission_id=submission.id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"competitions/{competition_id}/tasks/{task_id}/history",
|
||||
response={
|
||||
status.OK: list[HistorySubmissionOut],
|
||||
status.UNAUTHORIZED: UnauthorizedError,
|
||||
},
|
||||
)
|
||||
def get_submissions_history(request, competition_id: UUID, task_id: UUID):
|
||||
task = get_object_or_404(
|
||||
CompetitionTask, competition_id=competition_id, id=task_id
|
||||
)
|
||||
submissions_history = CompetitionTaskSubmission.objects.filter(
|
||||
task=task, user=request.auth
|
||||
)
|
||||
|
||||
return status.OK, submissions_history
|
||||
|
||||
|
||||
@router.get(
|
||||
"competitions/{competition_id}/tasks/{task_id}/attachments",
|
||||
response={
|
||||
status.OK: list[TaskAttachmentSchema],
|
||||
status.UNAUTHORIZED: UnauthorizedError,
|
||||
},
|
||||
)
|
||||
def get_task_attachments(request, competition_id: UUID, task_id: UUID):
|
||||
task = get_object_or_404(CompetitionTask, id=task_id)
|
||||
return status.OK, CompetitionTaskAttachment.objects.filter(
|
||||
competition_id=competition_id, task=task, user=request.auth
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from apps.team.models import Team
|
||||
|
||||
|
||||
class CreateTeamSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
"name",
|
||||
"members",
|
||||
)
|
||||
|
||||
|
||||
class TeamSchemaOut(ModelSchema):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
"members",
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
from uuid import UUID
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
|
||||
from api.v1.schemas import BadRequestError, NotFoundError, UnauthorizedError
|
||||
from api.v1.team.schemas import CreateTeamSchema, TeamSchemaOut
|
||||
from apps.team.models import Team
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response={
|
||||
201: TeamSchemaOut,
|
||||
400: BadRequestError,
|
||||
401: UnauthorizedError,
|
||||
},
|
||||
description="Create team. Note: members array must have team members uuid, default can be empty",
|
||||
)
|
||||
def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
|
||||
team = Team(name=team_data.name, owner=request.auth)
|
||||
team.members.add(request.auth)
|
||||
team.save()
|
||||
return 201, team
|
||||
|
||||
|
||||
@router.get(
|
||||
"{team_id}",
|
||||
response={
|
||||
200: TeamSchemaOut,
|
||||
401: UnauthorizedError,
|
||||
404: NotFoundError,
|
||||
},
|
||||
)
|
||||
def get_team(request, team_id: UUID) -> (int, TeamSchemaOut):
|
||||
return get_object_or_404(Team, pk=team_id)
|
||||
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
|
||||
class UserSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "username"]
|
||||
fields = ["id", "email", "username", "created_at",]
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus as status
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
from ninja.errors import AuthenticationError
|
||||
|
||||
from api.v1.auth import BearerAuth
|
||||
from api.v1.schemas import BadRequestError, ForbiddenError, NotFoundError, ConflictError
|
||||
from api.v1.schemas import (
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
)
|
||||
from api.v1.user.schemas import (
|
||||
LoginSchema,
|
||||
RegisterSchema,
|
||||
@@ -28,7 +34,9 @@ router = Router(tags=["user"])
|
||||
auth=None,
|
||||
)
|
||||
def sign_up(request, data: RegisterSchema):
|
||||
user = User(**data.dict())
|
||||
user = User(**data.dict(exclude={"password"}))
|
||||
user.password = make_password(data.password)
|
||||
user.created_at = datetime.now()
|
||||
user.save()
|
||||
|
||||
token = BearerAuth.generate_jwt(user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 10:26
|
||||
# Generated by Django 5.1.6 on 2025-03-02 10:28
|
||||
|
||||
import apps.competition.models
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
@@ -19,14 +20,14 @@ class Migration(migrations.Migration):
|
||||
name='Competition',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=100, verbose_name='Название')),
|
||||
('description', models.TextField(verbose_name='Описание')),
|
||||
('image_url', models.FileField(blank=True, null=True, upload_to='', verbose_name='Изображение соревнования')),
|
||||
('end_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='Тип участия')),
|
||||
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')),
|
||||
('participants', models.ManyToManyField(related_name='participants', to='user.user')),
|
||||
('title', models.CharField(max_length=100, verbose_name='название')),
|
||||
('description', models.TextField(verbose_name='описание')),
|
||||
('image_url', models.ImageField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')),
|
||||
('end_date', models.DateTimeField(blank=True, null=True, verbose_name='окончание соревнования')),
|
||||
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='начало соревнования')),
|
||||
('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')),
|
||||
('participation_type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=11, verbose_name='тип соревнования')),
|
||||
('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'соревнование',
|
||||
@@ -37,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
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)),
|
||||
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], default='not_started', max_length=11)),
|
||||
('changed_at', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('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')),
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 12:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0001_initial'),
|
||||
('task', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='tasks',
|
||||
field=models.ManyToManyField(blank=True, related_name='tasks', to='task.competitiontask'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='participants',
|
||||
field=models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='participation_type',
|
||||
field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='Тип соревнования'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='Тип участия'),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 13:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0002_competition_tasks_alter_competition_participants_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='competition',
|
||||
name='tasks',
|
||||
),
|
||||
]
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:46
|
||||
|
||||
import tinymce.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0003_remove_competition_tasks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='description',
|
||||
field=tinymce.models.HTMLField(verbose_name='описание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='image_url',
|
||||
field=models.FileField(blank=True, null=True, upload_to='', verbose_name='изображение соревнования'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='participation_type',
|
||||
field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='title',
|
||||
field=models.CharField(max_length=100, verbose_name='аазвание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия'),
|
||||
),
|
||||
]
|
||||
@@ -8,31 +8,31 @@ from apps.user.models import User
|
||||
|
||||
class Competition(BaseModel):
|
||||
class CompetitionType(models.TextChoices):
|
||||
SOLO = "solo", "Индивидуальный"
|
||||
|
||||
class CompetitionParticipationType(models.TextChoices):
|
||||
EDU = "edu", "Образовательный"
|
||||
COMPETITIVE = "competitive", "Соревновательный"
|
||||
|
||||
class CompetitionParticipationType(models.TextChoices):
|
||||
SOLO = "solo", "Индивидуальный"
|
||||
|
||||
def image_url_upload_to(instance, filename):
|
||||
return f"/competitions/{instance.id}/image"
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="название")
|
||||
description = models.TextField(verbose_name="описание")
|
||||
image_url = models.FileField(
|
||||
image_url = models.ImageField(
|
||||
verbose_name="изображение соревнования",
|
||||
null=True,
|
||||
blank=True,
|
||||
upload_to=image_url_upload_to,
|
||||
)
|
||||
end_date = models.DateTimeField(
|
||||
verbose_name="дедлайн участия", null=True, blank=True
|
||||
verbose_name="окончание соревнования", null=True, blank=True
|
||||
)
|
||||
start_date = models.DateTimeField(
|
||||
verbose_name="дедлайн участия", null=True, blank=True
|
||||
verbose_name="начало соревнования", null=True, blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=10,
|
||||
max_length=11,
|
||||
choices=CompetitionType.choices,
|
||||
verbose_name="тип участия",
|
||||
)
|
||||
@@ -61,5 +61,9 @@ class State(BaseModel):
|
||||
|
||||
user = models.ForeignKey(User, 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,
|
||||
default=StateChoices.NOT_STARTED.value,
|
||||
)
|
||||
changed_at = models.DateTimeField(default=datetime.now)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import uuid
|
||||
from datetime import timedelta, datetime, tzinfo
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
import pytz
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -12,14 +15,14 @@ class CompetitionEndpointTests(TestCase):
|
||||
self.user = User.objects.create(
|
||||
email="user@example.com",
|
||||
password=make_password("password123"),
|
||||
username="t1wk4"
|
||||
username="t1wk4",
|
||||
)
|
||||
|
||||
self.competition = Competition.objects.create(
|
||||
title="AI Challenge",
|
||||
description="Machine Learning Competition",
|
||||
type="solo",
|
||||
participation_type="edu"
|
||||
type="edu",
|
||||
participation_type="solo",
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
@@ -29,80 +32,204 @@ class CompetitionEndpointTests(TestCase):
|
||||
).json()
|
||||
token = resp["token"]
|
||||
|
||||
self.valid_headers = {
|
||||
"HTTP_AUTHORIZATION": f"Bearer {token}"
|
||||
}
|
||||
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||
|
||||
# --- Helper methods ---
|
||||
def get_url(self, competition_id):
|
||||
return f"/api/v1/competition/{competition_id}"
|
||||
|
||||
# --- Test Cases ---
|
||||
def test_get_competition_success(self):
|
||||
"""Authenticated user gets competition details (200 OK)"""
|
||||
response = self.client.get(
|
||||
self.get_url(self.competition.id),
|
||||
**self.valid_headers
|
||||
self.get_url(self.competition.id), **self.valid_headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
# Validate required fields
|
||||
self.assertEqual(data["id"], str(self.competition.id))
|
||||
self.assertEqual(data["title"], "AI Challenge")
|
||||
self.assertEqual(data["type"], "solo")
|
||||
self.assertEqual(data["type"], "edu")
|
||||
|
||||
# Validate optional null fields
|
||||
self.assertIsNone(data["image_url"])
|
||||
self.assertIsNone(data["start_date"])
|
||||
self.assertIsNone(data["end_date"])
|
||||
|
||||
def test_invalid_uuid_format(self):
|
||||
"""Invalid UUID format returns 400 Bad Request"""
|
||||
response = self.client.get(
|
||||
self.get_url("invalid-id"),
|
||||
**self.valid_headers
|
||||
self.get_url("invalid-id"), **self.valid_headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_unauthenticated_access(self):
|
||||
"""Missing auth token returns 401 Unauthorized"""
|
||||
response = self.client.get(self.get_url(self.competition.id))
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||
|
||||
def test_nonexistent_competition(self):
|
||||
"""Valid UUID but missing competition returns 404"""
|
||||
new_uuid = uuid.uuid4()
|
||||
response = self.client.get(
|
||||
self.get_url(new_uuid),
|
||||
**self.valid_headers
|
||||
self.get_url(new_uuid), **self.valid_headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["detail"], "Not Found")
|
||||
|
||||
def test_invalid_auth_token(self):
|
||||
"""Invalid token returns 401 Unauthorized"""
|
||||
response = self.client.get(
|
||||
self.get_url(self.competition.id),
|
||||
HTTP_AUTHORIZATION="Bearer invalid_token"
|
||||
HTTP_AUTHORIZATION="Bearer invalid_token",
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||
|
||||
def test_malformed_auth_header(self):
|
||||
"""Malformed Authorization header returns 401"""
|
||||
cases = [
|
||||
("InvalidScheme valid_token_123", 401),
|
||||
("Bearer", 401), # Missing token
|
||||
("", 401), # No header
|
||||
("Bearer", 401),
|
||||
("", 401),
|
||||
]
|
||||
|
||||
for header, expected_status in cases:
|
||||
with self.subTest(header=header):
|
||||
response = self.client.get(
|
||||
self.get_url(self.competition.id),
|
||||
HTTP_AUTHORIZATION=header
|
||||
HTTP_AUTHORIZATION=header,
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
|
||||
class CompetitionsEndpointTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
email="user@example.com",
|
||||
password=make_password("password123"),
|
||||
username="t1wk4",
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data={"email": self.user.email, "password": "password123"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
token = resp["token"]
|
||||
|
||||
now = datetime.now(tz=pytz.utc)
|
||||
self.competitions = []
|
||||
for i in range(1, 6):
|
||||
competition = Competition.objects.create(
|
||||
title=f"Competition {i}",
|
||||
description=f"Description {i}",
|
||||
type=(
|
||||
Competition.CompetitionType.EDU
|
||||
if i % 2 == 0
|
||||
else Competition.CompetitionType.COMPETITIVE
|
||||
),
|
||||
participation_type=Competition.CompetitionParticipationType.SOLO,
|
||||
start_date=(now + timedelta(days=i)).isoformat(),
|
||||
end_date=(now + timedelta(days=i + 7)).isoformat(),
|
||||
)
|
||||
if i <= 2:
|
||||
competition.participants.add(self.user)
|
||||
self.competitions.append(competition)
|
||||
|
||||
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||
|
||||
def get_url(self, params=None):
|
||||
base_url = "/api/v1/competitions"
|
||||
return f"{base_url}?{params}" if params else base_url
|
||||
|
||||
def test_get_participating_competitions(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=true"), **self.valid_headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 2)
|
||||
self.assertEqual(
|
||||
{item["id"] for item in data},
|
||||
{str(self.competitions[0].id), str(self.competitions[1].id)},
|
||||
)
|
||||
|
||||
def test_competition_type_values(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=true"), **self.valid_headers
|
||||
)
|
||||
|
||||
for i in range(len(response.json())):
|
||||
item = response.json()[i]
|
||||
if (i + 1) % 2 == 0:
|
||||
self.assertEqual(item["type"], "edu")
|
||||
else:
|
||||
self.assertEqual(item["type"], "competitive")
|
||||
|
||||
def test_participation_type_values(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=false"), **self.valid_headers
|
||||
)
|
||||
|
||||
types = [item["participation_type"] for item in response.json()]
|
||||
self.assertCountEqual(types, ["solo", "solo", "solo"])
|
||||
|
||||
def test_datetime_formatting(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=true"), **self.valid_headers
|
||||
)
|
||||
|
||||
for item in response.json():
|
||||
if item["start_date"]:
|
||||
try:
|
||||
isoparse(item["start_date"])
|
||||
except ValueError:
|
||||
self.fail("Invalid start_date format")
|
||||
if item["end_date"]:
|
||||
try:
|
||||
isoparse(item["end_date"])
|
||||
except ValueError:
|
||||
self.fail("Invalid end_date format")
|
||||
|
||||
def test_competition_metadata(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=true"), **self.valid_headers
|
||||
)
|
||||
|
||||
item = response.json()[0]
|
||||
self.assertEqual(item["title"], "Competition 1")
|
||||
self.assertEqual(item["description"], "Description 1")
|
||||
self.assertEqual(item["type"], "competitive")
|
||||
self.assertEqual(item["participation_type"], "solo")
|
||||
|
||||
def test_verbose_name_consistency(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=true"), **self.valid_headers
|
||||
)
|
||||
|
||||
item = response.json()[0]
|
||||
self.assertNotIn("название", item)
|
||||
self.assertIn("title", item)
|
||||
|
||||
def test_null_dates_handling(self):
|
||||
competition = Competition.objects.create(
|
||||
title="No Dates Competition",
|
||||
description="Test competition",
|
||||
type=Competition.CompetitionType.EDU,
|
||||
participation_type=Competition.CompetitionParticipationType.SOLO,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=false"), **self.valid_headers
|
||||
)
|
||||
|
||||
test_item = next(
|
||||
item
|
||||
for item in response.json()
|
||||
if item["id"] == str(competition.id)
|
||||
)
|
||||
self.assertIsNone(test_item["start_date"])
|
||||
self.assertIsNone(test_item["end_date"])
|
||||
|
||||
def test_participation_status_filtering(self):
|
||||
response = self.client.get(
|
||||
self.get_url("is_participating=false"), **self.valid_headers
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 3)
|
||||
|
||||
@@ -2,4 +2,3 @@ from django.contrib import admin
|
||||
from django.contrib.auth.models import Group, User
|
||||
|
||||
admin.site.unregister(Group)
|
||||
admin.site.unregister(User)
|
||||
|
||||
@@ -8,6 +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.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||
from apps.user.models import User, UserRole
|
||||
|
||||
@@ -19,11 +20,23 @@ 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.create_submissions(tasks, users)
|
||||
self.create_states(competitions, users)
|
||||
self.stdout.write("Data generation completed.")
|
||||
|
||||
def create_reviewers(self, count):
|
||||
reviewers = []
|
||||
for i in range(count):
|
||||
name = f"John_{i}"
|
||||
surname = f"Smith_{i}"
|
||||
token = random.randint(100000, 999999)
|
||||
reviewer = Reviewer(name=name, surname=surname, token=token)
|
||||
reviewer.save()
|
||||
reviewers.append(reviewer)
|
||||
return reviewers
|
||||
|
||||
def create_users(self, count):
|
||||
users = []
|
||||
for i in range(1, count + 1):
|
||||
@@ -60,8 +73,10 @@ class Command(BaseCommand):
|
||||
description=description,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
type="solo", # assuming only one type for now
|
||||
participation_type=random.choice(["edu", "competitive"]),
|
||||
type=random.choice(
|
||||
["edu", "competitive"]
|
||||
), # assuming only one type for now
|
||||
participation_type="solo",
|
||||
)
|
||||
# Add random participants
|
||||
selected_users = random.sample(
|
||||
@@ -89,11 +104,18 @@ class Command(BaseCommand):
|
||||
description=description,
|
||||
type=task_type,
|
||||
points=random.randint(1, 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.review.models import Review, 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",)
|
||||
@@ -4,3 +4,4 @@ from django.apps import AppConfig
|
||||
class CoreConfig(AppConfig):
|
||||
name = "apps.review"
|
||||
label = "review"
|
||||
verbose_name = "Проверка"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 08:47
|
||||
# Generated by Django 5.1.6 on 2025-03-02 10:28
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
@@ -12,16 +12,29 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
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=[
|
||||
('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)),
|
||||
('name', models.CharField(max_length=100, verbose_name='имя')),
|
||||
('surname', models.CharField(max_length=100, verbose_name='фамилия')),
|
||||
('token', models.CharField(max_length=100, verbose_name='токен для входа')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'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='проверяющий'),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Review',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], max_length=11)),
|
||||
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:47
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0002_review'),
|
||||
('task', '0005_alter_competitiontask_description_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='review',
|
||||
name='submission',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontasksubmission'),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +1,50 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
|
||||
class Reviewer(BaseModel):
|
||||
name = models.CharField(max_length=100)
|
||||
surname = models.CharField(max_length=100)
|
||||
name = models.CharField(max_length=100, verbose_name="имя")
|
||||
surname = models.CharField(max_length=100, verbose_name="фамилия")
|
||||
|
||||
token = models.CharField(max_length=100)
|
||||
token = models.CharField(max_length=100, verbose_name="токен для входа")
|
||||
|
||||
def __str__(self):
|
||||
return self.name + " " + self.surname
|
||||
|
||||
class Meta:
|
||||
verbose_name = "проверяющий"
|
||||
verbose_name_plural = "проверяющие"
|
||||
|
||||
|
||||
class ReviewStatusChoices(models.TextChoices):
|
||||
NOT_CHECKED = "not_checked"
|
||||
CHECKING = "checking"
|
||||
CHECKED = "checked"
|
||||
|
||||
|
||||
class Review(BaseModel):
|
||||
class ReviewStatusChoices(models.TextChoices):
|
||||
NOT_CHECKED = "not_checked"
|
||||
CHECKING = "checking"
|
||||
CHECKED = "checked"
|
||||
|
||||
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE)
|
||||
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
|
||||
verbose_name="проверяющий")
|
||||
submission = models.ForeignKey(
|
||||
CompetitionTaskSubmission, on_delete=models.CASCADE
|
||||
"task.CompetitionTaskSubmission",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reviews",
|
||||
verbose_name="посылка"
|
||||
)
|
||||
|
||||
evaluation = models.JSONField(default=list, null=True, blank=True,
|
||||
verbose_name="выполнение")
|
||||
state = models.CharField(
|
||||
choices=ReviewStatusChoices.choices, max_length=11
|
||||
choices=ReviewStatusChoices.choices,
|
||||
default=ReviewStatusChoices.NOT_CHECKED.value,
|
||||
max_length=11,
|
||||
verbose_name="состояние"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "проверка"
|
||||
verbose_name_plural = "проверки"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.task.models import CompetitionTask
|
||||
from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \
|
||||
CompetitionTaskSubmission
|
||||
|
||||
|
||||
class CompletionAttachmentInline(admin.StackedInline):
|
||||
model = CompetitionTaskAttachment
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(CompetitionTask)
|
||||
@@ -8,6 +14,20 @@ class CompetitionTaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "type", "points")
|
||||
|
||||
|
||||
@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):
|
||||
model = CompetitionTask
|
||||
extra = 0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 10:26
|
||||
# Generated by Django 5.1.6 on 2025-03-02 10:28
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
import tinymce.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -12,6 +13,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0001_initial'),
|
||||
('review', '0001_initial'),
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
@@ -20,31 +22,63 @@ class Migration(migrations.Migration):
|
||||
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(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)),
|
||||
('points', models.IntegerField(blank=True, null=True)),
|
||||
('answer_file_path', models.TextField(blank=True, null=True)),
|
||||
('criteries', models.JSONField(blank=True, null=True)),
|
||||
('in_competition_position', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('title', models.CharField(max_length=50, verbose_name='заголовок')),
|
||||
('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')),
|
||||
('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')),
|
||||
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')),
|
||||
('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': 'задание',
|
||||
'verbose_name_plural': 'задания',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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, 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,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CompetetionTaskSumbission',
|
||||
name='CompetitionTaskCriteria',
|
||||
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)),
|
||||
('name', models.TextField()),
|
||||
('slug', models.SlugField()),
|
||||
('description', models.TextField()),
|
||||
('max_value', models.PositiveSmallIntegerField()),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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, 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)),
|
||||
('earned_points', models.IntegerField()),
|
||||
('earned_points', models.IntegerField(blank=True, null=True)),
|
||||
('checked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('plagiarism_checked', models.BooleanField(default=False)),
|
||||
('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')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 12:21
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0002_competition_tasks_alter_competition_participants_and_more'),
|
||||
('task', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='competitiontask',
|
||||
options={'verbose_name': 'задание', 'verbose_name_plural': 'задания'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='competition',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='соревнование'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='correct_answer_file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='правильный ответ'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='criteries',
|
||||
field=models.JSONField(blank=True, null=True, verbose_name='критерии проверки'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='points',
|
||||
field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8, verbose_name='тип задания'),
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 12:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0001_initial'),
|
||||
('task', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competetiontasksumbission',
|
||||
name='reviewers',
|
||||
field=models.ManyToManyField(blank=True, related_name='reviewers', to='review.reviewer'),
|
||||
),
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 13:49
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0003_remove_competition_tasks'),
|
||||
('task', '0002_alter_competitiontask_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competitiontask',
|
||||
name='max_attemps',
|
||||
field=models.PositiveSmallIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='answer_file_path',
|
||||
field=models.TextField(blank=True, default='stdout', null=True, verbose_name='куда сохранять решения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='competition',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='correct_answer_file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='criteries',
|
||||
field=models.JSONField(blank=True, null=True, verbose_name='критерии'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='title',
|
||||
field=models.CharField(max_length=50, verbose_name='заголовок'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки'),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0002_competetiontasksumbission_reviewers'),
|
||||
('task', '0003_competitiontask_max_attemps_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:47
|
||||
|
||||
import apps.task.models
|
||||
import django.db.models.deletion
|
||||
import tinymce.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0004_merge_20250301_1739'),
|
||||
('user', '0002_alter_user_email_alter_user_password_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='description',
|
||||
field=tinymce.models.HTMLField(max_length=300, verbose_name='описание'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitiontask',
|
||||
name='max_attemps',
|
||||
field=models.PositiveSmallIntegerField(),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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)),
|
||||
('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)),
|
||||
('earned_points', models.IntegerField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='CompetetionTaskSumbission',
|
||||
),
|
||||
]
|
||||
@@ -1,27 +1,31 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from tinymce.models import HTMLField
|
||||
|
||||
from apps.competition.models import Competition
|
||||
from apps.core.models import BaseModel
|
||||
from apps.task.validators import ContestTaskCriteriesValidator
|
||||
from apps.review.models import Review, ReviewStatusChoices, Reviewer
|
||||
from apps.user.models import User
|
||||
|
||||
|
||||
class CompetitionTask(BaseModel):
|
||||
class CompetitionTaskType(models.TextChoices):
|
||||
INPUT = "input", "Ввод правильного ответа"
|
||||
CHECKER = "checker", "Вывод кода"
|
||||
CHECKER = "checker", "Ввод кода"
|
||||
REVIEW = "review", "Ручная"
|
||||
|
||||
def answer_file_upload_to(instance, filename) -> str:
|
||||
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)
|
||||
max_attemps = models.PositiveSmallIntegerField()
|
||||
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
type = models.CharField(
|
||||
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
||||
)
|
||||
@@ -41,36 +45,43 @@ class CompetitionTask(BaseModel):
|
||||
answer_file_path = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="куда сохранять решения",
|
||||
verbose_name="куда сделать вывод программы участнику",
|
||||
help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt",
|
||||
default="stdout",
|
||||
)
|
||||
|
||||
# only when "review" type
|
||||
# TODO make it more humanize
|
||||
criteries = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="критерии",
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
ContestTaskCriteriesValidator()(self)
|
||||
reviewers = models.ManyToManyField(Reviewer, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "задание"
|
||||
verbose_name_plural = "задания"
|
||||
|
||||
|
||||
class CompetitionTaskCriteria(BaseModel):
|
||||
task = models.ForeignKey(
|
||||
CompetitionTask, on_delete=models.CASCADE, related_name="criteries"
|
||||
)
|
||||
|
||||
name = models.TextField()
|
||||
slug = models.SlugField()
|
||||
description = models.TextField()
|
||||
max_value = models.PositiveSmallIntegerField()
|
||||
|
||||
|
||||
class CompetitionTaskAttachment(BaseModel):
|
||||
def file_upload_at(instance, filename):
|
||||
return f"/attachment/{instance.id}/file"
|
||||
|
||||
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE)
|
||||
file = models.FileField(upload_to=file_upload_at)
|
||||
bind_at = models.FilePathField()
|
||||
public = models.BooleanField(default=False)
|
||||
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="публичный")
|
||||
|
||||
|
||||
class CompetitionTaskSubmission(BaseModel):
|
||||
@@ -92,6 +103,7 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
choices=StatusChoices.choices,
|
||||
default=StatusChoices.SENT,
|
||||
max_length=8,
|
||||
verbose_name="статус"
|
||||
)
|
||||
|
||||
# code or text or file
|
||||
@@ -108,6 +120,32 @@ class CompetitionTaskSubmission(BaseModel):
|
||||
# - 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()
|
||||
earned_points = models.IntegerField(null=True, blank=True)
|
||||
|
||||
checked_at = models.DateTimeField(null=True, blank=True)
|
||||
plagiarism_checked = models.BooleanField(default=False)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
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,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("pending_count")
|
||||
.first()
|
||||
)
|
||||
review = Review.objects.create(
|
||||
reviewer=reviewer,
|
||||
submission=self,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ast
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
@@ -6,6 +7,7 @@ import tempfile
|
||||
from io import StringIO
|
||||
|
||||
from config.celery import app
|
||||
from apps.task.models import CompetitionTaskSubmission
|
||||
|
||||
ALLOWED_MODULES = {
|
||||
"pandas",
|
||||
@@ -19,6 +21,7 @@ ALLOWED_MODULES = {
|
||||
"csv",
|
||||
"math",
|
||||
"statistics",
|
||||
"statsmodels",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,18 +66,28 @@ def validate_code(code_str):
|
||||
raise SecurityException(f"Security check failed: {e!s}")
|
||||
|
||||
|
||||
def secure_exec(code_str, result_path):
|
||||
def secure_exec(code_str, result_path, input_files=None):
|
||||
original_dir = os.getcwd()
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = captured_stdout = StringIO()
|
||||
result_content = None
|
||||
|
||||
if input_files is None:
|
||||
input_files = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
os.chdir(temp_dir)
|
||||
|
||||
for file in input_files:
|
||||
file_path = os.path.join(temp_dir, file["bind_at"])
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file["content"])
|
||||
|
||||
restricted_globals = {
|
||||
"__builtins__": {
|
||||
"open": lambda f, *a, **kw: open(f, *a, **kw),
|
||||
"open": open,
|
||||
"print": print,
|
||||
"str": str,
|
||||
"int": int,
|
||||
@@ -105,15 +118,21 @@ def secure_exec(code_str, result_path):
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def analyze_data_task(self, code_str, result_path, expected_bytes):
|
||||
def analyze_data_task(
|
||||
self, code_str, result_path, expected_file_link, submission_id, input_files=[]
|
||||
):
|
||||
try:
|
||||
validate_code(code_str)
|
||||
|
||||
result_content = secure_exec(code_str, result_path)
|
||||
result_content = secure_exec(code_str, result_path, input_files)
|
||||
|
||||
result_hash = hashlib.sha256(result_content).hexdigest()
|
||||
expected_hash = hashlib.sha256(expected_bytes).hexdigest()
|
||||
|
||||
with contextlib.suppress(CompetitionTaskSubmission.DoesNotExist):
|
||||
submission = CompetitionTaskSubmission.objects.get(id=submission_id)
|
||||
submission.result = {"correct": True}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"match": result_hash == expected_hash,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import unittest
|
||||
|
||||
from apps.task.tasks import analyze_data_task
|
||||
|
||||
|
||||
class TestAnalyzeDataTask(unittest.TestCase):
|
||||
def test_task_execution_basic(self):
|
||||
code_str = 'print("Hello, World!")'
|
||||
result_path = "stdout"
|
||||
expected_bytes = b"Hello, World!\n"
|
||||
result = analyze_data_task(code_str, result_path, expected_bytes)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["match"])
|
||||
|
||||
def test_task_execution_with_files(self):
|
||||
code_str = """
|
||||
with open("file.txt") as f:
|
||||
print(f.read())
|
||||
"""
|
||||
result_path = "stdout"
|
||||
expected_bytes = b"some_content\n"
|
||||
result = analyze_data_task(
|
||||
code_str,
|
||||
result_path,
|
||||
expected_bytes,
|
||||
input_files=[{"bind_at": "file.txt", "content": b"some_content"}],
|
||||
)
|
||||
print(result)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["match"])
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
|
||||
class Criteria(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
max_value: int
|
||||
min_value: int
|
||||
|
||||
|
||||
class ContestTaskCriteriesValidator:
|
||||
def __call__(self, instance):
|
||||
if instance.criteries and not isinstance(instance.criteries, list):
|
||||
err = "criteries must be a valid dictionary"
|
||||
raise ValidationError(err)
|
||||
|
||||
try:
|
||||
for criteria in instance.criteries if instance.criteries else []:
|
||||
Criteria(**criteria)
|
||||
except PydanticValidationError:
|
||||
err = "invalid criteries data"
|
||||
raise ValidationError(err)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.team.models import Team
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner")
|
||||
search_fields = (
|
||||
"name",
|
||||
"owner",
|
||||
"members",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TeamConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.team"
|
||||
verbose_name = "Команды"
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=50, verbose_name='название')),
|
||||
('members', models.ManyToManyField(related_name='team_members', to='user.user', verbose_name='участники')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='владелец')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'команда',
|
||||
'verbose_name_plural': 'команды',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamInvite',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('link', models.UUIDField(verbose_name='инвайт')),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='team.team', verbose_name='команда')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'приглашение',
|
||||
'verbose_name_plural': 'приглашения',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
from apps.user.models import User
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=50, verbose_name="название")
|
||||
owner = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, verbose_name="владелец"
|
||||
)
|
||||
members = models.ManyToManyField(
|
||||
User, related_name="team_members", verbose_name="участники"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "команда"
|
||||
verbose_name_plural = "команды"
|
||||
|
||||
|
||||
class TeamInvite(BaseModel):
|
||||
team = models.ForeignKey(
|
||||
Team, on_delete=models.CASCADE, verbose_name="команда"
|
||||
)
|
||||
link = models.UUIDField(verbose_name="инвайт")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "приглашение"
|
||||
verbose_name_plural = "приглашения"
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 08:47
|
||||
# Generated by Django 5.1.6 on 2025-03-02 10:28
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
@@ -16,9 +16,10 @@ class Migration(migrations.Migration):
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')),
|
||||
('username', models.SlugField(unique=True, verbose_name='Юзернейм')),
|
||||
('password', models.TextField(verbose_name='Пароль')),
|
||||
('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={
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-01 14:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, unique=True, verbose_name='почта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='password',
|
||||
field=models.TextField(verbose_name='пароль'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='username',
|
||||
field=models.SlugField(unique=True, verbose_name='юзернейм'),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,8 @@ class User(BaseModel):
|
||||
username = models.SlugField(unique=True, verbose_name="юзернейм")
|
||||
password = models.TextField(verbose_name="пароль")
|
||||
|
||||
created_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@staticmethod
|
||||
def make_password(password: str):
|
||||
return make_password(password)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.user.models import User
|
||||
|
||||
@@ -47,7 +47,9 @@ class SignUpAPITestCase(TestCase):
|
||||
|
||||
def test_existing_user_conflict(self):
|
||||
User.objects.create(
|
||||
email="existing@example.com", password="existingpass123", username="testing"
|
||||
email="existing@example.com",
|
||||
password="existingpass123",
|
||||
username="testing",
|
||||
)
|
||||
payload = {
|
||||
"email": "existing@example.com",
|
||||
@@ -62,23 +64,24 @@ class SignUpAPITestCase(TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("detail", response.json())
|
||||
|
||||
|
||||
class SignInAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
email="valid@example.com",
|
||||
password=make_password("securepassword123"),
|
||||
username="testuser"
|
||||
username="testuser",
|
||||
)
|
||||
self.valid_payload = {
|
||||
"email": "valid@example.com",
|
||||
"password": "securepassword123"
|
||||
"password": "securepassword123",
|
||||
}
|
||||
|
||||
def test_successful_sign_in(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps(self.valid_payload),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("token", response.json())
|
||||
@@ -88,7 +91,7 @@ class SignInAPITestCase(TestCase):
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps({"password": "pass"}),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@@ -96,44 +99,35 @@ class SignInAPITestCase(TestCase):
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps({"email": "test@example.com"}),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
payload = {
|
||||
"email": "invalid-email",
|
||||
"password": "password123"
|
||||
}
|
||||
payload = {"email": "invalid-email", "password": "password123"}
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_incorrect_password(self):
|
||||
payload = {
|
||||
"email": "valid@example.com",
|
||||
"password": "wrongpassword"
|
||||
}
|
||||
payload = {"email": "valid@example.com", "password": "wrongpassword"}
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||
|
||||
def test_nonexistent_user(self):
|
||||
payload = {
|
||||
"email": "notexist@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
payload = {"email": "notexist@example.com", "password": "password123"}
|
||||
response = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||
@@ -145,21 +139,25 @@ class UserMeEndpointTestCase(TestCase):
|
||||
self.user = User.objects.create(
|
||||
email="johndoe@example.com",
|
||||
username="johndoe",
|
||||
password=make_password("securepassword123")
|
||||
password=make_password("securepassword123"),
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/v1/sign-in",
|
||||
data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}),
|
||||
content_type="application/json"
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": "johndoe@example.com",
|
||||
"password": "securepassword123",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
).json()
|
||||
self.token = resp['token']
|
||||
self.token = resp["token"]
|
||||
self.url = "/api/v1/me"
|
||||
|
||||
def test_get_authenticated_user_data(self):
|
||||
"""Test authenticated user can retrieve their profile (200 OK)"""
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}"
|
||||
self.url, HTTP_AUTHORIZATION=f"Bearer {self.token}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -191,8 +189,7 @@ class UserMeEndpointTestCase(TestCase):
|
||||
def test_invalid_auth_scheme(self):
|
||||
"""Test invalid authentication scheme returns 401"""
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
|
||||
self.url, HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
@@ -200,18 +197,12 @@ class UserMeEndpointTestCase(TestCase):
|
||||
|
||||
def test_malformed_token(self):
|
||||
"""Test malformed token returns 401"""
|
||||
test_cases = [
|
||||
"invalid.token.123",
|
||||
"Bearer",
|
||||
"",
|
||||
"123456"
|
||||
]
|
||||
test_cases = ["invalid.token.123", "Bearer", "", "123456"]
|
||||
|
||||
for token in test_cases:
|
||||
with self.subTest(token=token):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
self.url, HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||
|
||||
@@ -448,6 +448,7 @@ INSTALLED_APPS = [
|
||||
"apps.competition",
|
||||
"apps.review",
|
||||
"apps.task",
|
||||
"apps.team",
|
||||
]
|
||||
|
||||
# tinymce
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pydantic>=2.10.5",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dateutil>=2.9.0.post0",
|
||||
"python-gettext>=5.0",
|
||||
"python-json-logger>=3.2.1",
|
||||
"pytz>=2024.2",
|
||||
@@ -31,11 +32,11 @@ dependencies = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.6.12",
|
||||
"django-debug-toolbar>=4.4.6",
|
||||
"django-stubs[compatible-mypy]>=5.1.3",
|
||||
"mypy>=1.15.0",
|
||||
"ruff>=0.9.3",
|
||||
"coverage>=7.6.12",
|
||||
"django-debug-toolbar>=4.4.6",
|
||||
"django-stubs[compatible-mypy]>=5.1.3",
|
||||
"mypy>=1.15.0",
|
||||
"ruff>=0.9.3",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"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",
|
||||
@@ -158,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=="],
|
||||
@@ -178,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=="],
|
||||
|
||||
Generated
+5921
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -10,6 +10,8 @@ import LoginPage from "./pages/Login";
|
||||
import { AuthLayout } from "./widgets/auth-layout";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import ReviewPage from "./pages/Review";
|
||||
import CompetitionConstructor from "./pages/CompetitionConstructor";
|
||||
import UserProfile from "./pages/UserProfile";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -30,6 +32,17 @@ const App = () => {
|
||||
element={<CompetitionSession />}
|
||||
/>
|
||||
|
||||
<Route path="/constructor/:id" element={<CompetitionConstructor />} />
|
||||
|
||||
<Route path="/constructor/new" element={<CompetitionConstructor />} />
|
||||
|
||||
<Route
|
||||
path="/constructor/:id/tasks/:taskId"
|
||||
element={<CompetitionConstructor />}
|
||||
/>
|
||||
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
|
||||
<Route path="/review/:token" element={<ReviewPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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,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,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 }
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Task } from "@/shared/types";
|
||||
import { Settings, Plus } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ConstructorHeaderProps {
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
competitionId: string;
|
||||
onAddTaskClick: () => void;
|
||||
}
|
||||
|
||||
const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({
|
||||
title,
|
||||
tasks,
|
||||
competitionId,
|
||||
onAddTaskClick
|
||||
}) => {
|
||||
return (
|
||||
<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">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
|
||||
<Link
|
||||
to={`/constructor/${competitionId}/tasks/settings`}
|
||||
className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:bg-gray-200 flex-shrink-0 flex items-center"
|
||||
>
|
||||
<Settings size={16} className="mr-1" />
|
||||
</Link>
|
||||
|
||||
{tasks.map((task) => (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/constructor/${competitionId}/tasks/${task.id}`}
|
||||
className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
|
||||
transition-all hover:bg-blue-200 flex-shrink-0"
|
||||
>
|
||||
{task.number}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-lg flex items-center px-2 h-8"
|
||||
onClick={onAddTaskClick}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructorHeader;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Navigate, useNavigate } from "react-router-dom";
|
||||
import { Task, TaskStatus } from "@/shared/types";
|
||||
import ConstructorHeader from "./components/ConstructorHeader";
|
||||
import TaskCreationModal from "./modules/TaskCreationModal";
|
||||
|
||||
const CompetitionConstructor = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
||||
const isSettings = taskId === "settings";
|
||||
|
||||
const handleOpenTaskModal = () => {
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseTaskModal = () => {
|
||||
setIsTaskModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateTask = (taskData: Partial<Task>) => {
|
||||
const newTask: Task = {
|
||||
id: `task-${Date.now()}`,
|
||||
number: taskData.number || `${tasks.length + 1}`,
|
||||
status: TaskStatus.Uncleared,
|
||||
solutionType: taskData.solutionType || "input",
|
||||
description: taskData.description || "",
|
||||
requirements: taskData.requirements,
|
||||
attachments: taskData.attachments || []
|
||||
};
|
||||
|
||||
setTasks([...tasks, newTask]);
|
||||
setIsTaskModalOpen(false);
|
||||
navigate(`/constructor/${id}/tasks/${newTask.id}`);
|
||||
};
|
||||
|
||||
if (!taskId) {
|
||||
if (tasks.length > 0) {
|
||||
return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />;
|
||||
} else {
|
||||
return <Navigate to={`/constructor/${id}/tasks/settings`} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<ConstructorHeader
|
||||
title={competitionTitle}
|
||||
tasks={tasks}
|
||||
competitionId={id || ""}
|
||||
onAddTaskClick={handleOpenTaskModal}
|
||||
/>
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={handleCloseTaskModal}
|
||||
onCreateTask={handleCreateTask}
|
||||
taskCount={tasks.length}
|
||||
/>
|
||||
|
||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{isSettings ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2>
|
||||
<p className="text-gray-500 font-hse-sans">
|
||||
Здесь будет форма настроек олимпиады
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">
|
||||
{`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`}
|
||||
</h2>
|
||||
<p className="text-gray-500 font-hse-sans">
|
||||
Здесь будет форма редактирования задачи
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionConstructor;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskDescriptionFieldProps {
|
||||
description: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskDescriptionField: React.FC<TaskDescriptionFieldProps> = ({ description, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label htmlFor="description" className="text-right pt-2">
|
||||
Описание
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3 min-h-[100px]"
|
||||
placeholder="Введите описание задачи"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDescriptionField;
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { FileIcon, X, Upload } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskFileAttachmentsProps {
|
||||
files: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const TaskFileAttachments: React.FC<TaskFileAttachmentsProps> = ({ files, onChange }) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
onChange([...files, ...newFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
onChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right pt-2">
|
||||
Файлы
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.map((file, index) => (
|
||||
<FileListItem
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => removeFile(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FileUploadButton onChange={handleFileChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileListItemProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({ file, onRemove }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-2 border rounded-md">
|
||||
<div className="flex items-center">
|
||||
<FileIcon size={16} className="mr-2 text-gray-500" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({Math.round(file.size / 1024)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const FileUploadButton: React.FC<FileUploadButtonProps> = ({ onChange }) => {
|
||||
return (
|
||||
<label className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 p-2 border border-dashed rounded-md hover:bg-gray-50 transition-colors">
|
||||
<Upload size={16} className="text-gray-500" />
|
||||
<span className="text-sm text-gray-700">Добавить файлы</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskFileAttachments;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskNumberFieldProps {
|
||||
number: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskNumberField: React.FC<TaskNumberFieldProps> = ({ number, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="task-number" className="text-right">
|
||||
Номер задачи
|
||||
</Label>
|
||||
<Input
|
||||
id="task-number"
|
||||
value={number}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskNumberField;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskRequirementsFieldProps {
|
||||
requirements: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TaskRequirementsField: React.FC<TaskRequirementsFieldProps> = ({ requirements, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label htmlFor="requirements" className="text-right pt-2">
|
||||
Требования
|
||||
</Label>
|
||||
<Textarea
|
||||
id="requirements"
|
||||
value={requirements}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Введите требования к решению (необязательно)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRequirementsField;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem
|
||||
} from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface TaskSolutionTypeSelectorProps {
|
||||
solutionType: 'input' | 'file' | 'code';
|
||||
onChange: (value: 'input' | 'file' | 'code') => void;
|
||||
}
|
||||
|
||||
const TaskSolutionTypeSelector: React.FC<TaskSolutionTypeSelectorProps> = ({ solutionType, onChange }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right pt-2">
|
||||
Тип решения
|
||||
</Label>
|
||||
<RadioGroup
|
||||
className="col-span-3"
|
||||
value={solutionType}
|
||||
onValueChange={(value) => onChange(value as 'input' | 'file' | 'code')}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="input" id="input" />
|
||||
<Label htmlFor="input">Ввод ответа</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="file" id="file" />
|
||||
<Label htmlFor="file">Загрузка файла</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="code" id="code" />
|
||||
<Label htmlFor="code">Программный код</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSolutionTypeSelector;
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Task } from "@/shared/types";
|
||||
import TaskNumberField from './components/TaskNumberField';
|
||||
import TaskDescriptionField from './components/TaskDescriptionField';
|
||||
import TaskRequirementsField from './components/TaskRequirementsField';
|
||||
import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector';
|
||||
import TaskFileAttachments from './components/TaskFileAttachments';
|
||||
|
||||
interface TaskCreationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateTask: (task: Partial<Task>) => void;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
const TaskCreationModal: React.FC<TaskCreationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateTask,
|
||||
taskCount
|
||||
}) => {
|
||||
const [number, setNumber] = useState(`${taskCount + 1}`);
|
||||
const [description, setDescription] = useState('');
|
||||
const [requirements, setRequirements] = useState('');
|
||||
const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input');
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newTask: Partial<Task> = {
|
||||
number,
|
||||
description,
|
||||
requirements: requirements || undefined,
|
||||
solutionType,
|
||||
attachments: attachedFiles.map(file => file.name)
|
||||
};
|
||||
|
||||
onCreateTask(newTask);
|
||||
|
||||
setNumber(`${taskCount + 1}`);
|
||||
setDescription('');
|
||||
setRequirements('');
|
||||
setSolutionType('input');
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] font-hse-sans">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Создание новой задачи</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<TaskNumberField
|
||||
number={number}
|
||||
onChange={setNumber}
|
||||
/>
|
||||
|
||||
<TaskDescriptionField
|
||||
description={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
|
||||
<TaskRequirementsField
|
||||
requirements={requirements}
|
||||
onChange={setRequirements}
|
||||
/>
|
||||
|
||||
<TaskSolutionTypeSelector
|
||||
solutionType={solutionType}
|
||||
onChange={setSolutionType}
|
||||
/>
|
||||
|
||||
<TaskFileAttachments
|
||||
files={attachedFiles}
|
||||
onChange={setAttachedFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit}>
|
||||
Создать задачу
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCreationModal;
|
||||
+1
-1
@@ -15,7 +15,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">
|
||||
|
||||
@@ -1,24 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Navigate } from "react-router-dom";
|
||||
import { Task } from "@/shared/types";
|
||||
import { mockSolutions, mockTasks } from "@/shared/mocks/mocks";
|
||||
import { Task, TaskStatus } from "@/shared/types";
|
||||
import { mockSolutions } from "@/shared/mocks/mocks"; // Keep mocks for solutions for now
|
||||
import CompetitionHeader from "./components/CompetitionHeader";
|
||||
import TaskContent from "./components/TaskContent";
|
||||
import TaskSolution from "./modules/TaskSolution";
|
||||
import { getCompetitionTasks } from "@/shared/api/session";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const CompetitionSession = () => {
|
||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||
const [tasks] = useState<Task[]>(mockTasks);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === taskId) || tasks.at(0);
|
||||
const competitionId = id || "";
|
||||
|
||||
if (!taskId && tasks.length > 0) {
|
||||
return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />;
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedTasks = await getCompetitionTasks(competitionId);
|
||||
setTasks(fetchedTasks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch tasks:", err);
|
||||
setError("Не удалось загрузить задания. Пожалуйста, попробуйте позже.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (competitionId) {
|
||||
fetchTasks();
|
||||
}
|
||||
}, [competitionId]);
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === taskId) || null;
|
||||
|
||||
if (!taskId && tasks.length > 0 && !loading) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log("Submitting answer:", answer);
|
||||
const handleSubmit = async () => {
|
||||
if (!currentTask || !competitionId) return;
|
||||
|
||||
try {
|
||||
console.log("Solution submitted successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to submit solution:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -26,17 +63,26 @@ const CompetitionSession = () => {
|
||||
<CompetitionHeader
|
||||
title="Олимпиада DANO 2025. Индивидуальный этап"
|
||||
tasks={tasks}
|
||||
competitionId={id || ""}
|
||||
competitionId={competitionId}
|
||||
/>
|
||||
|
||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{currentTask ? (
|
||||
{loading ? (
|
||||
<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
|
||||
task={currentTask}
|
||||
solutions={mockSolutions}
|
||||
solutions={mockSolutions} // Still using mock solutions
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -44,7 +90,7 @@ const CompetitionSession = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
|
||||
<p className="font-hse-sans text-gray-500">Загрузка задания...</p>
|
||||
<p className="font-hse-sans text-gray-500">Задание не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,82 @@
|
||||
import { apiFetch } from './index';
|
||||
import { Task, TaskStatus } from '@/shared/types';
|
||||
|
||||
interface ApiTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'input' | 'file' | 'code';
|
||||
in_competition_position: number;
|
||||
points: number;
|
||||
status?: TaskStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches tasks for a specific competition
|
||||
* @param competitionId - The ID of the competition
|
||||
* @returns Promise with an array of tasks in the application's format
|
||||
*/
|
||||
export const getCompetitionTasks = async (competitionId: string): Promise<Task[]> => {
|
||||
try {
|
||||
const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`);
|
||||
|
||||
// Transform API tasks to application Task format
|
||||
return apiTasks.map(apiTask => transformApiTask(apiTask));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch competition tasks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms an API task to the application's Task format
|
||||
*/
|
||||
const transformApiTask = (apiTask: ApiTask): Task => {
|
||||
return {
|
||||
id: apiTask.id,
|
||||
number: String(apiTask.in_competition_position),
|
||||
status: apiTask.status || TaskStatus.Uncleared,
|
||||
solutionType: apiTask.type,
|
||||
description: apiTask.description,
|
||||
maxScore: apiTask.points
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// export const submitTaskSolution = async (
|
||||
// competitionId: string,
|
||||
// taskId: string,
|
||||
// solution: string | File
|
||||
// ): Promise<void> => {
|
||||
// const endpoint = `/api/v1/competitions/${competitionId}/tasks/${taskId}/submit`;
|
||||
|
||||
// // Handle different solution types
|
||||
// if (typeof solution === 'string') {
|
||||
// // Text or code solution
|
||||
// await apiFetch(endpoint, {
|
||||
// method: 'POST',
|
||||
// body: { answer: solution }
|
||||
// });
|
||||
// } else {
|
||||
// // File solution
|
||||
// const formData = new FormData();
|
||||
// formData.append('file', solution);
|
||||
|
||||
// await apiFetch(endpoint, {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
/**
|
||||
* Gets the status of a task submission
|
||||
* This would be used to poll for updates after submission
|
||||
*/
|
||||
// export const getTaskSubmissionStatus = async (
|
||||
// competitionId: string,
|
||||
// taskId: string
|
||||
// ): Promise<TaskStatus> => {
|
||||
// const response = await apiFetch(`/api/v1/competitions/${competitionId}/tasks/${taskId}/status`);
|
||||
// return response.status;
|
||||
// };
|
||||
@@ -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 };
|
||||
|
||||
@@ -30,11 +30,16 @@ 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 };
|
||||
|
||||
Reference in New Issue
Block a user