Merge remote-tracking branch 'origin/master'

This commit is contained in:
Timur
2025-03-02 19:11:41 +03:00
18 changed files with 257 additions and 49 deletions
+3
View File
@@ -5,3 +5,6 @@ class CoreConfig(AppConfig):
name = "apps.review" name = "apps.review"
label = "review" label = "review"
verbose_name = "Проверка" verbose_name = "Проверка"
def ready(self):
import apps.review.signals
+14
View File
@@ -0,0 +1,14 @@
# myapp/signals.py
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from apps.review.models import Review
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
@receiver(m2m_changed, sender=CompetitionTask.reviewers.through)
def print_reviewers(sender, instance, action, **kwargs):
if action in ['post_add', 'post_remove', 'post_clear']:
submissions = CompetitionTaskSubmission.objects.filter(task=instance)
for submission in submissions:
submission.send_on_review()
+1 -1
View File
@@ -182,7 +182,7 @@ class CompetitionTaskSubmission(BaseModel):
] # да это медленно работает и чо ] # да это медленно работает и чо
for reviewer in reviewers: for reviewer in reviewers:
Review.objects.create( Review.objects.update_or_create(
reviewer=reviewer, reviewer=reviewer,
submission=self, submission=self,
) )
+6
View File
@@ -15,6 +15,8 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"katex": "^0.16.21", "katex": "^0.16.21",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
@@ -392,6 +394,10 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
+2
View File
@@ -21,6 +21,8 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"katex": "^0.16.21", "katex": "^0.16.21",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
+2 -2
View File
@@ -32,9 +32,9 @@ const App = () => {
/> />
<Route path="/profile" element={<UserProfile />} /> <Route path="/profile" element={<UserProfile />} />
<Route path="/review/:token" element={<ReviewPage />} />
</Route> </Route>
<Route path="/review/:token" element={<ReviewPage />} />
</Routes> </Routes>
</QueryClientProvider> </QueryClientProvider>
); );
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,scale] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive not-disabled:active:scale-[0.95] ",
{ {
variants: { variants: {
variant: { variant: {
+4
View File
@@ -2,6 +2,10 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import App from "./App.tsx"; import App from "./App.tsx";
import dayjs from "dayjs";
import "dayjs/locale/ru";
dayjs.locale("ru");
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
@@ -6,10 +6,12 @@ import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TaskType } from "@/shared/types/task";
const CompetitionSession = () => { const CompetitionSession = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const competitionId = id || ""; const competitionId = id || "";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -20,12 +22,27 @@ const CompetitionSession = () => {
}); });
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer), mutationFn: () => {
if (!currentTask || !competitionId) throw new Error("Missing task or competition ID");
if (currentTask.type === TaskType.FILE) {
if (!selectedFile) throw new Error("No file selected");
return submitTaskSolution(competitionId, taskId || "", selectedFile);
} else {
if (!answer.trim()) throw new Error("Answer is empty");
return submitTaskSolution(competitionId, taskId || "", answer);
}
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['submissionHistory', competitionId, taskId] queryKey: ['solutionHistory', competitionId, taskId]
}); });
setAnswer(""); setAnswer("");
setSelectedFile(null);
},
onError: (error) => {
console.error("Error submitting solution:", error);
} }
}); });
@@ -45,8 +62,18 @@ const CompetitionSession = () => {
} }
const handleSubmit = () => { const handleSubmit = () => {
console.log(currentTask, competitionId, answer) if (!currentTask || !competitionId) return;
if (!currentTask || !competitionId || !answer.trim()) return;
if (currentTask.type === TaskType.FILE && !selectedFile) {
console.error("No file selected");
return;
}
if (currentTask.type !== TaskType.FILE && !answer.trim()) {
console.error("Answer is empty");
return;
}
submitMutation.mutate(); submitMutation.mutate();
}; };
@@ -74,9 +101,10 @@ const CompetitionSession = () => {
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={[]}
answer={answer} answer={answer}
setAnswer={setAnswer} setAnswer={setAnswer}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
</div> </div>
@@ -1,29 +1,31 @@
import React from 'react'; import React from 'react';
import { Solution } from '@/shared/types/task'; import { Solution } from '@/shared/types/task';
import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils'; import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
import { format, parseISO } from 'date-fns';
import { ru } from 'date-fns/locale';
interface SolutionStatusProps { interface SolutionStatusProps {
solution: Solution; solution: Solution;
maxPoints: number; maxPoints: number;
} }
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => { const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
const formattedDate = solution.timestamp ? format(parseISO(solution.timestamp), "d MMMM, HH:mm", { locale: ru }) : '';
return ( return (
<div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}> <div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}> <span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
Решение {solution.id} Решение {solution.id}
</span> </span>
<span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}> <span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
{getStatusText(solution.status, solution.earned_points, maxPoints)} {getStatusText(solution.status, solution.earned_points, maxPoints)}
</span> </span>
</div>
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
{formattedDate}
</div>
</div> </div>
<div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}> );
{solution.timestamp} };
</div>
</div>
);
};
export default SolutionStatus; export default SolutionStatus;
@@ -12,20 +12,21 @@ import SolutionHistorySheet from './components/SolutionHistorySheet';
interface TaskSolutionProps { interface TaskSolutionProps {
task: Task; task: Task;
solutions: Solution[];
answer: string; answer: string;
setAnswer: (value: string) => void; setAnswer: (value: string) => void;
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
onSubmit: () => void; onSubmit: () => void;
} }
const TaskSolution: React.FC<TaskSolutionProps> = ({ const TaskSolution: React.FC<TaskSolutionProps> = ({
task, task,
solutions = [],
answer, answer,
setAnswer, setAnswer,
selectedFile,
setSelectedFile,
onSubmit, onSubmit,
}) => { }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const { id: competitionId } = useParams<{ id: string }>(); const { id: competitionId } = useParams<{ id: string }>();
@@ -42,7 +43,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
setIsHistoryOpen(true); setIsHistoryOpen(true);
}; };
const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null; const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[0] : null;
return ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <div className="md:w-[500px] flex flex-col gap-4">
@@ -0,0 +1,37 @@
import { Review, ReviewStatus } from "@/shared/types/review";
import dayjs from "dayjs";
interface ReviewCardProps {
review: Review;
}
export const ReviewCard = ({ review }: ReviewCardProps) => {
const id = review.id.split("-").at(-1)?.slice(0, 6);
return (
<div className="bg-card flex items-center justify-between gap-8 rounded-lg px-8 py-5">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground font-semibold">
{review.competition_name}
</p>
<h1 className="text-2xl font-semibold">{review.task_title}</h1>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="text-muted-foreground flex gap-1.5 font-semibold">
<p>{id}</p>
<p></p>
<p>
{review.review_status === ReviewStatus.NOT_CHECKED
? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}`
: `Дата проверки: ${review.checked_at}`}
</p>
</div>
<h1 className="text-2xl font-semibold">
{review.review_status === ReviewStatus.NOT_CHECKED
? "Не проверено"
: ""}
</h1>
</div>
</div>
);
};
+44 -4
View File
@@ -3,7 +3,10 @@ import { getReviewer, getReviewerSubmissions } from "@/shared/api/review";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { ReviewHeader } from "./modules/review-header"; import { ReviewHeader } from "./modules/review-header";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ReviewsList } from "./modules/reviews-list";
import React from "react";
import { ReviewStatus } from "@/shared/types/review";
const ReviewPage = () => { const ReviewPage = () => {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -20,6 +23,22 @@ const ReviewPage = () => {
retry: 0, retry: 0,
}); });
const availableReviews = React.useMemo(
() =>
(submissionsQuery.data?.submissions || []).filter(
(s) => s.review_status === ReviewStatus.NOT_CHECKED,
),
[submissionsQuery.data],
);
const checkedReviews = React.useMemo(
() =>
(submissionsQuery.data?.submissions || []).filter(
(s) => s.review_status === ReviewStatus.CHECKED,
),
[submissionsQuery.data],
);
if (reviewerQuery.isLoading || submissionsQuery.isLoading) { if (reviewerQuery.isLoading || submissionsQuery.isLoading) {
return <Loading />; return <Loading />;
} }
@@ -34,14 +53,35 @@ const ReviewPage = () => {
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<ReviewHeader reviewer={reviewerQuery.data} /> <ReviewHeader reviewer={reviewerQuery.data} />
<Tabs defaultValue="available" className="my-3"> <Tabs
defaultValue="available"
className="my-3 flex flex-col items-stretch gap-6"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold">Посылки</h1> <h1 className="text-3xl font-semibold">Решения</h1>
<TabsList> <TabsList>
<TabsTrigger value="available">Доступные</TabsTrigger> <TabsTrigger
value="available"
className="flex items-center gap-2"
>
<span>Доступные</span>
{availableReviews.length > 0 && (
<div className="bg-primary min-w-5 rounded-full px-1.5 py-0.5 text-xs">
{availableReviews.length}
</div>
)}
</TabsTrigger>
<TabsTrigger value="checked">Проверенные</TabsTrigger> <TabsTrigger value="checked">Проверенные</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="available" asChild>
<ReviewsList reviews={availableReviews} />
</TabsContent>
<TabsContent value="checked" asChild>
<ReviewsList reviews={checkedReviews} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</div> </div>
@@ -0,0 +1,13 @@
import { Check } from "lucide-react";
export const NoReviews = () => {
return (
<div className="flex flex-col items-center gap-4">
<Check size={32} />
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold">Посылок пока нет</h2>
<p className="text-muted-foreground text-lg">Можете расслабиться</p>
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import { Review } from "@/shared/types/review";
import { ReviewCard } from "../components/review-card";
import { NoReviews } from "./no-reviews";
interface ReviewsListProp {
reviews: Review[];
}
export const ReviewsList = ({ reviews }: ReviewsListProp) => {
if (reviews.length === 0) {
return <NoReviews />;
}
return (
<div className="flex flex-col items-stretch gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
};
+4 -2
View File
@@ -1,10 +1,12 @@
import { apiFetch } from "."; import { apiFetch } from ".";
import { Reviewer } from "../types/review"; import { Review, Reviewer } from "../types/review";
export const getReviewer = async (token: string) => { export const getReviewer = async (token: string) => {
return await apiFetch<Reviewer>(`/review/${token}`); return await apiFetch<Reviewer>(`/review/${token}`);
}; };
export const getReviewerSubmissions = async (token: string) => { export const getReviewerSubmissions = async (token: string) => {
return await apiFetch(`/review/${token}/submissions`); return await apiFetch<{ submissions: Review[] }>(
`/review/${token}/submissions`,
);
}; };
+1 -1
View File
@@ -27,7 +27,7 @@ export const submitTaskSolution = async (
}); });
} else { } else {
const formData = new FormData(); const formData = new FormData();
formData.append('file', solution); formData.append('content', solution);
return await userFetch(endpoint, { return await userFetch(endpoint, {
method: 'POST', method: 'POST',
@@ -3,3 +3,38 @@ export interface Reviewer {
name: string; name: string;
surname: string; surname: string;
} }
export interface Review {
id: string;
review_status: ReviewStatus;
evaluation?: ReviewEvaluation[];
criteries?: ReviewCriteria[];
submitted_at: Date;
competition: string;
competition_name: string;
task: string;
content: string;
stdout?: string;
result?: {};
earned_points?: number;
checked_at?: Date;
task_title: string;
}
export enum ReviewStatus {
NOT_CHECKED = "not_checked",
CHECKED = "checked",
CHECKING = "checking",
}
export interface ReviewEvaluation {
slug: string;
mark: number;
}
export interface ReviewCriteria {
name: string;
slug: string;
max_value: number;
min_value: number;
}