mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-22 23:17:09 +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
|
- name: web
|
||||||
target: 80
|
target: 80
|
||||||
published: 8003
|
published: 8003
|
||||||
host_ip: 127.0.0.1
|
host_ip: 0.0.0.0
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
+3
-1
@@ -2,4 +2,6 @@
|
|||||||
sidebar_position: 1
|
sidebar_position: 1
|
||||||
---
|
---
|
||||||
|
|
||||||
# Начала!
|
# Начало!
|
||||||
|
|
||||||
|
Выбирай интересующий раздел слева и просвещайся!
|
||||||
|
|||||||
+66
-62
@@ -1,77 +1,81 @@
|
|||||||
import {themes as prismThemes} from 'prism-react-renderer';
|
import { themes as prismThemes } from "prism-react-renderer";
|
||||||
import type {Config} from '@docusaurus/types';
|
import type { Config } from "@docusaurus/types";
|
||||||
import type * as Preset from '@docusaurus/preset-classic';
|
import type * as Preset from "@docusaurus/preset-classic";
|
||||||
|
|
||||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
title: 'DataRush',
|
title: "DataRush",
|
||||||
tagline: 'Изучите основы анализа данных здесь!',
|
tagline: "Изучите основы анализа данных здесь!",
|
||||||
favicon: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
|
favicon: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
|
||||||
|
|
||||||
url: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru',
|
url: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru",
|
||||||
baseUrl: '/docs/',
|
baseUrl: "/docs/",
|
||||||
|
|
||||||
organizationName: 'megazord',
|
organizationName: "megazord",
|
||||||
projectName: 'megazord',
|
projectName: "megazord",
|
||||||
|
|
||||||
onBrokenLinks: 'throw',
|
onBrokenLinks: "throw",
|
||||||
onBrokenMarkdownLinks: 'warn',
|
onBrokenMarkdownLinks: "warn",
|
||||||
|
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'ru',
|
defaultLocale: "ru",
|
||||||
locales: ['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: 'Документация',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
footer: {
|
|
||||||
style: 'dark',
|
presets: [
|
||||||
links: [
|
[
|
||||||
{
|
"classic",
|
||||||
title: 'Документация',
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
label: 'Начало',
|
docs: {
|
||||||
to: '/docs/docs/intro',
|
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: "Документация",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
footer: {
|
||||||
copyright: `Создано для Megazord ♥`,
|
style: "dark",
|
||||||
},
|
links: [
|
||||||
prism: {
|
{
|
||||||
theme: prismThemes.github,
|
title: "Документация",
|
||||||
darkTheme: prismThemes.dracula,
|
items: [
|
||||||
},
|
{
|
||||||
} satisfies Preset.ThemeConfig,
|
label: "Начало",
|
||||||
|
to: "/intro",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
copyright: `Создано для Megazord ♥`,
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: prismThemes.github,
|
||||||
|
darkTheme: prismThemes.dracula,
|
||||||
|
},
|
||||||
|
} satisfies Preset.ThemeConfig,
|
||||||
|
|
||||||
|
staticDirectories: ["static"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres
|
|||||||
DJANGO_CREATE_SUPERUSER=True
|
DJANGO_CREATE_SUPERUSER=True
|
||||||
DJANGO_SUPERUSER_USERNAME=admin
|
DJANGO_SUPERUSER_USERNAME=admin
|
||||||
DJANGO_SUPERUSER_EMAIL=admin@mail.com
|
DJANGO_SUPERUSER_EMAIL=admin@mail.com
|
||||||
DJANGO_SUPERUSER_PASSWORD=admin
|
DJANGO_SUPERUSER_PASSWORD=prooooooood
|
||||||
|
|
||||||
MINIO_ENDPOINT=minio:9000
|
MINIO_ENDPOINT=minio:9000
|
||||||
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
|
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):
|
class CompetitionOut(ModelSchema):
|
||||||
id: UUID
|
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:
|
class Meta:
|
||||||
model = Competition
|
model = Competition
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from ninja import ModelSchema, Schema
|
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
|
from apps.task.models import CompetitionTaskSubmission
|
||||||
|
|
||||||
|
|
||||||
class PingOut(Schema):
|
|
||||||
status: str = "ok"
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewerOut(ModelSchema):
|
class ReviewerOut(ModelSchema):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|
||||||
@@ -19,20 +17,83 @@ class ReviewerOut(ModelSchema):
|
|||||||
exclude = ("token",)
|
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):
|
class SubmissionOut(ModelSchema):
|
||||||
id: UUID
|
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:
|
class Meta:
|
||||||
model = CompetitionTaskSubmission
|
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):
|
class SubmissionsOut(Schema):
|
||||||
submissions: list = None
|
submissions: list[SubmissionOut | None] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_submissions(self, context) -> list[SubmissionOut]:
|
def resolve_submissions(self, context) -> list[SubmissionOut | None]:
|
||||||
return list(
|
submissions = list(
|
||||||
Review.objects.filter(reviewer=context.get("request").auth)
|
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 http import HTTPStatus as status
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -7,31 +8,19 @@ from ninja import Router
|
|||||||
|
|
||||||
from api.v1 import schemas as global_schemas
|
from api.v1 import schemas as global_schemas
|
||||||
from api.v1.review import schemas
|
from api.v1.review import schemas
|
||||||
|
from apps.review.models import Review, ReviewStatusChoices
|
||||||
from apps.task.models import CompetitionTaskSubmission
|
from apps.task.models import CompetitionTaskSubmission
|
||||||
|
|
||||||
router = Router(tags=["review"])
|
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(
|
@router.get(
|
||||||
"{token}",
|
"{token}",
|
||||||
response={
|
response={
|
||||||
status.OK: schemas.ReviewerOut,
|
status.OK: schemas.ReviewerOut,
|
||||||
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
status.UNAUTHORIZED: global_schemas.UnauthorizedError,
|
||||||
},
|
},
|
||||||
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query",
|
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в path",
|
||||||
)
|
)
|
||||||
def get_reviewer_profile(request: HttpRequest, token: str):
|
def get_reviewer_profile(request: HttpRequest, token: str):
|
||||||
return status.OK, request.auth
|
return status.OK, request.auth
|
||||||
@@ -47,4 +36,69 @@ def get_submission(
|
|||||||
request: HttpRequest, token: str, submition_id: UUID
|
request: HttpRequest, token: str, submition_id: UUID
|
||||||
) -> tuple[status, schemas.SubmissionsOut]:
|
) -> tuple[status, schemas.SubmissionsOut]:
|
||||||
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
|
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
|
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.ping.views import router as ping_router
|
||||||
from api.v1.review.auth import ReviewerAuth
|
from api.v1.review.auth import ReviewerAuth
|
||||||
from api.v1.review.views import router as review_router
|
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
|
from api.v1.user.views import router as user_router
|
||||||
|
|
||||||
router = NinjaAPI(
|
router = NinjaAPI(
|
||||||
@@ -37,6 +39,16 @@ router.add_router(
|
|||||||
review_router,
|
review_router,
|
||||||
auth=ReviewerAuth(),
|
auth=ReviewerAuth(),
|
||||||
)
|
)
|
||||||
|
router.add_router(
|
||||||
|
"",
|
||||||
|
task_router,
|
||||||
|
auth=BearerAuth(),
|
||||||
|
)
|
||||||
|
router.add_router(
|
||||||
|
"team",
|
||||||
|
team_router,
|
||||||
|
auth=BearerAuth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
for exception, handler in handlers.exception_handlers:
|
for exception, handler in handlers.exception_handlers:
|
||||||
|
|||||||
@@ -3,19 +3,36 @@ from uuid import UUID
|
|||||||
|
|
||||||
from ninja import ModelSchema, Schema
|
from ninja import ModelSchema, Schema
|
||||||
|
|
||||||
from apps.task.models import CompetitionTask
|
from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
|
||||||
|
|
||||||
|
|
||||||
class TaskOutSchema(ModelSchema):
|
class TaskOutSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CompetitionTask
|
model = CompetitionTask
|
||||||
fields = ["id", "competition", "title", "description", "type"]
|
fields = [
|
||||||
|
"id",
|
||||||
|
"competition",
|
||||||
class TaskSubmissionIn(Schema):
|
"title",
|
||||||
type: Literal["input", "file", "code"]
|
"description",
|
||||||
content: str
|
"type",
|
||||||
|
"in_competition_position",
|
||||||
|
"points",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TaskSubmissionOut(Schema):
|
class TaskSubmissionOut(Schema):
|
||||||
submission_id: UUID
|
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 uuid import UUID
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
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.ping.schemas import PingOut
|
||||||
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
|
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
|
||||||
from api.v1.task.schemas import (
|
from api.v1.task.schemas import (
|
||||||
|
HistorySubmissionOut,
|
||||||
|
TaskAttachmentSchema,
|
||||||
TaskOutSchema,
|
TaskOutSchema,
|
||||||
TaskSubmissionIn,
|
|
||||||
TaskSubmissionOut,
|
TaskSubmissionOut,
|
||||||
)
|
)
|
||||||
from apps.competition.models import State
|
from apps.competition.models import State
|
||||||
from apps.task.models import (
|
from apps.task.models import (
|
||||||
Competition,
|
Competition,
|
||||||
CompetitionTask,
|
CompetitionTask,
|
||||||
|
CompetitionTaskAttachment,
|
||||||
CompetitionTaskSubmission,
|
CompetitionTaskSubmission,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,32 +89,71 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def submit_task(
|
def submit_task(
|
||||||
request, competition_id: str, task_id: str, submission: TaskSubmissionIn
|
request,
|
||||||
) -> PingOut:
|
competition_id: str,
|
||||||
|
task_id: str,
|
||||||
|
content: UploadedFile = File(...), # TODO: вот это надо переделать
|
||||||
|
) -> TaskSubmissionOut:
|
||||||
user = request.auth
|
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(
|
task = get_object_or_404(
|
||||||
CompetitionTask, competetion=competetion, id=task_id
|
CompetitionTask, competition=competition, id=task_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
|
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
|
||||||
CompetitionTaskSubmission.objects.create(
|
submission = CompetitionTaskSubmission.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
task=task,
|
task=task,
|
||||||
status=CompetitionTaskSubmission.StatusChoices.CHECKED,
|
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:
|
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
|
||||||
CompetitionTaskSubmission.objects.create(
|
submission = CompetitionTaskSubmission.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
task=task,
|
task=task,
|
||||||
status=CompetitionTaskSubmission.StatusChoices.SENT,
|
status=CompetitionTaskSubmission.StatusChoices.SENT,
|
||||||
|
content=content,
|
||||||
)
|
)
|
||||||
|
submission.send_on_review()
|
||||||
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
|
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
|
||||||
CompetitionTaskSubmission.objects.create(
|
submission = CompetitionTaskSubmission.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
task=task,
|
task=task,
|
||||||
status=CompetitionTaskSubmission.StatusChoices.CHECKING,
|
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 UserSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
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 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 django.shortcuts import get_object_or_404
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
from ninja.errors import AuthenticationError
|
from ninja.errors import AuthenticationError
|
||||||
|
|
||||||
from api.v1.auth import BearerAuth
|
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 (
|
from api.v1.user.schemas import (
|
||||||
LoginSchema,
|
LoginSchema,
|
||||||
RegisterSchema,
|
RegisterSchema,
|
||||||
@@ -28,7 +34,9 @@ router = Router(tags=["user"])
|
|||||||
auth=None,
|
auth=None,
|
||||||
)
|
)
|
||||||
def sign_up(request, data: RegisterSchema):
|
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()
|
user.save()
|
||||||
|
|
||||||
token = BearerAuth.generate_jwt(user)
|
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 datetime
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
@@ -19,14 +20,14 @@ class Migration(migrations.Migration):
|
|||||||
name='Competition',
|
name='Competition',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('title', models.CharField(max_length=100, verbose_name='Название')),
|
('title', models.CharField(max_length=100, verbose_name='название')),
|
||||||
('description', models.TextField(verbose_name='Описание')),
|
('description', models.TextField(verbose_name='описание')),
|
||||||
('image_url', models.FileField(blank=True, null=True, upload_to='', 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='Дедлайн участия')),
|
('end_date', models.DateTimeField(blank=True, null=True, verbose_name='окончание соревнования')),
|
||||||
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')),
|
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='начало соревнования')),
|
||||||
('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')),
|
('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')),
|
||||||
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')),
|
('participation_type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=11, verbose_name='тип соревнования')),
|
||||||
('participants', models.ManyToManyField(related_name='participants', to='user.user')),
|
('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'соревнование',
|
'verbose_name': 'соревнование',
|
||||||
@@ -37,7 +38,7 @@ class Migration(migrations.Migration):
|
|||||||
name='State',
|
name='State',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('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)),
|
('changed_at', models.DateTimeField(default=datetime.datetime.now)),
|
||||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
|
('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')),
|
('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 Competition(BaseModel):
|
||||||
class CompetitionType(models.TextChoices):
|
class CompetitionType(models.TextChoices):
|
||||||
SOLO = "solo", "Индивидуальный"
|
|
||||||
|
|
||||||
class CompetitionParticipationType(models.TextChoices):
|
|
||||||
EDU = "edu", "Образовательный"
|
EDU = "edu", "Образовательный"
|
||||||
COMPETITIVE = "competitive", "Соревновательный"
|
COMPETITIVE = "competitive", "Соревновательный"
|
||||||
|
|
||||||
|
class CompetitionParticipationType(models.TextChoices):
|
||||||
|
SOLO = "solo", "Индивидуальный"
|
||||||
|
|
||||||
def image_url_upload_to(instance, filename):
|
def image_url_upload_to(instance, filename):
|
||||||
return f"/competitions/{instance.id}/image"
|
return f"/competitions/{instance.id}/image"
|
||||||
|
|
||||||
title = models.CharField(max_length=100, verbose_name="название")
|
title = models.CharField(max_length=100, verbose_name="название")
|
||||||
description = models.TextField(verbose_name="описание")
|
description = models.TextField(verbose_name="описание")
|
||||||
image_url = models.FileField(
|
image_url = models.ImageField(
|
||||||
verbose_name="изображение соревнования",
|
verbose_name="изображение соревнования",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
upload_to=image_url_upload_to,
|
upload_to=image_url_upload_to,
|
||||||
)
|
)
|
||||||
end_date = models.DateTimeField(
|
end_date = models.DateTimeField(
|
||||||
verbose_name="дедлайн участия", null=True, blank=True
|
verbose_name="окончание соревнования", null=True, blank=True
|
||||||
)
|
)
|
||||||
start_date = models.DateTimeField(
|
start_date = models.DateTimeField(
|
||||||
verbose_name="дедлайн участия", null=True, blank=True
|
verbose_name="начало соревнования", null=True, blank=True
|
||||||
)
|
)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
max_length=10,
|
max_length=11,
|
||||||
choices=CompetitionType.choices,
|
choices=CompetitionType.choices,
|
||||||
verbose_name="тип участия",
|
verbose_name="тип участия",
|
||||||
)
|
)
|
||||||
@@ -61,5 +61,9 @@ class State(BaseModel):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||||
state = models.CharField(choices=StateChoices.choices, max_length=11)
|
state = models.CharField(
|
||||||
|
choices=StateChoices.choices,
|
||||||
|
max_length=11,
|
||||||
|
default=StateChoices.NOT_STARTED.value,
|
||||||
|
)
|
||||||
changed_at = models.DateTimeField(default=datetime.now)
|
changed_at = models.DateTimeField(default=datetime.now)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import uuid
|
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.contrib.auth.hashers import make_password
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
@@ -12,14 +15,14 @@ class CompetitionEndpointTests(TestCase):
|
|||||||
self.user = User.objects.create(
|
self.user = User.objects.create(
|
||||||
email="user@example.com",
|
email="user@example.com",
|
||||||
password=make_password("password123"),
|
password=make_password("password123"),
|
||||||
username="t1wk4"
|
username="t1wk4",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.competition = Competition.objects.create(
|
self.competition = Competition.objects.create(
|
||||||
title="AI Challenge",
|
title="AI Challenge",
|
||||||
description="Machine Learning Competition",
|
description="Machine Learning Competition",
|
||||||
type="solo",
|
type="edu",
|
||||||
participation_type="edu"
|
participation_type="solo",
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
@@ -29,80 +32,204 @@ class CompetitionEndpointTests(TestCase):
|
|||||||
).json()
|
).json()
|
||||||
token = resp["token"]
|
token = resp["token"]
|
||||||
|
|
||||||
self.valid_headers = {
|
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
"HTTP_AUTHORIZATION": f"Bearer {token}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Helper methods ---
|
|
||||||
def get_url(self, competition_id):
|
def get_url(self, competition_id):
|
||||||
return f"/api/v1/competition/{competition_id}"
|
return f"/api/v1/competition/{competition_id}"
|
||||||
|
|
||||||
# --- Test Cases ---
|
|
||||||
def test_get_competition_success(self):
|
def test_get_competition_success(self):
|
||||||
"""Authenticated user gets competition details (200 OK)"""
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.get_url(self.competition.id),
|
self.get_url(self.competition.id), **self.valid_headers
|
||||||
**self.valid_headers
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
self.assertEqual(data["id"], str(self.competition.id))
|
self.assertEqual(data["id"], str(self.competition.id))
|
||||||
self.assertEqual(data["title"], "AI Challenge")
|
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["image_url"])
|
||||||
self.assertIsNone(data["start_date"])
|
self.assertIsNone(data["start_date"])
|
||||||
self.assertIsNone(data["end_date"])
|
self.assertIsNone(data["end_date"])
|
||||||
|
|
||||||
def test_invalid_uuid_format(self):
|
def test_invalid_uuid_format(self):
|
||||||
"""Invalid UUID format returns 400 Bad Request"""
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.get_url("invalid-id"),
|
self.get_url("invalid-id"), **self.valid_headers
|
||||||
**self.valid_headers
|
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_unauthenticated_access(self):
|
def test_unauthenticated_access(self):
|
||||||
"""Missing auth token returns 401 Unauthorized"""
|
|
||||||
response = self.client.get(self.get_url(self.competition.id))
|
response = self.client.get(self.get_url(self.competition.id))
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||||
|
|
||||||
def test_nonexistent_competition(self):
|
def test_nonexistent_competition(self):
|
||||||
"""Valid UUID but missing competition returns 404"""
|
|
||||||
new_uuid = uuid.uuid4()
|
new_uuid = uuid.uuid4()
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.get_url(new_uuid),
|
self.get_url(new_uuid), **self.valid_headers
|
||||||
**self.valid_headers
|
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response.json()["detail"], "Not Found")
|
self.assertEqual(response.json()["detail"], "Not Found")
|
||||||
|
|
||||||
def test_invalid_auth_token(self):
|
def test_invalid_auth_token(self):
|
||||||
"""Invalid token returns 401 Unauthorized"""
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.get_url(self.competition.id),
|
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.status_code, 401)
|
||||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||||
|
|
||||||
def test_malformed_auth_header(self):
|
def test_malformed_auth_header(self):
|
||||||
"""Malformed Authorization header returns 401"""
|
|
||||||
cases = [
|
cases = [
|
||||||
("InvalidScheme valid_token_123", 401),
|
("InvalidScheme valid_token_123", 401),
|
||||||
("Bearer", 401), # Missing token
|
("Bearer", 401),
|
||||||
("", 401), # No header
|
("", 401),
|
||||||
]
|
]
|
||||||
|
|
||||||
for header, expected_status in cases:
|
for header, expected_status in cases:
|
||||||
with self.subTest(header=header):
|
with self.subTest(header=header):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.get_url(self.competition.id),
|
self.get_url(self.competition.id),
|
||||||
HTTP_AUTHORIZATION=header
|
HTTP_AUTHORIZATION=header,
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, expected_status)
|
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
|
from django.contrib.auth.models import Group, User
|
||||||
|
|
||||||
admin.site.unregister(Group)
|
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 django.utils import timezone
|
||||||
|
|
||||||
from apps.competition.models import Competition, State
|
from apps.competition.models import Competition, State
|
||||||
|
from apps.review.models import Review, Reviewer
|
||||||
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
|
||||||
from apps.user.models import User, UserRole
|
from apps.user.models import User, UserRole
|
||||||
|
|
||||||
@@ -19,11 +20,23 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("Starting data generation...")
|
self.stdout.write("Starting data generation...")
|
||||||
users = self.create_users(5)
|
users = self.create_users(5)
|
||||||
competitions = self.create_competitions(2, users)
|
competitions = self.create_competitions(2, users)
|
||||||
|
self.reviewers = self.create_reviewers(2)
|
||||||
tasks = self.create_tasks(competitions)
|
tasks = self.create_tasks(competitions)
|
||||||
self.create_submissions(tasks, users)
|
self.create_submissions(tasks, users)
|
||||||
self.create_states(competitions, users)
|
self.create_states(competitions, users)
|
||||||
self.stdout.write("Data generation completed.")
|
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):
|
def create_users(self, count):
|
||||||
users = []
|
users = []
|
||||||
for i in range(1, count + 1):
|
for i in range(1, count + 1):
|
||||||
@@ -60,8 +73,10 @@ class Command(BaseCommand):
|
|||||||
description=description,
|
description=description,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
type="solo", # assuming only one type for now
|
type=random.choice(
|
||||||
participation_type=random.choice(["edu", "competitive"]),
|
["edu", "competitive"]
|
||||||
|
), # assuming only one type for now
|
||||||
|
participation_type="solo",
|
||||||
)
|
)
|
||||||
# Add random participants
|
# Add random participants
|
||||||
selected_users = random.sample(
|
selected_users = random.sample(
|
||||||
@@ -89,11 +104,18 @@ class Command(BaseCommand):
|
|||||||
description=description,
|
description=description,
|
||||||
type=task_type,
|
type=task_type,
|
||||||
points=random.randint(1, 10),
|
points=random.randint(1, 10),
|
||||||
|
max_attempts=random.randint(1, 10),
|
||||||
)
|
)
|
||||||
tasks.append(task)
|
tasks.append(task)
|
||||||
self.stdout.write(f"Created task: {title} (type: {task_type})")
|
self.stdout.write(f"Created task: {title} (type: {task_type})")
|
||||||
|
self.add_reviewers_to_task(tasks)
|
||||||
return 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):
|
def create_submissions(self, tasks, users):
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
# Each task will get between 1 and 3 submissions
|
# 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):
|
class CoreConfig(AppConfig):
|
||||||
name = "apps.review"
|
name = "apps.review"
|
||||||
label = "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
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -12,16 +12,29 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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(
|
migrations.CreateModel(
|
||||||
name='Reviewer',
|
name='Reviewer',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=100)),
|
('name', models.CharField(max_length=100, verbose_name='имя')),
|
||||||
('surname', models.CharField(max_length=100)),
|
('surname', models.CharField(max_length=100, verbose_name='фамилия')),
|
||||||
('token', models.CharField(max_length=100)),
|
('token', models.CharField(max_length=100, verbose_name='токен для входа')),
|
||||||
],
|
],
|
||||||
options={
|
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 django.db import models
|
||||||
|
|
||||||
from apps.core.models import BaseModel
|
from apps.core.models import BaseModel
|
||||||
from apps.task.models import CompetitionTaskSubmission
|
|
||||||
|
|
||||||
|
|
||||||
class Reviewer(BaseModel):
|
class Reviewer(BaseModel):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100, verbose_name="имя")
|
||||||
surname = models.CharField(max_length=100)
|
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 Review(BaseModel):
|
||||||
class ReviewStatusChoices(models.TextChoices):
|
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
|
||||||
NOT_CHECKED = "not_checked"
|
verbose_name="проверяющий")
|
||||||
CHECKING = "checking"
|
|
||||||
CHECKED = "checked"
|
|
||||||
|
|
||||||
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE)
|
|
||||||
submission = models.ForeignKey(
|
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(
|
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 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)
|
@admin.register(CompetitionTask)
|
||||||
@@ -8,6 +14,20 @@ class CompetitionTaskAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("title", "type", "points")
|
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):
|
class CompetitionTaskInline(admin.StackedInline):
|
||||||
model = CompetitionTask
|
model = CompetitionTask
|
||||||
extra = 0
|
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 apps.task.models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import tinymce.models
|
||||||
import uuid
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('competition', '0001_initial'),
|
('competition', '0001_initial'),
|
||||||
|
('review', '0001_initial'),
|
||||||
('user', '0001_initial'),
|
('user', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -20,31 +22,63 @@ class Migration(migrations.Migration):
|
|||||||
name='CompetitionTask',
|
name='CompetitionTask',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('title', models.TextField(max_length=50, verbose_name='заголовок')),
|
('in_competition_position', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||||
('description', models.TextField(max_length=300, verbose_name='описание')),
|
('title', models.CharField(max_length=50, verbose_name='заголовок')),
|
||||||
('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)),
|
('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')),
|
||||||
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)),
|
('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||||
('points', models.IntegerField(blank=True, null=True)),
|
('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')),
|
||||||
('answer_file_path', models.TextField(blank=True, null=True)),
|
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')),
|
||||||
('criteries', models.JSONField(blank=True, null=True)),
|
('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')),
|
('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={
|
options={
|
||||||
'abstract': False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CompetetionTaskSumbission',
|
name='CompetitionTaskCriteria',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('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)),
|
('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)),
|
('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)),
|
('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)),
|
('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')),
|
('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={
|
options={
|
||||||
'abstract': False,
|
'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 uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Count, Q
|
||||||
from tinymce.models import HTMLField
|
from tinymce.models import HTMLField
|
||||||
|
|
||||||
from apps.competition.models import Competition
|
from apps.competition.models import Competition
|
||||||
from apps.core.models import BaseModel
|
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
|
from apps.user.models import User
|
||||||
|
|
||||||
|
|
||||||
class CompetitionTask(BaseModel):
|
class CompetitionTask(BaseModel):
|
||||||
class CompetitionTaskType(models.TextChoices):
|
class CompetitionTaskType(models.TextChoices):
|
||||||
INPUT = "input", "Ввод правильного ответа"
|
INPUT = "input", "Ввод правильного ответа"
|
||||||
CHECKER = "checker", "Вывод кода"
|
CHECKER = "checker", "Ввод кода"
|
||||||
REVIEW = "review", "Ручная"
|
REVIEW = "review", "Ручная"
|
||||||
|
|
||||||
def answer_file_upload_to(instance, filename) -> str:
|
def answer_file_upload_to(instance, filename) -> str:
|
||||||
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
|
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
|
||||||
|
|
||||||
|
in_competition_position = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
||||||
title = models.CharField(verbose_name="заголовок", max_length=50)
|
title = models.CharField(verbose_name="заголовок", max_length=50)
|
||||||
description = HTMLField(verbose_name="описание", max_length=300)
|
description = HTMLField(verbose_name="описание", max_length=300)
|
||||||
max_attemps = models.PositiveSmallIntegerField()
|
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
||||||
)
|
)
|
||||||
@@ -41,36 +45,43 @@ class CompetitionTask(BaseModel):
|
|||||||
answer_file_path = models.TextField(
|
answer_file_path = models.TextField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="куда сохранять решения",
|
verbose_name="куда сделать вывод программы участнику",
|
||||||
|
help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt",
|
||||||
default="stdout",
|
default="stdout",
|
||||||
)
|
)
|
||||||
|
|
||||||
# only when "review" type
|
# only when "review" type
|
||||||
# TODO make it more humanize
|
reviewers = models.ManyToManyField(Reviewer, blank=True)
|
||||||
criteries = models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="критерии",
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
ContestTaskCriteriesValidator()(self)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = "задание"
|
||||||
verbose_name_plural = "задания"
|
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):
|
class CompetitionTaskAttachment(BaseModel):
|
||||||
def file_upload_at(instance, filename):
|
def file_upload_at(instance, filename):
|
||||||
return f"/attachment/{instance.id}/file"
|
return f"/attachment/{instance.id}/file"
|
||||||
|
|
||||||
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE)
|
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
|
||||||
file = models.FileField(upload_to=file_upload_at)
|
verbose_name="задание")
|
||||||
bind_at = models.FilePathField()
|
file = models.FileField(upload_to=file_upload_at,
|
||||||
public = models.BooleanField(default=False)
|
verbose_name="файл")
|
||||||
|
bind_at = models.FilePathField(verbose_name="путь сохранения")
|
||||||
|
public = models.BooleanField(default=False, verbose_name="публичный")
|
||||||
|
|
||||||
|
|
||||||
class CompetitionTaskSubmission(BaseModel):
|
class CompetitionTaskSubmission(BaseModel):
|
||||||
@@ -92,6 +103,7 @@ class CompetitionTaskSubmission(BaseModel):
|
|||||||
choices=StatusChoices.choices,
|
choices=StatusChoices.choices,
|
||||||
default=StatusChoices.SENT,
|
default=StatusChoices.SENT,
|
||||||
max_length=8,
|
max_length=8,
|
||||||
|
verbose_name="статус"
|
||||||
)
|
)
|
||||||
|
|
||||||
# code or text or file
|
# code or text or file
|
||||||
@@ -108,6 +120,32 @@ class CompetitionTaskSubmission(BaseModel):
|
|||||||
# - code: {"correct": boolean}
|
# - code: {"correct": boolean}
|
||||||
result = models.JSONField(default=None, null=True, blank=True)
|
result = models.JSONField(default=None, null=True, blank=True)
|
||||||
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
|
# 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)
|
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 ast
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -6,6 +7,7 @@ import tempfile
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from config.celery import app
|
from config.celery import app
|
||||||
|
from apps.task.models import CompetitionTaskSubmission
|
||||||
|
|
||||||
ALLOWED_MODULES = {
|
ALLOWED_MODULES = {
|
||||||
"pandas",
|
"pandas",
|
||||||
@@ -19,6 +21,7 @@ ALLOWED_MODULES = {
|
|||||||
"csv",
|
"csv",
|
||||||
"math",
|
"math",
|
||||||
"statistics",
|
"statistics",
|
||||||
|
"statsmodels",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,18 +66,28 @@ def validate_code(code_str):
|
|||||||
raise SecurityException(f"Security check failed: {e!s}")
|
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_dir = os.getcwd()
|
||||||
original_stdout = sys.stdout
|
original_stdout = sys.stdout
|
||||||
sys.stdout = captured_stdout = StringIO()
|
sys.stdout = captured_stdout = StringIO()
|
||||||
result_content = None
|
result_content = None
|
||||||
|
|
||||||
|
if input_files is None:
|
||||||
|
input_files = []
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
try:
|
try:
|
||||||
os.chdir(temp_dir)
|
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 = {
|
restricted_globals = {
|
||||||
"__builtins__": {
|
"__builtins__": {
|
||||||
"open": lambda f, *a, **kw: open(f, *a, **kw),
|
"open": open,
|
||||||
"print": print,
|
"print": print,
|
||||||
"str": str,
|
"str": str,
|
||||||
"int": int,
|
"int": int,
|
||||||
@@ -105,15 +118,21 @@ def secure_exec(code_str, result_path):
|
|||||||
|
|
||||||
|
|
||||||
@app.task(bind=True)
|
@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:
|
try:
|
||||||
validate_code(code_str)
|
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()
|
result_hash = hashlib.sha256(result_content).hexdigest()
|
||||||
expected_hash = hashlib.sha256(expected_bytes).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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"match": result_hash == expected_hash,
|
"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
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -16,9 +16,10 @@ class Migration(migrations.Migration):
|
|||||||
name='User',
|
name='User',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')),
|
('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
|
||||||
('username', models.SlugField(unique=True, verbose_name='Юзернейм')),
|
('username', models.SlugField(unique=True, verbose_name='юзернейм')),
|
||||||
('password', models.TextField(verbose_name='Пароль')),
|
('password', models.TextField(verbose_name='пароль')),
|
||||||
|
('created_at', models.DateTimeField(auto_now=True)),
|
||||||
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
|
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
|
||||||
],
|
],
|
||||||
options={
|
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="юзернейм")
|
username = models.SlugField(unique=True, verbose_name="юзернейм")
|
||||||
password = models.TextField(verbose_name="пароль")
|
password = models.TextField(verbose_name="пароль")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make_password(password: str):
|
def make_password(password: str):
|
||||||
return make_password(password)
|
return make_password(password)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.user.models import User
|
from apps.user.models import User
|
||||||
|
|
||||||
@@ -47,7 +47,9 @@ class SignUpAPITestCase(TestCase):
|
|||||||
|
|
||||||
def test_existing_user_conflict(self):
|
def test_existing_user_conflict(self):
|
||||||
User.objects.create(
|
User.objects.create(
|
||||||
email="existing@example.com", password="existingpass123", username="testing"
|
email="existing@example.com",
|
||||||
|
password="existingpass123",
|
||||||
|
username="testing",
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"email": "existing@example.com",
|
"email": "existing@example.com",
|
||||||
@@ -62,23 +64,24 @@ class SignUpAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, 409)
|
self.assertEqual(response.status_code, 409)
|
||||||
self.assertIn("detail", response.json())
|
self.assertIn("detail", response.json())
|
||||||
|
|
||||||
|
|
||||||
class SignInAPITestCase(TestCase):
|
class SignInAPITestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(
|
self.user = User.objects.create(
|
||||||
email="valid@example.com",
|
email="valid@example.com",
|
||||||
password=make_password("securepassword123"),
|
password=make_password("securepassword123"),
|
||||||
username="testuser"
|
username="testuser",
|
||||||
)
|
)
|
||||||
self.valid_payload = {
|
self.valid_payload = {
|
||||||
"email": "valid@example.com",
|
"email": "valid@example.com",
|
||||||
"password": "securepassword123"
|
"password": "securepassword123",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_successful_sign_in(self):
|
def test_successful_sign_in(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps(self.valid_payload),
|
data=json.dumps(self.valid_payload),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn("token", response.json())
|
self.assertIn("token", response.json())
|
||||||
@@ -88,7 +91,7 @@ class SignInAPITestCase(TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps({"password": "pass"}),
|
data=json.dumps({"password": "pass"}),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
@@ -96,44 +99,35 @@ class SignInAPITestCase(TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps({"email": "test@example.com"}),
|
data=json.dumps({"email": "test@example.com"}),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_invalid_email_format(self):
|
def test_invalid_email_format(self):
|
||||||
payload = {
|
payload = {"email": "invalid-email", "password": "password123"}
|
||||||
"email": "invalid-email",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
def test_incorrect_password(self):
|
def test_incorrect_password(self):
|
||||||
payload = {
|
payload = {"email": "valid@example.com", "password": "wrongpassword"}
|
||||||
"email": "valid@example.com",
|
|
||||||
"password": "wrongpassword"
|
|
||||||
}
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||||
|
|
||||||
def test_nonexistent_user(self):
|
def test_nonexistent_user(self):
|
||||||
payload = {
|
payload = {"email": "notexist@example.com", "password": "password123"}
|
||||||
"email": "notexist@example.com",
|
|
||||||
"password": "password123"
|
|
||||||
}
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
content_type="application/json"
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||||
@@ -145,21 +139,25 @@ class UserMeEndpointTestCase(TestCase):
|
|||||||
self.user = User.objects.create(
|
self.user = User.objects.create(
|
||||||
email="johndoe@example.com",
|
email="johndoe@example.com",
|
||||||
username="johndoe",
|
username="johndoe",
|
||||||
password=make_password("securepassword123")
|
password=make_password("securepassword123"),
|
||||||
)
|
)
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
"/api/v1/sign-in",
|
"/api/v1/sign-in",
|
||||||
data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}),
|
data=json.dumps(
|
||||||
content_type="application/json"
|
{
|
||||||
|
"email": "johndoe@example.com",
|
||||||
|
"password": "securepassword123",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
).json()
|
).json()
|
||||||
self.token = resp['token']
|
self.token = resp["token"]
|
||||||
self.url = "/api/v1/me"
|
self.url = "/api/v1/me"
|
||||||
|
|
||||||
def test_get_authenticated_user_data(self):
|
def test_get_authenticated_user_data(self):
|
||||||
"""Test authenticated user can retrieve their profile (200 OK)"""
|
"""Test authenticated user can retrieve their profile (200 OK)"""
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.url,
|
self.url, HTTP_AUTHORIZATION=f"Bearer {self.token}"
|
||||||
HTTP_AUTHORIZATION=f"Bearer {self.token}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -191,8 +189,7 @@ class UserMeEndpointTestCase(TestCase):
|
|||||||
def test_invalid_auth_scheme(self):
|
def test_invalid_auth_scheme(self):
|
||||||
"""Test invalid authentication scheme returns 401"""
|
"""Test invalid authentication scheme returns 401"""
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.url,
|
self.url, HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
|
||||||
HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
@@ -200,18 +197,12 @@ class UserMeEndpointTestCase(TestCase):
|
|||||||
|
|
||||||
def test_malformed_token(self):
|
def test_malformed_token(self):
|
||||||
"""Test malformed token returns 401"""
|
"""Test malformed token returns 401"""
|
||||||
test_cases = [
|
test_cases = ["invalid.token.123", "Bearer", "", "123456"]
|
||||||
"invalid.token.123",
|
|
||||||
"Bearer",
|
|
||||||
"",
|
|
||||||
"123456"
|
|
||||||
]
|
|
||||||
|
|
||||||
for token in test_cases:
|
for token in test_cases:
|
||||||
with self.subTest(token=token):
|
with self.subTest(token=token):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.url,
|
self.url, HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}"
|
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response.json()["detail"], "Unauthorized")
|
self.assertEqual(response.json()["detail"], "Unauthorized")
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.competition",
|
"apps.competition",
|
||||||
"apps.review",
|
"apps.review",
|
||||||
"apps.task",
|
"apps.task",
|
||||||
|
"apps.team",
|
||||||
]
|
]
|
||||||
|
|
||||||
# tinymce
|
# tinymce
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"psycopg2-binary>=2.9.10",
|
"psycopg2-binary>=2.9.10",
|
||||||
"pydantic>=2.10.5",
|
"pydantic>=2.10.5",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
|
"python-dateutil>=2.9.0.post0",
|
||||||
"python-gettext>=5.0",
|
"python-gettext>=5.0",
|
||||||
"python-json-logger>=3.2.1",
|
"python-json-logger>=3.2.1",
|
||||||
"pytz>=2024.2",
|
"pytz>=2024.2",
|
||||||
@@ -31,11 +32,11 @@ dependencies = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"coverage>=7.6.12",
|
"coverage>=7.6.12",
|
||||||
"django-debug-toolbar>=4.4.6",
|
"django-debug-toolbar>=4.4.6",
|
||||||
"django-stubs[compatible-mypy]>=5.1.3",
|
"django-stubs[compatible-mypy]>=5.1.3",
|
||||||
"mypy>=1.15.0",
|
"mypy>=1.15.0",
|
||||||
"ruff>=0.9.3",
|
"ruff>=0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@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-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@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-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-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-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-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-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=="],
|
"@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-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-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=="],
|
"@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": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@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-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Routes, Route } from "react-router";
|
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
|
import { Routes, Route } from "react-router";
|
||||||
|
|
||||||
import { NavbarLayout } from "./widgets/navbar-layout";
|
import { NavbarLayout } from "./widgets/navbar-layout";
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import LoginPage from "./pages/Login";
|
|||||||
import { AuthLayout } from "./widgets/auth-layout";
|
import { AuthLayout } from "./widgets/auth-layout";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import ReviewPage from "./pages/Review";
|
import ReviewPage from "./pages/Review";
|
||||||
|
import CompetitionConstructor from "./pages/CompetitionConstructor";
|
||||||
|
import UserProfile from "./pages/UserProfile";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -30,6 +32,17 @@ const App = () => {
|
|||||||
element={<CompetitionSession />}
|
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 path="/review/:token" element={<ReviewPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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
|
competitionId
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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="mx-auto max-w-6xl px-4">
|
||||||
<div className="py-4 text-center">
|
<div className="py-4 text-center">
|
||||||
<h1 className="font-hse-sans text-xl font-semibold">
|
<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 { useParams, Navigate } from "react-router-dom";
|
||||||
import { Task } from "@/shared/types";
|
import { Task, TaskStatus } from "@/shared/types";
|
||||||
import { mockSolutions, mockTasks } from "@/shared/mocks/mocks";
|
import { mockSolutions } from "@/shared/mocks/mocks"; // Keep mocks for solutions for now
|
||||||
import CompetitionHeader from "./components/CompetitionHeader";
|
import CompetitionHeader from "./components/CompetitionHeader";
|
||||||
import TaskContent from "./components/TaskContent";
|
import TaskContent from "./components/TaskContent";
|
||||||
import TaskSolution from "./modules/TaskSolution";
|
import TaskSolution from "./modules/TaskSolution";
|
||||||
|
import { getCompetitionTasks } from "@/shared/api/session";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const CompetitionSession = () => {
|
const CompetitionSession = () => {
|
||||||
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
|
||||||
const [tasks] = useState<Task[]>(mockTasks);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [answer, setAnswer] = useState("");
|
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) {
|
useEffect(() => {
|
||||||
return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />;
|
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 = () => {
|
const handleSubmit = async () => {
|
||||||
console.log("Submitting answer:", answer);
|
if (!currentTask || !competitionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Solution submitted successfully");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to submit solution:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,17 +63,26 @@ const CompetitionSession = () => {
|
|||||||
<CompetitionHeader
|
<CompetitionHeader
|
||||||
title="Олимпиада DANO 2025. Индивидуальный этап"
|
title="Олимпиада DANO 2025. Индивидуальный этап"
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
competitionId={id || ""}
|
competitionId={competitionId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
<main className="flex-1 bg-[#F8F8F8] pb-8">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<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">
|
<div className="font-hse-sans flex flex-col gap-6 md:flex-row">
|
||||||
<TaskContent task={currentTask} />
|
<TaskContent task={currentTask} />
|
||||||
<TaskSolution
|
<TaskSolution
|
||||||
task={currentTask}
|
task={currentTask}
|
||||||
solutions={mockSolutions}
|
solutions={mockSolutions} // Still using mock solutions
|
||||||
answer={answer}
|
answer={answer}
|
||||||
setAnswer={setAnswer}
|
setAnswer={setAnswer}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -44,7 +90,7 @@ const CompetitionSession = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 { DataRushReview } from "@/components/ui/icons/datarush-review";
|
||||||
import { Reviewer } from "@/shared/types/review";
|
import { Reviewer } from "@/shared/types/review";
|
||||||
import { Link } from "react-router";
|
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",
|
id: "1",
|
||||||
number: "1.1",
|
number: "1.1",
|
||||||
status: TaskStatus.Uncleared,
|
status: TaskStatus.Uncleared,
|
||||||
solutionType: "input"
|
solutionType: "input",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
number: "1.2",
|
number: "1.2",
|
||||||
status: TaskStatus.Checking,
|
status: TaskStatus.Checking,
|
||||||
solutionType: "file"
|
solutionType: "file",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
number: "1.3",
|
number: "1.3",
|
||||||
status: TaskStatus.Correct,
|
status: TaskStatus.Correct,
|
||||||
solutionType: "code"
|
solutionType: "code",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "4",
|
id: "4",
|
||||||
number: "2.1",
|
number: "2.1",
|
||||||
status: TaskStatus.Partial,
|
status: TaskStatus.Partial,
|
||||||
solutionType: "input"
|
solutionType: "input",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5",
|
id: "5",
|
||||||
number: "2.2",
|
number: "2.2",
|
||||||
status: TaskStatus.Wrong,
|
status: TaskStatus.Wrong,
|
||||||
solutionType: "file"
|
solutionType: "file",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "6",
|
id: "6",
|
||||||
number: "2.3",
|
number: "2.3",
|
||||||
status: TaskStatus.Uncleared,
|
status: TaskStatus.Uncleared,
|
||||||
solutionType: "code"
|
solutionType: "code",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "7",
|
id: "7",
|
||||||
number: "3.1",
|
number: "3.1",
|
||||||
status: TaskStatus.Checking,
|
status: TaskStatus.Checking,
|
||||||
solutionType: "file"
|
solutionType: "file",
|
||||||
|
description: "123",
|
||||||
|
maxScore: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "8",
|
id: "8",
|
||||||
number: "3.2",
|
number: "3.2",
|
||||||
status: TaskStatus.Correct,
|
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,
|
score?: number,
|
||||||
maxScore?: number,
|
maxScore?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
number: string;
|
||||||
|
description: string;
|
||||||
|
maxScore: number;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
solutionType: SolutionType;
|
solutionType: SolutionType;
|
||||||
|
requirements?: string;
|
||||||
|
attachments?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CompetitionStatus, TaskStatus };
|
export { CompetitionStatus, TaskStatus };
|
||||||
|
|||||||
Reference in New Issue
Block a user