diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 9e73c37..3e07b92 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -33,6 +33,7 @@ class TaskOutSchema(ModelSchema): "description", "in_competition_position", "points", + "max_attempts" ] diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 5028e87..1344e8f 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -21,7 +21,7 @@ from api.v1.user.schemas import ( TokenSchema, UserSchema, ) -from apps.task.models import CompetitionTaskSubmission +from apps.task.models import CompetitionTaskSubmission, CompetitionTask from apps.user.models import User router = Router(tags=["user"]) @@ -104,9 +104,10 @@ def get_my_stat(request): success_attempts_cnt = 0 for attempt in checked_attempts: - is_correct = attempt.result.get("correct", None) - if is_correct is None: - is_correct = attempt.result.get("total_points", 0) > 0 + if attempt.task.type == CompetitionTask.CompetitionTaskType.REVIEW: + is_correct = attempt.earned_points > 0 + else: + is_correct = attempt.result.get("correct", None) if is_correct: success_attempts_cnt += 1 diff --git a/services/backend/apps/achievement/icons/start_competition.png b/services/backend/apps/achievement/icons/start_competition.png new file mode 100644 index 0000000..d8a3519 Binary files /dev/null and b/services/backend/apps/achievement/icons/start_competition.png differ diff --git a/services/backend/apps/achievement/management/commands/init_achievments.py b/services/backend/apps/achievement/management/commands/init_achievments.py index 9619b58..bb9f2fb 100644 --- a/services/backend/apps/achievement/management/commands/init_achievments.py +++ b/services/backend/apps/achievement/management/commands/init_achievments.py @@ -30,3 +30,13 @@ class Command(BaseCommand): slug="welcome", icon=welcome_icon, ) + + if not Achievement.objects.filter(slug="start_competition").exists(): + with open(f"{icons_dir}/start_competition.png", "rb") as f: + start_competition = File(f, name="start_competition.png") + Achievement.objects.get_or_create( + name="Да начнётся битва!", + description="Начните соревнование", + slug="start_competition", + icon=start_competition, + ) diff --git a/services/backend/apps/competition/apps.py b/services/backend/apps/competition/apps.py index d343cd0..f10ee38 100644 --- a/services/backend/apps/competition/apps.py +++ b/services/backend/apps/competition/apps.py @@ -5,3 +5,6 @@ class CompetitionsConfig(AppConfig): name = "apps.competition" label = "competition" verbose_name = "Соревнование" + + def ready(self): + import apps.competition.signals diff --git a/services/backend/apps/competition/signals.py b/services/backend/apps/competition/signals.py new file mode 100644 index 0000000..9a832bd --- /dev/null +++ b/services/backend/apps/competition/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.achievement.models import Achievement, UserAchievement +from apps.competition.models import State +from apps.user.models import User + + +@receiver(post_save, sender=State) +def assign_start_competition_achievement(sender, instance, created, **kwargs): + if created: + if State.objects.filter(user=instance.user, state=State.StateChoices.STARTED.value).count() == 1 \ + and not State.objects.filter(user=instance.user, state=State.StateChoices.FINISHED.value).exists(): + start_competition_achievement = Achievement.objects.get(slug="start_competition") + UserAchievement.objects.create( + user=instance.user, achievement=start_competition_achievement + ) diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index 849b020..c695d5e 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Task } from '@/shared/types/task'; import { ArrowLeft } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; interface CompetitionHeaderProps { title: string; tasks: Task[]; competitionId: string; setAnswer: (value: string) => void; - setSelectedFile: (file: File | null) => void; // заглушка + setSelectedFile: (file: File | null) => void; } const CompetitionHeader: React.FC = ({ @@ -18,11 +19,13 @@ const CompetitionHeader: React.FC = ({ setAnswer, setSelectedFile }) => { + const navigate = useNavigate(); - const handleTaskSelect = () => { - setAnswer("") - setSelectedFile(null) - console.log("STOP IT") + const handleTaskSelect = (taskId: string) => { + setAnswer(""); + setSelectedFile(null); + + navigate(`/competition/${competitionId}/tasks/${taskId}`); } return ( @@ -32,7 +35,6 @@ const CompetitionHeader: React.FC = ({ @@ -46,16 +48,16 @@ const CompetitionHeader: React.FC = ({
{tasks.map((task) => ( - - {task.in_competition_position} - + ))}
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx index b7d351b..59c883e 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx @@ -8,6 +8,7 @@ interface FileSolutionProps { fileInputRef: React.RefObject; existingFileUrl?: string | null; onClearExistingFile?: () => void; // New prop to clear existing file URL + firstSolution: boolean } const FileSolution: React.FC = ({ @@ -15,7 +16,8 @@ const FileSolution: React.FC = ({ setSelectedFile, fileInputRef, existingFileUrl = null, - onClearExistingFile + onClearExistingFile, + firstSolution }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -59,9 +61,6 @@ const FileSolution: React.FC = ({ } }; - const handleSelectNewFile = () => { - fileInputRef.current?.click(); - }; const fileName = selectedFile ? selectedFile.name @@ -69,7 +68,7 @@ const FileSolution: React.FC = ({ ? existingFileUrl.split('/').pop() || 'file' : ''; - const hasFile = !!selectedFile || !!existingFileUrl; + const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution); return ( <> diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 9deb45a..e7233d5 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,168 +1,170 @@ -import React, { useState, useRef, useEffect } from 'react'; -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'; + import React, { useState, useRef, useEffect } from 'react'; + 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; - answer: string; - setAnswer: (value: string) => void; - selectedFile: File | null; - setSelectedFile: (file: File | null) => void; - onSubmit: () => void; -} + interface TaskSolutionProps { + task: Task; + answer: string; + setAnswer: (value: string) => void; + selectedFile: File | null; + setSelectedFile: (file: File | null) => void; + onSubmit: () => void; + } -const TaskSolution: React.FC = ({ - task, - answer, - setAnswer, - selectedFile, - setSelectedFile, - onSubmit, -}) => { - const fileInputRef = useRef(null); - const [isHistoryOpen, setIsHistoryOpen] = useState(false); - const [selectedSolutionUrl, setSelectedSolutionUrl] = useState(null); - const [displayedSolution, setDisplayedSolution] = useState(null); - const { id: competitionId } = useParams<{ id: string }>(); - const prevTaskIdRef = useRef(null); + const TaskSolution: React.FC = ({ + task, + answer, + setAnswer, + selectedFile, + setSelectedFile, + onSubmit, + }) => { + const fileInputRef = useRef(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [selectedSolutionUrl, setSelectedSolutionUrl] = useState(null); + const [displayedSolution, setDisplayedSolution] = useState(null); + const { id: competitionId } = useParams<{ id: string }>(); + const prevTaskIdRef = useRef(null); - const solutionsQuery = useQuery({ - queryKey: ['solutionHistory', competitionId, task.id], - queryFn: () => getTaskSolutionHistory(competitionId || '', task.id), - enabled: !!(competitionId && task.id), - }); + const solutionsQuery = useQuery({ + queryKey: ['solutionHistory', competitionId, task.id], + queryFn: () => getTaskSolutionHistory(competitionId || '', task.id), + enabled: !!(competitionId && task.id), + }); - const solutionHistory = solutionsQuery.data || []; + const solutionHistory = solutionsQuery.data || []; - useEffect(() => { - if (solutionHistory.length > 0 && !displayedSolution) { - const latestSolution = solutionHistory[solutionHistory.length - 1]; - setDisplayedSolution(latestSolution); - } - }, [solutionHistory, displayedSolution]); - - useEffect(() => { - if (prevTaskIdRef.current !== task.id) { - setDisplayedSolution(null); - setSelectedSolutionUrl(null); - - // If solutions are already loaded for the new task, set the latest one - if (solutionHistory.length > 0) { + useEffect(() => { + if (solutionHistory.length > 0 && !displayedSolution) { const latestSolution = solutionHistory[solutionHistory.length - 1]; setDisplayedSolution(latestSolution); } - - prevTaskIdRef.current = task.id; - } - }, [task.id, solutionHistory]); + }, [solutionHistory, displayedSolution]); - // Check if a new solution was submitted (latest solution ID changed) - useEffect(() => { - if (solutionHistory.length > 0 && displayedSolution) { - const latestSolution = solutionHistory[solutionHistory.length - 1]; - - // If the latest solution ID is different from the displayed one, - // a new solution was submitted - update to show the latest - if (latestSolution.id !== displayedSolution.id) { - setDisplayedSolution(latestSolution); - } - } - }, [solutionHistory, displayedSolution]); - - // Load solution content when the displayed solution changes - useEffect(() => { - const loadSolutionContent = async () => { - if (!displayedSolution || !displayedSolution.content) return; - - try { - if (task.type === TaskType.FILE) { - setSelectedFile(null); - setSelectedSolutionUrl(displayedSolution.content); - } else { - const response = await fetch(displayedSolution.content); - if (!response.ok) { - throw new Error(`Failed to fetch solution content: ${response.status}`); - } - const text = await response.text(); - setAnswer(text); + useEffect(() => { + if (prevTaskIdRef.current !== task.id) { + setDisplayedSolution(null); + setSelectedSolutionUrl(null); + + if (solutionHistory.length > 0) { + const latestSolution = solutionHistory[solutionHistory.length - 1]; + setDisplayedSolution(latestSolution); } - } catch (error) { - console.error('Error loading solution content:', error); + + prevTaskIdRef.current = task.id; } + }, [task.id, solutionHistory]); + + useEffect(() => { + if (solutionHistory.length > 0 && displayedSolution) { + const latestSolution = solutionHistory[solutionHistory.length - 1]; + + if (latestSolution.id !== displayedSolution.id) { + setDisplayedSolution(latestSolution); + } + } + }, [solutionHistory, displayedSolution]); + + // Load solution content when the displayed solution changes + useEffect(() => { + const loadSolutionContent = async () => { + if (!displayedSolution || !displayedSolution.content) return; + + try { + if (task.type === TaskType.FILE) { + setSelectedFile(null); + setSelectedSolutionUrl(displayedSolution.content); + } else { + const response = await fetch(displayedSolution.content); + if (!response.ok) { + throw new Error(`Failed to fetch solution content: ${response.status}`); + } + const text = await response.text(); + setAnswer(text); + } + } catch (error) { + console.error('Error loading solution content:', error); + } + }; + + loadSolutionContent(); + }, [displayedSolution, task.type, setAnswer, setSelectedFile]); + + const handleOpenHistory = () => { + setIsHistoryOpen(true); }; - loadSolutionContent(); - }, [displayedSolution, task.type, setAnswer, setSelectedFile]); + const handleSolutionSelect = (solution: Solution) => { + setDisplayedSolution(solution); + }; - const handleOpenHistory = () => { - setIsHistoryOpen(true); + const handleClearExistingFile = () => { + setSelectedSolutionUrl(null); + }; + + return ( +
+ {displayedSolution ? ( + <> +
+ Последнее решение +
+ + + + ) : ( +
+ Решение еще не отправлено +
+ )} + + {task.type === TaskType.INPUT && ( + + )} + + {task.type === TaskType.FILE && ( + 0} + /> + )} + + {task.type === TaskType.CODE && ( + + )} + + + + +
+ ); }; - const handleSolutionSelect = (solution: Solution) => { - setDisplayedSolution(solution); - console.log(displayedSolution) - }; - - const handleClearExistingFile = () => { - setSelectedSolutionUrl(null); - }; - - return ( -
- {displayedSolution ? ( - - ) : ( -
- Решение еще не отправлено -
- )} - - {task.type === TaskType.INPUT && ( - - )} - - {task.type === TaskType.FILE && ( - - )} - - {task.type === TaskType.CODE && ( - - )} - - - - -
- ); -}; - -export default TaskSolution; \ No newline at end of file + export default TaskSolution; \ No newline at end of file