diff --git a/README.md b/README.md index 6c44ca1..3659497 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ # DataRush + +Инновационный сервис для проведения соревнований по анализу данных + + +## Запуск + +Склонируйте репозиторий и пропишите + +```bash +docker compose up +``` + +## Основные ручки + +* `/` - основное приложение +* `/api/v1/docs` - swagger-ui документация +* `/admin` - админка +* `/admin/grafana` - графана +* `/docs` - гайд по анализу данных + +После запуска по методу выше создается пользователь в админке (`/admin`) с данными ниже:`admin` +- `admin` - логин +- `proooooood` - пароль + + +## Тесты + +Написаны unit-тесты (на базе Django TestCase) и E2E (Postman коллекция) + +![Postman data](img/postman.gif) + +![django test]() diff --git a/img/postman.gif b/img/postman.gif new file mode 100644 index 0000000..7f62348 Binary files /dev/null and b/img/postman.gif differ diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index a203f22..95f2fb4 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -67,3 +67,4 @@ class TaskAttachmentSchema(ModelSchema): class TaskStatusSchema(Schema): task_name: str result: int + max_points: int diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index e0f83cc..f9e2d53 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -196,14 +196,21 @@ def get_competition_results(request, competition_id: UUID): for task in tasks: submissions = CompetitionTaskSubmission.objects.filter( user=request.auth, task=task - ).filter(status="checked").all() + ).filter(status="checked").order_by("-earned_points").all() if not submissions: - result = 0 + all_submissions_count = CompetitionTaskSubmission.objects.filter( + user=request.auth, task=task + ).count() + if all_submissions_count == 0: + result = -2 + else: + result = -1 else: result = submissions[0].earned_points data.append(TaskStatusSchema( task_name=task.title, - result=result + result=result, + max_points=task.points, )) return status.OK, data diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index b68ecf2..0b866f9 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -21,12 +21,12 @@ def analyze_data_task(self, submission_id): { "url": ( f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}/" - f"{urlparse(submission.content.url).path}" + f"{urlparse(attachment.file.url).path}" ), "bind_path": attachment.bind_at, } for attachment in submission.task.attachments.filter( - bind_path__isnull=False + bind_at__isnull=False ) ] @@ -37,7 +37,7 @@ def analyze_data_task(self, submission_id): "code_url": code_url, "answer_file_path": submission.task.answer_file_path, "expected_hash": hashlib.sha256( - submission.task.correct_answer_file.read().encode() + submission.task.correct_answer_file.read() ).hexdigest(), }, timeout=30, diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index 3448773..97110af 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -5,6 +5,7 @@ "name": "frontend", "dependencies": { "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", @@ -154,8 +155,12 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collapsible": "1.1.3", "@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-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-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.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-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.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-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@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-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@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-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="], "@radix-ui/react-compose-refs": ["@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-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 98bd7bf..ebb93cd 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/services/frontend/src/components/layout/header.tsx b/services/frontend/src/components/layout/header.tsx index 27b0421..ae5a16d 100644 --- a/services/frontend/src/components/layout/header.tsx +++ b/services/frontend/src/components/layout/header.tsx @@ -1,5 +1,5 @@ import { DataRush } from "@/components/ui/icons/datarush"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, FileText } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { useUserStore } from "@/shared/stores/user"; import { @@ -14,6 +14,13 @@ import { removeToken } from "@/shared/token"; export const Header = () => { const navigate = useNavigate(); const user = useUserStore((state) => state.user); + const clearUser = useUserStore((state) => state.clearUser); + + const handleLogout = () => { + removeToken(); + clearUser(); + navigate("/login"); + }; return (
@@ -21,33 +28,39 @@ export const Header = () => { - - - - - - - Аккаунт - - - - { - removeToken(); - navigate("/login"); - }} - > - Выйти - - - + +
+ + + + + + + + + + + Аккаунт + + + + Выйти + + + +
); -}; +}; \ No newline at end of file diff --git a/services/frontend/src/components/ui/accordion.tsx b/services/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..8ad3ccc --- /dev/null +++ b/services/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/shared/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index fd8ec50..42ea9c4 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useParams, Navigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { useParams, Navigate, useNavigate } from "react-router-dom"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; @@ -13,8 +13,10 @@ const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const [answer, setAnswer] = useState(""); const [selectedFile, setSelectedFile] = useState(null); + const [isReloading, setIsReloading] = useState(false); const competitionId = id || ""; const queryClient = useQueryClient(); + const navigate = useNavigate(); const competitionQuery = useQuery({ queryKey: ["competition", competitionId], @@ -45,8 +47,12 @@ const CompetitionSession = () => { queryKey: ['solutionHistory', competitionId, taskId] }); - setAnswer(""); - setSelectedFile(null); + setIsReloading(true); + + setTimeout(() => { + window.location.reload() + setIsReloading(false); + }, 2500); }, onError: (error) => { console.error("Error submitting solution:", error); @@ -89,6 +95,13 @@ const CompetitionSession = () => { const competitionTitle = competition?.title || "Загрузка соревнования..."; + useEffect(() => { + setAnswer(""); + setSelectedFile(null); + }, [taskId]); + + const isSubmitting = submitMutation.isPending || isReloading; + return (
{ selectedFile={selectedFile} setSelectedFile={setSelectedFile} onSubmit={handleSubmit} - isSubmitting={submitMutation.isPending} + isSubmitting={isSubmitting} />
) : ( diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index cd7906b..049345b 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -27,7 +27,7 @@ const TaskSolution: React.FC = ({ selectedFile, setSelectedFile, onSubmit, - isSubmitting = false + isSubmitting = false, }) => { const fileInputRef = useRef(null); const [isHistoryOpen, setIsHistoryOpen] = useState(false); @@ -70,14 +70,6 @@ const TaskSolution: React.FC = ({ } }, [task.id, solutionHistory]); - // useEffect(() => { - // if (solutionHistory.length > 0 && - // (!displayedSolution || - // (solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id))) { - // setDisplayedSolution(solutionHistory[solutionHistory.length - 1]); - // } - // }, [solutionHistory, displayedSolution]); - useEffect(() => { const loadSolutionContent = async () => { if (!displayedSolution || !displayedSolution.content) return; @@ -122,9 +114,6 @@ const TaskSolution: React.FC = ({
{displayedSolution ? ( <> -
- Результат последней посылки: -
) : ( diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index 1c43e18..2880692 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -3,7 +3,11 @@ import { CompetitionGrid } from "./modules/CompetitionsGrid"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { getCompetitions } from "@/shared/api/competitions"; -import { NoCompetitions } from "./modules/NoCompetitions"; +import { + NoActiveCompetitions, + NoCompetitions, + NoCompletedCompetitions, +} from "./modules/NoCompetitions"; import { TabsContent } from "@radix-ui/react-tabs"; import { Loading } from "@/components/ui/loading"; import { CompetitionState } from "@/shared/types/competition"; @@ -54,8 +58,8 @@ const CompetitionsPage = () => { return (
{(activeCompetitionsQuery.data ?? []).length > 0 && ( -
- + +
Мои события @@ -70,14 +74,22 @@ const CompetitionsPage = () => { - + {startedCompetitions.length > 0 ? ( + + ) : ( + + )} - + {finishedCompetitions.length > 0 ? ( + + ) : ( + + )} - -
+
+ )}
diff --git a/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx index 8b71193..a82a112 100644 --- a/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx +++ b/services/frontend/src/pages/Competitions/modules/NoCompetitions.tsx @@ -13,3 +13,27 @@ export const NoCompetitions = () => {
); }; + +export const NoActiveCompetitions = () => { + return ( +
+ +
+

Нет активных событий

+

Начните новое

+
+
+ ); +}; + +export const NoCompletedCompetitions = () => { + return ( +
+ +
+

Завершенных событий нет

+

Завершите начатое

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Profile/index.tsx b/services/frontend/src/pages/Profile/index.tsx index d4b20c4..75cd2ff 100644 --- a/services/frontend/src/pages/Profile/index.tsx +++ b/services/frontend/src/pages/Profile/index.tsx @@ -1,4 +1,3 @@ -import { User } from "@/shared/types/user"; import { UserInfo } from "./widgets/user-info"; import { UserAchievements } from "./widgets/user-achievements"; import { UserStats } from "./widgets/user-stats"; diff --git a/services/frontend/src/pages/Review/modules/review-header.tsx b/services/frontend/src/pages/Review/modules/review-header.tsx index d27e9e8..a7e14c5 100644 --- a/services/frontend/src/pages/Review/modules/review-header.tsx +++ b/services/frontend/src/pages/Review/modules/review-header.tsx @@ -1,13 +1,22 @@ import { buttonVariants } from "@/components/ui/button"; import { DataRushReview } from "@/components/ui/icons/datarush-review"; import { Reviewer } from "@/shared/types/review"; -import { Link } from "react-router"; +import { useUserStore } from "@/shared/stores/user"; +import { useNavigate } from "react-router-dom"; interface ReviewHeaderProps { reviewer: Reviewer; } export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => { + const clearUser = useUserStore((state) => state.clearUser); + const navigate = useNavigate(); + + const handleLogout = () => { + clearUser(); + navigate("/"); + }; + return (
@@ -15,13 +24,13 @@ export const ReviewHeader = ({ reviewer }: ReviewHeaderProps) => {

{reviewer.name} {reviewer.surname}

- Выйти - +
); -}; +}; \ No newline at end of file diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts index 3add5dc..2c96f4c 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -1,5 +1,5 @@ import { userFetch } from "."; -import { Competition } from "../types/competition"; +import { Competition, CompetitionResult } from "../types/competition"; export const getCompetitions = async (participating?: boolean) => { return await userFetch("/competitions", { @@ -13,6 +13,10 @@ export const getCompetition = async (id: string) => { return await userFetch(`/competitions/${id}`); }; +export const getCompetitionResults = async (id: string) => { + return await userFetch(`/competitions/${id}/results`); +} + export const startCompetition = async (competitionId: string) => { return await userFetch(`/competitions/${competitionId}/start`, { method: "POST", diff --git a/services/frontend/src/shared/types/competition.ts b/services/frontend/src/shared/types/competition.ts index beea20e..cff4cd9 100644 --- a/services/frontend/src/shared/types/competition.ts +++ b/services/frontend/src/shared/types/competition.ts @@ -24,3 +24,8 @@ export enum CompetitionType { export enum CompetitionParticipationType { SOLO = "solo", } + +export interface CompetitionResult { + task_name: string; + result: number; +} \ No newline at end of file