From 823bb76ee90bfdb6d4e76d02ea4602471c5dfd07 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 20:07:43 +0900 Subject: [PATCH 01/40] feat: started working on events fetch --- .../frontend/src/pages/Competitions/index.tsx | 74 +++++++++++++---- .../frontend/src/shared/api/competitions.ts | 83 +++++++++++++++++++ services/frontend/src/shared/types.ts | 10 ++- 3 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 services/frontend/src/shared/api/competitions.ts diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index cd09103..97aa23e 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -1,18 +1,35 @@ -import { useState } from "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 { mockCompetitions } from "@/shared/mocks/mocks"; +import { Loader2 } from "lucide-react"; +import { getAllCompetitions } from "@/shared/api/competitions"; const CompetitionsPage = () => { - const [competitions] = useState(mockCompetitions); + const [myCompetitions, setMyCompetitions] = useState([]); + const [availableCompetitions, setAvailableCompetitions] = useState([]); const [activeTab, setActiveTab] = useState("ongoing"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const myCompetitions = competitions.filter( - (comp) => - comp.status === CompetitionStatus.InProgress || - comp.status === CompetitionStatus.Completed, - ); + useEffect(() => { + const fetchCompetitions = async () => { + try { + setLoading(true); + const { participating, nonParticipating } = await getAllCompetitions(); + setMyCompetitions(participating); + setAvailableCompetitions(nonParticipating); + setError(null); + } catch (err) { + console.error("Failed to fetch competitions:", err); + setError("Не удалось загрузить события. Пожалуйста, попробуйте позже."); + } finally { + setLoading(false); + } + }; + + fetchCompetitions(); + }, []); const filteredMyCompetitions = myCompetitions.filter((comp) => activeTab === "ongoing" @@ -20,9 +37,22 @@ const CompetitionsPage = () => { : comp.status === CompetitionStatus.Completed, ); - const availableCompetitions = competitions.filter( - (comp) => comp.status === "Не участвую", - ); + if (loading) { + return ( +
+ +

Загрузка событий...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } return (
@@ -36,14 +66,22 @@ const CompetitionsPage = () => { - + {filteredMyCompetitions.length > 0 ? ( + + ) : ( + + )}
События - + {availableCompetitions.length > 0 ? ( + + ) : ( + + )}
); @@ -65,4 +103,12 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => { return

{children}

; }; -export default CompetitionsPage; +const EmptyState = ({ message }: { message: string }) => { + return ( +
+

{message}

+
+ ); +}; + +export default CompetitionsPage; \ No newline at end of file diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts new file mode 100644 index 0000000..6ba02ad --- /dev/null +++ b/services/frontend/src/shared/api/competitions.ts @@ -0,0 +1,83 @@ +import { apiFetch } from '.'; +import { Competition, CompetitionStatus, ParticipationType } from '@/shared/types'; + +interface ApiCompetition { + id: string; + state: 'started' | 'not_started' | 'finished'; + title: string; + description: string; + image_url: string | null; + end_date: string; + start_date: string; + type: string; + participation_type: ParticipationType; +} + +const mapStateToStatus = (state: string, isParticipating: boolean): CompetitionStatus => { + if (!isParticipating) { + return CompetitionStatus.NotParticipating; + } + + switch (state) { + case 'started': + return CompetitionStatus.InProgress; + case 'finished': + return CompetitionStatus.Completed; + case 'not_started': + return CompetitionStatus.InProgress; + default: + return CompetitionStatus.NotParticipating; + } +}; + +const transformApiCompetition = (apiComp: ApiCompetition, isParticipating: boolean): Competition => { + return { + id: apiComp.id, + name: apiComp.title, + imageUrl: apiComp.image_url || '/DANO.png', + isOlympics: apiComp.type !== 'edu', + status: mapStateToStatus(apiComp.state, isParticipating), + description: apiComp.description, + startDate: new Date(apiComp.start_date), + endDate: new Date(apiComp.end_date), + participationType: apiComp.participation_type + }; +}; + +export const getParticipatingCompetitions = async (): Promise => { + try { + const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', { + query: { is_participating: true } + }); + + return apiCompetitions.map(comp => transformApiCompetition(comp, true)); + } catch (error) { + console.error('Failed to fetch participating competitions:', error); + throw error; + } +}; + +export const getNonParticipatingCompetitions = async (): Promise => { + try { + const apiCompetitions: ApiCompetition[] = await apiFetch('/api/v1/competitions', { + query: { is_participating: false } + }); + + return apiCompetitions.map(comp => transformApiCompetition(comp, false)); + } catch (error) { + console.error('Failed to fetch non-participating competitions:', error); + throw error; + } +}; + +export const getAllCompetitions = async (): Promise<{ + participating: Competition[]; + nonParticipating: Competition[]; +}> => { + const [participating, nonParticipating] = await Promise.all([ + getParticipatingCompetitions(), + getNonParticipatingCompetitions() + ]); + + return { participating, nonParticipating }; +}; \ No newline at end of file diff --git a/services/frontend/src/shared/types.ts b/services/frontend/src/shared/types.ts index a67e044..083614d 100644 --- a/services/frontend/src/shared/types.ts +++ b/services/frontend/src/shared/types.ts @@ -12,6 +12,11 @@ enum TaskStatus { Wrong = "wrong" } +enum ParticipationType { + Solo = "solo", + Team = "team" +} + interface Competition { id: string; name: string; @@ -19,6 +24,9 @@ interface Competition { isOlympics: boolean; status: CompetitionStatus; description?: string; + startDate: Date; + endDate: Date; + participationType: ParticipationType } type SolutionType = "input" | "file" | "code"; @@ -42,5 +50,5 @@ interface Task { attachments?: string[]; } -export { CompetitionStatus, TaskStatus }; +export { CompetitionStatus, TaskStatus, ParticipationType }; export type { Solution, Competition, Task }; From 8dd0b514710108d4531180d420f5f28d32d81e6a Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 20:17:03 +0900 Subject: [PATCH 02/40] fix: build errors --- services/frontend/src/pages/Review/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index 4d21663..233b3c0 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -1,4 +1,4 @@ -import { Loading } from "@/components/ui/Loading"; +import { Loading } from "@/components/ui/loading"; import { getReviewer, getReviewerSubmissions } from "@/shared/api/review"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; From 74d0c9ac4a53f7e6d48bf5c1e3f10be8f97ec79b Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 20:54:12 +0900 Subject: [PATCH 03/40] placeholder check --- .../components/CompetitionCard/index.tsx | 2 +- services/frontend/src/pages/Competitions/index.tsx | 7 ------- services/frontend/src/shared/types/task.ts | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 services/frontend/src/shared/types/task.ts diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx index 8ae52eb..2298b9f 100644 --- a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx +++ b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx @@ -22,7 +22,7 @@ export function CompetitionCard({
{competition.image_url && ( {competition.title} diff --git a/services/frontend/src/pages/Competitions/index.tsx b/services/frontend/src/pages/Competitions/index.tsx index 511ac15..e818fa2 100644 --- a/services/frontend/src/pages/Competitions/index.tsx +++ b/services/frontend/src/pages/Competitions/index.tsx @@ -112,12 +112,5 @@ const SectionTitle = ({ children }: { children: React.ReactNode }) => { return

{children}

; }; -const EmptyState = ({ message }: { message: string }) => { - return ( -
-

{message}

-
- ); -}; export default CompetitionsPage; \ No newline at end of file diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts new file mode 100644 index 0000000..6de248b --- /dev/null +++ b/services/frontend/src/shared/types/task.ts @@ -0,0 +1,14 @@ +export interface Task { + id: string; + title: string; + description: string; + type: 'input' | 'file' | 'code'; + in_competition_position: number; + points: number; +} + +enum TaskType { + INPUT = "input", + FILE = "file", + CODE = "code", +} From 2d961ea4e2de6159c1f28455f859374e17ff1dfe Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 20:59:58 +0900 Subject: [PATCH 04/40] placeholder check --- .../frontend/src/pages/Competition/index.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index b434fa8..290e979 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -35,6 +35,7 @@ const CompetitionPage = () => { } }; + console.log(competition) return (
{
- {competition.image_url && ( -
- {competition.title} -
- )} +
+ {competition.title} +
@@ -67,7 +66,7 @@ const CompetitionPage = () => {
From 710b40dfe1acb6346ecdb2fdcd3dbda35740adf8 Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:10:54 +0300 Subject: [PATCH 05/40] add achievement app --- services/backend/apps/achievement/__init__.py | 0 services/backend/apps/achievement/admin.py | 9 +++ services/backend/apps/achievement/apps.py | 7 ++ .../achievement/migrations/0001_initial.py | 29 ++++++++ .../apps/achievement/migrations/__init__.py | 0 services/backend/apps/achievement/models.py | 22 ++++++ ...petitiontasksubmission_options_and_more.py | 71 +++++++++++++++++++ services/backend/config/settings.py | 1 + 8 files changed, 139 insertions(+) create mode 100644 services/backend/apps/achievement/__init__.py create mode 100644 services/backend/apps/achievement/admin.py create mode 100644 services/backend/apps/achievement/apps.py create mode 100644 services/backend/apps/achievement/migrations/0001_initial.py create mode 100644 services/backend/apps/achievement/migrations/__init__.py create mode 100644 services/backend/apps/achievement/models.py create mode 100644 services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py diff --git a/services/backend/apps/achievement/__init__.py b/services/backend/apps/achievement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/achievement/admin.py b/services/backend/apps/achievement/admin.py new file mode 100644 index 0000000..4657244 --- /dev/null +++ b/services/backend/apps/achievement/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from apps.achievement.models import Achievement + + +@admin.register(Achievement) +class AchievementAdmin(admin.ModelAdmin): + list_display = ("id", "name",) + search_fields = ("name", "description",) diff --git a/services/backend/apps/achievement/apps.py b/services/backend/apps/achievement/apps.py new file mode 100644 index 0000000..2e3e606 --- /dev/null +++ b/services/backend/apps/achievement/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AchievementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.achievement' + verbose_name = "Ачивки" diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py new file mode 100644 index 0000000..b20fb21 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:09 + +import apps.achievement.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Achievement', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=30, unique=True, verbose_name='название')), + ('description', models.TextField(verbose_name='описание')), + ('icon', models.FileField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')), + ], + options={ + 'verbose_name': 'ачивка', + 'verbose_name_plural': 'ачивки', + }, + ), + ] diff --git a/services/backend/apps/achievement/migrations/__init__.py b/services/backend/apps/achievement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py new file mode 100644 index 0000000..1e0460a --- /dev/null +++ b/services/backend/apps/achievement/models.py @@ -0,0 +1,22 @@ +from django.db import models + +from apps.core.models import BaseModel + +class Achievement(BaseModel): + def image_url_upload_to(instance, filename): + return f"/achievements/{instance.id}/icon" + + name = models.CharField(max_length=30, verbose_name="название", + unique=True) + description = models.TextField(verbose_name="описание") + icon = models.FileField( + verbose_name="иконка достижения", + upload_to=image_url_upload_to, + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "ачивка" + verbose_name_plural = "ачивки" diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py new file mode 100644 index 0000000..9cc1672 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:09 + +import apps.task.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('review', '0002_initial'), + ('task', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='competitiontasksubmission', + options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'}, + ), + migrations.AlterField( + model_name='competitiontask', + name='reviewers', + field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='checked_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='content', + field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='earned_points', + field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='plagiarism_checked', + field=models.BooleanField(default=False, verbose_name='проверено на плагиат'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='result', + field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='stdout', + field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'), + ), + migrations.AlterField( + model_name='competitiontasksubmission', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'), + ), + ] diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 6bf014b..63bc06a 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -449,6 +449,7 @@ INSTALLED_APPS = [ "apps.review", "apps.task", "apps.team", + "apps.achievement", ] # tinymce From 4e8f931e7d90d86da0d4d709a9e76f4ebcb2131f Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 21:12:53 +0900 Subject: [PATCH 06/40] test: competition start --- .../frontend/src/pages/Competition/index.tsx | 81 +++++++++----- .../frontend/src/shared/api/competitions.ts | 6 ++ services/frontend/src/shared/api/session.ts | 101 +++++------------- 3 files changed, 82 insertions(+), 106 deletions(-) diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index 290e979..fe37509 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -2,40 +2,57 @@ import { useParams, Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; import ReactMarkdown from "react-markdown"; -import { mockTasks } from "@/shared/mocks/mocks"; -import { useQuery } from "@tanstack/react-query"; -import { getCompetition } from "@/shared/api/competitions"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { getCompetition, startCompetition } from "@/shared/api/competitions"; +import { getCompetitionTasks } from "@/shared/api/session"; import { Loading } from "@/components/ui/loading"; const CompetitionPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const competitionId = id || ""; - const { data: competition, isLoading } = useQuery({ - queryKey: ["competition", id], - queryFn: async () => getCompetition(id || ""), + const competitionQuery = useQuery({ + queryKey: ["competition", competitionId], + queryFn: () => getCompetition(competitionId), + enabled: !!competitionId, }); - if (isLoading) { + const startMutation = useMutation({ + mutationFn: () => startCompetition(competitionId), + onSuccess: async () => { + try { + const tasks = await getCompetitionTasks(competitionId); + + if (tasks && tasks.length > 0) { + navigate(`/competition/${competitionId}/tasks/${tasks[0].id}`); + } else { + navigate(`/competition/${competitionId}/tasks`); + } + } catch (error) { + console.error("Failed to fetch tasks:", error); + navigate(`/competition/${competitionId}/tasks`); + } + }, + onError: (error) => { + console.error("Failed to start competition:", error); + } + }); + + const handleStart = () => { + startMutation.mutate(); + }; + + if (competitionQuery.isLoading) { return ; } - if (!id || !competition) { + if (!competitionId || !competitionQuery.data) { return <>; } - const handleContinue = () => { - if (competition?.id) { - if (mockTasks && mockTasks.length > 0) { - const firstTaskId = mockTasks[0].id; - navigate(`/competition/${competition.id}/tasks/${firstTaskId}`); - } else { - navigate(`/competition/${competition.id}/tasks`); - } - } - }; + const competition = competitionQuery.data; - console.log(competition) return (
{
-
- {competition.title} -
+ {competition.image_url && ( +
+ {competition.title} +
+ )}
@@ -65,8 +84,12 @@ const CompetitionPage = () => {
-
@@ -75,4 +98,4 @@ const CompetitionPage = () => { ); }; -export default CompetitionPage; +export default CompetitionPage; \ 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 5a5eba0..8c47564 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -12,3 +12,9 @@ export const getCompetitions = async (participating?: boolean) => { export const getCompetition = async (id: string) => { return await userFetch(`/competition/${id}`); }; + +export const startCompetition = async (competitionId: string) => { + return await userFetch(`/competitions/${competitionId}/start`, { + method: 'POST' + }); +}; \ No newline at end of file diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts index 564e91a..0cc6383 100644 --- a/services/frontend/src/shared/api/session.ts +++ b/services/frontend/src/shared/api/session.ts @@ -1,82 +1,29 @@ -import { apiFetch } from './index'; -import { Task, TaskStatus } from '@/shared/types'; +import { userFetch } from "."; +import { Task } from "../types/task"; -interface ApiTask { - id: string; - title: string; - description: string; - type: 'input' | 'file' | 'code'; - in_competition_position: number; - points: number; - status?: TaskStatus; -} - -/** - * Fetches tasks for a specific competition - * @param competitionId - The ID of the competition - * @returns Promise with an array of tasks in the application's format - */ -export const getCompetitionTasks = async (competitionId: string): Promise => { - try { - const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`); - - // Transform API tasks to application Task format - return apiTasks.map(apiTask => transformApiTask(apiTask)); - } catch (error) { - console.error('Failed to fetch competition tasks:', error); - throw error; - } +export const getCompetitionTasks = async (competitionId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks`); }; -/** - * Transforms an API task to the application's Task format - */ -const transformApiTask = (apiTask: ApiTask): Task => { - return { - id: apiTask.id, - number: String(apiTask.in_competition_position), - status: apiTask.status || TaskStatus.Uncleared, - solutionType: apiTask.type, - description: apiTask.description, - maxScore: apiTask.points - }; -}; - - -// export const submitTaskSolution = async ( -// competitionId: string, -// taskId: string, -// solution: string | File -// ): Promise => { -// const endpoint = `/api/v1/competitions/${competitionId}/tasks/${taskId}/submit`; +export const submitTaskSolution = async ( + competitionId: string, + taskId: string, + solution: string | File +) => { + const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`; -// // Handle different solution types -// if (typeof solution === 'string') { -// // Text or code solution -// await apiFetch(endpoint, { -// method: 'POST', -// body: { answer: solution } -// }); -// } else { -// // File solution -// const formData = new FormData(); -// formData.append('file', solution); + if (typeof solution === 'string') { + return await userFetch(endpoint, { + method: 'POST', + body: { answer: solution } + }); + } else { + const formData = new FormData(); + formData.append('file', solution); -// await apiFetch(endpoint, { -// method: 'POST', -// body: formData -// }); -// } -// }; - -/** - * Gets the status of a task submission - * This would be used to poll for updates after submission - */ -// export const getTaskSubmissionStatus = async ( -// competitionId: string, -// taskId: string -// ): Promise => { -// const response = await apiFetch(`/api/v1/competitions/${competitionId}/tasks/${taskId}/status`); -// return response.status; -// }; \ No newline at end of file + return await userFetch(endpoint, { + method: 'POST', + body: formData + }); + } +}; \ No newline at end of file From f64419d2f07c051faca56536d37e73406b679b28 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 21:14:50 +0900 Subject: [PATCH 07/40] fix: placeholder added --- .../components/CompetitionCard/index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx index 2298b9f..67e8950 100644 --- a/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx +++ b/services/frontend/src/pages/Competitions/components/CompetitionCard/index.tsx @@ -20,13 +20,11 @@ export function CompetitionCard({ className={cn("aspect-square h-full w-auto overflow-hidden", className)} >
- {competition.image_url && ( - {competition.title} - )} + {competition.title}
From b631b09648e607d60e43b1cf4a266d61d57676ea Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:17:22 +0300 Subject: [PATCH 08/40] add endpoint for get all achievements --- services/backend/api/v1/achievement/__init__.py | 0 services/backend/api/v1/achievement/schemas.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 services/backend/api/v1/achievement/__init__.py create mode 100644 services/backend/api/v1/achievement/schemas.py diff --git a/services/backend/api/v1/achievement/__init__.py b/services/backend/api/v1/achievement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py new file mode 100644 index 0000000..a869f0c --- /dev/null +++ b/services/backend/api/v1/achievement/schemas.py @@ -0,0 +1,9 @@ +from ninja import ModelSchema, Schema + +from apps.achievement.models import Achievement + + +class AchievementSchema(ModelSchema): + class Meta: + model = Achievement + fields = ("id", "name", "description", "icon",) From 05f8300976727eb7418345203e2036f1e4b8054b Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:17:38 +0300 Subject: [PATCH 09/40] add achievements field to user model --- services/backend/api/v1/achievement/views.py | 20 +++++++++++++++++++ services/backend/api/v1/user/schemas.py | 2 +- .../user/migrations/0002_user_achievements.py | 19 ++++++++++++++++++ services/backend/apps/user/models.py | 4 ++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 services/backend/api/v1/achievement/views.py create mode 100644 services/backend/apps/user/migrations/0002_user_achievements.py diff --git a/services/backend/api/v1/achievement/views.py b/services/backend/api/v1/achievement/views.py new file mode 100644 index 0000000..d6e2a5a --- /dev/null +++ b/services/backend/api/v1/achievement/views.py @@ -0,0 +1,20 @@ +from http import HTTPStatus as status + +from ninja import Router + +from apps.achievement.models import Achievement +from api.v1.achievement.schemas import AchievementSchema +from api.v1.schemas import UnauthorizedError + +router = Router() + + +@router.get( + "", + response={ + status.OK: list[AchievementSchema], + status.UNAUTHORIZED: UnauthorizedError, + } +) +def get_all_achievements(request): + return Achievement.objects.all() diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index 3e03423..832d91f 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -22,4 +22,4 @@ class LoginSchema(ModelSchema): class UserSchema(ModelSchema): class Meta: model = User - fields = ["id", "email", "username", "created_at",] + fields = ["id", "email", "username", "created_at", "achievements"] diff --git a/services/backend/apps/user/migrations/0002_user_achievements.py b/services/backend/apps/user/migrations/0002_user_achievements.py new file mode 100644 index 0000000..33adefa --- /dev/null +++ b/services/backend/apps/user/migrations/0002_user_achievements.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievement', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='achievements', + field=models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя'), + ), + ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index 2f2d69a..61a9b9c 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.hashers import check_password, make_password from django.db import models +from apps.achievement.models import Achievement from apps.core.models import BaseModel @@ -16,6 +17,9 @@ class User(BaseModel): created_at = models.DateTimeField(auto_now=True) + achievements = models.ManyToManyField(Achievement, blank=True, + verbose_name="ачивки пользователя") + @staticmethod def make_password(password: str): return make_password(password) From 6eadbad773dd4b32d5b24ceafaaab0f5a4fb813f Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:19:36 +0300 Subject: [PATCH 10/40] add filter_horizontal to user achievements management --- services/backend/apps/user/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/apps/user/admin.py b/services/backend/apps/user/admin.py index 89dca07..d13c779 100644 --- a/services/backend/apps/user/admin.py +++ b/services/backend/apps/user/admin.py @@ -7,3 +7,4 @@ from apps.user.models import User class UserAdmin(admin.ModelAdmin): list_display = ("email", "username") search_fields = ("id", "email", "username") + filter_horizontal = ("achievements",) From b67d03798af67535d308e6a6658ea5fd496d7c8b Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:23:37 +0300 Subject: [PATCH 11/40] remove task description max length --- .../0003_alter_competitiontask_description.py | 19 +++++++++++++++++++ services/backend/apps/task/models.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 services/backend/apps/task/migrations/0003_alter_competitiontask_description.py diff --git a/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py new file mode 100644 index 0000000..2dfa914 --- /dev/null +++ b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:23 + +import tinymce.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0002_alter_competitiontasksubmission_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='competitiontask', + name='description', + field=tinymce.models.HTMLField(verbose_name='описание'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 60e54c9..28cf455 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -24,7 +24,7 @@ class CompetitionTask(BaseModel): ) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) - description = HTMLField(verbose_name="описание", max_length=300) + description = HTMLField(verbose_name="описание") max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" From dca1d1b7d957809be5232acc0755fb6c3f0807bb 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: Sun, 2 Mar 2025 15:28:52 +0300 Subject: [PATCH 12/40] feat: added task_position in data generation --- services/backend/apps/core/management/commands/generate_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index 0b8b31c..8781590 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -99,6 +99,7 @@ class Command(BaseCommand): title = f"Task {i} for {comp.title}" description = f"Task description for task {i} in {comp.title}" task = CompetitionTask.objects.create( + in_competition_position=i, competition=comp, title=title, description=description, From bcfd0968b9e53bd668d72ebba5aeb964a2d9a592 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 21:31:01 +0900 Subject: [PATCH 13/40] feat: task list generated based on fetch --- .../frontend/src/pages/Competition/index.tsx | 18 ++++---- .../components/CompetitionHeader/index.tsx | 6 +-- .../components/TaskContent/index.tsx | 26 +---------- .../src/pages/CompetitionSession/index.tsx | 45 +++++++------------ services/frontend/src/shared/types/task.ts | 2 +- 5 files changed, 29 insertions(+), 68 deletions(-) diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index fe37509..2c87809 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -64,15 +64,13 @@ const CompetitionPage = () => {
- {competition.image_url && ( -
- {competition.title} -
- )} +
+ {competition.title} +
@@ -89,7 +87,7 @@ const CompetitionPage = () => { onClick={handleStart} disabled={startMutation.isPending} > - {startMutation.isPending ? "Загрузка..." : "Начать"} + {startMutation.isPending ? "Загрузка..." : "Приступить к выполнению"}
diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index 74d11e4..96e36ef 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Task } from "@/shared/types"; +import { Task } from '@/shared/types/task'; import { getTaskBgColor, getTaskTextColor } from '../../utils/utils'; interface CompetitionHeaderProps { @@ -28,12 +28,12 @@ const CompetitionHeader: React.FC = ({ - {task.number} + {task.in_competition_position} ))}
diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx index af3e4f4..a1abc5f 100644 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx @@ -10,30 +10,6 @@ interface TaskContentProps { } const TaskContent: React.FC = ({ task }) => { - const markdownContent = ` -## Задача на числовую последовательность - -Рассмотрим последовательность чисел: -\`2, 3, 5, 9, 17, 33, 65, 129, ...\` - -Каждый член этой последовательности, **начиная с третьего**, равен сумме двух предыдущих членов: -- $a_1 = 2$ -- $a_2 = 3$ -- $a_n = a_{n-1} + a_{n-2}$ для всех $n ≥ 3$ - -### Задание: -Найдите сумму первых 15 членов этой последовательности. - -*Примечание:* Для решения задачи вам может быть полезно записать несколько первых членов последовательности: -1. $a_1 = 2$ -2. $a_2 = 3$ -3. $a_3 = 3 + 2 = 5$ -4. $a_4 = 5 + 3 = 8$ -5. $a_5 = 8 + 5 = 13$ - -**В ответе укажите целое число.** - `; - return (

@@ -45,7 +21,7 @@ const TaskContent: React.FC = ({ task }) => { remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]} > - {markdownContent} + {task.description}

diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index f924000..acf4b00 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,45 +1,32 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useParams, Navigate } from "react-router-dom"; -import { Task, TaskStatus } from "@/shared/types"; -import { mockSolutions } from "@/shared/mocks/mocks"; // Keep mocks for solutions for now +import { mockSolutions } from "@/shared/mocks/mocks"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; import { getCompetitionTasks } from "@/shared/api/session"; import { Loader2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); - const [tasks, setTasks] = useState([]); const [answer, setAnswer] = useState(""); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const competitionId = id || ""; - useEffect(() => { - const fetchTasks = async () => { - try { - setLoading(true); - const fetchedTasks = await getCompetitionTasks(competitionId); - setTasks(fetchedTasks); - setError(null); - } catch (err) { - console.error("Failed to fetch tasks:", err); - setError("Не удалось загрузить задания. Пожалуйста, попробуйте позже."); - } finally { - setLoading(false); - } - }; + const tasksQuery = useQuery({ + queryKey: ["competitionTasks", competitionId], + queryFn: () => getCompetitionTasks(competitionId), + enabled: !!competitionId, + // refetchOnWindowFocus: false, + }); - if (competitionId) { - fetchTasks(); - } - }, [competitionId]); + const tasks = tasksQuery.data || []; + const isLoading = tasksQuery.isLoading; + const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null; const currentTask = tasks.find((t) => t.id === taskId) || null; - if (!taskId && tasks.length > 0 && !loading) { + if (!taskId && tasks.length > 0 && !isLoading) { return ( {
- {loading ? ( + {isLoading ? (

Загрузка заданий...

@@ -82,7 +69,7 @@ const CompetitionSession = () => { { ); }; -export default CompetitionSession; +export default CompetitionSession; \ No newline at end of file diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts index 6de248b..9d215d3 100644 --- a/services/frontend/src/shared/types/task.ts +++ b/services/frontend/src/shared/types/task.ts @@ -2,7 +2,7 @@ export interface Task { id: string; title: string; description: string; - type: 'input' | 'file' | 'code'; + type: TaskType; in_competition_position: number; points: number; } From c4d36dcf8bdeea713fc402f15308c7cc48b465ac Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:39:23 +0300 Subject: [PATCH 14/40] change markdown editor to martor --- services/backend/apps/task/models.py | 3 ++- services/backend/config/settings.py | 37 +++++++++++++++++++++++++++- services/backend/config/urls.py | 2 ++ services/backend/pyproject.toml | 1 + 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 28cf455..14a892d 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.db import models from django.db.models import Count, Q from tinymce.models import HTMLField +from martor.models import MartorField from apps.competition.models import Competition from apps.core.models import BaseModel @@ -24,7 +25,7 @@ class CompetitionTask(BaseModel): ) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) - description = HTMLField(verbose_name="описание") + description = MartorField(verbose_name="описание") max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 63bc06a..266990b 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -442,6 +442,7 @@ INSTALLED_APPS = [ "ninja", "minio_storage", "tinymce", + "martor", # Internal apps "apps.core", "apps.user", @@ -459,15 +460,49 @@ TINYMCE_DEFAULT_CONFIG = { "menubar": False, "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,help,wordcount", + "code,help,wordcount,markdown", "toolbar": "undo redo | formatselect | " "bold italic backcolor | alignleft aligncenter " "alignright alignjustify | bullist numlist outdent indent | " "removeformat | help", "skin": "oxide-dark", "content_css": "dark", + "textpattern_patterns": [ + {"start": "*", "end": "*", "format": "italic"}, + {"start": "**", "end": "**", "format": "bold"}, + {"start": "#", "format": "h1"}, + {"start": "##", "format": "h2"}, + {"start": "###", "format": "h3"}, + {"start": "####", "format": "h4"}, + {"start": "#####", "format": "h5"}, + {"start": "######", "format": "h6"}, + {"start": "1. ", "cmd": "InsertOrderedList"}, + {"start": "* ", "cmd": "InsertUnorderedList"}, + {"start": "- ", "cmd": "InsertUnorderedList"} + ] } +# martor + +MARTOR_THEME = 'bootstrap' + +MARTOR_ENABLE_CONFIGS = { + 'emoji': 'true', # to enable/disable emoji icons. + 'imgur': 'true', # to enable/disable imgur/custom uploader. + 'mention': 'false', # to enable/disable mention + 'jquery': 'true', # to include/revoke jquery (require for admin default django) + 'living': 'false', # to enable/disable live updates in preview + 'spellcheck': 'false', # to enable/disable spellcheck in form textareas + 'hljs': 'true', # to enable/disable hljs highlighting in preview +} + +MARTOR_TOOLBAR_BUTTONS = [ + 'bold', 'italic', 'horizontal', 'heading', 'pre-code', + 'blockquote', 'unordered-list', 'ordered-list', + 'link', 'emoji', + 'direct-mention', 'toggle-maximize', 'help' +] + # GUID DJANGO_GUID = { diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 27b279a..4e50a9a 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -14,6 +14,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ # tinymce path("tinymce/", include("tinymce.urls")), + # martor + path('martor/', include('martor.urls')), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index 20b218e..d3b2371 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "django-tinymce>=4.1.0", "gunicorn>=23.0.0", "httpx>=0.28.1", + "martor>=1.6.45", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", "pydantic>=2.10.5", From bfb5f574a3a8a8a253d0b28224ad9d06dae2db59 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: Sun, 2 Mar 2025 15:42:02 +0300 Subject: [PATCH 15/40] feat: added multiple reviewers support --- services/backend/api/v1/review/views.py | 25 +++++++++++++++++-------- services/backend/apps/task/models.py | 18 +++++++++--------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py index 281358b..772ade8 100644 --- a/services/backend/api/v1/review/views.py +++ b/services/backend/api/v1/review/views.py @@ -1,5 +1,6 @@ from datetime import datetime from http import HTTPStatus as status +from statistics import median from uuid import UUID from django.http import HttpRequest @@ -84,15 +85,22 @@ def evaluate_submission( review.evaluation = evaluation review.state = ReviewStatusChoices.CHECKED.value review.submission.checked_at = datetime.now() - - points = 0 - for criterea in evaluation: - points += criterea["mark"] - review.submission.earned_points = ( - points # TODO: оценка не от последнего проверяющего а средняя по всем - ) review.save() + submission_evaluations = Review.objects.filter(submission=submission).values_list('evaluation', flat=True) + + marks = [] + for evaluation in submission_evaluations: + mark = 0 + for criterea in evaluation: + mark += criterea["mark"] + marks.append(mark) + earned_points = median(marks) + + review.submission.earned_points = ( + earned_points + ) + all_checked = not submission.reviews.exclude( state=ReviewStatusChoices.CHECKED ).exists() @@ -100,5 +108,6 @@ def evaluate_submission( review.submission.status = ( CompetitionTaskSubmission.StatusChoices.CHECKED.value ) - review.submission.save() + review.submission.save() + return status.OK, review.submission diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 28cf455..8151452 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -57,6 +57,7 @@ class CompetitionTask(BaseModel): verbose_name="ревьюверы", help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему" ) + submission_reviewers_count = models.PositiveSmallIntegerField(default=1, null=True, blank=True) def __str__(self): return self.title @@ -152,8 +153,8 @@ class CompetitionTaskSubmission(BaseModel): if not self.task.reviewers.exists(): return - reviewer = ( - self.task.reviewers.annotate( + reviewers_count = self.task.submission_reviewers_count + reviewers = self.task.reviewers.annotate( pending_count=Count( "review", filter=Q( @@ -163,11 +164,10 @@ class CompetitionTaskSubmission(BaseModel): ] ), ) + ).order_by("pending_count")[:reviewers_count] # да это медленно работает и чо + + for reviewer in reviewers: + Review.objects.create( + reviewer=reviewer, + submission=self, ) - .order_by("pending_count") - .first() - ) - review = Review.objects.create( - reviewer=reviewer, - submission=self, - ) From 73d77c51b91b260d2c6fb96d21bfe57bbccf82f4 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: Sun, 2 Mar 2025 15:45:34 +0300 Subject: [PATCH 16/40] fix: fixed data generation --- services/backend/apps/core/management/commands/generate_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index 8781590..754f5e0 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -105,6 +105,7 @@ class Command(BaseCommand): description=description, type=task_type, points=random.randint(1, 10), + submission_reviewers_count=random.randint(2, 10), max_attempts=random.randint(1, 10), ) tasks.append(task) From 00dcbe4df2de89c695ba15fe2db7b0589523e9f4 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: Sun, 2 Mar 2025 15:49:22 +0300 Subject: [PATCH 17/40] fix: added migrations --- ...ask_submission_reviewers_count_and_more.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py diff --git a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py new file mode 100644 index 0000000..400255c --- /dev/null +++ b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:49 + +import martor.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0003_alter_competitiontask_description'), + ] + + operations = [ + migrations.AddField( + model_name='competitiontask', + name='submission_reviewers_count', + field=models.PositiveSmallIntegerField(blank=True, default=1, null=True), + ), + migrations.AlterField( + model_name='competitiontask', + name='description', + field=martor.models.MartorField(verbose_name='описание'), + ), + ] From 9893f6d5483c3572c46c92e40aa71bc647d5309b Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 15:57:02 +0300 Subject: [PATCH 18/40] add achievement type and need actions for complete it --- ...achievement_need_count_achievement_type.py | 23 +++++++++++++++++++ services/backend/apps/achievement/models.py | 16 +++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py diff --git a/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py new file mode 100644 index 0000000..e16f3b6 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-02 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievement', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='achievement', + name='need_count', + field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'), + ), + migrations.AddField( + model_name='achievement', + name='type', + field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'), + ), + ] diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py index 1e0460a..7bb796d 100644 --- a/services/backend/apps/achievement/models.py +++ b/services/backend/apps/achievement/models.py @@ -3,6 +3,9 @@ from django.db import models from apps.core.models import BaseModel class Achievement(BaseModel): + class AchievementType(models.TextChoices): + CORRECT_TASKS = "correct_tasks", "Выполненные задания" + def image_url_upload_to(instance, filename): return f"/achievements/{instance.id}/icon" @@ -14,6 +17,19 @@ class Achievement(BaseModel): upload_to=image_url_upload_to, ) + type = models.CharField( + max_length=20, + choices=AchievementType.choices, + verbose_name="тип", + help_text="За какой тип достижений будет выдаваться ачивка", + default=AchievementType.CORRECT_TASKS, + ) + need_count = models.IntegerField( + verbose_name="кол-во того, что нужно для получения ачивки", + help_text="Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте \"Выполненные задания\" а тут 5, то ачивка будет выдаваться за 5 решенных заданий", + default=5 + ) + def __str__(self): return self.name From 1511505b8cd507276c68fb53f5219de5d9ad94ab 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: Sun, 2 Mar 2025 16:10:13 +0300 Subject: [PATCH 19/40] feat: added task title in submissions --- services/backend/api/v1/review/schemas.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 1b79004..97a9fd1 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema): competition: UUID = Field(..., alias="task.competition.id") competition_name: str = Field(..., alias="task.competition.title") task_position: int = Field(..., alias="task.in_competition_position") + task_title: str = Field(..., alias="task.title") @staticmethod def resolve_criteries(self, context) -> list[CriteriaOut] | None: From 4d07439f27771643631c31a71ebdb846a55a2c12 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: Sun, 2 Mar 2025 16:28:16 +0300 Subject: [PATCH 20/40] feat: added status in tasks --- services/backend/api/v1/task/schemas.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 836643d..054a9b5 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -2,11 +2,20 @@ from typing import Literal from uuid import UUID from ninja import ModelSchema, Schema +from pydantic import Field from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment class TaskOutSchema(ModelSchema): + status: Literal["sent", "checked", "checking", "not_submitted"] = None + + @staticmethod + def resolve_status(self, context) -> Literal["sent", "checked", "checking", "not_submitted"]: + if submission := CompetitionTaskSubmission.objects.filter(task=self, user=context.get("request").auth).first(): + return submission.status + return "not_submitted" + class Meta: model = CompetitionTask fields = [ From 7de03ecf8631cd51125369a8fbe8d57b93039ad0 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 22:28:57 +0900 Subject: [PATCH 21/40] continued working on session fetch --- .../components/ConstructorHeader/index.tsx | 114 ++++++------ .../pages/CompetitionConstructor/index.tsx | 156 ++++++++-------- .../modules/TaskCreationModal/index.tsx | 176 +++++++++--------- .../components/TaskContent/index.tsx | 57 +++++- .../src/pages/CompetitionSession/index.tsx | 30 +-- .../components/ActionButtons/index.tsx | 53 ++---- .../components/SolutionHistorySheet/index.tsx | 8 +- .../components/SolutionStatus/index.tsx | 37 ++-- .../modules/TaskSolution/index.tsx | 41 +++- .../pages/CompetitionSession/utils/utils.ts | 38 +++- services/frontend/src/shared/api/session.ts | 11 +- services/frontend/src/shared/types/task.ts | 24 ++- 12 files changed, 431 insertions(+), 314 deletions(-) diff --git a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx index 04442d1..b0f3adb 100644 --- a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx @@ -1,63 +1,63 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { Task } from "@/shared/types"; -import { Settings, Plus } from 'lucide-react'; -import { Button } from "@/components/ui/button"; +// import React from 'react'; +// import { Link } from 'react-router-dom'; +// import { Task } from "@/shared/types"; +// import { Settings, Plus } from 'lucide-react'; +// import { Button } from "@/components/ui/button"; -interface ConstructorHeaderProps { - title: string; - tasks: Task[]; - competitionId: string; - onAddTaskClick: () => void; -} +// interface ConstructorHeaderProps { +// title: string; +// tasks: Task[]; +// competitionId: string; +// onAddTaskClick: () => void; +// } -const ConstructorHeader: React.FC = ({ - title, - tasks, - competitionId, - onAddTaskClick -}) => { - return ( -
-
-
-

- {title} -

-
+// const ConstructorHeader: React.FC = ({ +// title, +// tasks, +// competitionId, +// onAddTaskClick +// }) => { +// return ( +//
+//
+//
+//

+// {title} +//

+//
-
- - - +//
+// +// +// - {tasks.map((task) => ( - - {task.number} - - ))} +// {tasks.map((task) => ( +// +// {task.number} +// +// ))} - -
-
-
- ); -}; +// +//
+//
+// +// ); +// }; -export default ConstructorHeader; \ No newline at end of file +// export default ConstructorHeader; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/index.tsx b/services/frontend/src/pages/CompetitionConstructor/index.tsx index 4f7f247..fcfe774 100644 --- a/services/frontend/src/pages/CompetitionConstructor/index.tsx +++ b/services/frontend/src/pages/CompetitionConstructor/index.tsx @@ -1,89 +1,89 @@ -import { useState } from "react"; -import { useParams, Navigate, useNavigate } from "react-router-dom"; -import { Task, TaskStatus } from "@/shared/types"; -import ConstructorHeader from "./components/ConstructorHeader"; -import TaskCreationModal from "./modules/TaskCreationModal"; +// import { useState } from "react"; +// import { useParams, Navigate, useNavigate } from "react-router-dom"; +// import { Task, TaskStatus } from "@/shared/types"; +// import ConstructorHeader from "./components/ConstructorHeader"; +// import TaskCreationModal from "./modules/TaskCreationModal"; -const CompetitionConstructor = () => { - const { id, taskId } = useParams<{ id: string; taskId?: string }>(); - const navigate = useNavigate(); - const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); - const [tasks, setTasks] = useState([]); - const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); +// const CompetitionConstructor = () => { +// const { id, taskId } = useParams<{ id: string; taskId?: string }>(); +// const navigate = useNavigate(); +// const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); +// const [tasks, setTasks] = useState([]); +// const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); - const isSettings = taskId === "settings"; +// const isSettings = taskId === "settings"; - const handleOpenTaskModal = () => { - setIsTaskModalOpen(true); - }; +// const handleOpenTaskModal = () => { +// setIsTaskModalOpen(true); +// }; - const handleCloseTaskModal = () => { - setIsTaskModalOpen(false); - }; +// const handleCloseTaskModal = () => { +// setIsTaskModalOpen(false); +// }; - const handleCreateTask = (taskData: Partial) => { - const newTask: Task = { - id: `task-${Date.now()}`, - number: taskData.number || `${tasks.length + 1}`, - status: TaskStatus.Uncleared, - solutionType: taskData.solutionType || "input", - description: taskData.description || "", - requirements: taskData.requirements, - attachments: taskData.attachments || [] - }; +// const handleCreateTask = (taskData: Partial) => { +// const newTask: Task = { +// id: `task-${Date.now()}`, +// number: taskData.number || `${tasks.length + 1}`, +// status: TaskStatus.Uncleared, +// solutionType: taskData.solutionType || "input", +// description: taskData.description || "", +// requirements: taskData.requirements, +// attachments: taskData.attachments || [] +// }; - setTasks([...tasks, newTask]); - setIsTaskModalOpen(false); - navigate(`/constructor/${id}/tasks/${newTask.id}`); - }; +// setTasks([...tasks, newTask]); +// setIsTaskModalOpen(false); +// navigate(`/constructor/${id}/tasks/${newTask.id}`); +// }; - if (!taskId) { - if (tasks.length > 0) { - return ; - } else { - return ; - } - } +// if (!taskId) { +// if (tasks.length > 0) { +// return ; +// } else { +// return ; +// } +// } - return ( -
- +// return ( +//
+// - +// -
-
- {isSettings ? ( -
-

Настройки олимпиады

-

- Здесь будет форма настроек олимпиады -

-
- ) : ( -
-

- {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} -

-

- Здесь будет форма редактирования задачи -

-
- )} -
-
-
- ); -}; +//
+//
+// {isSettings ? ( +//
+//

Настройки олимпиады

+//

+// Здесь будет форма настроек олимпиады +//

+//
+// ) : ( +//
+//

+// {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} +//

+//

+// Здесь будет форма редактирования задачи +//

+//
+// )} +//
+//
+//
+// ); +// }; -export default CompetitionConstructor; \ No newline at end of file +// export default CompetitionConstructor; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx index 9380af4..b71b311 100644 --- a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx +++ b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx @@ -1,101 +1,101 @@ -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Task } from "@/shared/types"; -import TaskNumberField from './components/TaskNumberField'; -import TaskDescriptionField from './components/TaskDescriptionField'; -import TaskRequirementsField from './components/TaskRequirementsField'; -import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector'; -import TaskFileAttachments from './components/TaskFileAttachments'; +// import React, { useState } from 'react'; +// import { +// Dialog, +// DialogContent, +// DialogHeader, +// DialogTitle, +// DialogFooter +// } from "@/components/ui/dialog"; +// import { Button } from "@/components/ui/button"; +// import { Task } from "@/shared/types"; +// import TaskNumberField from './components/TaskNumberField'; +// import TaskDescriptionField from './components/TaskDescriptionField'; +// import TaskRequirementsField from './components/TaskRequirementsField'; +// import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector'; +// import TaskFileAttachments from './components/TaskFileAttachments'; -interface TaskCreationModalProps { - isOpen: boolean; - onClose: () => void; - onCreateTask: (task: Partial) => void; - taskCount: number; -} +// interface TaskCreationModalProps { +// isOpen: boolean; +// onClose: () => void; +// onCreateTask: (task: Partial) => void; +// taskCount: number; +// } -const TaskCreationModal: React.FC = ({ - isOpen, - onClose, - onCreateTask, - taskCount -}) => { - const [number, setNumber] = useState(`${taskCount + 1}`); - const [description, setDescription] = useState(''); - const [requirements, setRequirements] = useState(''); - const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input'); - const [attachedFiles, setAttachedFiles] = useState([]); +// const TaskCreationModal: React.FC = ({ +// isOpen, +// onClose, +// onCreateTask, +// taskCount +// }) => { +// const [number, setNumber] = useState(`${taskCount + 1}`); +// const [description, setDescription] = useState(''); +// const [requirements, setRequirements] = useState(''); +// const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input'); +// const [attachedFiles, setAttachedFiles] = useState([]); - const handleSubmit = () => { - const newTask: Partial = { - number, - description, - requirements: requirements || undefined, - solutionType, - attachments: attachedFiles.map(file => file.name) - }; +// const handleSubmit = () => { +// const newTask: Partial = { +// number, +// description, +// requirements: requirements || undefined, +// solutionType, +// attachments: attachedFiles.map(file => file.name) +// }; - onCreateTask(newTask); +// onCreateTask(newTask); - setNumber(`${taskCount + 1}`); - setDescription(''); - setRequirements(''); - setSolutionType('input'); - setAttachedFiles([]); - }; +// setNumber(`${taskCount + 1}`); +// setDescription(''); +// setRequirements(''); +// setSolutionType('input'); +// setAttachedFiles([]); +// }; - return ( - - - - Создание новой задачи - +// return ( +// +// +// +// Создание новой задачи +// -
- +//
+// - +// - +// - +// - -
+// +//
- - - - -
-
- ); -}; +// +// +// +// +//
+//
+// ); +// }; -export default TaskCreationModal; \ No newline at end of file +// export default TaskCreationModal; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx index a1abc5f..0c49ca3 100644 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx @@ -3,20 +3,34 @@ import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; -import { Task } from "@/shared/types"; +import { Task } from '@/shared/types/task'; +import { useQuery } from '@tanstack/react-query'; +import { getTaskAttachments } from '@/shared/api/session'; +import { FileIcon, Loader2 } from 'lucide-react'; +import { useParams } from 'react-router-dom'; interface TaskContentProps { task: Task; } const TaskContent: React.FC = ({ task }) => { + const { id: competitionId } = useParams<{ id: string }>(); + + const attachmentsQuery = useQuery({ + queryKey: ['taskAttachments', competitionId, task.id], + queryFn: () => getTaskAttachments(competitionId || '', task.id), + enabled: !!(competitionId && task.id), + }); + + const attachments = attachmentsQuery.data || []; + return (

- Задача {task.number} + Задача {task.in_competition_position}

-
+
= ({ task }) => { {task.description}
+ + {attachmentsQuery.isLoading ? ( +
+ + Загрузка файлов... +
+ ) : attachments.length > 0 ? ( +
+

Прикрепленные файлы

+
+ {attachments.map((attachment) => ( + + + + {getFileNameFromUrl(attachment.file)} + + + ))} +
+
+ ) : null}
); }; -export default TaskContent; +const getFileNameFromUrl = (url: string): string => { + try { + const parts = url.split('/'); + return parts[parts.length - 1]; + } catch (e) { + return 'Файл'; + } +}; + +export default TaskContent; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index acf4b00..a461dbf 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -1,23 +1,32 @@ import { useState } from "react"; import { useParams, Navigate } from "react-router-dom"; -import { mockSolutions } from "@/shared/mocks/mocks"; import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; -import { getCompetitionTasks } from "@/shared/api/session"; +import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; import { Loader2 } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const [answer, setAnswer] = useState(""); const competitionId = id || ""; + const queryClient = useQueryClient(); const tasksQuery = useQuery({ queryKey: ["competitionTasks", competitionId], queryFn: () => getCompetitionTasks(competitionId), enabled: !!competitionId, - // refetchOnWindowFocus: false, + }); + + const submitMutation = useMutation({ + mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['submissionHistory', competitionId, taskId] + }); + setAnswer(""); + } }); const tasks = tasksQuery.data || []; @@ -35,14 +44,9 @@ const CompetitionSession = () => { ); } - const handleSubmit = async () => { - if (!currentTask || !competitionId) return; - - try { - console.log("Solution submitted successfully"); - } catch (err) { - console.error("Failed to submit solution:", err); - } + const handleSubmit = () => { + if (!currentTask || !competitionId || !answer.trim()) return; + submitMutation.mutate(); }; return ( @@ -69,7 +73,7 @@ const CompetitionSession = () => { void; - solutionHistory?: Solution[]; + onHistoryClick: () => void; } const ActionButtons: React.FC = ({ onSubmit, - solutionHistory = mockSolutions + onHistoryClick }) => { - const [isHistoryOpen, setIsHistoryOpen] = useState(false); - - const handleHistoryClick = () => { - setIsHistoryOpen(true); - }; - return ( - <> -
- - -
- {/* чуть-чуть рак */} - - +
+ + +
); }; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx index dcaaa82..dc8fe95 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx @@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp import { Button } from "@/components/ui/button"; import { X } from "lucide-react"; import SolutionStatus from '../SolutionStatus'; -import { Solution } from "@/shared/types"; +import { Solution } from '@/shared/types/task'; interface SolutionHistorySheetProps { isOpen: boolean; onOpenChange: (open: boolean) => void; solutions: Solution[]; + maxPoints: number } const SolutionHistorySheet: React.FC = ({ isOpen, onOpenChange, - solutions + solutions, + maxPoints }) => { return ( @@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC = ({ {solutions.length > 0 ? ( solutions.map((solution, index) => (
- +
)) ) : ( diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx index dbe77e9..33a412a 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionStatus/index.tsx @@ -1,41 +1,26 @@ import React from 'react'; -import { Solution, TaskStatus } from "@/shared/types"; -import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; +import { Solution } from '@/shared/types/task'; +import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils'; interface SolutionStatusProps { solution: Solution; + maxPoints: number; } -const SolutionStatus: React.FC = ({ solution }) => { - const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => { - switch (status) { - case TaskStatus.Checking: - return 'На проверке'; - case TaskStatus.Wrong: - return 'Неверный ответ'; - case TaskStatus.Correct: - return `Зачтено ${maxScore}/${maxScore} баллов`; - case TaskStatus.Partial: - return `Зачтено ${score}/${maxScore} баллов`; - case TaskStatus.Uncleared: - return 'Не решено'; - default: - return ''; - } - }; - +const SolutionStatus: React.FC = ({ solution, maxPoints }) => { + return ( -
+
- + Решение {solution.id} - - {getStatusText(solution.status, solution.score, solution.maxScore)} + + {getStatusText(solution.status, solution.earned_points, maxPoints)}
-
- {solution.date} +
+ {solution.timestamp}
); diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 9c25bf8..44ebfd0 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,10 +1,14 @@ import React, { useState, useRef } from 'react'; -import { Solution, Task } from "@/shared/types"; +import { useParams } from 'react-router-dom'; +import { Task, TaskType, Solution } from '@/shared/types/task'; +import { useQuery } from '@tanstack/react-query'; +import { getTaskSolutionHistory } from '@/shared/api/session'; import SolutionStatus from './components/SolutionStatus'; import InputSolution from './components/InputSolution'; import FileSolution from './components/FileSolution'; import CodeSolution from './components/CodeSolution'; import ActionButtons from './components/ActionButtons'; +import SolutionHistorySheet from './components/SolutionHistorySheet'; interface TaskSolutionProps { task: Task; @@ -12,7 +16,6 @@ interface TaskSolutionProps { answer: string; setAnswer: (value: string) => void; onSubmit: () => void; - } const TaskSolution: React.FC = ({ @@ -24,16 +27,30 @@ const TaskSolution: React.FC = ({ }) => { const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const { id: competitionId } = useParams<{ id: string }>(); + + const solutionsQuery = useQuery({ + queryKey: ['solutionHistory', competitionId, task.id], + queryFn: () => getTaskSolutionHistory(competitionId || '', task.id), + enabled: !!(competitionId && task.id), + }); + + const solutionHistory = solutionsQuery.data || []; + + const handleOpenHistory = () => { + setIsHistoryOpen(true); + }; return (
- + - {task.solutionType === 'input' && ( + {task.type === TaskType.INPUT && ( )} - {task.solutionType === 'file' && ( + {task.type === TaskType.FILE && ( = ({ /> )} - {task.solutionType === 'code' && ( + {task.type === TaskType.CODE && ( )} - + + +
); }; diff --git a/services/frontend/src/pages/CompetitionSession/utils/utils.ts b/services/frontend/src/pages/CompetitionSession/utils/utils.ts index 9ba336e..fa21f4c 100644 --- a/services/frontend/src/pages/CompetitionSession/utils/utils.ts +++ b/services/frontend/src/pages/CompetitionSession/utils/utils.ts @@ -1,4 +1,5 @@ import { TaskStatus } from "@/shared/types"; +import { SolutionStatus } from "@/shared/types/task"; const getTaskBgColor = (status: TaskStatus): string => { switch (status) { case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]"; @@ -19,4 +20,39 @@ const getTaskTextColor = (status: TaskStatus): string => { } }; -export {getTaskBgColor, getTaskTextColor} \ No newline at end of file +const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]"; + case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "text-[var(--color-task-wrong)]"; + else if (earned_points === maxPoints) "text-[var(--color-task-correct)]"; + return "text-[var(--color-task-partial)]"; + } + } +} + +const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]"; + case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "text-[var(--color-task-text-wrong)]"; + else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]"; + return "text-[var(--color-task-text-partial)]"; + } + } +} + +const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => { + switch (status) { + case SolutionStatus.SENT: return "Решение отправлено"; + case SolutionStatus.CHECKING: return "Решение проверяется"; + case SolutionStatus.CHECKED: { + if (earned_points === 0) return "Неверный ответ"; + else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`; + return `Зачтено ${earned_points}/${maxPoints} баллов`; + } + } +} +export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText} \ No newline at end of file diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts index 0cc6383..4709179 100644 --- a/services/frontend/src/shared/api/session.ts +++ b/services/frontend/src/shared/api/session.ts @@ -1,10 +1,19 @@ import { userFetch } from "."; -import { Task } from "../types/task"; +import { Task, Solution, TaskAttachment } from "../types/task"; export const getCompetitionTasks = async (competitionId: string) => { return await userFetch(`/competitions/${competitionId}/tasks`); }; +export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/history`); +}; + +export const getTaskAttachments = async (competitionId: string, taskId: string) => { + return await userFetch(`/competitions/${competitionId}/tasks/${taskId}/attachments`); +}; + + export const submitTaskSolution = async ( competitionId: string, taskId: string, diff --git a/services/frontend/src/shared/types/task.ts b/services/frontend/src/shared/types/task.ts index 9d215d3..c1434d5 100644 --- a/services/frontend/src/shared/types/task.ts +++ b/services/frontend/src/shared/types/task.ts @@ -1,4 +1,4 @@ -export interface Task { +interface Task { id: string; title: string; description: string; @@ -7,8 +7,30 @@ export interface Task { points: number; } +export interface TaskAttachment { + id: string; + file: string; + public: boolean; +} + enum TaskType { INPUT = "input", FILE = "file", CODE = "code", } + +enum SolutionStatus { + SENT = "sent", + CHECKING = "checking", + CHECKED = "checked", +} + +interface Solution { + id: string, + status: SolutionStatus, + timestamp: string, + earned_points: number +} + +export type {Task, Solution} +export {TaskType, SolutionStatus} \ No newline at end of file From 5bf70546ca7132eb0e1e3a0cb7d3b3a31e997c2c Mon Sep 17 00:00:00 2001 From: Timur Date: Sun, 2 Mar 2025 16:37:39 +0300 Subject: [PATCH 22/40] add slug field and remove old logic --- ...re_squashed_0004_alter_achievement_slug.py | 28 +++++++++++++++++++ services/backend/apps/achievement/models.py | 13 ++------- 2 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py diff --git a/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py new file mode 100644 index 0000000..682a718 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-03-02 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')] + + dependencies = [ + ('achievement', '0002_achievement_need_count_achievement_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='achievement', + name='need_count', + ), + migrations.RemoveField( + model_name='achievement', + name='type', + ), + migrations.AddField( + model_name='achievement', + name='slug', + field=models.SlugField(unique=True, verbose_name='слаг'), + ), + ] diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py index 7bb796d..ef0689a 100644 --- a/services/backend/apps/achievement/models.py +++ b/services/backend/apps/achievement/models.py @@ -17,17 +17,8 @@ class Achievement(BaseModel): upload_to=image_url_upload_to, ) - type = models.CharField( - max_length=20, - choices=AchievementType.choices, - verbose_name="тип", - help_text="За какой тип достижений будет выдаваться ачивка", - default=AchievementType.CORRECT_TASKS, - ) - need_count = models.IntegerField( - verbose_name="кол-во того, что нужно для получения ачивки", - help_text="Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте \"Выполненные задания\" а тут 5, то ачивка будет выдаваться за 5 решенных заданий", - default=5 + slug = models.SlugField( + verbose_name="слаг", unique=True ) def __str__(self): From 15d9dc2b9a960d57f4a220aead937a8259998c85 Mon Sep 17 00:00:00 2001 From: rngsurrounded Date: Sun, 2 Mar 2025 22:37:57 +0900 Subject: [PATCH 23/40] deleted competition constructor --- services/frontend/src/App.tsx | 10 -- .../components/ConstructorHeader/index.tsx | 63 ----------- .../pages/CompetitionConstructor/index.tsx | 89 --------------- .../components/TaskDescriptionField/index.tsx | 27 ----- .../components/TaskFileAttachments/index.tsx | 92 ---------------- .../components/TaskNumberField/index.tsx | 26 ----- .../TaskRequirementsField/index.tsx | 27 ----- .../TaskSolutionTypeSelector/index.tsx | 41 ------- .../modules/TaskCreationModal/index.tsx | 101 ------------------ .../CompetitionConstructor/modules/index.tsx | 0 10 files changed, 476 deletions(-) delete mode 100644 services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskFileAttachments/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskNumberField/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskRequirementsField/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskSolutionTypeSelector/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/index.tsx delete mode 100644 services/frontend/src/pages/CompetitionConstructor/modules/index.tsx diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 9b3a5ae..86735e1 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -10,7 +10,6 @@ import LoginPage from "./pages/Login"; import { AuthLayout } from "./widgets/auth-layout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ReviewPage from "./pages/Review"; -import CompetitionConstructor from "./pages/CompetitionConstructor"; import UserProfile from "./pages/UserProfile"; const queryClient = new QueryClient(); @@ -32,15 +31,6 @@ const App = () => { element={} /> - } /> - - } /> - - } - /> - } /> } /> diff --git a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx b/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx deleted file mode 100644 index b0f3adb..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/components/ConstructorHeader/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// import React from 'react'; -// import { Link } from 'react-router-dom'; -// import { Task } from "@/shared/types"; -// import { Settings, Plus } from 'lucide-react'; -// import { Button } from "@/components/ui/button"; - -// interface ConstructorHeaderProps { -// title: string; -// tasks: Task[]; -// competitionId: string; -// onAddTaskClick: () => void; -// } - -// const ConstructorHeader: React.FC = ({ -// title, -// tasks, -// competitionId, -// onAddTaskClick -// }) => { -// return ( -//
-//
-//
-//

-// {title} -//

-//
- -//
-// -// -// - -// {tasks.map((task) => ( -// -// {task.number} -// -// ))} - -// -//
-//
-//
-// ); -// }; - -// export default ConstructorHeader; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/index.tsx b/services/frontend/src/pages/CompetitionConstructor/index.tsx deleted file mode 100644 index fcfe774..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// import { useState } from "react"; -// import { useParams, Navigate, useNavigate } from "react-router-dom"; -// import { Task, TaskStatus } from "@/shared/types"; -// import ConstructorHeader from "./components/ConstructorHeader"; -// import TaskCreationModal from "./modules/TaskCreationModal"; - -// const CompetitionConstructor = () => { -// const { id, taskId } = useParams<{ id: string; taskId?: string }>(); -// const navigate = useNavigate(); -// const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); -// const [tasks, setTasks] = useState([]); -// const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); - -// const isSettings = taskId === "settings"; - -// const handleOpenTaskModal = () => { -// setIsTaskModalOpen(true); -// }; - -// const handleCloseTaskModal = () => { -// setIsTaskModalOpen(false); -// }; - -// const handleCreateTask = (taskData: Partial) => { -// const newTask: Task = { -// id: `task-${Date.now()}`, -// number: taskData.number || `${tasks.length + 1}`, -// status: TaskStatus.Uncleared, -// solutionType: taskData.solutionType || "input", -// description: taskData.description || "", -// requirements: taskData.requirements, -// attachments: taskData.attachments || [] -// }; - -// setTasks([...tasks, newTask]); -// setIsTaskModalOpen(false); -// navigate(`/constructor/${id}/tasks/${newTask.id}`); -// }; - -// if (!taskId) { -// if (tasks.length > 0) { -// return ; -// } else { -// return ; -// } -// } - -// return ( -//
-// - -// - -//
-//
-// {isSettings ? ( -//
-//

Настройки олимпиады

-//

-// Здесь будет форма настроек олимпиады -//

-//
-// ) : ( -//
-//

-// {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} -//

-//

-// Здесь будет форма редактирования задачи -//

-//
-// )} -//
-//
-//
-// ); -// }; - -// export default CompetitionConstructor; \ No newline at end of file diff --git a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx b/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx deleted file mode 100644 index c5f6876..0000000 --- a/services/frontend/src/pages/CompetitionConstructor/modules/TaskCreationModal/components/TaskDescriptionField/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; - -interface TaskDescriptionFieldProps { - description: string; - onChange: (value: string) => void; -} - -const TaskDescriptionField: React.FC = ({ description, onChange }) => { - return ( -
- -