From 077074d424754a1e4391a70fd897864c1f3db00a Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 09:04:51 +0300 Subject: [PATCH 01/13] add basic openapi scheme for competitions tasks --- services/backend/api/v1/router.py | 5 ++ services/backend/api/v1/task/__init__.py | 0 services/backend/api/v1/task/schemas.py | 10 ++++ services/backend/api/v1/task/views.py | 64 ++++++++++++++++++++++++ services/backend/apps/task/models.py | 7 ++- services/backend/config/settings.py | 1 + 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 services/backend/api/v1/task/__init__.py create mode 100644 services/backend/api/v1/task/schemas.py create mode 100644 services/backend/api/v1/task/views.py diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 241af1e..2c24b2b 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -6,6 +6,7 @@ from api.v1 import handlers from api.v1.auth import BearerAuth from api.v1.competition.views import router as competition_router from api.v1.ping.views import router as ping_router +from api.v1.task.views import router as task_router from api.v1.user.views import router as user_router router = NinjaAPI( @@ -29,6 +30,10 @@ router.add_router( "", competition_router, ) +router.add_router( + "", + task_router, +) for exception, handler in handlers.exception_handlers: diff --git a/services/backend/api/v1/task/__init__.py b/services/backend/api/v1/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py new file mode 100644 index 0000000..4a3ec2e --- /dev/null +++ b/services/backend/api/v1/task/schemas.py @@ -0,0 +1,10 @@ +from ninja import ModelSchema, Schema + +from apps.competition.models import State +from apps.task.models import CompetitionTask + + +class TaskOutSchema(ModelSchema): + class Meta: + model = CompetitionTask + fields = ["id", "competition", "title", "description", "type"] diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py new file mode 100644 index 0000000..81301c8 --- /dev/null +++ b/services/backend/api/v1/task/views.py @@ -0,0 +1,64 @@ +from http import HTTPStatus as status + +from ninja import Router + +from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError +from api.v1.ping.schemas import PingOut +from api.v1.task.schemas import TaskOutSchema + +router = Router(tags=["competition"]) + + +@router.post( + "competitions/{competition_id}/start", + description="Start a competition completing (open access to tasks)", + response={ + status.OK: PingOut, + status.UNAUTHORIZED: UnauthorizedError, + status.NOT_FOUND: NotFoundError, + }, +) +def start_competition(request, competition_id: str) -> PingOut: + ... + + +@router.get( + "competitions/{competition_id}/tasks", + description="Get all tasks of competition (works only if user started competition)", + response={ + status.OK: list[TaskOutSchema], + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: + ... + + +@router.get( + "competitions/{competition_id}/tasks/{task_id}", + description="Get a task of competition task", + response={ + status.OK: TaskOutSchema, + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: + ... + + +@router.post( + "competitions/{competition_id}/tasks/{task_id}/submit", + description="Submit task solution", + response={ + status.OK: PingOut, # todo maybe I should write an other schema for this + status.UNAUTHORIZED: UnauthorizedError, + status.FORBIDDEN: ForbiddenError, + status.NOT_FOUND: NotFoundError, + } +) +def submit_task(request, competition_id: str, task_id: str) -> PingOut: + ... diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index eee9f4f..3ff7ac4 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,11 +1,10 @@ from uuid import uuid4 -from competition.models import Competition -from core.models import BaseModel from django.db import models from apps.task.validators import ContestTaskCriteriesValidator - +from apps.competition.models import Competition +from apps.core.models import BaseModel class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): @@ -19,7 +18,7 @@ class CompetitionTask(BaseModel): competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.TextField(verbose_name="заголовок", max_length=50) description = models.TextField(verbose_name="описание", max_length=300) - type = models.CharField(choices=CompetitionTaskType) + type = models.CharField(choices=CompetitionTaskType, max_length=8) # only when "input" or "checker" type correct_answer_file = models.FileField(upload_to=answer_file_upload_to) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index c3a64da..ffae770 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -445,6 +445,7 @@ INSTALLED_APPS = [ "apps.core", "apps.user", "apps.competition", + "apps.task", ] # GUID From 7eef7a76109dc7dafed6b5e52ded23c33d62e511 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:00:55 +0300 Subject: [PATCH 02/13] add not found status to get competition id --- services/backend/api/v1/competition/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 095549c..5891d9d 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -18,6 +18,7 @@ router = Router(tags=["competition"]) status.OK: schemas.CompetitionOut, status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, + status.NOT_FOUND: global_schemas.NotFoundError, }, ) def get_competition( From 0e65fc5fdda13626eea360b7ba0e89c6c712d24f Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:11:22 +0300 Subject: [PATCH 03/13] add callback to start competition endpoint --- services/backend/api/v1/task/views.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 81301c8..043ad10 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,10 +1,12 @@ from http import HTTPStatus as status +from django.shortcuts import get_object_or_404 from ninja import Router from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut from api.v1.task.schemas import TaskOutSchema +from apps.competition.models import Competition, State router = Router(tags=["competition"]) @@ -19,7 +21,11 @@ router = Router(tags=["competition"]) }, ) def start_competition(request, competition_id: str) -> PingOut: - ... + competition = get_object_or_404(Competition, pk=competition_id) + state_obj, _ = State.objects.update_or_create( + user=request.auth, competition=competition, state="started" + ) + return status.OK, PingOut() @router.get( From d5bf9f3acce417136bf6169bf47eb6b224348a20 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 11:11:34 +0300 Subject: [PATCH 04/13] add created_at field to state model --- .../migrations/0004_state_changed_at.py | 19 +++++++++++++++++++ services/backend/apps/competition/models.py | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 services/backend/apps/competition/migrations/0004_state_changed_at.py diff --git a/services/backend/apps/competition/migrations/0004_state_changed_at.py b/services/backend/apps/competition/migrations/0004_state_changed_at.py new file mode 100644 index 0000000..365f995 --- /dev/null +++ b/services/backend/apps/competition/migrations/0004_state_changed_at.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:10 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0003_state'), + ] + + operations = [ + migrations.AddField( + model_name='state', + name='changed_at', + field=models.DateTimeField(default=datetime.datetime.now), + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 644f733..589ce91 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.db import models from apps.core.models import BaseModel @@ -49,3 +51,4 @@ class State(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) state = models.CharField(choices=StateChoices.choices, max_length=11) + changed_at = models.DateTimeField(default=datetime.now) From 156c4d036be51912a28eec25fb865d70f8a253d3 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:00:15 +0300 Subject: [PATCH 05/13] add callback to get competition tasks endpoint --- services/backend/api/v1/task/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 043ad10..5899492 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -7,6 +7,7 @@ from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut from api.v1.task.schemas import TaskOutSchema from apps.competition.models import Competition, State +from apps.task.models import CompetitionTask router = Router(tags=["competition"]) @@ -39,7 +40,14 @@ def start_competition(request, competition_id: str) -> PingOut: } ) def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: - ... + competition = get_object_or_404(Competition, pk=competition_id) + state = State.objects.filter( + user=request.auth, competition=competition, state="started" + ).first() + if not state: + return 403, ForbiddenError() + + return status.OK, CompetitionTask.objects.filter(competition=competition).all() @router.get( From 52c19d2f80166c2f8eef514acece66c957232d45 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:24:47 +0300 Subject: [PATCH 06/13] change str to uuid in path params --- services/backend/api/v1/task/views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 5899492..07b729f 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,4 +1,5 @@ from http import HTTPStatus as status +from uuid import UUID from django.shortcuts import get_object_or_404 from ninja import Router @@ -21,7 +22,7 @@ router = Router(tags=["competition"]) status.NOT_FOUND: NotFoundError, }, ) -def start_competition(request, competition_id: str) -> PingOut: +def start_competition(request, competition_id: UUID) -> PingOut: competition = get_object_or_404(Competition, pk=competition_id) state_obj, _ = State.objects.update_or_create( user=request.auth, competition=competition, state="started" @@ -39,7 +40,7 @@ def start_competition(request, competition_id: str) -> PingOut: status.NOT_FOUND: NotFoundError, } ) -def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: +def get_competition_tasks(request, competition_id: UUID) -> list[TaskOutSchema]: competition = get_object_or_404(Competition, pk=competition_id) state = State.objects.filter( user=request.auth, competition=competition, state="started" @@ -60,7 +61,7 @@ def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: status.NOT_FOUND: NotFoundError, } ) -def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: +def get_task(request, competition_id: UUID, task_id: UUID) -> TaskOutSchema: ... @@ -74,5 +75,5 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: status.NOT_FOUND: NotFoundError, } ) -def submit_task(request, competition_id: str, task_id: str) -> PingOut: +def submit_task(request, competition_id: UUID, task_id: UUID) -> PingOut: ... From d062e96bbc978b7b7f88ad0f542d55761d0fdaa4 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:28:27 +0300 Subject: [PATCH 07/13] add partipication type and competition type to get all competitions --- services/backend/api/v1/competition/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 5891d9d..1f7eb77 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -31,14 +31,14 @@ def get_competition( @router.get( "competitions", response={ - status.OK: list[schemas.CompetitionListInstanceOut], + status.OK: list[schemas.CompetitionOut], status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, ) def list_competitions( request: HttpRequest, is_participating: bool -) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: +) -> tuple[status, list[schemas.CompetitionOut]]: user = request.auth if is_participating: competitions = Competition.objects.filter(participants=user) From 37fb9097bbd9c452d4a9fc7cb967e35e68b1fe69 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 12:29:07 +0300 Subject: [PATCH 08/13] remove competition from task schema --- services/backend/api/v1/task/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 4a3ec2e..1f9f264 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -7,4 +7,4 @@ from apps.task.models import CompetitionTask class TaskOutSchema(ModelSchema): class Meta: model = CompetitionTask - fields = ["id", "competition", "title", "description", "type"] + fields = ["id", "title", "description", "type"] From 65f73fb4a0b5170776b131e158efb4893133275e Mon Sep 17 00:00:00 2001 From: moolcoov Date: Sat, 1 Mar 2025 12:39:09 +0300 Subject: [PATCH 09/13] fix: main page --- services/frontend/src/App.tsx | 14 +- .../frontend/src/components/layout/header.tsx | 21 ++ services/frontend/src/components/ui/card.tsx | 36 ++-- .../src/components/ui/icons/datarush.tsx | 22 ++ services/frontend/src/components/ui/tabs.tsx | 36 ++-- .../frontend/src/modules/Navbar/index.tsx | 24 --- .../pages/CompetitionPreviewPage/index.tsx | 55 ++--- .../src/pages/CompetitionRunnerPage/index.tsx | 69 +++--- .../components/CompetitionCard/index.tsx | 74 +++---- .../src/pages/CompetitionsPage/index.tsx | 198 ++++++++++-------- .../modules/CompetitionGrid/index.tsx | 40 +--- services/frontend/src/shared/mocks/mocks.ts | 66 +++--- services/frontend/src/shared/types.ts | 25 +++ services/frontend/src/shared/types/types.ts | 25 --- services/frontend/src/styles/globals.css | 13 +- .../frontend/src/widgets/navbar-layout.tsx | 15 ++ services/frontend/tailwind.config.js | 11 - 17 files changed, 383 insertions(+), 361 deletions(-) create mode 100644 services/frontend/src/components/layout/header.tsx create mode 100644 services/frontend/src/components/ui/icons/datarush.tsx delete mode 100644 services/frontend/src/modules/Navbar/index.tsx create mode 100644 services/frontend/src/shared/types.ts delete mode 100644 services/frontend/src/shared/types/types.ts create mode 100644 services/frontend/src/widgets/navbar-layout.tsx delete mode 100644 services/frontend/tailwind.config.js diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 820d663..d7bd1eb 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -3,17 +3,21 @@ import "./styles/globals.css"; import CompetitionsPage from "./pages/CompetitionsPage"; import CompetitionPreviewPage from "./pages/CompetitionPreviewPage"; import CompetitionRunnerPage from "./pages/CompetitionRunnerPage"; - +import { NavbarLayout } from "./widgets/navbar-layout"; const App = () => { return ( - } /> + }> + } /> + } /> - } /> + } + /> - ); }; -export default App; \ No newline at end of file +export default App; diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..f9a7264 --- /dev/null +++ b/services/frontend/src/components/layout/header.tsx @@ -0,0 +1,21 @@ +import { DataRush } from "@/components/ui/icons/datarush"; +import { ChevronDown } from "lucide-react"; +import { Link } from "react-router"; + +const Header = () => { + return ( +
+
+ + + +
+ itqdev + +
+
+
+ ); +}; + +export { Header }; diff --git a/services/frontend/src/components/ui/card.tsx b/services/frontend/src/components/ui/card.tsx index 4704955..7f668c5 100644 --- a/services/frontend/src/components/ui/card.tsx +++ b/services/frontend/src/components/ui/card.tsx @@ -1,18 +1,15 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/shared/lib/utils" +import { cn } from "@/shared/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return (
- ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -22,7 +19,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-1.5 px-6", className)} {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -32,7 +29,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { className={cn("leading-none font-semibold", className)} {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -42,17 +39,13 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return ( -
- ) +
+ ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -62,7 +55,14 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center px-6", className)} {...props} /> - ) + ); } -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/services/frontend/src/components/ui/icons/datarush.tsx b/services/frontend/src/components/ui/icons/datarush.tsx new file mode 100644 index 0000000..ecc1627 --- /dev/null +++ b/services/frontend/src/components/ui/icons/datarush.tsx @@ -0,0 +1,22 @@ +const DataRush = ({ size = 52 }: { size?: number }) => { + return ( + + + + + + ); +}; + +export { DataRush }; diff --git a/services/frontend/src/components/ui/tabs.tsx b/services/frontend/src/components/ui/tabs.tsx index 55c8eae..44d0b06 100644 --- a/services/frontend/src/components/ui/tabs.tsx +++ b/services/frontend/src/components/ui/tabs.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; -import { cn } from "@/shared/lib/utils" +import { cn } from "@/shared/lib/utils"; function Tabs({ className, @@ -10,10 +10,10 @@ function Tabs({ return ( - ) + ); } function TabsList({ @@ -24,34 +24,28 @@ function TabsList({ - ) + ); } function TabsTrigger({ className, - value, ...props -}: React.ComponentProps & { value: string }) { +}: React.ComponentProps) { return ( - ) + ); } function TabsContent({ @@ -61,10 +55,10 @@ function TabsContent({ return ( - ) + ); } -export { Tabs, TabsList, TabsTrigger, TabsContent } \ No newline at end of file +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/services/frontend/src/modules/Navbar/index.tsx b/services/frontend/src/modules/Navbar/index.tsx deleted file mode 100644 index ba1062a..0000000 --- a/services/frontend/src/modules/Navbar/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ChevronDown } from "lucide-react"; - -const Navbar = () => { - return ( - - ); -}; - - -export default Navbar \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionPreviewPage/index.tsx b/services/frontend/src/pages/CompetitionPreviewPage/index.tsx index 7feea79..e6261f7 100644 --- a/services/frontend/src/pages/CompetitionPreviewPage/index.tsx +++ b/services/frontend/src/pages/CompetitionPreviewPage/index.tsx @@ -1,12 +1,11 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import Navbar from "@/modules/Navbar"; +import Navbar from "@/widgets/Navbar"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; -import { Competition } from "@/shared/types/types"; +import { Competition } from "@/shared/types"; import { mockCompetitions, mockTasks } from "@/shared/mocks/mocks"; - const CompetitionPreview = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -17,7 +16,7 @@ const CompetitionPreview = () => { const fetchCompetition = async () => { try { setTimeout(() => { - const found = mockCompetitions.find(comp => comp.id === id); + const found = mockCompetitions.find((comp) => comp.id === id); setCompetition(found || null); setIsLoading(false); }, 500); @@ -37,7 +36,7 @@ const CompetitionPreview = () => { const handleContinue = () => { if (competition?.id) { const competitionTasks = mockTasks[competition.id]; - + if (competitionTasks && competitionTasks.length > 0) { const firstTaskId = competitionTasks[0].id; navigate(`/competition/${competition.id}/tasks/${firstTaskId}`); @@ -50,49 +49,55 @@ const CompetitionPreview = () => { return ( <> -
- {isLoading ? ( -
+

Загрузка...

) : competition ? ( -
-
- +
+ {competition.name}
- +
-
-

{competition.name}

-
- -
+ +

{competition.description}

) : ( -
-

Соревнование не найдено

-

Запрошенное соревнование не существует или было удалено.

+
+

+ Соревнование не найдено +

+

+ Запрошенное соревнование не существует или было удалено. +

)}
@@ -100,4 +105,4 @@ const CompetitionPreview = () => { ); }; -export default CompetitionPreview; \ No newline at end of file +export default CompetitionPreview; diff --git a/services/frontend/src/pages/CompetitionRunnerPage/index.tsx b/services/frontend/src/pages/CompetitionRunnerPage/index.tsx index fe030dd..73b8797 100644 --- a/services/frontend/src/pages/CompetitionRunnerPage/index.tsx +++ b/services/frontend/src/pages/CompetitionRunnerPage/index.tsx @@ -1,9 +1,7 @@ import { useState } from "react"; import { useParams } from "react-router-dom"; -import Navbar from "@/modules/Navbar"; -import { Task, TaskStatus } from "@/shared/types/types"; - - +import Navbar from "@/widgets/Navbar"; +import { Task, TaskStatus } from "@/shared/types"; const sampleTasks: Task[] = [ { id: "1", number: "1.1", status: "uncleared" }, @@ -18,27 +16,39 @@ const sampleTasks: Task[] = [ const CompetitionRunnerPage = () => { const { id } = useParams<{ id: string }>(); - const [competitionTitle, setCompetitionTitle] = useState("Олимпиада DANO 2025. Индивидуальный этап"); + const [competitionTitle, setCompetitionTitle] = useState( + "Олимпиада DANO 2025. Индивидуальный этап", + ); const [tasks, setTasks] = useState(sampleTasks); const [selectedTaskId, setSelectedTaskId] = useState(null); const getTaskBgColor = (status: TaskStatus): string => { switch (status) { - case "uncleared": return "bg-[var(--color-task-uncleared)]"; - case "checking": return "bg-[var(--color-task-checking)]"; - case "correct": return "bg-[var(--color-task-correct)]"; - case "partial": return "bg-[var(--color-task-partial)]"; - case "wrong": return "bg-[var(--color-task-wrong)]"; + case "uncleared": + return "bg-[var(--color-task-uncleared)]"; + case "checking": + return "bg-[var(--color-task-checking)]"; + case "correct": + return "bg-[var(--color-task-correct)]"; + case "partial": + return "bg-[var(--color-task-partial)]"; + case "wrong": + return "bg-[var(--color-task-wrong)]"; } }; const getTaskTextColor = (status: TaskStatus): string => { switch (status) { - case "uncleared": return "text-gray-600"; - case "checking": return "text-gray-800"; - case "correct": return "text-green-800"; - case "partial": return "text-green-700"; - case "wrong": return "text-red-800"; + case "uncleared": + return "text-gray-600"; + case "checking": + return "text-gray-800"; + case "correct": + return "text-green-800"; + case "partial": + return "text-green-700"; + case "wrong": + return "text-red-800"; } }; @@ -49,21 +59,20 @@ const CompetitionRunnerPage = () => { return ( <> - -
+ +
-

{competitionTitle}

+

+ {competitionTitle} +

- -
+ +
{tasks.map((task) => ( -
handleTaskClick(task.id)} > {task.number} @@ -72,13 +81,13 @@ const CompetitionRunnerPage = () => {
- +
-
+
{selectedTaskId ? (
-

- Задание {tasks.find(t => t.id === selectedTaskId)?.number} +

+ Задание {tasks.find((t) => t.id === selectedTaskId)?.number}

Содержание задания будет отображаться здесь. @@ -95,4 +104,4 @@ const CompetitionRunnerPage = () => { ); }; -export default CompetitionRunnerPage; \ No newline at end of file +export default CompetitionRunnerPage; diff --git a/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx b/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx index 5c06328..2e65038 100644 --- a/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx +++ b/services/frontend/src/pages/CompetitionsPage/components/CompetitionCard/index.tsx @@ -1,55 +1,47 @@ -import { Competition } from "@/shared/types/types"; +import { Competition, CompetitionStatus } from "@/shared/types"; import { cn } from "@/shared/lib/utils"; -import { - Card, - CardContent, - CardFooter, -} from "@/components/ui/card"; -import { useNavigate } from "react-router"; +import { Card, CardContent } from "@/components/ui/card"; interface CompetitionCardProps { competition: Competition; className?: string; } -export function CompetitionCard({ competition, className }: CompetitionCardProps) { - const { id, name, imageUrl, isOlympics, status } = competition; - const navigate = useNavigate(); - - const handleClick = () => { - navigate(`/competition/${id}`); - }; - +export function CompetitionCard({ + competition, + className, +}: CompetitionCardProps) { return ( - -

- {name} + {competition.name}
- - - - {isOlympics ? "Олимпиада" : "Тренировка"} - - - - {status.replace(/^\w/, c => c.toUpperCase())} - - - - -

{name}

+ + +
+
+ {competition.isOlympics ? "Олимпиада" : "Тренировка"} + {competition.status != CompetitionStatus.NotParticipating && ( + <> + + + {competition.status} + + + )} +
+

{competition.name}

+
); -} \ No newline at end of file +} diff --git a/services/frontend/src/pages/CompetitionsPage/index.tsx b/services/frontend/src/pages/CompetitionsPage/index.tsx index 3996b7b..f7c208b 100644 --- a/services/frontend/src/pages/CompetitionsPage/index.tsx +++ b/services/frontend/src/pages/CompetitionsPage/index.tsx @@ -1,98 +1,122 @@ -import { useState, useEffect } from 'react'; -import { Competition, Status } from '@/shared/types/types'; -import { CompetitionGrid } from './modules/CompetitionGrid'; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle } from "lucide-react"; +import { useState, useEffect } from "react"; +import { Competition, CompetitionStatus } from "@/shared/types"; +import { CompetitionGrid } from "./modules/CompetitionGrid"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import Navbar from '@/modules/Navbar'; -import { mockCompetitions } from '@/shared/mocks/mocks'; + +const mockCompetitions: Competition[] = [ + { + id: "1", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: true, + status: CompetitionStatus.InProgress, + }, + { + id: "2", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: false, + status: CompetitionStatus.NotParticipating, + }, + { + id: "3", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: false, + status: CompetitionStatus.InProgress, + }, + { + id: "4", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: true, + status: CompetitionStatus.Completed, + }, + { + id: "5", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: false, + status: CompetitionStatus.Completed, + }, + { + id: "6", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: true, + status: CompetitionStatus.NotParticipating, + }, + { + id: "6", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: true, + status: CompetitionStatus.NotParticipating, + }, + { + id: "6", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", + isOlympics: true, + status: CompetitionStatus.NotParticipating, + }, +]; const CompetitionsPage = () => { - const [competitions, setCompetitions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [competitions] = useState(mockCompetitions); const [activeTab, setActiveTab] = useState("ongoing"); - useEffect(() => { - // ! симуляция фетча - const fetchCompetitions = async () => { - try { - setTimeout(() => { - setCompetitions(mockCompetitions); - setIsLoading(false); - }, 800); - } catch (error) { - setError('Соревнования не найдены, пожалуйста, попробуйте позже'); - setIsLoading(false); - } - }; - - fetchCompetitions(); - }, []); - - const myCompetitions = competitions.filter(comp => - comp.status === Status.InProgress || comp.status === Status.Completed + const myCompetitions = competitions.filter( + (comp) => + comp.status === CompetitionStatus.InProgress || + comp.status === CompetitionStatus.Completed, ); - - const filteredMyCompetitions = myCompetitions.filter(comp => - activeTab === "ongoing" ? comp.status === Status.InProgress : comp.status === Status.Completed + + const filteredMyCompetitions = myCompetitions.filter((comp) => + activeTab === "ongoing" + ? comp.status === CompetitionStatus.InProgress + : comp.status === CompetitionStatus.Completed, ); - - const availableCompetitions = competitions.filter(comp => - comp.status === 'Не участвую' + + const availableCompetitions = competitions.filter( + (comp) => comp.status === "Не участвую", ); return ( - <> - -
- {error && ( - - - {error} - - )} - -
-
-

Мои события

- - - Текущие - Завершенные - - -
- - {isLoading ? ( - - ) : filteredMyCompetitions.length > 0 ? ( - - ) : ( -
-

- {activeTab === "ongoing" ? "У вас нет текущих соревнований" : "У вас нет завершенных соревнований"} -

-
- )} -
- -
-

Доступные события

- - {isLoading ? ( - - ) : availableCompetitions.length > 0 ? ( - - ) : ( -
-

Нет доступных соревнований

-
- )} -
-
- - ); -} +
+
+ + Мои события + + + В процессе + Завершенные + + + + +
-export default CompetitionsPage; \ No newline at end of file +
+ + События + + +
+
+ ); +}; + +const Section = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +const SectionHeader = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; + +const SectionTitle = ({ children }: { children: React.ReactNode }) => { + return

{children}

; +}; + +export default CompetitionsPage; diff --git a/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx b/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx index 7bcc1fa..19b376c 100644 --- a/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx +++ b/services/frontend/src/pages/CompetitionsPage/modules/CompetitionGrid/index.tsx @@ -1,46 +1,16 @@ -import { Competition } from "@/shared/types/types"; +import { Competition } from "@/shared/types"; import { CompetitionCard } from "../../components/CompetitionCard"; -import CompetitionSkeleton from "../../components/CompetitionSkeleton"; -import { cn } from "@/shared/lib/utils"; interface CompetitionGridProps { competitions: Competition[]; - isLoading?: boolean; - className?: string; - skeletonCount?: number; } -export function CompetitionGrid({ - competitions, - isLoading = false, - className, - skeletonCount -}: CompetitionGridProps) { - const gridClasses = cn( - "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6", - className - ); - - const numberOfSkeletons = skeletonCount ?? (competitions.length > 0 ? competitions.length : 4); - - if (isLoading) { - return ( -
- {Array.from({ length: numberOfSkeletons }).map((_, index) => ( - - ))} -
- ); - } - +export function CompetitionGrid({ competitions }: CompetitionGridProps) { return ( -
+
{competitions.map((competition) => ( - + ))}
); -} \ No newline at end of file +} diff --git a/services/frontend/src/shared/mocks/mocks.ts b/services/frontend/src/shared/mocks/mocks.ts index ac54fe8..b05ad67 100644 --- a/services/frontend/src/shared/mocks/mocks.ts +++ b/services/frontend/src/shared/mocks/mocks.ts @@ -1,62 +1,64 @@ -import { Competition, Status } from "../types/types"; +import { Competition, CompetitionStatus } from "../types"; const mockCompetitions: Competition[] = [ { - id: '1', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "1", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: true, - status: Status.InProgress, - description: 'Проверка глубоких знаний и навыков в анализе данных. Будет несколько творческих заданий со свободным ответом. Задания выполняются индивидуально, вес тура в итоговом результате – 0,5. Этап пройдет онлайн в заданное время, с применением системы прокторинга. На работу дается 240 минут.' + status: CompetitionStatus.InProgress, + description: + "Проверка глубоких знаний и навыков в анализе данных. Будет несколько творческих заданий со свободным ответом. Задания выполняются индивидуально, вес тура в итоговом результате – 0,5. Этап пройдет онлайн в заданное время, с применением системы прокторинга. На работу дается 240 минут.", }, { - id: '2', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "2", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: false, - status: Status.NotParticipating, - description: 'Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.' + status: CompetitionStatus.NotParticipating, + description: + "Индивидуальный этап олимпиады DANO 2025 – это уникальная возможность для студентов продемонстрировать свои навыки анализа данных и решения сложных задач. Участники будут работать с реальными наборами данных и применять современные методы машинного обучения и статистического анализа.", }, { - id: '3', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "3", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: false, - status: Status.InProgress + status: CompetitionStatus.InProgress, }, { - id: '4', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "4", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: true, - status: Status.Completed + status: CompetitionStatus.Completed, }, { - id: '5', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "5", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: false, - status: Status.Completed + status: CompetitionStatus.Completed, }, { - id: '6', - name: 'Олимпиада DANO 2025. Индивидуальный этап', - imageUrl: '/DANO.png', + id: "6", + name: "Олимпиада DANO 2025. Индивидуальный этап", + imageUrl: "/DANO.png", isOlympics: true, - status: Status.NotParticipating - } + status: CompetitionStatus.NotParticipating, + }, ]; const mockTasks = { - '1': [ + "1": [ { id: "1.1", number: "1.1", status: "uncleared" }, { id: "1.2", number: "1.2", status: "checking" }, { id: "1.3", number: "1.3", status: "correct" }, ], - '2': [ + "2": [ { id: "2.1", number: "1.1", status: "uncleared" }, { id: "2.2", number: "1.2", status: "uncleared" }, - ] + ], }; -export { mockCompetitions, mockTasks } \ No newline at end of file +export { mockCompetitions, mockTasks }; diff --git a/services/frontend/src/shared/types.ts b/services/frontend/src/shared/types.ts new file mode 100644 index 0000000..3732350 --- /dev/null +++ b/services/frontend/src/shared/types.ts @@ -0,0 +1,25 @@ +enum CompetitionStatus { + InProgress = "В процессе", + NotParticipating = "Не участвую", + Completed = "Завершено", +} + +interface Competition { + id: string; + name: string; + imageUrl: string; + isOlympics: boolean; + status: CompetitionStatus; + description?: string; +} + +type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong"; + +interface Task { + id: string; + number: string; + status: TaskStatus; +} + +export { CompetitionStatus }; +export type { Competition, TaskStatus, Task }; diff --git a/services/frontend/src/shared/types/types.ts b/services/frontend/src/shared/types/types.ts deleted file mode 100644 index d670376..0000000 --- a/services/frontend/src/shared/types/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -enum Status { - InProgress = 'В процессе', - NotParticipating = 'Не участвую', - Completed = 'Завершено' -} - -interface Competition { - id: string; - name: string; - imageUrl: string; - isOlympics: boolean; - status: Status; - description?: string; -} - -type TaskStatus = "uncleared" | "checking" | "correct" | "partial" | "wrong"; - -interface Task { - id: string; - number: string; - status: TaskStatus; -} - -export {Status} -export type {Competition, TaskStatus, Task} \ No newline at end of file diff --git a/services/frontend/src/styles/globals.css b/services/frontend/src/styles/globals.css index bcfe1c4..f7807bf 100644 --- a/services/frontend/src/styles/globals.css +++ b/services/frontend/src/styles/globals.css @@ -5,14 +5,14 @@ @custom-variant dark (&:is(.dark *)); :root { - --background: oklch(1 0 0); + --background: oklch(0.97 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); + --primary: oklch(89.97% 0.1763 97.07); + --primary-foreground: oklch(82.87% 0.1701 94.8); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); @@ -41,7 +41,7 @@ } @theme inline { - --font-hse-sans: "HSE Sans", system-ui, sans-serif + --font-hse-sans: "HSE Sans", system-ui, sans-serif; } .dark { --background: oklch(0.145 0 0); @@ -76,7 +76,7 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); - + --task-uncleared: oklch(0.955 0 0); --task-checking: oklch(0.899 0.1763 97.07); --task-correct: oklch(0.962 0.0561 158.62); @@ -127,12 +127,11 @@ --color-task-correct: var(--task-correct); --color-task-partial: var(--task-partial); --color-task-wrong: var(--task-wrong); - } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border outline-ring/50 font-hse-sans; } body { @apply bg-background text-foreground; diff --git a/services/frontend/src/widgets/navbar-layout.tsx b/services/frontend/src/widgets/navbar-layout.tsx new file mode 100644 index 0000000..3b40070 --- /dev/null +++ b/services/frontend/src/widgets/navbar-layout.tsx @@ -0,0 +1,15 @@ +import { Header } from "@/components/layout/header"; +import { Outlet } from "react-router"; + +const NavbarLayout = () => { + return ( + <> +
+
+ +
+ + ); +}; + +export { NavbarLayout }; diff --git a/services/frontend/tailwind.config.js b/services/frontend/tailwind.config.js deleted file mode 100644 index 50cd5f6..0000000 --- a/services/frontend/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - theme: { - extend: { - fontFamily: { - 'hse-sans': ['"HSE Sans"', 'system-ui', 'sans-serif'], - }, - }, - }, - plugins: [], -} From 688862ca786879525f60a1656170b77cc4d4f5e0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 1 Mar 2025 12:40:10 +0300 Subject: [PATCH 10/13] super aboba --- services/backend/apps/task/models.py | 38 +++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 3ff7ac4..1c4a3ae 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,3 +1,4 @@ +from random import choice from uuid import uuid4 from django.db import models @@ -5,6 +6,9 @@ from django.db import models from apps.task.validators import ContestTaskCriteriesValidator from apps.competition.models import Competition from apps.core.models import BaseModel +from apps.user.models import User +from apps.task.models import CompetitionTask + class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): @@ -13,7 +17,7 @@ class CompetitionTask(BaseModel): REVIEW = "review" def answer_file_upload_to(instance, filename) -> str: - return f"/tasks/{instance.id}/answer/{uuid4}" + return f"/tasks/{instance.id}/answer/{uuid4()}/filename" competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.TextField(verbose_name="заголовок", max_length=50) @@ -21,13 +25,41 @@ class CompetitionTask(BaseModel): type = models.CharField(choices=CompetitionTaskType, max_length=8) # only when "input" or "checker" type - correct_answer_file = models.FileField(upload_to=answer_file_upload_to) + correct_answer_file = models.FileField( + upload_to=answer_file_upload_to, null=True, blank=True + ) + points = models.IntegerField(null=True, blank=True) # only when "checker" type - answer_file_path = models.TextField() + answer_file_path = models.TextField(null=True, blank=True) # only when "review" type criteries = models.JSONField(blank=True, null=True) def clean(self): ContestTaskCriteriesValidator()(self) + + +class CompetetionTaskSumbission(BaseModel): + class StatusChoices(models.TextChoices): + SENT = "sent" + CHECKING = "checking" + CHECKED = "checked" + + def submission_content_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/content" + + def submission_stdout_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/stdout" + + status = models.CharField( + choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=2 + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + content = models.FileField(upload_to=submission_content_upload_to) + stdout = models.FileField( + upload_to=submission_stdout_upload_to, null=True, blank=True + ) + result = models.JSONField(default=None, null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) From 07dc5210a0a807f9f02b1e62d68566fca9282969 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 1 Mar 2025 13:06:30 +0300 Subject: [PATCH 11/13] (scope): [body] [footer(s)] --- services/backend/api/v1/task/schemas.py | 11 ++ services/backend/api/v1/task/views.py | 63 +++++++++--- services/backend/apps/task/models.py | 23 ++++- services/backend/apps/task/tasks.py | 128 ++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 services/backend/apps/task/tasks.py diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 4a3ec2e..ecc98f3 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -1,3 +1,5 @@ +from typing import Literal +from uuid import UUID from ninja import ModelSchema, Schema from apps.competition.models import State @@ -8,3 +10,12 @@ class TaskOutSchema(ModelSchema): class Meta: model = CompetitionTask fields = ["id", "competition", "title", "description", "type"] + + +class TaskSubmissionIn(Schema): + type: Literal["input", "file", "code"] + content: str + + +class TaskSubmissionOut(Schema): + submission_id: UUID diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 81301c8..b805998 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,10 +1,20 @@ from http import HTTPStatus as status from ninja import Router +from django.shortcuts import get_object_or_404 from api.v1.schemas import NotFoundError, UnauthorizedError, ForbiddenError from api.v1.ping.schemas import PingOut -from api.v1.task.schemas import TaskOutSchema +from api.v1.task.schemas import ( + TaskOutSchema, + TaskSubmissionOut, + TaskSubmissionIn, +) +from apps.task.models import ( + Competition, + CompetitionTask, + CompetetionTaskSumbission, +) router = Router(tags=["competition"]) @@ -18,8 +28,7 @@ router = Router(tags=["competition"]) status.NOT_FOUND: NotFoundError, }, ) -def start_competition(request, competition_id: str) -> PingOut: - ... +def start_competition(request, competition_id: str) -> PingOut: ... @router.get( @@ -30,10 +39,11 @@ def start_competition(request, competition_id: str) -> PingOut: status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: - ... +def get_competition_tasks( + request, competition_id: str +) -> list[TaskOutSchema]: ... @router.get( @@ -44,21 +54,48 @@ def get_competition_tasks(request, competition_id: str) -> list[TaskOutSchema]: status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: - ... +def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ... @router.post( "competitions/{competition_id}/tasks/{task_id}/submit", description="Submit task solution", response={ - status.OK: PingOut, # todo maybe I should write an other schema for this + status.OK: TaskSubmissionOut, status.UNAUTHORIZED: UnauthorizedError, status.FORBIDDEN: ForbiddenError, status.NOT_FOUND: NotFoundError, - } + }, ) -def submit_task(request, competition_id: str, task_id: str) -> PingOut: - ... +def submit_task( + request, competition_id: str, task_id: str, submission: TaskSubmissionIn +) -> PingOut: + user = request.auth + competetion = get_object_or_404(Competition, id=competition_id) + task = get_object_or_404( + CompetitionTask, competetion=competetion, id=task_id + ) + + if task.type == CompetitionTask.CompetitionTaskType.INPUT: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.CHECKED, + result={"correct": submission.content == task.answer_file_path}, + ) + if task.type == CompetitionTask.CompetitionTaskType.REVIEW: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.SENT, + ) + if task.type == CompetitionTask.CompetitionTaskType.CHECKER: + CompetetionTaskSumbission.objects.create( + user=user, + task=task, + status=CompetetionTaskSumbission.StatusChoices.CHECKING, + ) + + return TaskSubmissionOut(id=CompetetionTaskSumbission.id) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 1c4a3ae..c76d925 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,4 +1,3 @@ -from random import choice from uuid import uuid4 from django.db import models @@ -7,7 +6,6 @@ from apps.task.validators import ContestTaskCriteriesValidator from apps.competition.models import Competition from apps.core.models import BaseModel from apps.user.models import User -from apps.task.models import CompetitionTask class CompetitionTask(BaseModel): @@ -52,14 +50,29 @@ class CompetetionTaskSumbission(BaseModel): def submission_stdout_upload_to(instance, filename) -> str: return f"/submissions/{instance.id}/stdout" - status = models.CharField( - choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=2 - ) user = models.ForeignKey(User, on_delete=models.CASCADE) task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + + status = models.CharField( + choices=StatusChoices.choices, + default=StatusChoices.SENT, + max_length=8, + ) + + # code or text or file content = models.FileField(upload_to=submission_content_upload_to) + + # only if task type is checker stdout = models.FileField( upload_to=submission_stdout_upload_to, null=True, blank=True ) + + # depends on task type: + # - input: {"correct": boolean} + # - file: {"total_points": integer, "by_criteria": ["criteria_name": integer]} + # - code: {"correct": boolean} result = models.JSONField(default=None, null=True, blank=True) + # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria + earned_points = models.IntegerField() + timestamp = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py new file mode 100644 index 0000000..b2f0286 --- /dev/null +++ b/services/backend/apps/task/tasks.py @@ -0,0 +1,128 @@ +import tempfile +import os +import sys +import ast +from io import StringIO +import hashlib +from config.celery import app + +ALLOWED_MODULES = { + "pandas", + "numpy", + "matplotlib", + "seaborn", + "scipy", + "sklearn", + "datetime", + "json", + "csv", + "math", + "statistics", +} + + +class SecurityException(Exception): + pass + + +def validate_code(code_str): + try: + tree = ast.parse(code_str) + except SyntaxError as e: + raise SecurityException(f"Syntax error: {str(e)}") + + class ImportVisitor(ast.NodeVisitor): + def visit_Import(self, node): + for alias in node.names: + module = alias.name.split(".")[0] + if module not in ALLOWED_MODULES: + raise SecurityException(f"Disallowed import: {module}") + + def visit_ImportFrom(self, node): + if node.module: + module = node.module.split(".")[0] + if module not in ALLOWED_MODULES: + raise SecurityException( + f"Disallowed import from: {module}" + ) + + class SecurityVisitor(ast.NodeVisitor): + def generic_visit(self, node): + if isinstance(node, (ast.Call, ast.Attribute)): + if "system" in getattr(node, "attr", ""): + raise SecurityException("Dangerous system call detected") + super().generic_visit(node) + + try: + ImportVisitor().visit(tree) + SecurityVisitor().visit(tree) + except SecurityException as e: + raise + except Exception as e: + raise SecurityException(f"Security check failed: {str(e)}") + + +def secure_exec(code_str, result_path): + original_dir = os.getcwd() + original_stdout = sys.stdout + sys.stdout = captured_stdout = StringIO() + result_content = None + + with tempfile.TemporaryDirectory() as temp_dir: + try: + os.chdir(temp_dir) + restricted_globals = { + "__builtins__": { + "open": lambda f, *a, **kw: open(f, *a, **kw), + "print": print, + "str": str, + "int": int, + "float": float, + "bool": bool, + "list": list, + "dict": dict, + "tuple": tuple, + "set": set, + } + } + + exec(code_str, restricted_globals) + + if result_path == "stdout": + result_content = captured_stdout.getvalue().encode("utf-8") + else: + with open(result_path, "rb") as f: + result_content = f.read() + + except Exception as e: + raise RuntimeError(f"Execution error: {str(e)}") + finally: + os.chdir(original_dir) + sys.stdout = original_stdout + + return result_content + + +@app.task(bind=True) +def analyze_data_task(self, code_str, result_path, expected_bytes): + try: + validate_code(code_str) + + result_content = secure_exec(code_str, result_path) + + result_hash = hashlib.sha256(result_content).hexdigest() + expected_hash = hashlib.sha256(expected_bytes).hexdigest() + + return { + "success": True, + "match": result_hash == expected_hash, + "result_hash": result_hash, + "expected_hash": expected_hash, + } + + except SecurityException as e: + return {"success": False, "error": f"Security violation: {str(e)}"} + except RuntimeError as e: + return {"success": False, "error": f"Execution error: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"Unexpected error: {str(e)}"} From 22727dda92cdbac880066e69916f17032756eb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 1 Mar 2025 13:09:09 +0300 Subject: [PATCH 12/13] feat: added task submission logic --- services/backend/api/v1/review/__init__.py | 0 services/backend/api/v1/review/auth.py | 26 +++++++++++++ services/backend/api/v1/review/schemas.py | 37 +++++++++++++++++++ services/backend/api/v1/review/views.py | 34 +++++++++++++++++ services/backend/api/v1/router.py | 8 ++++ .../competition/migrations/0001_initial.py | 32 ---------------- .../0002_competition_participants.py | 19 ---------- .../apps/competition/migrations/0003_state.py | 28 -------------- services/backend/apps/review/__init__.py | 0 services/backend/apps/review/apps.py | 6 +++ .../apps/review/migrations/__init__.py | 0 services/backend/apps/review/models.py | 10 +++++ .../backend/apps/task/migrations/__init__.py | 0 services/backend/apps/task/models.py | 34 ++++++++++++++--- .../apps/user/migrations/0001_initial.py | 28 -------------- .../apps/user/migrations/0002_user_status.py | 18 --------- .../user/migrations/0003_alter_user_status.py | 18 --------- services/backend/config/settings.py | 2 + 18 files changed, 152 insertions(+), 148 deletions(-) create mode 100644 services/backend/api/v1/review/__init__.py create mode 100644 services/backend/api/v1/review/auth.py create mode 100644 services/backend/api/v1/review/schemas.py create mode 100644 services/backend/api/v1/review/views.py delete mode 100644 services/backend/apps/competition/migrations/0001_initial.py delete mode 100644 services/backend/apps/competition/migrations/0002_competition_participants.py delete mode 100644 services/backend/apps/competition/migrations/0003_state.py create mode 100644 services/backend/apps/review/__init__.py create mode 100644 services/backend/apps/review/apps.py create mode 100644 services/backend/apps/review/migrations/__init__.py create mode 100644 services/backend/apps/review/models.py create mode 100644 services/backend/apps/task/migrations/__init__.py delete mode 100644 services/backend/apps/user/migrations/0001_initial.py delete mode 100644 services/backend/apps/user/migrations/0002_user_status.py delete mode 100644 services/backend/apps/user/migrations/0003_alter_user_status.py diff --git a/services/backend/api/v1/review/__init__.py b/services/backend/api/v1/review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/review/auth.py b/services/backend/api/v1/review/auth.py new file mode 100644 index 0000000..9fbd270 --- /dev/null +++ b/services/backend/api/v1/review/auth.py @@ -0,0 +1,26 @@ +from abc import ABC +from typing import Optional + +from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from django.urls import resolve +from ninja.errors import AuthenticationError +from ninja.security import APIKeyQuery +from ninja.security.apikey import APIKeyBase + +from apps.review.models import Reviewer + +class APIKeyPath(APIKeyBase, ABC): + openapi_in: str = "path" + + def _get_key(self, request: HttpRequest) -> Optional[str]: + func, args, kwargs = resolve(request.path) + return kwargs.get(self.param_name) + +class ReviewerAuth(APIKeyPath): + param_name = "token" + + def authenticate(self, request, token): + if not (reviewer := Reviewer.objects.filter(token=token).first()): + raise AuthenticationError + return reviewer \ No newline at end of file diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py new file mode 100644 index 0000000..824e62d --- /dev/null +++ b/services/backend/api/v1/review/schemas.py @@ -0,0 +1,37 @@ +from typing import List, Literal +from uuid import UUID + +from django.http import HttpRequest +from ninja import Schema, ModelSchema + +from apps.review.models import Reviewer +from apps.task.models import CompetetionTaskSumbission + + +class PingOut(Schema): + status: str = "ok" + +class ReviewerOut(ModelSchema): + id: UUID + + class Meta: + model = Reviewer + exclude = ("token",) + +class SubmissionOut(ModelSchema): + id: UUID + status: Literal["sent", "checking", "checked"] + + class Meta: + model = CompetetionTaskSumbission + exclude = ( + "user", + ) + +class SubmissionsOut(Schema): + submissions: list[SubmissionOut] = [] + + @staticmethod + def resolve_submissions(self, context: HttpRequest) -> List[SubmissionOut]: + print(CompetetionTaskSumbission.objects.all()) + return list(CompetetionTaskSumbission.objects.all()) \ No newline at end of file diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py new file mode 100644 index 0000000..b3e1bdf --- /dev/null +++ b/services/backend/api/v1/review/views.py @@ -0,0 +1,34 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest +from ninja import Router + +from api.v1.review import schemas +from api.v1 import schemas as global_schemas + +router = Router(tags=["review"]) + + +@router.get( + "{token}/tasks", + response={ + status.OK: schemas.SubmissionsOut, + }, +) +def ping(request: HttpRequest, token) -> tuple[status, schemas.SubmissionsOut]: + return status.OK, schemas.SubmissionsOut() + + +@router.get( + "{token}", + response={ + status.OK: schemas.ReviewerOut, + status.UNAUTHORIZED: global_schemas.UnauthorizedError + }, + description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query" +) +def get_reviewer( + request: HttpRequest, + token: str +): + return status.OK, request.auth \ No newline at end of file diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 1ad7924..e85570a 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -6,7 +6,9 @@ from api.v1 import handlers from api.v1.auth import BearerAuth from api.v1.competition.views import router as competition_router from api.v1.ping.views import router as ping_router +from api.v1.review.auth import ReviewerAuth from api.v1.user.views import router as user_router +from api.v1.review.views import router as review_router router = NinjaAPI( title="DataRush API", @@ -30,6 +32,12 @@ router.add_router( competition_router, auth=BearerAuth(), ) +router.add_router( + "review", + review_router, + auth=ReviewerAuth(), +) + for exception, handler in handlers.exception_handlers: diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py deleted file mode 100644 index dd16963..0000000 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 21:27 - -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Competition', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('title', models.CharField(max_length=100, verbose_name='Название')), - ('description', models.TextField(verbose_name='Описание')), - ('image_url', models.FileField(blank=True, null=True, upload_to='', verbose_name='Изображение соревнования')), - ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), - ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), - ('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), - ('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), - ], - options={ - 'verbose_name': 'соревнование', - 'verbose_name_plural': 'соревнования', - }, - ), - ] diff --git a/services/backend/apps/competition/migrations/0002_competition_participants.py b/services/backend/apps/competition/migrations/0002_competition_participants.py deleted file mode 100644 index a15dafd..0000000 --- a/services/backend/apps/competition/migrations/0002_competition_participants.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0001_initial'), - ('user', '0002_user_status'), - ] - - operations = [ - migrations.AddField( - model_name='competition', - name='participants', - field=models.ManyToManyField(related_name='participants', to='user.user'), - ), - ] diff --git a/services/backend/apps/competition/migrations/0003_state.py b/services/backend/apps/competition/migrations/0003_state.py deleted file mode 100644 index 2212552..0000000 --- a/services/backend/apps/competition/migrations/0003_state.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 23:26 - -import django.db.models.deletion -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0002_competition_participants'), - ('user', '0003_alter_user_status'), - ] - - operations = [ - migrations.CreateModel( - name='State', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)), - ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/services/backend/apps/review/__init__.py b/services/backend/apps/review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/review/apps.py b/services/backend/apps/review/apps.py new file mode 100644 index 0000000..fc4d048 --- /dev/null +++ b/services/backend/apps/review/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "apps.review" + label = "review" diff --git a/services/backend/apps/review/migrations/__init__.py b/services/backend/apps/review/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py new file mode 100644 index 0000000..58bd512 --- /dev/null +++ b/services/backend/apps/review/models.py @@ -0,0 +1,10 @@ +from django.db import models + +from apps.core.models import BaseModel + + +class Reviewer(BaseModel): + name = models.CharField(max_length=100) + surname = models.CharField(max_length=100) + + token = models.CharField(max_length=100) \ No newline at end of file diff --git a/services/backend/apps/task/migrations/__init__.py b/services/backend/apps/task/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index eee9f4f..e7adf8a 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -1,11 +1,12 @@ +from random import choice from uuid import uuid4 -from competition.models import Competition -from core.models import BaseModel from django.db import models from apps.task.validators import ContestTaskCriteriesValidator - +from apps.competition.models import Competition +from apps.core.models import BaseModel +from apps.user.models import User class CompetitionTask(BaseModel): class CompetitionTaskType(models.TextChoices): @@ -14,12 +15,12 @@ class CompetitionTask(BaseModel): REVIEW = "review" def answer_file_upload_to(instance, filename) -> str: - return f"/tasks/{instance.id}/answer/{uuid4}" + return f"/tasks/{instance.id}/answer/{uuid4()}/filename" competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.TextField(verbose_name="заголовок", max_length=50) description = models.TextField(verbose_name="описание", max_length=300) - type = models.CharField(choices=CompetitionTaskType) + type = models.CharField(choices=CompetitionTaskType, max_length=8) # only when "input" or "checker" type correct_answer_file = models.FileField(upload_to=answer_file_upload_to) @@ -32,3 +33,26 @@ class CompetitionTask(BaseModel): def clean(self): ContestTaskCriteriesValidator()(self) + + +class CompetetionTaskSumbission(BaseModel): + class StatusChoices(models.TextChoices): + SENT = "sent" + CHECKING = "checking" + CHECKED = "checked" + + def submission_content_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/content" + + def submission_stdout_upload_to(instance, filename) -> str: + return f"/submissions/{instance.id}/stdout" + + status = models.CharField( + choices=StatusChoices.choices, default=StatusChoices.SENT, max_length=8 + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) + content = models.FileField(upload_to=submission_content_upload_to) + stdout = models.FileField(upload_to=submission_stdout_upload_to) + result = models.JSONField(default={}) + timestamp = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py deleted file mode 100644 index 41491e9..0000000 --- a/services/backend/apps/user/migrations/0001_initial.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 20:46 - -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), - ('username', models.SlugField(unique=True, verbose_name='Юзернейм')), - ('password', models.TextField(verbose_name='Пароль')), - ], - options={ - 'verbose_name': 'пользователь', - 'verbose_name_plural': 'пользователи', - }, - ), - ] diff --git a/services/backend/apps/user/migrations/0002_user_status.py b/services/backend/apps/user/migrations/0002_user_status.py deleted file mode 100644 index 281d8fd..0000000 --- a/services/backend/apps/user/migrations/0002_user_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='status', - field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), - ), - ] diff --git a/services/backend/apps/user/migrations/0003_alter_user_status.py b/services/backend/apps/user/migrations/0003_alter_user_status.py deleted file mode 100644 index a7c766f..0000000 --- a/services/backend/apps/user/migrations/0003_alter_user_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 22:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0002_user_status'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='status', - field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), - ), - ] diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index f4f2ba2..af79c52 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -445,6 +445,8 @@ INSTALLED_APPS = [ "apps.core", "apps.user", "apps.competition", + "apps.review", + "apps.task" ] # GUID From 9e6a1a6bfeb4ecd9dc863193464114768e1ad756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 1 Mar 2025 13:12:38 +0300 Subject: [PATCH 13/13] added migrations --- .../competition/migrations/0001_initial.py | 47 +++++++++++++++++ .../apps/review/migrations/0001_initial.py | 27 ++++++++++ .../apps/task/migrations/0001_initial.py | 51 +++++++++++++++++++ .../apps/user/migrations/0001_initial.py | 29 +++++++++++ 4 files changed, 154 insertions(+) create mode 100644 services/backend/apps/competition/migrations/0001_initial.py create mode 100644 services/backend/apps/review/migrations/0001_initial.py create mode 100644 services/backend/apps/task/migrations/0001_initial.py create mode 100644 services/backend/apps/user/migrations/0001_initial.py diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py new file mode 100644 index 0000000..2699fe9 --- /dev/null +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:47 + +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='Competition', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(max_length=100, verbose_name='Название')), + ('description', models.TextField(verbose_name='Описание')), + ('image_url', models.FileField(blank=True, null=True, upload_to='', verbose_name='Изображение соревнования')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), + ('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), + ('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), + ('participants', models.ManyToManyField(related_name='participants', to='user.user')), + ], + options={ + 'verbose_name': 'соревнование', + 'verbose_name_plural': 'соревнования', + }, + ), + migrations.CreateModel( + name='State', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py new file mode 100644 index 0000000..ceed39d --- /dev/null +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:47 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Reviewer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('surname', models.CharField(max_length=100)), + ('token', models.CharField(max_length=100)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py new file mode 100644 index 0000000..5549424 --- /dev/null +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.1.6 on 2025-03-01 09:42 + +import apps.task.models +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competition', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CompetitionTask', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.TextField(max_length=50, verbose_name='заголовок')), + ('description', models.TextField(max_length=300, verbose_name='описание')), + ('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)), + ('correct_answer_file', models.FileField(upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)), + ('answer_file_path', models.TextField()), + ('criteries', models.JSONField(blank=True, null=True)), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CompetetionTaskSumbission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), + ('content', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_content_upload_to)), + ('stdout', models.FileField(upload_to=apps.task.models.CompetetionTaskSumbission.submission_stdout_upload_to)), + ('result', models.JSONField(default={})), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py new file mode 100644 index 0000000..6fb8be0 --- /dev/null +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.6 on 2025-03-01 08:47 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), + ('username', models.SlugField(unique=True, verbose_name='Юзернейм')), + ('password', models.TextField(verbose_name='Пароль')), + ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), + ], + options={ + 'verbose_name': 'пользователь', + 'verbose_name_plural': 'пользователи', + }, + ), + ]