diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 1f7eb77..4dacad9 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -13,7 +13,7 @@ router = Router(tags=["competition"]) @router.get( - "competition/{competition_id}", + "competitions/{competition_id}", response={ status.OK: schemas.CompetitionOut, status.BAD_REQUEST: global_schemas.BadRequestError, diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index c9fad87..2b9cdbe 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -75,7 +75,7 @@ def get_me(request): @router.get( - path="/user/{user_id}", + path="/users/{user_id}", response={ status.OK: UserSchema, status.BAD_REQUEST: BadRequestError, diff --git a/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py b/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py new file mode 100644 index 0000000..7ed0851 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-03-02 14:03 + +import apps.achievement.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('achievement', '0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='achievement', + name='icon', + field=models.ImageField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения'), + ), + ] diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py index 2c7724f..292598f 100644 --- a/services/backend/apps/achievement/models.py +++ b/services/backend/apps/achievement/models.py @@ -14,7 +14,7 @@ class Achievement(BaseModel): max_length=30, verbose_name="название", unique=True ) description = models.TextField(verbose_name="описание") - icon = models.FileField( + icon = models.ImageField( verbose_name="иконка достижения", upload_to=image_url_upload_to, ) diff --git a/services/backend/integrations/checker/healthcheck.py b/services/backend/integrations/checker/healthcheck.py index 1ab32f5..cf48951 100644 --- a/services/backend/integrations/checker/healthcheck.py +++ b/services/backend/integrations/checker/healthcheck.py @@ -11,7 +11,7 @@ class CheckerHealthCheck(BaseHealthCheckBackend): def check_status(self) -> None: try: response = httpx.get( - f"{settings.CHECKER_API_ENDPOINT}/ping", timeout=1 + f"{settings.CHECKER_API_ENDPOINT}/health", timeout=10 ) if response.status_code >= status.INTERNAL_SERVER_ERROR: self.add_error("Checker service is unaccessible") diff --git a/services/checker/Dockerfile b/services/checker/Dockerfile index 9f0295f..d4266d1 100644 --- a/services/checker/Dockerfile +++ b/services/checker/Dockerfile @@ -25,10 +25,6 @@ COPY --from=builder /opt/venv /opt/venv COPY . . -RUN adduser -D -g '' app && chown -R app:app ./ - -USER app - ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONOPTIMIZE=2 \ @@ -37,6 +33,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/ping || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1 CMD uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index ecd048c..02e8791 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -15,6 +15,8 @@ "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "katex": "^0.16.21", "lucide-react": "^0.476.0", @@ -392,6 +394,10 @@ "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=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 9b45c78..6a2fce5 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -21,6 +21,8 @@ "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "katex": "^0.16.21", "lucide-react": "^0.476.0", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 86735e1..554e52c 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -32,9 +32,9 @@ const App = () => { /> } /> - - } /> + + } /> ); diff --git a/services/frontend/src/components/ui/button.tsx b/services/frontend/src/components/ui/button.tsx index 11b8b06..bc84fe8 100644 --- a/services/frontend/src/components/ui/button.tsx +++ b/services/frontend/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/shared/lib/utils"; 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: { variant: { diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx index f046499..3ba38f1 100644 --- a/services/frontend/src/main.tsx +++ b/services/frontend/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router"; import App from "./App.tsx"; +import dayjs from "dayjs"; + +import "dayjs/locale/ru"; +dayjs.locale("ru"); createRoot(document.getElementById("root")!).render( diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index 405d5c0..96f990b 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Task } from '@/shared/types/task'; +import { ArrowLeft } from 'lucide-react'; interface CompetitionHeaderProps { title: string; @@ -16,10 +17,20 @@ const CompetitionHeader: React.FC = ({ return (
-
-

+
+ + + Обратно + + +

{title}

+ +
diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 451968e..09fc40c 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -6,10 +6,12 @@ import TaskSolution from "./modules/TaskSolution"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; import { Loader2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { TaskType } from "@/shared/types/task"; const CompetitionSession = () => { const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const [answer, setAnswer] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); const competitionId = id || ""; const queryClient = useQueryClient(); @@ -20,12 +22,27 @@ const CompetitionSession = () => { }); 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: () => { queryClient.invalidateQueries({ - queryKey: ['submissionHistory', competitionId, taskId] + queryKey: ['solutionHistory', competitionId, taskId] }); + setAnswer(""); + setSelectedFile(null); + }, + onError: (error) => { + console.error("Error submitting solution:", error); } }); @@ -45,8 +62,18 @@ const CompetitionSession = () => { } const handleSubmit = () => { - console.log(currentTask, competitionId, answer) - if (!currentTask || !competitionId || !answer.trim()) return; + if (!currentTask || !competitionId) 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(); }; @@ -74,9 +101,10 @@ const CompetitionSession = () => {
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 33a412a..b67c5ac 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,29 +1,31 @@ -import React from 'react'; -import { Solution } from '@/shared/types/task'; -import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils'; + import React from 'react'; + import { Solution } from '@/shared/types/task'; + import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils'; + import { format, parseISO } from 'date-fns'; + import { ru } from 'date-fns/locale'; + + interface SolutionStatusProps { + solution: Solution; + maxPoints: number; + } -interface SolutionStatusProps { - solution: Solution; - maxPoints: number; -} - -const SolutionStatus: React.FC = ({ solution, maxPoints }) => { - - return ( -
-
- - Решение {solution.id} - - - {getStatusText(solution.status, solution.earned_points, maxPoints)} - + const SolutionStatus: React.FC = ({ solution, maxPoints }) => { + const formattedDate = solution.timestamp ? format(parseISO(solution.timestamp), "d MMMM, HH:mm", { locale: ru }) : ''; + return ( +
+
+ + Решение {solution.id} + + + {getStatusText(solution.status, solution.earned_points, maxPoints)} + +
+
+ {formattedDate} +
-
- {solution.timestamp} -
-
- ); -}; + ); + }; -export default SolutionStatus; + export default SolutionStatus; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index d4b9c2c..ea0c35c 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -12,20 +12,21 @@ import SolutionHistorySheet from './components/SolutionHistorySheet'; interface TaskSolutionProps { task: Task; - solutions: Solution[]; answer: string; setAnswer: (value: string) => void; + selectedFile: File | null; + setSelectedFile: (file: File | null) => void; onSubmit: () => void; } const TaskSolution: React.FC = ({ task, - solutions = [], - answer, - setAnswer, - onSubmit, + answer, + setAnswer, + selectedFile, + setSelectedFile, + onSubmit, }) => { - const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const { id: competitionId } = useParams<{ id: string }>(); @@ -42,7 +43,7 @@ const TaskSolution: React.FC = ({ setIsHistoryOpen(true); }; - const latestSolution = solutions && solutions.length > 0 ? solutions[0] : null; + const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[solutionHistory.length - 1] : null; return (
diff --git a/services/frontend/src/pages/Review/components/review-card.tsx b/services/frontend/src/pages/Review/components/review-card.tsx new file mode 100644 index 0000000..75003f8 --- /dev/null +++ b/services/frontend/src/pages/Review/components/review-card.tsx @@ -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 ( +
+
+

+ {review.competition_name} +

+

{review.task_title}

+
+
+
+

{id}

+

+

+ {review.review_status === ReviewStatus.NOT_CHECKED + ? `Дата посылки: ${dayjs(review.submitted_at).format("D MMMM, HH:mm")}` + : `Дата проверки: ${review.checked_at}`} +

+
+

+ {review.review_status === ReviewStatus.NOT_CHECKED + ? "Не проверено" + : ""} +

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Review/index.tsx b/services/frontend/src/pages/Review/index.tsx index 233b3c0..0701e14 100644 --- a/services/frontend/src/pages/Review/index.tsx +++ b/services/frontend/src/pages/Review/index.tsx @@ -3,7 +3,10 @@ import { getReviewer, getReviewerSubmissions } from "@/shared/api/review"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; 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 { token } = useParams<{ token: string }>(); @@ -20,6 +23,22 @@ const ReviewPage = () => { 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) { return ; } @@ -34,14 +53,35 @@ const ReviewPage = () => {
- +
-

Посылки

+

Решения

- Доступные + + Доступные + {availableReviews.length > 0 && ( +
+ {availableReviews.length} +
+ )} +
Проверенные
+ + + + + + + +
diff --git a/services/frontend/src/pages/Review/modules/no-reviews.tsx b/services/frontend/src/pages/Review/modules/no-reviews.tsx new file mode 100644 index 0000000..f82ec5b --- /dev/null +++ b/services/frontend/src/pages/Review/modules/no-reviews.tsx @@ -0,0 +1,13 @@ +import { Check } from "lucide-react"; + +export const NoReviews = () => { + return ( +
+ +
+

Посылок пока нет

+

Можете расслабиться

+
+
+ ); +}; diff --git a/services/frontend/src/pages/Review/modules/reviews-list.tsx b/services/frontend/src/pages/Review/modules/reviews-list.tsx new file mode 100644 index 0000000..0715a77 --- /dev/null +++ b/services/frontend/src/pages/Review/modules/reviews-list.tsx @@ -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 ; + } + + return ( +
+ {reviews.map((review) => ( + + ))} +
+ ); +}; diff --git a/services/frontend/src/shared/api/competitions.ts b/services/frontend/src/shared/api/competitions.ts index 8c47564..eea1533 100644 --- a/services/frontend/src/shared/api/competitions.ts +++ b/services/frontend/src/shared/api/competitions.ts @@ -10,7 +10,7 @@ export const getCompetitions = async (participating?: boolean) => { }; export const getCompetition = async (id: string) => { - return await userFetch(`/competition/${id}`); + return await userFetch(`/competitions/${id}`); }; export const startCompetition = async (competitionId: string) => { diff --git a/services/frontend/src/shared/api/review.ts b/services/frontend/src/shared/api/review.ts index 887999b..1acc8d0 100644 --- a/services/frontend/src/shared/api/review.ts +++ b/services/frontend/src/shared/api/review.ts @@ -1,10 +1,12 @@ import { apiFetch } from "."; -import { Reviewer } from "../types/review"; +import { Review, Reviewer } from "../types/review"; export const getReviewer = async (token: string) => { return await apiFetch(`/review/${token}`); }; export const getReviewerSubmissions = async (token: string) => { - return await apiFetch(`/review/${token}/submissions`); + return await apiFetch<{ submissions: Review[] }>( + `/review/${token}/submissions`, + ); }; diff --git a/services/frontend/src/shared/api/session.ts b/services/frontend/src/shared/api/session.ts index a1dca15..4a04dc4 100644 --- a/services/frontend/src/shared/api/session.ts +++ b/services/frontend/src/shared/api/session.ts @@ -20,18 +20,18 @@ export const submitTaskSolution = async ( solution: string | File ) => { const endpoint = `/competitions/${competitionId}/tasks/${taskId}/submit`; + const formData = new FormData(); + + // туповатый костыль но для мвп сойдет if (typeof solution === 'string') { - return await userFetch(endpoint, { - method: 'POST', - body: { content: solution } - }); + const textFile = new File([solution], 'solution.txt', { type: 'text/plain' }); + formData.append('content', textFile); } else { - const formData = new FormData(); - formData.append('file', solution); - - return await userFetch(endpoint, { - method: 'POST', - body: formData - }); + formData.append('content', solution); } + + return await userFetch(endpoint, { + method: 'POST', + body: formData + }); }; \ No newline at end of file diff --git a/services/frontend/src/shared/types/review.ts b/services/frontend/src/shared/types/review.ts index f3a9094..47e0a14 100644 --- a/services/frontend/src/shared/types/review.ts +++ b/services/frontend/src/shared/types/review.ts @@ -3,3 +3,38 @@ export interface Reviewer { name: 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; +}