diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 97a9fd1..c623e2f 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -26,7 +26,6 @@ class CriteriaOut(Schema): name: str slug: str max_value: int - min_value: int class SubmissionOut(ModelSchema): diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index 832d91f..b97f6ac 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -23,3 +23,8 @@ class UserSchema(ModelSchema): class Meta: model = User fields = ["id", "email", "username", "created_at", "achievements"] + + +class StatSchema(Schema): + total_attempts: int + solved_tasks: int diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index 2b9cdbe..d7a3dfb 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -11,15 +11,17 @@ from api.v1.schemas import ( BadRequestError, ConflictError, ForbiddenError, - NotFoundError, + NotFoundError, UnauthorizedError, ) from api.v1.user.schemas import ( LoginSchema, RegisterSchema, TokenSchema, UserSchema, + StatSchema ) from apps.user.models import User +from apps.task.models import CompetitionTaskSubmission, ReviewStatusChoices router = Router(tags=["user"]) @@ -85,3 +87,31 @@ def get_me(request): def get_user(request, user_id: str): user = get_object_or_404(User, id=user_id) return status.OK, user + + +@router.get( + "/me/stat", + response={ + status.OK: StatSchema, + status.UNAUTHORIZED: UnauthorizedError + }, +) +def get_my_stat(request): + user_submissions = CompetitionTaskSubmission.objects.filter( + user=request.auth + ) + checked_attempts = user_submissions.filter(status=CompetitionTaskSubmission.StatusChoices.CHECKED).all() + 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 is_correct: + success_attempts_cnt += 1 + + return StatSchema( + total_attempts=len(user_submissions), + solved_tasks=success_attempts_cnt + ) diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py index 03f94c6..b4a1609 100644 --- a/services/backend/apps/competition/tests.py +++ b/services/backend/apps/competition/tests.py @@ -35,7 +35,7 @@ class CompetitionEndpointTests(TestCase): self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} def get_url(self, competition_id): - return f"/api/v1/competition/{competition_id}" + return f"/api/v1/competitions/{competition_id}" def test_get_competition_success(self): response = self.client.get( diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index d78d0a5..756130c 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -9,7 +9,7 @@ from django.utils import timezone from apps.competition.models import Competition, State from apps.review.models import Reviewer -from apps.task.models import CompetitionTask, CompetitionTaskSubmission +from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskCriteria from apps.user.models import User, UserRole @@ -91,6 +91,8 @@ class Command(BaseCommand): tasks = [] task_types = [ CompetitionTask.CompetitionTaskType.INPUT.value, + CompetitionTask.CompetitionTaskType.REVIEW.value, + CompetitionTask.CompetitionTaskType.INPUT.value ] for comp in competitions: # Create 3 tasks per competition @@ -108,6 +110,15 @@ class Command(BaseCommand): submission_reviewers_count=random.randint(2, 10), max_attempts=random.randint(1, 10), ) + if task_type == CompetitionTask.CompetitionTaskType.REVIEW.value: + for j in range(5): + CompetitionTaskCriteria.objects.create( + task=task, + name=f"Criteria_{j}", + slug=f"criteria_{j}", + description=f"Criteria description {j}", + max_value=random.randint(1, 10), + ) tasks.append(task) self.stdout.write(f"Created task: {title} (type: {task_type})") self.add_reviewers_to_task(tasks) diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index 1cf4361..4a8ff15 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -31,7 +31,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): "user__username", "user__email", ) - filter = ("plagiarism_checked",) + list_filter = ("plagiarism_checked", "status",) ordering = ["-timestamp"] def has_add_permission(self, request, obj=None): diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index dc917ef..8bf11e3 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -94,9 +94,9 @@ class CompetitionTaskAttachment(BaseModel): class CompetitionTaskSubmission(BaseModel): class StatusChoices(models.TextChoices): - SENT = "sent" - CHECKING = "checking" - CHECKED = "checked" + SENT = "sent", "Отправлено на проверку" + CHECKING = "checking", "Проверка" + CHECKED = "checked", "Проверено" def submission_content_upload_to(instance, filename) -> str: return f"submissions/{instance.id}/content/{filename}" 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 5e103b6..ed1a191 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 @@ -1,17 +1,19 @@ import React from 'react'; -import { FileIcon } from 'lucide-react'; +import { FileIcon, Download } from 'lucide-react'; import { Button } from "@/components/ui/button"; interface FileSolutionProps { selectedFile: File | null; setSelectedFile: (file: File | null) => void; fileInputRef: React.RefObject; + fileUrl?: string | null; } const FileSolution: React.FC = ({ selectedFile, setSelectedFile, - fileInputRef + fileInputRef, + fileUrl = null }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -42,6 +44,8 @@ const FileSolution: React.FC = ({ } }; + const fileName = selectedFile ? selectedFile.name : fileUrl ? fileUrl.split('/').pop() || 'file' : ''; + return ( <> = ({ accept=".jpg,.jpeg,.png,.pptx,.docx,.pdf,.xlsx,.txt" /> - {selectedFile ? ( + {(selectedFile || fileUrl) ? (
- {selectedFile.name} - {(selectedFile.size / 1024).toFixed(1)} KB - + {fileName} + +
+ {fileUrl && ( + + + Скачать + + )} + +
) : ( @@ -82,7 +98,7 @@ const FileSolution: React.FC = ({ Загрузить файл

- Доступные форматы: jpg, jpeg, png + Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt

)} 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 dc8fe95..870d511 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,20 +3,22 @@ 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/task'; +import { Solution, TaskType } from '@/shared/types/task'; interface SolutionHistorySheetProps { isOpen: boolean; onOpenChange: (open: boolean) => void; solutions: Solution[]; - maxPoints: number + maxPoints: number; + onSolutionSelect: (solution: Solution) => void; } const SolutionHistorySheet: React.FC = ({ isOpen, onOpenChange, solutions, - maxPoints + maxPoints, + onSolutionSelect }) => { return ( @@ -32,10 +34,17 @@ const SolutionHistorySheet: React.FC = ({ -
+
{solutions.length > 0 ? ( solutions.map((solution, index) => ( -
+
{ + onSolutionSelect(solution); + onOpenChange(false); + }} + >
)) diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index ea0c35c..cd2b1d4 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,89 +1,115 @@ -import React, { useState, useRef } 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 from 'react'; +import { FileIcon, Download } from 'lucide-react'; +import { Button } from "@/components/ui/button"; -interface TaskSolutionProps { - task: Task; - answer: string; - setAnswer: (value: string) => void; +interface FileSolutionProps { selectedFile: File | null; setSelectedFile: (file: File | null) => void; - onSubmit: () => void; + fileInputRef: React.RefObject; + existingFileUrl?: string | null; } -const TaskSolution: React.FC = ({ - task, - answer, - setAnswer, - selectedFile, +const FileSolution: React.FC = ({ + selectedFile, setSelectedFile, - onSubmit, + fileInputRef, + existingFileUrl = 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); + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setSelectedFile(event.target.files[0]); + } }; - const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[solutionHistory.length - 1] : null; + const handleFileUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.currentTarget.classList.add('bg-gray-50'); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.currentTarget.classList.remove('bg-gray-50'); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.currentTarget.classList.remove('bg-gray-50'); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + setSelectedFile(e.dataTransfer.files[0]); + } + }; + + const fileName = selectedFile + ? selectedFile.name + : existingFileUrl + ? existingFileUrl.split('/').pop() || 'file' + : ''; + + const hasFile = !!selectedFile || !!existingFileUrl; return ( -
- {latestSolution ? ( - + <> + + + {hasFile ? ( +
+
+ + {fileName} + +
+ {existingFileUrl && !selectedFile && ( + + + Скачать + + )} + +
+
+
) : ( -
- Решение еще не отправлено +
+ + + Загрузить файл + +

+ Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt +

)} - - {task.type === TaskType.INPUT && ( - - )} - - {task.type === TaskType.FILE && ( - - )} - - {task.type === TaskType.CODE && ( - - )} - - - - -
+ ); }; -export default TaskSolution; \ No newline at end of file +export default FileSolution; \ 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 07c6fe1..64e829e 100644 --- a/services/frontend/src/shared/types/task.ts +++ b/services/frontend/src/shared/types/task.ts @@ -15,8 +15,8 @@ export interface TaskAttachment { enum TaskType { INPUT = "input", - FILE = "checker", - CODE = "review", + FILE = "review", + CODE = "checker", } enum SolutionStatus { @@ -29,7 +29,8 @@ interface Solution { id: string, status: SolutionStatus, timestamp: string, - earned_points: number + earned_points: number, + content: string } export type {Task, Solution}