Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
ITQ
2025-03-02 23:29:19 +03:00
11 changed files with 198 additions and 101 deletions
@@ -26,7 +26,6 @@ class CriteriaOut(Schema):
name: str name: str
slug: str slug: str
max_value: int max_value: int
min_value: int
class SubmissionOut(ModelSchema): class SubmissionOut(ModelSchema):
+5
View File
@@ -23,3 +23,8 @@ class UserSchema(ModelSchema):
class Meta: class Meta:
model = User model = User
fields = ["id", "email", "username", "created_at", "achievements"] fields = ["id", "email", "username", "created_at", "achievements"]
class StatSchema(Schema):
total_attempts: int
solved_tasks: int
+31 -1
View File
@@ -11,15 +11,17 @@ from api.v1.schemas import (
BadRequestError, BadRequestError,
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
NotFoundError, NotFoundError, UnauthorizedError,
) )
from api.v1.user.schemas import ( from api.v1.user.schemas import (
LoginSchema, LoginSchema,
RegisterSchema, RegisterSchema,
TokenSchema, TokenSchema,
UserSchema, UserSchema,
StatSchema
) )
from apps.user.models import User from apps.user.models import User
from apps.task.models import CompetitionTaskSubmission, ReviewStatusChoices
router = Router(tags=["user"]) router = Router(tags=["user"])
@@ -85,3 +87,31 @@ def get_me(request):
def get_user(request, user_id: str): def get_user(request, user_id: str):
user = get_object_or_404(User, id=user_id) user = get_object_or_404(User, id=user_id)
return status.OK, user 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
)
+1 -1
View File
@@ -35,7 +35,7 @@ class CompetitionEndpointTests(TestCase):
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def get_url(self, competition_id): 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): def test_get_competition_success(self):
response = self.client.get( response = self.client.get(
@@ -9,7 +9,7 @@ from django.utils import timezone
from apps.competition.models import Competition, State from apps.competition.models import Competition, State
from apps.review.models import Reviewer 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 from apps.user.models import User, UserRole
@@ -91,6 +91,8 @@ class Command(BaseCommand):
tasks = [] tasks = []
task_types = [ task_types = [
CompetitionTask.CompetitionTaskType.INPUT.value, CompetitionTask.CompetitionTaskType.INPUT.value,
CompetitionTask.CompetitionTaskType.REVIEW.value,
CompetitionTask.CompetitionTaskType.INPUT.value
] ]
for comp in competitions: for comp in competitions:
# Create 3 tasks per competition # Create 3 tasks per competition
@@ -108,6 +110,15 @@ class Command(BaseCommand):
submission_reviewers_count=random.randint(2, 10), submission_reviewers_count=random.randint(2, 10),
max_attempts=random.randint(1, 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) tasks.append(task)
self.stdout.write(f"Created task: {title} (type: {task_type})") self.stdout.write(f"Created task: {title} (type: {task_type})")
self.add_reviewers_to_task(tasks) self.add_reviewers_to_task(tasks)
+1 -1
View File
@@ -31,7 +31,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
"user__username", "user__username",
"user__email", "user__email",
) )
filter = ("plagiarism_checked",) list_filter = ("plagiarism_checked", "status",)
ordering = ["-timestamp"] ordering = ["-timestamp"]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
+3 -3
View File
@@ -94,9 +94,9 @@ class CompetitionTaskAttachment(BaseModel):
class CompetitionTaskSubmission(BaseModel): class CompetitionTaskSubmission(BaseModel):
class StatusChoices(models.TextChoices): class StatusChoices(models.TextChoices):
SENT = "sent" SENT = "sent", "Отправлено на проверку"
CHECKING = "checking" CHECKING = "checking", "Проверка"
CHECKED = "checked" CHECKED = "checked", "Проверено"
def submission_content_upload_to(instance, filename) -> str: def submission_content_upload_to(instance, filename) -> str:
return f"submissions/{instance.id}/content/{filename}" return f"submissions/{instance.id}/content/{filename}"
@@ -1,17 +1,19 @@
import React from 'react'; import React from 'react';
import { FileIcon } from 'lucide-react'; import { FileIcon, Download } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
interface FileSolutionProps { interface FileSolutionProps {
selectedFile: File | null; selectedFile: File | null;
setSelectedFile: (file: File | null) => void; setSelectedFile: (file: File | null) => void;
fileInputRef: React.RefObject<HTMLInputElement>; fileInputRef: React.RefObject<HTMLInputElement>;
fileUrl?: string | null;
} }
const FileSolution: React.FC<FileSolutionProps> = ({ const FileSolution: React.FC<FileSolutionProps> = ({
selectedFile, selectedFile,
setSelectedFile, setSelectedFile,
fileInputRef fileInputRef,
fileUrl = null
}) => { }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) { if (event.target.files && event.target.files[0]) {
@@ -42,6 +44,8 @@ const FileSolution: React.FC<FileSolutionProps> = ({
} }
}; };
const fileName = selectedFile ? selectedFile.name : fileUrl ? fileUrl.split('/').pop() || 'file' : '';
return ( return (
<> <>
<input <input
@@ -52,19 +56,31 @@ const FileSolution: React.FC<FileSolutionProps> = ({
accept=".jpg,.jpeg,.png,.pptx,.docx,.pdf,.xlsx,.txt" accept=".jpg,.jpeg,.png,.pptx,.docx,.pdf,.xlsx,.txt"
/> />
{selectedFile ? ( {(selectedFile || fileUrl) ? (
<div className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px]"> <div className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px]">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<FileIcon size={28} className="text-black mb-2" /> <FileIcon size={28} className="text-black mb-2" />
<span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{selectedFile.name}</span> <span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span>
<span className="text-xs text-gray-500 font-hse-sans">{(selectedFile.size / 1024).toFixed(1)} KB</span>
<Button <div className="flex items-center mt-2">
variant="ghost" {fileUrl && (
className="text-blue-500 text-sm mt-2 p-0 h-auto hover:bg-transparent hover:text-blue-600 font-hse-sans" <a
onClick={() => setSelectedFile(null)} href={fileUrl}
> download
Выбрать другой файл className="flex items-center text-blue-500 text-sm mr-3 hover:text-blue-600"
</Button> >
<Download size={16} className="mr-1" />
Скачать
</a>
)}
<Button
variant="ghost"
className="text-blue-500 text-sm p-0 h-auto hover:bg-transparent hover:text-blue-600 font-hse-sans"
onClick={() => setSelectedFile(null)}
>
{fileUrl ? "Выбрать другой файл" : "Очистить"}
</Button>
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -82,7 +98,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
Загрузить файл Загрузить файл
</span> </span>
<p className="text-xs text-gray-500 text-center font-hse-sans"> <p className="text-xs text-gray-500 text-center font-hse-sans">
Доступные форматы: jpg, jpeg, png Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt
</p> </p>
</div> </div>
)} )}
@@ -3,20 +3,22 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X } from "lucide-react";
import SolutionStatus from '../SolutionStatus'; import SolutionStatus from '../SolutionStatus';
import { Solution } from '@/shared/types/task'; import { Solution, TaskType } from '@/shared/types/task';
interface SolutionHistorySheetProps { interface SolutionHistorySheetProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
solutions: Solution[]; solutions: Solution[];
maxPoints: number maxPoints: number;
onSolutionSelect: (solution: Solution) => void;
} }
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
solutions, solutions,
maxPoints maxPoints,
onSolutionSelect
}) => { }) => {
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -32,10 +34,17 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
</div> </div>
</SheetHeader> </SheetHeader>
<div className="flex flex-col mt-3 space-y-2.5 overflow-y-auto max-h-[calc(100vh-80px)] px-4"> <div className="flex flex-col mt-3 space-y-2.5 overflow-y-auto max-h-[calc(100vh-80px)] px-4 pb-4">
{solutions.length > 0 ? ( {solutions.length > 0 ? (
solutions.map((solution, index) => ( solutions.map((solution, index) => (
<div key={index} className="w-full"> <div
key={solution.id || index}
className="w-full cursor-pointer transition-transform hover:scale-[1.01]"
onClick={() => {
onSolutionSelect(solution);
onOpenChange(false);
}}
>
<SolutionStatus solution={solution} maxPoints={maxPoints} /> <SolutionStatus solution={solution} maxPoints={maxPoints} />
</div> </div>
)) ))
@@ -1,89 +1,115 @@
import React, { useState, useRef } from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { FileIcon, Download } from 'lucide-react';
import { Task, TaskType, Solution } from '@/shared/types/task'; import { Button } from "@/components/ui/button";
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 { interface FileSolutionProps {
task: Task;
answer: string;
setAnswer: (value: string) => void;
selectedFile: File | null; selectedFile: File | null;
setSelectedFile: (file: File | null) => void; setSelectedFile: (file: File | null) => void;
onSubmit: () => void; fileInputRef: React.RefObject<HTMLInputElement>;
existingFileUrl?: string | null;
} }
const TaskSolution: React.FC<TaskSolutionProps> = ({ const FileSolution: React.FC<FileSolutionProps> = ({
task, selectedFile,
answer,
setAnswer,
selectedFile,
setSelectedFile, setSelectedFile,
onSubmit, fileInputRef,
existingFileUrl = null
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const [isHistoryOpen, setIsHistoryOpen] = useState(false); if (event.target.files && event.target.files[0]) {
const { id: competitionId } = useParams<{ id: string }>(); setSelectedFile(event.target.files[0]);
}
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 latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[solutionHistory.length - 1] : null; const handleFileUploadClick = () => {
fileInputRef.current?.click();
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.add('bg-gray-50');
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.remove('bg-gray-50');
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
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 ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <>
{latestSolution ? ( <input
<SolutionStatus solution={latestSolution} maxPoints={task.points}/> type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept=".jpg,.jpeg,.png,.pptx,.docx,.pdf,.xlsx,.txt"
/>
{hasFile ? (
<div className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px]">
<div className="flex flex-col items-center">
<FileIcon size={28} className="text-black mb-2" />
<span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span>
<div className="flex items-center mt-2">
{existingFileUrl && !selectedFile && (
<a
href={existingFileUrl}
download
className="flex items-center text-blue-500 text-sm mr-3 hover:text-blue-600"
>
<Download size={16} className="mr-1" />
Скачать
</a>
)}
<Button
variant="ghost"
className="text-blue-500 text-sm p-0 h-auto hover:bg-transparent hover:text-blue-600 font-hse-sans"
onClick={() => setSelectedFile(null)}
>
{!selectedFile && existingFileUrl ? "Выбрать другой файл" : "Очистить"}
</Button>
</div>
</div>
</div>
) : ( ) : (
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans"> <div
Решение еще не отправлено className="bg-white rounded-lg p-6 flex flex-col items-center justify-center min-h-[180px] cursor-pointer transition-colors"
onClick={handleFileUploadClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<FileIcon size={28} className="text-black mb-3" />
<span
className="bg-[var(--color-yellow-standard)] text-black font-medium rounded-full px-4 py-1.5 text-sm mb-2 font-hse-sans inline-block"
>
Загрузить файл
</span>
<p className="text-xs text-gray-500 text-center font-hse-sans">
Доступные форматы: jpg, jpeg, png, pptx, docx, pdf, xlsx, txt
</p>
</div> </div>
)} )}
</>
{task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} />
)}
{task.type === TaskType.FILE && (
<FileSolution
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
fileInputRef={fileInputRef}
/>
)}
{task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} />
)}
<ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
/>
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
maxPoints={task.points}
/>
</div>
); );
}; };
export default TaskSolution; export default FileSolution;
+4 -3
View File
@@ -15,8 +15,8 @@ export interface TaskAttachment {
enum TaskType { enum TaskType {
INPUT = "input", INPUT = "input",
FILE = "checker", FILE = "review",
CODE = "review", CODE = "checker",
} }
enum SolutionStatus { enum SolutionStatus {
@@ -29,7 +29,8 @@ interface Solution {
id: string, id: string,
status: SolutionStatus, status: SolutionStatus,
timestamp: string, timestamp: string,
earned_points: number earned_points: number,
content: string
} }
export type {Task, Solution} export type {Task, Solution}