From 803d1d293227545396471d7e062443ee4afa56eb Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 29 Sep 2025 13:19:22 +0300 Subject: [PATCH 01/77] add: file uploader element to add solution page --- .../Homeworks/CourseHomeworkExperimental.tsx | 11 +-- .../Solutions/AddOrEditSolution.tsx | 69 ++++++++++++------- .../Solutions/TaskSolutionsPage.tsx | 59 ++++++++-------- 3 files changed, 79 insertions(+), 60 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index bf29b0755..028413a12 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -38,7 +38,7 @@ import {enqueueSnackbar} from "notistack"; import DeletionConfirmation from "../DeletionConfirmation"; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; -import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; @@ -455,7 +455,7 @@ const CourseHomeworkExperimental: FC<{ onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo - const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) + const deferredHomeworks = homework.tasks!.filter(t => t.isDeferred!) const tasksCount = homework.tasks!.length const [showEditMode, setShowEditMode] = useState(false) const [editMode, setEditMode] = useState(false) @@ -486,11 +486,12 @@ const CourseHomeworkExperimental: FC<{ {homework.title} + {props.isMentor && deferredHomeworks!.length > 0 && + + } {tasksCount > 0 && 0 ? ` (🕘 ${deferredTasks.length} ` + Utils.pluralizeHelper(["отложенная", "отложенные", "отложенных"], deferredTasks.length) + ")" : "")}/> + label={tasksCount + " " + Utils.pluralizeHelper(["Задача", "Задачи", "Задач"], tasksCount)}/> } {homework.tags?.filter(t => DefaultTags.includes(t)).map((tag, index) => ( diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index d4f553115..04b73fd51 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -1,35 +1,34 @@ import * as React from 'react'; import ApiSingleton from "../../api/ApiSingleton"; -import { - AccountDataDto, - GetSolutionModel, - HomeworkTaskViewModel, - PostSolutionModel, - SolutionState -} from "@/api"; -import {FC, useState} from "react"; -import {Alert, Autocomplete, Grid, DialogContent, Dialog, DialogTitle, DialogActions, Button} from "@mui/material"; -import {MarkdownEditor} from "../Common/MarkdownEditor"; -import {TestTag} from "../Common/HomeworkTags"; -import {LoadingButton} from "@mui/lab"; +import { AccountDataDto, GetSolutionModel, HomeworkTaskViewModel, SolutionState, SolutionViewModel } from "@/api"; +import { FC, useState } from "react"; +import { Alert, Autocomplete, Grid, DialogContent, Dialog, DialogTitle, DialogActions, Button } from "@mui/material"; +import { MarkdownEditor } from "../Common/MarkdownEditor"; +import { TestTag } from "../Common/HomeworkTags"; +import { LoadingButton } from "@mui/lab"; import TextField from "@mui/material/TextField"; +import FilesUploader from '../Files/FilesUploader'; +import { IEditFilesState } from '../Homeworks/CourseHomeworkExperimental'; +import { IFileInfo } from '../Files/IFileInfo'; +import { CourseUnitType } from '../Files/CourseUnitType'; interface IAddSolutionProps { userId: string lastSolution: GetSolutionModel | undefined, task: HomeworkTaskViewModel, supportsGroup: boolean, - students: AccountDataDto[] + students: AccountDataDto[], + filesInfo: IFileInfo[], onAdd: () => void, onCancel: () => void, } const AddOrEditSolution: FC = (props) => { - const {lastSolution} = props + const { lastSolution } = props const isEdit = lastSolution?.state === SolutionState.NUMBER_0 const lastGroup = lastSolution?.groupMates?.map(x => x.userId!) || [] - const [solution, setSolution] = useState({ + const [solution, setSolution] = useState({ githubUrl: lastSolution?.githubUrl || "", comment: isEdit ? lastSolution!.comment : "", groupMateIds: lastGroup @@ -44,21 +43,28 @@ const AddOrEditSolution: FC = (props) => { props.onAdd() } - const {githubUrl} = solution + const { githubUrl } = solution const isTest = props.task.tags?.includes(TestTag) const showTestGithubInfo = isTest && githubUrl?.startsWith("https://github") && githubUrl.includes("/pull/") const courseMates = props.students.filter(s => props.userId !== s.userId) + const initialFilesInfo = props.filesInfo.filter(x => x.id !== undefined) + const [filesState, setFilesState] = useState({ + initialFilesInfo: initialFilesInfo, + selectedFilesInfo: props.filesInfo, + isLoadingInfo: false + }); + return ( props.onCancel()} aria-labelledby="form-dialog-title"> + maxWidth="md" + open={true} + onClose={() => props.onCancel()} aria-labelledby="form-dialog-title"> Отправить новое решение - + = (props) => { }} /> {showTestGithubInfo && - + Для данного решения будет сохранена информация о коммитах на момент отправки. -
+
Убедитесь, что работа закончена, и отправьте решение в конце.
} {!isEdit && githubUrl === lastSolution?.githubUrl && !showTestGithubInfo && - Ссылка + Ссылка взята из предыдущего решения}
- {props.supportsGroup && + {props.supportsGroup && = (props) => { )} /> {!isEdit && lastGroup?.length > 0 && solution.groupMateIds === lastGroup && - Команда + Команда взята из предыдущего решения} } - + = (props) => { })) }} /> + { + setFilesState((prevState) => ({ + ...prevState, + selectedFilesInfo: filesInfo + })); + }} + courseUnitType={CourseUnitType.Solution} + courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : 0} />
diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 581129625..4f5076a21 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -12,8 +12,8 @@ import { SolutionState } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; -import {FC, useEffect, useState} from "react"; -import {Grid, Tab, Tabs} from "@material-ui/core"; +import { FC, useEffect, useState } from "react"; +import { Grid, Tab, Tabs } from "@material-ui/core"; import { Checkbox, Chip, @@ -21,14 +21,14 @@ import { Stack, Tooltip } from "@mui/material"; -import {useParams, Link, useNavigate} from "react-router-dom"; +import { useParams, Link, useNavigate } from "react-router-dom"; import Step from "@mui/material/Step"; import StepButton from "@mui/material/StepButton"; import StudentStatsUtils from "../../services/StudentStatsUtils"; -import {getTip} from "../Common/HomeworkTags"; +import { getTip } from "../Common/HomeworkTags"; import Lodash from "lodash"; -import {appBarStateManager} from "../AppBar"; -import {DotLottieReact} from "@lottiefiles/dotlottie-react"; +import { appBarStateManager } from "../AppBar"; +import { DotLottieReact } from "@lottiefiles/dotlottie-react"; interface ITaskSolutionsState { isLoaded: boolean @@ -52,7 +52,7 @@ const FilterProps = { } const TaskSolutionsPage: FC = () => { - const {taskId} = useParams() + const { taskId } = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -100,11 +100,11 @@ const TaskSolutionsPage: FC = () => { }) } - const {homeworkGroupedSolutions, courseId, courseMates} = taskSolutionPage + const { homeworkGroupedSolutions, courseId, courseMates } = taskSolutionPage const student = courseMates.find(x => x.userId === userId)! useEffect(() => { - appBarStateManager.setContextAction({actionName: "К курсу", link: `/courses/${courseId}`}) + appBarStateManager.setContextAction({ actionName: "К курсу", link: `/courses/${courseId}` }) return () => appBarStateManager.reset() }, [courseId]) @@ -113,11 +113,11 @@ const TaskSolutionsPage: FC = () => { .map(x => ({ ...x, homeworkSolutions: x.homeworkSolutions!.map(t => - ({ - homeworkTitle: t.homeworkTitle, - previews: t.studentSolutions!.map(y => - ({...y, ...StudentStatsUtils.calculateLastRatedSolutionInfo(y.solutions!, y.maxRating!)})) - })) + ({ + homeworkTitle: t.homeworkTitle, + previews: t.studentSolutions!.map(y => + ({ ...y, ...StudentStatsUtils.calculateLastRatedSolutionInfo(y.solutions!, y.maxRating!) })) + })) })) const taskSolutionsPreview = taskSolutionsWithPreview.flatMap(x => { @@ -171,19 +171,19 @@ const TaskSolutionsPage: FC = () => { const renderRatingChip = (solutionsDescription: string, color: string, lastRatedSolution: Solution) => { return {solutionsDescription}}> - + style={{ whiteSpace: 'pre-line' }}>{solutionsDescription}}> + } - return taskSolutionPage.isLoaded ?
- + return taskSolutionPage.isLoaded ?
+ + style={{ overflowY: "hidden", overflowX: "auto", minHeight: 80 }}> {taskSolutionsPreviewFiltered.map((t, index) => { const isCurrent = versionsOfCurrentTask.includes(t.taskId!.toString()) const { @@ -192,13 +192,13 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = t return - {index > 0 &&
} + {index > 0 &&
} + style={{ color: "black", textDecoration: "none" }}> { - if (isCurrent) ref?.scrollIntoView({inline: "nearest"}) + if (isCurrent) ref?.scrollIntoView({ inline: "nearest" }) }} color={color} icon={renderRatingChip(solutionsDescription, color, lastRatedSolution)}> @@ -216,7 +216,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")} /> Только нерешенные
@@ -253,11 +253,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- }/>; + } />; })} } @@ -281,7 +281,7 @@ const TaskSolutionsPage: FC = () => { forMentor={false} student={student} courseStudents={[student]} - solutions={currentTaskSolutions}/> + solutions={currentTaskSolutions} />
)}
@@ -294,7 +294,8 @@ const TaskSolutionsPage: FC = () => { onCancel={onCancelAddSolution} lastSolution={lastSolution} students={courseMates} - supportsGroup={task.isGroupWork!}/>} + supportsGroup={task.isGroupWork!} + filesInfo={[]} />}
: (
From ae19141380bc3db3ee52f40dfa1a64c8a0e9d471 Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 30 Sep 2025 12:02:38 +0300 Subject: [PATCH 02/77] add: files count validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 28334ef80..1e5963e8c 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -44,8 +44,14 @@ const FilesUploader: React.FC = (props) => { }, [props.initialFilesInfo]); const maxFileSizeInBytes = 100 * 1024 * 1024; + const maxFilesCount = 5; const validateFiles = (files: File[]): boolean => { + if ((props.initialFilesInfo == null ? 0 : props.initialFilesInfo.length) + files.length > maxFilesCount) { + setError(`Выбрано слишком много файлов. + Максимально допустимое количество файлов: ${maxFilesCount} штук.`); + return false; + } for (const file of files) { if (file.size > maxFileSizeInBytes) { setError(`Файл "${file.name}" слишком большой. From 9a525521cd29360e2975d4b593520b52744a680f Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 30 Sep 2025 12:03:22 +0300 Subject: [PATCH 03/77] add: file type validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 1e5963e8c..e65ff59cc 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -58,6 +58,10 @@ const FilesUploader: React.FC = (props) => { Максимальный допустимый размер: ${(maxFileSizeInBytes / 1024 / 1024).toFixed(1)} MB.`); return false; } + if (file.type.startsWith("application")) { + setError(`Файл "${file.name}" имеет недопустимый тип "${file.type}`); + return false; + } } return true From 7390848e6638f880e2e4386b7f365e4aafad2a55 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 2 Oct 2025 21:36:39 +0300 Subject: [PATCH 04/77] fix: files count validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index e65ff59cc..ac2b2cf7b 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -47,7 +47,7 @@ const FilesUploader: React.FC = (props) => { const maxFilesCount = 5; const validateFiles = (files: File[]): boolean => { - if ((props.initialFilesInfo == null ? 0 : props.initialFilesInfo.length) + files.length > maxFilesCount) { + if ((props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > maxFilesCount) { setError(`Выбрано слишком много файлов. Максимально допустимое количество файлов: ${maxFilesCount} штук.`); return false; From 47f612347b7fb1ffdd1682f75c9559d7442f94a8 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 11:06:00 +0300 Subject: [PATCH 05/77] fix: default lastSolution id --- hwproj.front/src/components/Solutions/AddOrEditSolution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 04b73fd51..7d7d37dfa 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -140,7 +140,7 @@ const AddOrEditSolution: FC = (props) => { })); }} courseUnitType={CourseUnitType.Solution} - courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : 0} /> + courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : -1} /> From ee101405c7b7c27bd70ac2179e09cb444d66cd0e Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:37:32 +0300 Subject: [PATCH 06/77] feat: files processing in taskSolutionPage --- .../Solutions/TaskSolutionsPage.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 4f5076a21..00354e044 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -138,6 +138,152 @@ const TaskSolutionsPage: FC = () => { }); }) + const [courseFilesState, setCourseFilesState] = useState({ + processingFilesState: {}, + courseFiles: [] + }) + + const intervalsRef = React.useRef>({}); + + const unsetCommonLoading = (solutionId: number) => { + setCourseFilesState(prev => ({ + ...prev, + processingFilesState: { + ...prev.processingFilesState, + [solutionId]: {isLoading: false} + } + })); + } + + const stopProcessing = (homeworkId: number) => { + if (intervalsRef.current[homeworkId]) { + const {interval, timeout} = intervalsRef.current[homeworkId]; + clearInterval(interval); + clearTimeout(timeout); + delete intervalsRef.current[homeworkId]; + } + }; + + const setCommonLoading = (solutionId: number) => { + setCourseFilesState(prev => ({ + ...prev, + processingFilesState: { + ...prev.processingFilesState, + [solutionId]: {isLoading: true} + } + })); + } + + const updateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { + setCourseFilesState(prev => ({ + ...prev, + courseFiles: [ + ...prev.courseFiles.filter( + f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), + ...files + ] + })); + }; + + // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками + const getFilesByInterval = (solutionId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => { + // Очищаем предыдущие таймеры + stopProcessing(solutionId); + + let attempt = 0; + const maxAttempts = 10; + let delay = 1000; // Начальная задержка 1 сек + + const scopeDto: ScopeDTO = { + courseId: +courseId!, + courseUnitType: CourseUnitType.Solution, + courseUnitId: solutionId + } + + const fetchFiles = async () => { + if (attempt >= maxAttempts) { + stopProcessing(solutionId); + enqueueSnackbar("Превышено допустимое количество попыток получения информации о файлах", { + variant: "warning", + autoHideDuration: 2000 + }); + return; + } + + attempt++; + try { + const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); + console.log(`Попытка ${attempt}:`, files); + + // Первый вариант для явного отображения всех файлов + if (waitingNewFilesCount === 0 + && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { + updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + unsetCommonLoading(solutionId) + } + + // Второй вариант для явного отображения всех файлов + if (waitingNewFilesCount > 0 + && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { + updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + unsetCommonLoading(solutionId) + } + + // Условие прекращения отправки запросов на получения записей файлов + if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount + && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { + stopProcessing(solutionId); + unsetCommonLoading(solutionId) + } + + } catch (error) { + console.error(`Ошибка (попытка ${attempt}):`, error); + } + } + // Создаем интервал с задержкой + const interval = setInterval(fetchFiles, delay); + + // Создаем таймаут для автоматической остановки + const timeout = setTimeout(() => { + stopProcessing(solutionId); + unsetCommonLoading(solutionId); + }, 10000); + + // Сохраняем интервал и таймаут в ref + intervalsRef.current[solutionId] = {interval, timeout}; + + // Сигнализируем о начале загрузки через состояние + setCommonLoading(solutionId) + } + + // Останавливаем все активные интервалы при размонтировании + useEffect(() => { + return () => { + Object.values(intervalsRef.current).forEach(({interval, timeout}) => { + clearInterval(interval); + clearTimeout(timeout); + }); + intervalsRef.current = {}; + }; + }, []); + + const getCourseFilesInfo = async () => { + let courseFilesInfo = [] as FileInfoDTO[] + try { + courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) + enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); + } + setCourseFilesState(prevState => ({ + ...prevState, + courseFiles: courseFilesInfo + })) + } + useEffect(() => { + getCourseFilesInfo() + }) + const currentHomeworksGroup = taskSolutionsWithPreview .find(x => x.homeworkSolutions! .some(h => h.previews! From b949a2e004a6060355cf75ff49e2f5e362095555 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:38:12 +0300 Subject: [PATCH 07/77] feat: files processing in studentSolutionPage --- .../Solutions/StudentSolutionsPage.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index f34014719..6c4cf3c73 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -97,6 +97,28 @@ const StudentSolutionsPage: FC = () => { const [secondMentorId, setSecondMentorId] = useState(undefined) + const [courseFilesState, setCourseFilesState] = useState({ + processingFilesState: {}, + courseFiles: [] + }) + + const getCourseFilesInfo = async () => { + let courseFilesInfo = [] as FileInfoDTO[] + try { + courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) + enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); + } + setCourseFilesState(prevState => ({ + ...prevState, + courseFiles: courseFilesInfo + })) + } + useEffect(() => { + getCourseFilesInfo() + }) + const handleFilterChange = (event: SelectChangeEvent) => { const filters = filterState.length > 0 ? [] : ["Только непроверенные" as Filter] localStorage.setItem(FilterStorageKey, filters.join(", ")) From 403a459aa24fb7adc315dae346dec983db6a638d Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:42:25 +0300 Subject: [PATCH 08/77] feat: make course files state universal --- hwproj.front/src/components/Courses/Course.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index c2309e0fe..d436e405d 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -58,9 +58,9 @@ interface ICourseState { showQrCode: boolean; } -interface ICourseFilesState { +export interface ICourseFilesState { processingFilesState: { - [homeworkId: number]: { + [courseUnitId: number]: { isLoading: boolean; intervalId?: NodeJS.Timeout; }; From e3a9190c942d263eee0c16b87119caccd0cb5bfa Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:49:31 +0300 Subject: [PATCH 09/77] feat: add props in task solutions component ^ Conflicts: ^ hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx --- .../components/Solutions/StudentSolutionsPage.tsx | 5 ++++- .../Solutions/TaskSolutionComponent.tsx | 8 ++++++-- .../src/components/Solutions/TaskSolutions.tsx | 15 +++++++++++++-- .../components/Solutions/TaskSolutionsPage.tsx | 13 ++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index 6c4cf3c73..b10cd3032 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -5,7 +5,7 @@ import { HomeworkTaskViewModel, Solution, TaskSolutionsStats, - SolutionState, StudentDataDto, AccountDataDto + SolutionState, StudentDataDto, AccountDataDto, FileInfoDTO } from "@/api"; import Typography from "@material-ui/core/Typography"; import Task from "../Tasks/Task"; @@ -41,6 +41,9 @@ import {appBarStateManager} from "../AppBar"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {RemovedFromCourseTag} from "@/components/Common/StudentTags"; import AuthService from "@/services/AuthService"; +import {ICourseFilesState} from "@/components/Courses/Course"; +import {enqueueSnackbar} from "notistack"; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; interface IStudentSolutionsPageState { currentTaskId: string diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 5cf885748..1c4264060 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -9,7 +9,7 @@ import { HomeworkTaskViewModel, SolutionState, SolutionActualityDto, - SolutionActualityPart, StudentDataDto + SolutionActualityPart, StudentDataDto, FileInfoDTO } from '@/api' import ApiSingleton from "../../api/ApiSingleton"; import { @@ -41,6 +41,8 @@ import KeyboardCommandKeyIcon from '@mui/icons-material/KeyboardCommandKey'; import MouseOutlinedIcon from '@mui/icons-material/MouseOutlined'; import BlurOnIcon from '@mui/icons-material/BlurOn'; import BlurOffIcon from '@mui/icons-material/BlurOff'; +import {UserAvatar} from "../Common/UserAvatar"; +import FileInfoConverter from "@/components/Utils/FileInfoConverter"; type TaskWithCriteria = HomeworkTaskViewModel & {}; @@ -52,7 +54,6 @@ type CriterionRating = { comment: string; }; - interface ISolutionProps { courseId: number, solution: GetSolutionModel | undefined, @@ -62,6 +63,8 @@ interface ISolutionProps { lastRating?: number, onRateSolutionClick?: () => void, isLastSolution: boolean, + courseFilesInfo: FileInfoDTO[], + isProcessing: boolean, } interface ISolutionState { @@ -373,6 +376,7 @@ const TaskSolutionComponent: FC = (props) => { const lecturer = solution?.lecturer const lecturerName = lecturer && (lecturer.surname + " " + lecturer.name) const commitsActuality = solutionActuality?.commitsActuality + const filesInfo = solution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, solution.id) : [] const getDatesDiff = (_date1: Date, _date2: Date) => { const truncateToMinutes = (date: Date) => { diff --git a/hwproj.front/src/components/Solutions/TaskSolutions.tsx b/hwproj.front/src/components/Solutions/TaskSolutions.tsx index acb7891da..1f974db98 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutions.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutions.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import {FC, useEffect, useState} from 'react'; import TaskSolutionComponent from "./TaskSolutionComponent"; import { + FileInfoDTO, GetSolutionModel, GetTaskQuestionDto, HomeworkTaskViewModel, @@ -24,6 +25,12 @@ interface ITaskSolutionsProps { courseStudents: StudentDataDto[] forMentor: boolean onSolutionRateClick?: () => void + courseFiles: FileInfoDTO[] + processingFiles: { + [solutionId: number]: { + isLoading: boolean; + } + }; } interface ITaskSolutionsState { @@ -191,7 +198,9 @@ const TaskSolutions: FC = (props) => { lastRating={lastRating} onRateSolutionClick={onSolutionRateClick} isLastSolution={true} - courseId={props.courseId}/> + courseId={props.courseId} + courseFilesInfo={props.courseFiles} + isProcessing={props.processingFiles[lastSolution.id!]?.isLoading || false}/> :
Студент не отправил ни одного решения. = (props) => { student={student!} onRateSolutionClick={onSolutionRateClick} isLastSolution={false} - courseId={props.courseId}/> + courseId={props.courseId} + courseFilesInfo={props.courseFiles} + isProcessing={props.processingFiles[x.id!]?.isLoading || false}/> {i < arrayOfRatedSolutions.length - 1 ? : null} )} diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 00354e044..10cb8c299 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -5,9 +5,9 @@ import AddOrEditSolution from "./AddOrEditSolution"; import Button from "@material-ui/core/Button"; import TaskSolutions from "./TaskSolutions"; import { - AccountDataDto, + AccountDataDto, FileInfoDTO, HomeworksGroupUserTaskSolutions, - HomeworkTaskViewModel, + HomeworkTaskViewModel, ScopeDTO, Solution, SolutionState } from "@/api"; @@ -29,6 +29,11 @@ import { getTip } from "../Common/HomeworkTags"; import Lodash from "lodash"; import { appBarStateManager } from "../AppBar"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import {enqueueSnackbar} from "notistack"; +import {FileStatus} from "@/components/Files/FileStatus"; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {ICourseFilesState} from "@/components/Courses/Course"; interface ITaskSolutionsState { isLoaded: boolean @@ -427,7 +432,9 @@ const TaskSolutionsPage: FC = () => { forMentor={false} student={student} courseStudents={[student]} - solutions={currentTaskSolutions} /> + solutions={currentTaskSolutions} + courseFiles={courseFilesState.courseFiles} + processingFiles={courseFilesState.processingFilesState}/> )} From b62c1d5e35084a193162141a12261be960c0190d Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:50:07 +0300 Subject: [PATCH 10/77] feat: get files info for solutions in converter --- hwproj.front/src/components/Utils/FileInfoConverter.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hwproj.front/src/components/Utils/FileInfoConverter.ts b/hwproj.front/src/components/Utils/FileInfoConverter.ts index 0c8e534fe..e2a70a9b1 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -31,4 +31,11 @@ export default class FileInfoConverter { && filesInfo.courseUnitId === homeworkId) ) } + + public static getSolutionFilesInfo(filesInfo: FileInfoDTO[], solutionId: number): IFileInfo[] { + return FileInfoConverter.fromFileInfoDTOArray( + filesInfo.filter(filesInfo => filesInfo.courseUnitType === CourseUnitType.Solution + && filesInfo.courseUnitId === solutionId) + ) + } } \ No newline at end of file From b59500081ccca17e0784d69bdd3715c9ad10e797 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:50:57 +0300 Subject: [PATCH 11/77] feat: files preview in solution component --- .../Solutions/TaskSolutionComponent.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 1c4264060..dbc9e99ce 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1066,6 +1066,24 @@ const TaskSolutionComponent: FC = (props) => { ? {solution.comment} : } + {props.isProcessing ? ( +
+ +   Обрабатываем файлы... +
+ ) : filesInfo.length > 0 && ( +
+ { + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) + window.open(url, '_blank'); + }} + /> +
+ ) + } } From 987cc2aba64c66fc7172f06835c9d1b2cc6cc59b Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:55:02 +0300 Subject: [PATCH 12/77] feat: processing files after adding solution --- .../Solutions/AddOrEditSolution.tsx | 56 +++++++++++++++++-- .../Solutions/StudentSolutionsPage.tsx | 2 + .../Solutions/TaskSolutionsPage.tsx | 4 +- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 7d7d37dfa..49c41f3df 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -11,16 +11,21 @@ import FilesUploader from '../Files/FilesUploader'; import { IEditFilesState } from '../Homeworks/CourseHomeworkExperimental'; import { IFileInfo } from '../Files/IFileInfo'; import { CourseUnitType } from '../Files/CourseUnitType'; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {enqueueSnackbar} from "notistack"; +import FileInfoConverter from "@/components/Utils/FileInfoConverter"; interface IAddSolutionProps { + courseId: number userId: string lastSolution: GetSolutionModel | undefined, task: HomeworkTaskViewModel, supportsGroup: boolean, students: AccountDataDto[], - filesInfo: IFileInfo[], + courseFilesInfo: FileInfoDTO[], onAdd: () => void, onCancel: () => void, + onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; } const AddOrEditSolution: FC = (props) => { @@ -35,12 +40,53 @@ const AddOrEditSolution: FC = (props) => { }) const [disableSend, setDisableSend] = useState(false) + const filesInfo = lastSolution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, lastSolution.id) : [] const handleSubmit = async (e: any) => { e.preventDefault(); setDisableSend(true) - await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) - props.onAdd() + + let solutionId = await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) + + // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить + const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => + initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) + .map(fileInfo => fileInfo.id!) + + // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые + const newFiles = filesState.selectedFilesInfo.filter(selectedFile => + selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) + + // Если требуется, отправляем запрос на обработку файлов + if (deletingFileIds.length + newFiles.length > 0) { + try { + await ApiSingleton.customFilesApi.processFiles({ + courseId: props.courseId!, + courseUnitType: CourseUnitType.Solution, + courseUnitId: solutionId, + deletingFileIds: deletingFileIds, + newFiles: newFiles, + }); + } catch (e) { + const errors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(`Проблема при обработке файлов. ${errors[0]}`, { + variant: "warning", + autoHideDuration: 2000 + }); + } + } + if (deletingFileIds.length === 0 && newFiles.length === 0) { + props.onAdd() + } else { + try { + props.onStartProcessing(solutionId, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds) + props.onAdd() + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) + enqueueSnackbar(responseErrors[0], { variant: "warning", autoHideDuration: 4000 }); + props.onAdd() + } + } } const { githubUrl } = solution @@ -48,10 +94,10 @@ const AddOrEditSolution: FC = (props) => { const showTestGithubInfo = isTest && githubUrl?.startsWith("https://github") && githubUrl.includes("/pull/") const courseMates = props.students.filter(s => props.userId !== s.userId) - const initialFilesInfo = props.filesInfo.filter(x => x.id !== undefined) + const initialFilesInfo = filesInfo.filter(x => x.id !== undefined) const [filesState, setFilesState] = useState({ initialFilesInfo: initialFilesInfo, - selectedFilesInfo: props.filesInfo, + selectedFilesInfo: filesInfo, isLoadingInfo: false }); diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index b10cd3032..0a5dd7e0a 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -445,6 +445,8 @@ const StudentSolutionsPage: FC = () => { await getTaskData(currentTaskId, secondMentorId, true) //else navigate(`/task/${currentTaskId}/${studentSolutionsPreview[nextStudentIndex].student.userId}`) }} + courseFiles={courseFilesState.courseFiles} + processingFiles={courseFilesState.processingFilesState} /> diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 10cb8c299..0cca229d3 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -441,6 +441,7 @@ const TaskSolutionsPage: FC = () => { {taskSolutionPage.addSolution && { lastSolution={lastSolution} students={courseMates} supportsGroup={task.isGroupWork!} - filesInfo={[]} />} + courseFilesInfo={courseFilesState.courseFiles} + onStartProcessing={getFilesByInterval}/>}
: (
From d7e052a125ccf5cf2d5e55b55dec49323f4bff53 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:55:56 +0300 Subject: [PATCH 13/77] fix: add solution imports --- hwproj.front/src/components/Solutions/AddOrEditSolution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 49c41f3df..dfe0ae25c 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import ApiSingleton from "../../api/ApiSingleton"; -import { AccountDataDto, GetSolutionModel, HomeworkTaskViewModel, SolutionState, SolutionViewModel } from "@/api"; +import { AccountDataDto, GetSolutionModel, HomeworkTaskViewModel, SolutionState, SolutionViewModel, FileInfoDTO } from "@/api"; import { FC, useState } from "react"; import { Alert, Autocomplete, Grid, DialogContent, Dialog, DialogTitle, DialogActions, Button } from "@mui/material"; import { MarkdownEditor } from "../Common/MarkdownEditor"; From d301e61b4c6cadcd92a7cb51bd68c8fb5be7a93d Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 15 Oct 2025 12:25:32 +0300 Subject: [PATCH 14/77] feat: make edit files intarface exporting --- .../src/components/Homeworks/CourseHomeworkExperimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 028413a12..b93e3746c 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -56,7 +56,7 @@ interface IEditHomeworkState { hasErrors: boolean; } -interface IEditFilesState { +export interface IEditFilesState { initialFilesInfo: IFileInfo[] selectedFilesInfo: IFileInfo[] isLoadingInfo: boolean From 3119729fb6bda25d280137a3ddcad001798af3b6 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:31:24 +0300 Subject: [PATCH 15/77] fix: start processing after adding solution --- hwproj.front/src/components/Solutions/AddOrEditSolution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index dfe0ae25c..358468fca 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -79,8 +79,8 @@ const AddOrEditSolution: FC = (props) => { props.onAdd() } else { try { - props.onStartProcessing(solutionId, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds) props.onAdd() + props.onStartProcessing(solutionId, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds) } catch (e) { const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) enqueueSnackbar(responseErrors[0], { variant: "warning", autoHideDuration: 4000 }); From 4f753eb4fed603fb975d83dc265a6b24f96d3c44 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:08 +0300 Subject: [PATCH 16/77] feat: add solution privacy attribute --- .../Filters/SolutionPrivacyAttribute.cs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs new file mode 100644 index 000000000..d308c7a13 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs @@ -0,0 +1,119 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using HwProj.CoursesService.Client; +using HwProj.SolutionsService.Client; +using HwProj.Models.ContentService.DTO; +using HwProj.Models.Roles; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace HwProj.APIGateway.API.Filters; + +public class SolutionPrivacyAttribute : ActionFilterAttribute +{ + private readonly ICoursesServiceClient _coursesServiceClient; + private readonly ISolutionsServiceClient _solutionsServiceClient; + + public SolutionPrivacyAttribute(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) + { + _coursesServiceClient = coursesServiceClient; + _solutionsServiceClient = solutionsServiceClient; + } + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var userId = context.HttpContext.User.Claims + .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; + var userRole = context.HttpContext.User.Claims + .FirstOrDefault(claim => claim.Type.ToString().EndsWith("role"))?.Value; + + if (userId == null || userRole == null) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "В запросе не передан идентификатор пользователя", + ContentType = "application/json" + }; + return; + } + + + long courseId = -1; + var courseUnitType = ""; + long courseUnitId = -1; + + // Для метода GetStatuses (параметр: filesScope) + if (context.ActionArguments.TryGetValue("filesScope", out var filesScope) && + filesScope is ScopeDTO scopeDto) + { + courseId = scopeDto.CourseId; + courseUnitType = scopeDto.CourseUnitType; + courseUnitId = scopeDto.CourseUnitId; + } + // Для метода GetDownloadLink (параметр: fileScope) + else if (context.ActionArguments.TryGetValue("fileScope", out var fileScope) && + fileScope is FileScopeDTO fileScopeDto) + { + courseId = fileScopeDto.CourseId; + courseUnitType = fileScopeDto.CourseUnitType; + courseUnitId = fileScopeDto.CourseUnitId; + } + + if (courseUnitType == "Homework") return; + + if (userRole == Roles.StudentRole) + { + string? studentId = null; + Console.WriteLine(courseId); + if (courseId != -1) + { + var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); + Console.WriteLine(courseUnitId); + studentId = solution.StudentId; + } + + if (userId != studentId) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "Недостаточно прав для работы с файлами: Вы не являетесь студентом, отправляющим задание", + ContentType = "application/json" + }; + return; + } + } else if (userRole == Roles.LecturerRole) + { + string[]? mentorIds = null; + + if (courseId != -1) + mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + if (mentorIds == null || !mentorIds.Contains(userId)) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "Недостаточно прав для работы с файлами: Вы не являетесь ментором на курсе", + ContentType = "application/json" + }; + return; + } + } + + await next.Invoke(); + } + + private static string? GetValueFromRequest(HttpRequest request, string key) + { + if (request.Query.TryGetValue(key, out var queryValue)) + return queryValue.ToString(); + + if (request.HasFormContentType && request.Form.TryGetValue(key, out var formValue)) + return formValue.ToString(); + + return null; + } +} From af469075efec1509dadea8a42771353a6f223c80 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:36 +0300 Subject: [PATCH 17/77] feat: add privacy attribute for processing --- .../CourseMentorOrSolutionStudentAttribute.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs new file mode 100644 index 000000000..3ca1103e5 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs @@ -0,0 +1,101 @@ +namespace HwProj.APIGateway.API.Filters; +using System.Linq; +using System.Threading.Tasks; +using CoursesService.Client; +using SolutionsService.Client; +using HwProj.Models.ContentService.DTO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public class CourseMentorOrSolutionStudentAttribute : ActionFilterAttribute +{ + private readonly ICoursesServiceClient _coursesServiceClient; + private readonly ISolutionsServiceClient _solutionsServiceClient; + + public CourseMentorOrSolutionStudentAttribute(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) + { + _coursesServiceClient = coursesServiceClient; + _solutionsServiceClient = solutionsServiceClient; + } + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var userId = context.HttpContext.User.Claims + .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; + if (userId == null) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "В запросе не передан идентификатор пользователя", + ContentType = "application/json" + }; + return; + } + + long courseId = -1; + var courseUnitType = ""; + long courseUnitId = -1; + + // Для метода Process (параметр: processFilesDto) + if (context.ActionArguments.TryGetValue("processFilesDto", out var processFilesDto) && + processFilesDto is ProcessFilesDTO dto) + { + courseId = dto.FilesScope.CourseId; + courseUnitType = dto.FilesScope.CourseUnitType; + courseUnitId = dto.FilesScope.CourseUnitId; + } + + if (courseUnitType == "Solution") + { + string? studentId = null; + + if (courseId != -1) + { + var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); + studentId = solution.StudentId; + } + + if (userId != studentId) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "Недостаточно прав для работы с файлами: Вы не являетесь студентом, отправляющим задание", + ContentType = "application/json" + }; + return; + } + } else if (courseUnitType == "Homework") + { + string[]? mentorIds = null; + + if (courseId != -1) + mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + if (mentorIds == null || !mentorIds.Contains(userId)) + { + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status403Forbidden, + Content = "Недостаточно прав для работы с файлами: Вы не являетесь ментором на курсе", + ContentType = "application/json" + }; + return; + } + } + + await next.Invoke(); + } + + private static string? GetValueFromRequest(HttpRequest request, string key) + { + if (request.Query.TryGetValue(key, out var queryValue)) + return queryValue.ToString(); + + if (request.HasFormContentType && request.Form.TryGetValue(key, out var formValue)) + return formValue.ToString(); + + return null; + } +} \ No newline at end of file From 75a5dab45e32e457d3d791a2078a8be172326618 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:35:29 +0300 Subject: [PATCH 18/77] feat: add attributes to startup --- HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index ec49a65cd..07a20bb44 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -79,7 +79,9 @@ public void ConfigureServices(IServiceCollection services) services.AddNotificationsServiceClient(); services.AddContentServiceClient(); - services.AddScoped(); + services.AddScoped(); //TODO : delete? + services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IHostEnvironment env) From ba113ae1196ffb32b2e8668237e5fbb98292d1f1 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:36:14 +0300 Subject: [PATCH 19/77] feat: add lecturer or student role --- HwProj.Common/HwProj.Models/Roles/Roles.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.Common/HwProj.Models/Roles/Roles.cs b/HwProj.Common/HwProj.Models/Roles/Roles.cs index a78aa84b6..d66b9effb 100644 --- a/HwProj.Common/HwProj.Models/Roles/Roles.cs +++ b/HwProj.Common/HwProj.Models/Roles/Roles.cs @@ -6,5 +6,6 @@ public static class Roles public const string StudentRole = "Student"; public const string ExpertRole = "Expert"; public const string LecturerOrExpertRole = "Lecturer, Expert"; + public const string LecturerOrStudentRole = "Lecturer, Student"; } } From e5f1c9c54c98da21a1d4ea68d331a5a41e6fe835 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:37:00 +0300 Subject: [PATCH 20/77] feat: change processing validation (back) --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 83247af98..93230c1cb 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -24,8 +24,8 @@ public FilesController(IAuthServiceClient authServiceClient, } [HttpPost("process")] - [Authorize(Roles = Roles.LecturerRole)] - [ServiceFilter(typeof(CourseMentorOnlyAttribute))] + [Authorize(Roles = Roles.LecturerOrStudentRole)] + [ServiceFilter(typeof(CourseMentorOrSolutionStudentAttribute))] [ProducesResponseType((int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) From 611f208acd044689282982cf37a24a3d33b25f8e Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:37:21 +0300 Subject: [PATCH 21/77] feat: change get statuses validation (back) --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 93230c1cb..c9e0311c5 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -37,8 +37,8 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles } [HttpPost("statuses")] - [Authorize(Roles = Roles.LecturerRole)] - [ServiceFilter(typeof(CourseMentorOnlyAttribute))] + [Authorize(Roles = Roles.LecturerOrStudentRole)] + [ServiceFilter(typeof(SolutionPrivacyAttribute))] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task GetStatuses(ScopeDTO filesScope) From d708bd057bfe077ceeb2d45cd33d16101f0fb86c Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:38:15 +0300 Subject: [PATCH 22/77] feat: change download link validation (back) --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index c9e0311c5..41ec13055 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -49,12 +49,13 @@ public async Task GetStatuses(ScopeDTO filesScope) : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesStatusesResult.Errors); } - [HttpGet("downloadLink")] + [HttpPost("downloadLink")] + [ServiceFilter(typeof(SolutionPrivacyAttribute))] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] - public async Task GetDownloadLink([FromQuery] long fileId) + public async Task GetDownloadLink([FromForm] FileScopeDTO fileScope) { - var result = await _contentServiceClient.GetDownloadLinkAsync(fileId); + var result = await _contentServiceClient.GetDownloadLinkAsync(fileScope.FileId); return result.Succeeded ? Ok(result.Value) : NotFound(result.Errors); From e55d7434b491d5432e8f90dccc187b4759fafd44 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:39:53 +0300 Subject: [PATCH 23/77] feat: add scope dto with file id --- .../HwProj.Models/ContentService/DTO/FileScopeDTO.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs new file mode 100644 index 000000000..ac89e4ac9 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs @@ -0,0 +1,10 @@ +namespace HwProj.Models.ContentService.DTO +{ + public class FileScopeDTO + { + public long FileId { get; set; } + public long CourseId { get; set; } + public string CourseUnitType { get; set; } + public long CourseUnitId { get; set; } + } +} From daa0e631c093749be8ee854ac6237133e43a22d6 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:41:29 +0300 Subject: [PATCH 24/77] feat: change download link api call (front) --- hwproj.front/src/api/CustomFilesApi.ts | 18 ++++++++++++------ .../Homeworks/CourseHomeworkExperimental.tsx | 8 ++++++-- .../Solutions/TaskSolutionComponent.tsx | 7 +++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/hwproj.front/src/api/CustomFilesApi.ts b/hwproj.front/src/api/CustomFilesApi.ts index 7ba971a66..d8a546994 100644 --- a/hwproj.front/src/api/CustomFilesApi.ts +++ b/hwproj.front/src/api/CustomFilesApi.ts @@ -1,4 +1,4 @@ -import {BaseAPI} from "./api"; +import {BaseAPI, ScopeDTO} from "./api"; import { IProcessFilesDto } from "../components/Files/IProcessFilesDto"; export default class CustomFilesApi extends BaseAPI { @@ -35,10 +35,16 @@ export default class CustomFilesApi extends BaseAPI { } } - public getDownloadFileLink = async (fileKey: number) => { - // Необходимо, чтобы символы & и др. не влияли на обработку запроса на бэкенде - const response = await fetch(this.basePath + `/api/Files/downloadLink?fileId=${fileKey}`, { - method: 'GET', + public getDownloadFileLink = async (fileKey: number, fileScope: ScopeDTO) => { + const formData = new FormData(); + formData.append('FileId', fileKey.toString()); + formData.append('CourseId', fileScope.courseId?.toString() ?? "-1"); + formData.append('CourseUnitType', fileScope.courseUnitType?.toString() ?? "-1"); + formData.append('CourseUnitId', fileScope.courseUnitId?.toString() ?? "-1"); + + const response = await fetch(this.basePath + `/api/Files/downloadLink`, { + method: 'POST', + body: formData, headers: { 'Authorization': this.getApiKeyValue(), }, @@ -75,4 +81,4 @@ export default class CustomFilesApi extends BaseAPI { ? this.configuration.apiKey('Authorization') : this.configuration.apiKey; } -} \ No newline at end of file +} diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index b93e3746c..5b8c10869 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -38,7 +38,7 @@ import {enqueueSnackbar} from "notistack"; import DeletionConfirmation from "../DeletionConfirmation"; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; -import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; @@ -532,7 +532,11 @@ const CourseHomeworkExperimental: FC<{ showOkStatus={props.isMentor} filesInfo={filesInfo} onClickFileInfo={async (fileInfo: IFileInfo) => { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { + courseId: homework.courseId, + courseUnitType: CourseUnitType.Homework, + courseUnitId: homework.id, + }) window.open(url, '_blank'); }} /> diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index dbc9e99ce..5c6bfdc80 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1074,10 +1074,13 @@ const TaskSolutionComponent: FC = (props) => { ) : filesInfo.length > 0 && (
{ - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { + courseId: props.courseId, + courseUnitType: fileInfo.courseUnitType, + courseUnitId: fileInfo.courseUnitId + }) window.open(url, '_blank'); }} /> From 1278615fb099970f9c06aa8f7968354f837e4b2f Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 22:03:44 +0300 Subject: [PATCH 25/77] fix: files preview without comment --- .../Solutions/TaskSolutionComponent.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 5c6bfdc80..0b4e2e874 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1066,29 +1066,31 @@ const TaskSolutionComponent: FC = (props) => { ? {solution.comment} : } - {props.isProcessing ? ( -
- -   Обрабатываем файлы... -
- ) : filesInfo.length > 0 && ( -
- { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { - courseId: props.courseId, - courseUnitType: fileInfo.courseUnitType, - courseUnitId: fileInfo.courseUnitId - }) - window.open(url, '_blank'); - }} - /> -
- ) - } } + + {props.isProcessing ? ( +
+ +   Обрабатываем файлы... +
+ ) : filesInfo.length > 0 && ( +
+ { + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { + courseId: props.courseId, + courseUnitType: fileInfo.courseUnitType, + courseUnitId: fileInfo.courseUnitId + }) + window.open(url, '_blank'); + }} + /> +
+ )} +
} {props.forMentor && props.isLastSolution && student && From 990027c2d6b3402bf58aa3c1bbc126f5d62e906b Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 20 Oct 2025 11:20:24 +0300 Subject: [PATCH 26/77] feat: file type validation (back) --- .../Attributes/CorrectFileTypeAttribute.cs | 67 +++++++++++++++++++ .../ContentService/DTO/ProcessFilesDTO.cs | 1 + 2 files changed, 68 insertions(+) create mode 100644 HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs new file mode 100644 index 000000000..2eea1e536 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace HwProj.Models.ContentService.Attributes +{ + [AttributeUsage(AttributeTargets.Property)] + public class CorrectFileTypeAttribute : ValidationAttribute + { + private static HashSet forbiddenFileSignatures = new HashSet{ + + new byte[] { 0x4d, 0x5a }, // MZ (exe BE) + new byte[] { 0x5a, 0x4d }, // ZM (exe LE) + + new byte[] { 0x7F, 0x45, 0x4C, 0x46 }, // ELF + + new byte[] { 0xfe, 0xed, 0xfa, 0xce }, // Mach-O BE 32-bit + new byte[] { 0xfe, 0xed, 0xfa, 0xcf }, // Mach-O BE 64-bit + new byte[] { 0xce, 0xfa, 0xed, 0xfe }, // Mach-O LE 32-bit + new byte[] { 0xcf, 0xfa, 0xed, 0xfe }, // Mach-O LE 64-bit + + }; + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var files = value switch + { + IFormFile singleFile => new[] { singleFile }, + IEnumerable filesCollection => filesCollection, + _ => null + }; + + if (files == null) return ValidationResult.Success; + + foreach (var file in files) + { + try + { + // Первые байты для проверки сигнатуры + var buffer = new byte[4]; + var bytesRead = file.OpenReadStream().Read(buffer, 0, buffer.Length); + + if (bytesRead < 2) + return ValidationResult.Success; // Слишком короткий файл, не исполняемый + + foreach (var signature in forbiddenFileSignatures) + { + if (signature.SequenceEqual(buffer.Take(signature.Length))) + { + return new ValidationResult( + $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); + } + } + } + catch + { + return new ValidationResult( + $"Невозможно открыть файл `{file.FileName}`"); + } + } + + return ValidationResult.Success; + } + } +} \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs index 36616f013..39deb7be3 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs @@ -10,6 +10,7 @@ public class ProcessFilesDTO public List DeletingFileIds { get; set; } = new List(); + [CorrectFileType] [MaxFileSize(100 * 1024 * 1024)] public List NewFiles { get; set; } = new List(); } From ae5b58b02021c133b7f0372c3563cd01b9b0865b Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 20 Oct 2025 11:21:54 +0300 Subject: [PATCH 27/77] fix: front file type validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 12 +++++++++++- .../src/components/Solutions/AddOrEditSolution.tsx | 1 + .../src/components/Solutions/TaskSolutionsPage.tsx | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index ac2b2cf7b..27b2afe8e 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -46,6 +46,16 @@ const FilesUploader: React.FC = (props) => { const maxFileSizeInBytes = 100 * 1024 * 1024; const maxFilesCount = 5; + const forbiddenFileTypes = [ + 'application/vnd.microsoft.portable-executable', + 'application/x-msdownload', + 'application/x-ms-installer', + 'application/x-ms-dos-executable', + 'application/x-dosexec', + 'application/x-msdos-program', + 'application/octet-stream', // если тип двоичного файла не определен, отбрасывать + ] + const validateFiles = (files: File[]): boolean => { if ((props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > maxFilesCount) { setError(`Выбрано слишком много файлов. @@ -58,7 +68,7 @@ const FilesUploader: React.FC = (props) => { Максимальный допустимый размер: ${(maxFileSizeInBytes / 1024 / 1024).toFixed(1)} MB.`); return false; } - if (file.type.startsWith("application")) { + if (forbiddenFileTypes.includes(file.type)) { setError(`Файл "${file.name}" имеет недопустимый тип "${file.type}`); return false; } diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 358468fca..7e6f6608a 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -49,6 +49,7 @@ const AddOrEditSolution: FC = (props) => { let solutionId = await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить + // TODO: вынести? const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) .map(fileInfo => fileInfo.id!) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 0cca229d3..c705836a4 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -191,6 +191,7 @@ const TaskSolutionsPage: FC = () => { }; // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками + //TODO: вынести в отдельную функцию? const getFilesByInterval = (solutionId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => { // Очищаем предыдущие таймеры stopProcessing(solutionId); From 364127e413a2100f69077b179868bbdfd5547266 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 20 Oct 2025 18:53:32 +0300 Subject: [PATCH 28/77] feat: add files access for groups --- .../Filters/SolutionPrivacyAttribute.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs index d308c7a13..24ea74b2a 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using HwProj.CoursesService.Client; @@ -61,21 +62,21 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context courseUnitType = fileScopeDto.CourseUnitType; courseUnitId = fileScopeDto.CourseUnitId; } - - if (courseUnitType == "Homework") return; + + if (courseUnitType == "Homework") next.Invoke(); if (userRole == Roles.StudentRole) { - string? studentId = null; - Console.WriteLine(courseId); + IEnumerable studentIds = []; if (courseId != -1) { var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); - Console.WriteLine(courseUnitId); - studentId = solution.StudentId; + studentIds = studentIds.Append(solution.StudentId); + var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + studentIds = studentIds.Concat(group.FirstOrDefault()?.StudentsIds ?? Array.Empty()); } - if (userId != studentId) + if (!studentIds.Contains(userId)) { context.Result = new ContentResult { From b4ceae3f2796b0a91a194da110e6de3d21f676ac Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:32:51 +0300 Subject: [PATCH 29/77] refactor: make studentIds HashSet --- .../Filters/SolutionPrivacyAttribute.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs index 24ea74b2a..a566fa578 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -67,13 +66,13 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context if (userRole == Roles.StudentRole) { - IEnumerable studentIds = []; + HashSet studentIds = []; if (courseId != -1) { var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); - studentIds = studentIds.Append(solution.StudentId); + studentIds.Add(solution.StudentId); var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds = studentIds.Concat(group.FirstOrDefault()?.StudentsIds ?? Array.Empty()); + studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); } if (!studentIds.Contains(userId)) From 4b52b46374a8d18391fefb17152fdceb72a6034f Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:33:57 +0300 Subject: [PATCH 30/77] feat: process files for groupmates --- .../Filters/CourseMentorOrSolutionStudentAttribute.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs index 3ca1103e5..6a34b2cb4 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs @@ -1,6 +1,7 @@ namespace HwProj.APIGateway.API.Filters; using System.Linq; using System.Threading.Tasks; +using System.Collections.Generic; using CoursesService.Client; using SolutionsService.Client; using HwProj.Models.ContentService.DTO; @@ -38,7 +39,6 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context var courseUnitType = ""; long courseUnitId = -1; - // Для метода Process (параметр: processFilesDto) if (context.ActionArguments.TryGetValue("processFilesDto", out var processFilesDto) && processFilesDto is ProcessFilesDTO dto) { @@ -51,13 +51,16 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context { string? studentId = null; + HashSet studentIds = []; if (courseId != -1) { var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); - studentId = solution.StudentId; + studentIds.Add(solution.StudentId); + var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); } - if (userId != studentId) + if (!studentIds.Contains(userId)) { context.Result = new ContentResult { From 1e5bd317ab2e9bdbf2cf39b08f5744bce3a2218d Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 17:21:35 +0300 Subject: [PATCH 31/77] fix: dispose stream in back type validation --- .../Attributes/CorrectFileTypeAttribute.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 2eea1e536..374b19fae 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -40,24 +40,27 @@ public class CorrectFileTypeAttribute : ValidationAttribute { // Первые байты для проверки сигнатуры var buffer = new byte[4]; - var bytesRead = file.OpenReadStream().Read(buffer, 0, buffer.Length); + using (var stream = file.OpenReadStream()) + { + var bytesRead = stream.Read(buffer, 0, buffer.Length); - if (bytesRead < 2) - return ValidationResult.Success; // Слишком короткий файл, не исполняемый + if (bytesRead < 2) + return ValidationResult.Success; // Слишком короткий файл, не исполняемый - foreach (var signature in forbiddenFileSignatures) - { - if (signature.SequenceEqual(buffer.Take(signature.Length))) + foreach (var signature in forbiddenFileSignatures) { - return new ValidationResult( - $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); + if (signature.SequenceEqual(buffer.Take(signature.Length))) + { + return new ValidationResult( + $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); + } } } } catch { return new ValidationResult( - $"Невозможно открыть файл `{file.FileName}`"); + $"Невозможно прочитать файл `{file.FileName}`"); } } From eb2d2724eaf834bb8050d9dbb99755d4911e17f7 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 17:23:24 +0300 Subject: [PATCH 32/77] feat: separate access files functionality --- .../components/Files/FilesAccessService.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 hwproj.front/src/components/Files/FilesAccessService.ts diff --git a/hwproj.front/src/components/Files/FilesAccessService.ts b/hwproj.front/src/components/Files/FilesAccessService.ts new file mode 100644 index 000000000..7f0507875 --- /dev/null +++ b/hwproj.front/src/components/Files/FilesAccessService.ts @@ -0,0 +1,176 @@ +import {useState, useEffect, useRef} from "react"; +import {ICourseFilesState} from "@/components/Courses/Course"; +import {FileInfoDTO, ScopeDTO} from "@/api"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import {enqueueSnackbar} from "notistack"; +import ApiSingleton from "@/api/ApiSingleton"; +import {FileStatus} from "@/components/Files/FileStatus"; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; + +export const FilesAccessService = (courseId: number, isOwner?: boolean) => { + const intervalsRef = useRef>({}); + + const [courseFilesState, setCourseFilesState] = useState({ + processingFilesState: {}, + courseFiles: [] + }) + + const stopIntervals = () => { + Object.values(intervalsRef).forEach(({interval, timeout}) => { + clearInterval(interval); + clearTimeout(timeout); + }); + intervalsRef.current = {}; + } + + // Останавливаем все активные интервалы при размонтировании + useEffect(() => { + return () => stopIntervals(); + }, []); + + const unsetCommonLoading = (courseUnitId: number) => { + setCourseFilesState(prev => ({ + ...prev, + processingFilesState: { + ...prev.processingFilesState, + [courseUnitId]: {isLoading: false} + } + })); + } + + const stopProcessing = (courseUnitId: number) => { + if (intervalsRef[courseUnitId]) { + const {interval, timeout} = intervalsRef[courseUnitId]; + clearInterval(interval); + clearTimeout(timeout); + delete intervalsRef[courseUnitId]; + } + }; + + const setCommonLoading = (courseUnitId: number) => { + setCourseFilesState(prev => ({ + ...prev, + processingFilesState: { + ...prev.processingFilesState, + [courseUnitId]: {isLoading: true} + } + })); + } + + const updCourseFiles = async () => { + let courseFilesInfo = [] as FileInfoDTO[] + try { + courseFilesInfo = isOwner + ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) + : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) + enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); + } + setCourseFilesState(prevState => ({ + ...prevState, + courseFiles: courseFilesInfo + })) + } + + useEffect(() => { + updCourseFiles(); + }, []); + + const updateCourseUnitFilesInfo = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { + setCourseFilesState(prev => ({ + ...prev, + courseFiles: [ + ...prev.courseFiles.filter( + f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), + ...files + ] + })); + }; + + // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками + const updCourseUnitFiles = + (courseUnitId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] + ) => { + // Очищаем предыдущие таймеры + stopProcessing(courseUnitId); + + let attempt = 0; + const maxAttempts = 10; + let delay = 1000; // Начальная задержка 1 сек + + const scopeDto: ScopeDTO = { + courseId: +courseId, + courseUnitType: courseUnitType, + courseUnitId: courseUnitId + } + + const fetchFiles = async () => { + if (attempt >= maxAttempts) { + stopProcessing(courseUnitId); + enqueueSnackbar("Превышено допустимое количество попыток получения информации о файлах", { + variant: "warning", + autoHideDuration: 2000 + }); + return; + } + + attempt++; + try { + const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); + console.log(`Попытка ${attempt}:`, files); + + // Первый вариант для явного отображения всех файлов + if (waitingNewFilesCount === 0 + && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { + updateCourseUnitFilesInfo(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + unsetCommonLoading(courseUnitId) + } + + // Второй вариант для явного отображения всех файлов + if (waitingNewFilesCount > 0 + && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { + updateCourseUnitFilesInfo(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + unsetCommonLoading(courseUnitId) + } + + // Условие прекращения отправки запросов на получения записей файлов + if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount + && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { + stopProcessing(courseUnitId); + unsetCommonLoading(courseUnitId) + } + + } catch (error) { + console.error(`Ошибка (попытка ${attempt}):`, error); + } + } + // Создаем интервал с задержкой + const interval = setInterval(fetchFiles, delay); + + // Создаем таймаут для автоматической остановки + const timeout = setTimeout(() => { + stopProcessing(courseUnitId); + unsetCommonLoading(courseUnitId); + }, 10000); + + // Сохраняем интервал и таймаут в ref + intervalsRef[courseUnitId] = {interval, timeout}; + + // Сигнализируем о начале загрузки через состояние + setCommonLoading(courseUnitId); + } + + return { + courseFilesState, + updCourseFiles, + updCourseUnitFiles, + } +} From 4adb6e03b1ecbf310ea58ebd2b8806bbccaa1c56 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 17:27:05 +0300 Subject: [PATCH 33/77] fix: show solution files uploading status for students only --- hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 0b4e2e874..b869c786a 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1077,7 +1077,7 @@ const TaskSolutionComponent: FC = (props) => { ) : filesInfo.length > 0 && (
{ const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { From 18b170bbcaf739281fa2f58cdb4752f6c4e93d54 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 18:58:59 +0300 Subject: [PATCH 34/77] refactor: delete unused function in files accessor --- .../src/components/Files/FilesAccessService.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesAccessService.ts b/hwproj.front/src/components/Files/FilesAccessService.ts index 7f0507875..f4933d00e 100644 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ b/hwproj.front/src/components/Files/FilesAccessService.ts @@ -18,17 +18,15 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { courseFiles: [] }) - const stopIntervals = () => { - Object.values(intervalsRef).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - } - // Останавливаем все активные интервалы при размонтировании useEffect(() => { - return () => stopIntervals(); + return () => { + Object.values(intervalsRef.current).forEach(({interval, timeout}) => { + clearInterval(interval); + clearTimeout(timeout); + }); + intervalsRef.current = {}; + }; }, []); const unsetCommonLoading = (courseUnitId: number) => { From 510b12e6e026f2c1a36ff36ad60e810413316d1e Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:00:33 +0300 Subject: [PATCH 35/77] fix: intervalRef usage in files accessor --- .../components/Files/FilesAccessService.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesAccessService.ts b/hwproj.front/src/components/Files/FilesAccessService.ts index f4933d00e..5d559adc5 100644 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ b/hwproj.front/src/components/Files/FilesAccessService.ts @@ -29,22 +29,12 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { }; }, []); - const unsetCommonLoading = (courseUnitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [courseUnitId]: {isLoading: false} - } - })); - } - const stopProcessing = (courseUnitId: number) => { - if (intervalsRef[courseUnitId]) { - const {interval, timeout} = intervalsRef[courseUnitId]; + if (intervalsRef.current[courseUnitId]) { + const {interval, timeout} = intervalsRef.current[courseUnitId]; clearInterval(interval); clearTimeout(timeout); - delete intervalsRef[courseUnitId]; + delete intervalsRef.current[courseUnitId]; } }; @@ -58,6 +48,16 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { })); } + const unsetCommonLoading = (courseUnitId: number) => { + setCourseFilesState(prev => ({ + ...prev, + processingFilesState: { + ...prev.processingFilesState, + [courseUnitId]: {isLoading: false} + } + })); + } + const updCourseFiles = async () => { let courseFilesInfo = [] as FileInfoDTO[] try { @@ -160,7 +160,7 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { }, 10000); // Сохраняем интервал и таймаут в ref - intervalsRef[courseUnitId] = {interval, timeout}; + intervalsRef.current[courseUnitId] = {interval, timeout}; // Сигнализируем о начале загрузки через состояние setCommonLoading(courseUnitId); From 8a14f89bbb1d5829b1cc8dc72b2e5a31b66e7182 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:01:39 +0300 Subject: [PATCH 36/77] fix: subscribe updating course files on course id --- hwproj.front/src/components/Files/FilesAccessService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesAccessService.ts b/hwproj.front/src/components/Files/FilesAccessService.ts index 5d559adc5..e752582e7 100644 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ b/hwproj.front/src/components/Files/FilesAccessService.ts @@ -76,7 +76,7 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { useEffect(() => { updCourseFiles(); - }, []); + }, [courseId, isOwner]); const updateCourseUnitFilesInfo = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { setCourseFilesState(prev => ({ @@ -105,7 +105,7 @@ export const FilesAccessService = (courseId: number, isOwner?: boolean) => { let delay = 1000; // Начальная задержка 1 сек const scopeDto: ScopeDTO = { - courseId: +courseId, + courseId: +courseId!, courseUnitType: courseUnitType, courseUnitId: courseUnitId } From 6716f0cb2e64757498d9131cf3d0a7b827d906ff Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:11:13 +0300 Subject: [PATCH 37/77] feat: update solutions components for files accessor --- .../Solutions/AddOrEditSolution.tsx | 54 +++--- .../Solutions/StudentSolutionsPage.tsx | 31 +--- .../components/Solutions/TaskSolutions.tsx | 8 +- .../Solutions/TaskSolutionsPage.tsx | 166 ++---------------- 4 files changed, 53 insertions(+), 206 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 7e6f6608a..b81ac99ed 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -1,16 +1,22 @@ import * as React from 'react'; +import {FC, useState} from 'react'; import ApiSingleton from "../../api/ApiSingleton"; -import { AccountDataDto, GetSolutionModel, HomeworkTaskViewModel, SolutionState, SolutionViewModel, FileInfoDTO } from "@/api"; -import { FC, useState } from "react"; -import { Alert, Autocomplete, Grid, DialogContent, Dialog, DialogTitle, DialogActions, Button } from "@mui/material"; -import { MarkdownEditor } from "../Common/MarkdownEditor"; -import { TestTag } from "../Common/HomeworkTags"; -import { LoadingButton } from "@mui/lab"; +import { + AccountDataDto, + FileInfoDTO, + GetSolutionModel, + HomeworkTaskViewModel, + SolutionState, + SolutionViewModel +} from "@/api"; +import {Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid} from "@mui/material"; +import {MarkdownEditor} from "../Common/MarkdownEditor"; +import {TestTag} from "../Common/HomeworkTags"; +import {LoadingButton} from "@mui/lab"; import TextField from "@mui/material/TextField"; import FilesUploader from '../Files/FilesUploader'; -import { IEditFilesState } from '../Homeworks/CourseHomeworkExperimental'; -import { IFileInfo } from '../Files/IFileInfo'; -import { CourseUnitType } from '../Files/CourseUnitType'; +import {IEditFilesState} from '../Homeworks/CourseHomeworkExperimental'; +import {CourseUnitType} from '../Files/CourseUnitType'; import ErrorsHandler from "@/components/Utils/ErrorsHandler"; import {enqueueSnackbar} from "notistack"; import FileInfoConverter from "@/components/Utils/FileInfoConverter"; @@ -25,7 +31,7 @@ interface IAddSolutionProps { courseFilesInfo: FileInfoDTO[], onAdd: () => void, onCancel: () => void, - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (solutionId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; } const AddOrEditSolution: FC = (props) => { @@ -40,7 +46,14 @@ const AddOrEditSolution: FC = (props) => { }) const [disableSend, setDisableSend] = useState(false) + const filesInfo = lastSolution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, lastSolution.id) : [] + const initialFilesInfo = filesInfo.filter(x => x.id !== undefined) + const [filesState, setFilesState] = useState({ + initialFilesInfo: initialFilesInfo, + selectedFilesInfo: filesInfo, + isLoadingInfo: false + }); const handleSubmit = async (e: any) => { e.preventDefault(); @@ -49,7 +62,6 @@ const AddOrEditSolution: FC = (props) => { let solutionId = await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить - // TODO: вынести? const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) .map(fileInfo => fileInfo.id!) @@ -80,8 +92,14 @@ const AddOrEditSolution: FC = (props) => { props.onAdd() } else { try { - props.onAdd() - props.onStartProcessing(solutionId, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds) + props.onAdd(); + props.onStartProcessing( + solutionId, + CourseUnitType.Solution, + filesState.initialFilesInfo.length, + newFiles.length, + deletingFileIds + ); } catch (e) { const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) enqueueSnackbar(responseErrors[0], { variant: "warning", autoHideDuration: 4000 }); @@ -95,13 +113,6 @@ const AddOrEditSolution: FC = (props) => { const showTestGithubInfo = isTest && githubUrl?.startsWith("https://github") && githubUrl.includes("/pull/") const courseMates = props.students.filter(s => props.userId !== s.userId) - const initialFilesInfo = filesInfo.filter(x => x.id !== undefined) - const [filesState, setFilesState] = useState({ - initialFilesInfo: initialFilesInfo, - selectedFilesInfo: filesInfo, - isLoadingInfo: false - }); - return ( = (props) => { })); }} courseUnitType={CourseUnitType.Solution} - courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : -1} /> + courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : -1} + /> diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index 0a5dd7e0a..497708e81 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -5,7 +5,7 @@ import { HomeworkTaskViewModel, Solution, TaskSolutionsStats, - SolutionState, StudentDataDto, AccountDataDto, FileInfoDTO + SolutionState, StudentDataDto, AccountDataDto } from "@/api"; import Typography from "@material-ui/core/Typography"; import Task from "../Tasks/Task"; @@ -40,10 +40,7 @@ import {getTip} from "../Common/HomeworkTags"; import {appBarStateManager} from "../AppBar"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {RemovedFromCourseTag} from "@/components/Common/StudentTags"; -import AuthService from "@/services/AuthService"; -import {ICourseFilesState} from "@/components/Courses/Course"; -import {enqueueSnackbar} from "notistack"; -import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {FilesAccessService} from "@/components/Files/FilesAccessService"; interface IStudentSolutionsPageState { currentTaskId: string @@ -100,28 +97,6 @@ const StudentSolutionsPage: FC = () => { const [secondMentorId, setSecondMentorId] = useState(undefined) - const [courseFilesState, setCourseFilesState] = useState({ - processingFilesState: {}, - courseFiles: [] - }) - - const getCourseFilesInfo = async () => { - let courseFilesInfo = [] as FileInfoDTO[] - try { - courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); - } - setCourseFilesState(prevState => ({ - ...prevState, - courseFiles: courseFilesInfo - })) - } - useEffect(() => { - getCourseFilesInfo() - }) - const handleFilterChange = (event: SelectChangeEvent) => { const filters = filterState.length > 0 ? [] : ["Только непроверенные" as Filter] localStorage.setItem(FilterStorageKey, filters.join(", ")) @@ -289,6 +264,8 @@ const StudentSolutionsPage: FC = () => {
} + const {courseFilesState} = FilesAccessService(courseId, false); + if (isLoaded) { return (
diff --git a/hwproj.front/src/components/Solutions/TaskSolutions.tsx b/hwproj.front/src/components/Solutions/TaskSolutions.tsx index 1f974db98..550af6e49 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutions.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutions.tsx @@ -18,7 +18,7 @@ import ApiSingleton from "../../api/ApiSingleton"; import {DotLottieReact} from '@lottiefiles/dotlottie-react'; interface ITaskSolutionsProps { - courseId: number, + courseId: number task: HomeworkTaskViewModel solutions: GetSolutionModel[] student: StudentDataDto | undefined @@ -200,7 +200,8 @@ const TaskSolutions: FC = (props) => { isLastSolution={true} courseId={props.courseId} courseFilesInfo={props.courseFiles} - isProcessing={props.processingFiles[lastSolution.id!]?.isLoading || false}/> + isProcessing={props.processingFiles[lastSolution.id!]?.isLoading || false} + /> :
Студент не отправил ни одного решения. = (props) => { isLastSolution={false} courseId={props.courseId} courseFilesInfo={props.courseFiles} - isProcessing={props.processingFiles[x.id!]?.isLoading || false}/> + isProcessing={props.processingFiles[x.id!]?.isLoading || false} + /> {i < arrayOfRatedSolutions.length - 1 ? : null} )} diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index c705836a4..60d112a4e 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -5,9 +5,9 @@ import AddOrEditSolution from "./AddOrEditSolution"; import Button from "@material-ui/core/Button"; import TaskSolutions from "./TaskSolutions"; import { - AccountDataDto, FileInfoDTO, + AccountDataDto, HomeworksGroupUserTaskSolutions, - HomeworkTaskViewModel, ScopeDTO, + HomeworkTaskViewModel, Solution, SolutionState } from "@/api"; @@ -29,11 +29,7 @@ import { getTip } from "../Common/HomeworkTags"; import Lodash from "lodash"; import { appBarStateManager } from "../AppBar"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import {CourseUnitType} from "@/components/Files/CourseUnitType"; -import {enqueueSnackbar} from "notistack"; -import {FileStatus} from "@/components/Files/FileStatus"; -import ErrorsHandler from "@/components/Utils/ErrorsHandler"; -import {ICourseFilesState} from "@/components/Courses/Course"; +import { FilesAccessService } from "@/components/Files/FilesAccessService"; interface ITaskSolutionsState { isLoaded: boolean @@ -143,152 +139,10 @@ const TaskSolutionsPage: FC = () => { }); }) - const [courseFilesState, setCourseFilesState] = useState({ - processingFilesState: {}, - courseFiles: [] - }) - - const intervalsRef = React.useRef>({}); - - const unsetCommonLoading = (solutionId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [solutionId]: {isLoading: false} - } - })); - } - - const stopProcessing = (homeworkId: number) => { - if (intervalsRef.current[homeworkId]) { - const {interval, timeout} = intervalsRef.current[homeworkId]; - clearInterval(interval); - clearTimeout(timeout); - delete intervalsRef.current[homeworkId]; - } - }; - - const setCommonLoading = (solutionId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [solutionId]: {isLoading: true} - } - })); - } - - const updateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - courseFiles: [ - ...prev.courseFiles.filter( - f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), - ...files - ] - })); - }; - - // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками - //TODO: вынести в отдельную функцию? - const getFilesByInterval = (solutionId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => { - // Очищаем предыдущие таймеры - stopProcessing(solutionId); - - let attempt = 0; - const maxAttempts = 10; - let delay = 1000; // Начальная задержка 1 сек - - const scopeDto: ScopeDTO = { - courseId: +courseId!, - courseUnitType: CourseUnitType.Solution, - courseUnitId: solutionId - } - - const fetchFiles = async () => { - if (attempt >= maxAttempts) { - stopProcessing(solutionId); - enqueueSnackbar("Превышено допустимое количество попыток получения информации о файлах", { - variant: "warning", - autoHideDuration: 2000 - }); - return; - } - - attempt++; - try { - const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); - console.log(`Попытка ${attempt}:`, files); - - // Первый вариант для явного отображения всех файлов - if (waitingNewFilesCount === 0 - && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(solutionId) - } - - // Второй вариант для явного отображения всех файлов - if (waitingNewFilesCount > 0 - && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(solutionId) - } - - // Условие прекращения отправки запросов на получения записей файлов - if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount - && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { - stopProcessing(solutionId); - unsetCommonLoading(solutionId) - } - - } catch (error) { - console.error(`Ошибка (попытка ${attempt}):`, error); - } - } - // Создаем интервал с задержкой - const interval = setInterval(fetchFiles, delay); - - // Создаем таймаут для автоматической остановки - const timeout = setTimeout(() => { - stopProcessing(solutionId); - unsetCommonLoading(solutionId); - }, 10000); - - // Сохраняем интервал и таймаут в ref - intervalsRef.current[solutionId] = {interval, timeout}; - - // Сигнализируем о начале загрузки через состояние - setCommonLoading(solutionId) - } - - // Останавливаем все активные интервалы при размонтировании - useEffect(() => { - return () => { - Object.values(intervalsRef.current).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - }; - }, []); - - const getCourseFilesInfo = async () => { - let courseFilesInfo = [] as FileInfoDTO[] - try { - courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); - } - setCourseFilesState(prevState => ({ - ...prevState, - courseFiles: courseFilesInfo - })) - } - useEffect(() => { - getCourseFilesInfo() - }) + const { + courseFilesState, + updCourseUnitFiles, + } = FilesAccessService(courseId, true); const currentHomeworksGroup = taskSolutionsWithPreview .find(x => x.homeworkSolutions! @@ -435,7 +289,8 @@ const TaskSolutionsPage: FC = () => { courseStudents={[student]} solutions={currentTaskSolutions} courseFiles={courseFilesState.courseFiles} - processingFiles={courseFilesState.processingFilesState}/> + processingFiles={courseFilesState.processingFilesState} + /> )} @@ -451,7 +306,8 @@ const TaskSolutionsPage: FC = () => { students={courseMates} supportsGroup={task.isGroupWork!} courseFilesInfo={courseFilesState.courseFiles} - onStartProcessing={getFilesByInterval}/>} + onStartProcessing={updCourseUnitFiles} + />}
: (
From 81f6806221787e42f6e663e3b6d960c3bf889e6f Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 26 Oct 2025 12:52:58 +0300 Subject: [PATCH 38/77] fix: padding after solution files --- .../Solutions/TaskSolutionComponent.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index b869c786a..35a3d76d5 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1060,35 +1060,35 @@ const TaskSolutionComponent: FC = (props) => { + {solution.comment && - + {showOriginalCommentText ? {solution.comment} : } } - - {props.isProcessing ? ( -
- -   Обрабатываем файлы... -
- ) : filesInfo.length > 0 && ( -
- { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { - courseId: props.courseId, - courseUnitType: fileInfo.courseUnitType, - courseUnitId: fileInfo.courseUnitId - }) - window.open(url, '_blank'); - }} - /> -
+ {props.isProcessing ? ( +
+ +   Обрабатываем файлы... +
+ ) : filesInfo.length > 0 && ( +
+ { + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { + courseId: props.courseId, + courseUnitType: fileInfo.courseUnitType, + courseUnitId: fileInfo.courseUnitId + }) + window.open(url, '_blank'); + }} + /> +
)}
From fb9e64639b1e2ab5ced80e0b5d4486a6b55aa099 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 26 Oct 2025 13:31:32 +0300 Subject: [PATCH 39/77] fix: return alien code --- .../components/Homeworks/CourseHomeworkExperimental.tsx | 9 ++++----- .../src/components/Solutions/AddOrEditSolution.tsx | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 5b8c10869..f16c4b33c 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -455,7 +455,7 @@ const CourseHomeworkExperimental: FC<{ onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo - const deferredHomeworks = homework.tasks!.filter(t => t.isDeferred!) + const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) const tasksCount = homework.tasks!.length const [showEditMode, setShowEditMode] = useState(false) const [editMode, setEditMode] = useState(false) @@ -486,12 +486,11 @@ const CourseHomeworkExperimental: FC<{ {homework.title}
- {props.isMentor && deferredHomeworks!.length > 0 && - - } {tasksCount > 0 && + label={tasksCount + " " + + Utils.pluralizeHelper(["Задача", "Задачи", "Задач"], tasksCount) + + (deferredTasks!.length > 0 ? ` (🕘 ${deferredTasks.length} ` + Utils.pluralizeHelper(["отложенная", "отложенные", "отложенных"], deferredTasks.length) + ")" : "")}/> } {homework.tags?.filter(t => DefaultTags.includes(t)).map((tag, index) => ( diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index b81ac99ed..04431df97 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -6,8 +6,8 @@ import { FileInfoDTO, GetSolutionModel, HomeworkTaskViewModel, + PostSolutionModel, SolutionState, - SolutionViewModel } from "@/api"; import {Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid} from "@mui/material"; import {MarkdownEditor} from "../Common/MarkdownEditor"; @@ -39,7 +39,7 @@ const AddOrEditSolution: FC = (props) => { const isEdit = lastSolution?.state === SolutionState.NUMBER_0 const lastGroup = lastSolution?.groupMates?.map(x => x.userId!) || [] - const [solution, setSolution] = useState({ + const [solution, setSolution] = useState({ githubUrl: lastSolution?.githubUrl || "", comment: isEdit ? lastSolution!.comment : "", groupMateIds: lastGroup From a916c7b8799abeea4f1073e141b6286d56f10184 Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 28 Oct 2025 11:58:34 +0300 Subject: [PATCH 40/77] refactor: deleteunused variables, await with async calls --- .../Filters/CourseMentorOrSolutionStudentAttribute.cs | 4 +--- .../Filters/SolutionPrivacyAttribute.cs | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs index 6a34b2cb4..2c7b129f3 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs @@ -49,9 +49,7 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context if (courseUnitType == "Solution") { - string? studentId = null; - - HashSet studentIds = []; + var studentIds = new HashSet(); if (courseId != -1) { var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs index a566fa578..d242eb459 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs @@ -1,3 +1,4 @@ +namespace HwProj.APIGateway.API.Filters; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -9,8 +10,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -namespace HwProj.APIGateway.API.Filters; - public class SolutionPrivacyAttribute : ActionFilterAttribute { private readonly ICoursesServiceClient _coursesServiceClient; @@ -62,11 +61,11 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context courseUnitId = fileScopeDto.CourseUnitId; } - if (courseUnitType == "Homework") next.Invoke(); + if (courseUnitType == "Homework") await next.Invoke(); if (userRole == Roles.StudentRole) { - HashSet studentIds = []; + var studentIds = new HashSet(); if (courseId != -1) { var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); From 1f04d4b18d61e90691f494ac83575983409e6e48 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:23:24 +0300 Subject: [PATCH 41/77] feat: [back] add class for privacy validation --- .../Filters/FilesPrivacyFilter.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs new file mode 100644 index 000000000..458ed3055 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using HwProj.CoursesService.Client; +using HwProj.Models.Roles; +using HwProj.SolutionsService.Client; + +namespace HwProj.APIGateway.API.Filters; + +public class FilesPrivacyFilter +{ + public enum Method + { + Upload, + Download + } + + private readonly ICoursesServiceClient _coursesServiceClient; + private readonly ISolutionsServiceClient _solutionsServiceClient; + + public FilesPrivacyFilter(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) + { + _coursesServiceClient = coursesServiceClient; + _solutionsServiceClient = solutionsServiceClient; + } + + public async Task CheckRights(ClaimsPrincipal user, long courseId, string courseUnitType, long courseUnitId, Method method) + { + var userId = user.Claims + .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; + if (userId == null) return false; + + var userRole = user.Claims + .FirstOrDefault(claim => claim.Type.ToString().EndsWith("role"))?.Value; + if (userRole == null) return false; + + if (courseUnitType == "Homework") + { + if (method == Method.Download) return true; + if (method == Method.Upload) + { + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + if (!mentorIds.Contains(userId)) + { + return false; + } + return true; + } + } else if (courseUnitType == "Solution") + { + + if (userRole == Roles.StudentRole) + { + var studentIds = new HashSet(); + var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); + studentIds.Add(solution.StudentId); + var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); + + if (!studentIds.Contains(userId)) return false; + } + else + { + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + if (!mentorIds.Contains(userId)) return false; + } + + return true; + } + + return false; + } +} From d7f0add41c556935f3cec0754a21fcd65a4f1963 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:25:45 +0300 Subject: [PATCH 42/77] feat [back]: method to get file scope --- .../Repositories/FileRecordRepository.cs | 7 +++++++ .../Repositories/IFileRecordRepository.cs | 1 + 2 files changed, 8 insertions(+) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 987cb0a35..fc84f77ae 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -51,6 +51,13 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat => await _contentContext.FileRecords .AsNoTracking() .SingleOrDefaultAsync(fr => fr.Id == fileRecordId); + + public async Task GetScopeByRecordIdAsync(long fileRecordId) + => await _contentContext.FileToCourseUnits + .AsNoTracking() + .Where(fr => fr.FileRecordId == fileRecordId) + .Select(fc => fc.ToScope()) + .SingleOrDefaultAsync(); public async Task> GetByScopeAsync(Scope scope) => await _contentContext.FileToCourseUnits diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs index b9522b2fb..bbdbe8ffa 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs @@ -13,6 +13,7 @@ public interface IFileRecordRepository public Task UpdateAsync(long id, Expression, SetPropertyCalls>> setPropertyCalls); public Task GetFileRecordByIdAsync(long fileRecordId); + public Task GetScopeByRecordIdAsync(long fileRecordId); public Task> GetByScopeAsync(Scope scope); public Task> GetByCourseIdAsync(long courseId); public Task> GetByCourseIdAndStatusAsync(long courseId, FileStatus filesStatus); From ad3578afd97a2dde92fe1fac46eb48523e9de0dd Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:41:13 +0300 Subject: [PATCH 43/77] fix: delete attribute validation --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 41ec13055..6aad40e36 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -24,8 +24,6 @@ public FilesController(IAuthServiceClient authServiceClient, } [HttpPost("process")] - [Authorize(Roles = Roles.LecturerOrStudentRole)] - [ServiceFilter(typeof(CourseMentorOrSolutionStudentAttribute))] [ProducesResponseType((int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) @@ -37,8 +35,6 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles } [HttpPost("statuses")] - [Authorize(Roles = Roles.LecturerOrStudentRole)] - [ServiceFilter(typeof(SolutionPrivacyAttribute))] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task GetStatuses(ScopeDTO filesScope) @@ -50,7 +46,6 @@ public async Task GetStatuses(ScopeDTO filesScope) } [HttpPost("downloadLink")] - [ServiceFilter(typeof(SolutionPrivacyAttribute))] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromForm] FileScopeDTO fileScope) From a8a991dd9fad2055e4054d96c571e3456ff3f33c Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:41:46 +0300 Subject: [PATCH 44/77] fix: delete unused role --- HwProj.Common/HwProj.Models/Roles/Roles.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/HwProj.Common/HwProj.Models/Roles/Roles.cs b/HwProj.Common/HwProj.Models/Roles/Roles.cs index d66b9effb..a78aa84b6 100644 --- a/HwProj.Common/HwProj.Models/Roles/Roles.cs +++ b/HwProj.Common/HwProj.Models/Roles/Roles.cs @@ -6,6 +6,5 @@ public static class Roles public const string StudentRole = "Student"; public const string ExpertRole = "Expert"; public const string LecturerOrExpertRole = "Lecturer, Expert"; - public const string LecturerOrStudentRole = "Lecturer, Student"; } } From 8c4e93edfac36ac9eb147850ff0ba21c709e4f7d Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:48:50 +0300 Subject: [PATCH 45/77] feat [back]: method to get files scope in info service --- .../HwProj.ContentService.API/Services/FilesInfoService.cs | 6 ++++++ .../Services/Interfaces/IFilesInfoService.cs | 2 ++ 2 files changed, 8 insertions(+) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs index 0310b1866..39312070b 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs @@ -36,6 +36,12 @@ public async Task> GetFilesStatusesAsync(Scope filesScope) var fileRecord = await _fileRecordRepository.GetFileRecordByIdAsync(fileId); return fileRecord?.ExternalKey; } + + public async Task GetFileScopeAsync(long fileId) + { + var fileToCourseUnit = await _fileRecordRepository.GetScopeByRecordIdAsync(fileId); + return fileToCourseUnit; + } public async Task> GetFilesInfoAsync(long courseId) { diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs index 51417c164..1c9a67e31 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs @@ -1,4 +1,5 @@ using HwProj.ContentService.API.Models; +using HwProj.ContentService.API.Models.Database; using HwProj.ContentService.API.Models.Enums; using HwProj.Models.ContentService.DTO; @@ -8,6 +9,7 @@ public interface IFilesInfoService { public Task> GetFilesStatusesAsync(Scope filesScope); public Task GetFileExternalKeyAsync(long fileId); + public Task GetFileScopeAsync(long fileId); public Task> GetFilesInfoAsync(long courseId); public Task> GetFilesInfoAsync(long courseId, FileStatus filesStatus); public Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); From aeefc3b1ce325e9c3767f862e2f265d686956091 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:50:59 +0300 Subject: [PATCH 46/77] refactor [back]: return dto from files controller download link --- .../Controllers/FilesController.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index b1a1ae2d3..59875982a 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -81,14 +81,27 @@ public async Task GetStatuses(ScopeDTO scopeDto) } [HttpGet("downloadLink")] - [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(FileLinkDTO), (int)HttpStatusCode.OK)] public async Task GetDownloadLink([FromQuery] long fileId) { var externalKey = await _filesInfoService.GetFileExternalKeyAsync(fileId); - if (externalKey is null) return Ok(Result.Failed("Файл не найден")); + if (externalKey is null) return Ok(Result.Failed("Файл не найден")); - var downloadUrlResult = await _s3FilesService.GetDownloadUrl(externalKey); - return Ok(downloadUrlResult); + var fileScope = await _filesInfoService.GetFileScopeAsync(fileId); + if (fileScope is null) return Ok(Result.Failed("Файл не найден")); + + var downloadUrl = await _s3FilesService.GetDownloadUrl(externalKey); + if (!downloadUrl.Succeeded) return Ok(Result.Failed(downloadUrl.Errors)); + + var result = new FileLinkDTO + { + DownloadUrl = downloadUrl.Value, + CourseId = fileScope.CourseId, + CourseUnitType = fileScope.CourseUnitType.ToString(), + CourseUnitId = fileScope.CourseUnitId + }; + + return Ok(result); } [HttpGet("info/course/{courseId}")] From b21bf75b80085a8711ba2f79cd4a20fba320ca23 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:52:25 +0300 Subject: [PATCH 47/77] refactor [back]: return dto from content client download link --- .../HwProj.ContentService.Client/ContentServiceClient.cs | 8 +++++--- .../HwProj.ContentService.Client/IContentServiceClient.cs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs index c659d0356..4b012985a 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs @@ -92,7 +92,7 @@ public async Task> GetFilesStatuses(ScopeDTO scopeDto) } } - public async Task> GetDownloadLinkAsync(long fileId) + public async Task> GetDownloadLinkAsync(long fileId) { using var httpRequest = new HttpRequestMessage( HttpMethod.Get, @@ -101,11 +101,13 @@ public async Task> GetDownloadLinkAsync(long fileId) try { var response = await _httpClient.SendAsync(httpRequest); - return await response.DeserializeAsync>(); + var result = await response.DeserializeAsync(); + + return Result.Success(result); } catch (HttpRequestException e) { - return Result.Failed( + return Result.Failed( "Пока не можем открыть файл. \nВсе ваши данные сохранены — попробуйте повторить позже"); } } diff --git a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs index 162c44978..f480a8781 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs @@ -8,7 +8,7 @@ public interface IContentServiceClient { Task ProcessFilesAsync(ProcessFilesDTO processFilesDto); Task> GetFilesStatuses(ScopeDTO scopeDto); - Task> GetDownloadLinkAsync(long fileId); + Task> GetDownloadLinkAsync(long fileId); Task> GetFilesInfo(long courseId); Task> GetUploadedFilesInfo(long courseId); Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); From a11ad47712a12c4025f1d5bf2df9ac09a7936322 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:53:52 +0300 Subject: [PATCH 48/77] feat [back]: add privacy filter to start up --- HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index 07a20bb44..d6ae69cdf 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -79,9 +79,8 @@ public void ConfigureServices(IServiceCollection services) services.AddNotificationsServiceClient(); services.AddContentServiceClient(); - services.AddScoped(); //TODO : delete? - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IHostEnvironment env) From 017741291b928a1d3249aa6a96fdfe4bc84aa9af Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:54:45 +0300 Subject: [PATCH 49/77] feat [back]: file link dto --- .../ContentService/DTO/{FileScopeDTO.cs => FileLinkDTO.cs} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename HwProj.Common/HwProj.Models/ContentService/DTO/{FileScopeDTO.cs => FileLinkDTO.cs} (70%) diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs similarity index 70% rename from HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs rename to HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs index ac89e4ac9..95337ea61 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs @@ -1,8 +1,9 @@ namespace HwProj.Models.ContentService.DTO { - public class FileScopeDTO + + public class FileLinkDTO { - public long FileId { get; set; } + public string DownloadUrl { get; set; } public long CourseId { get; set; } public string CourseUnitType { get; set; } public long CourseUnitId { get; set; } From 3f1d84f3ff1369e8446057a4e84f5380bf8ec10c Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:09 +0300 Subject: [PATCH 50/77] fix: download link request --- .../Controllers/FilesController.cs | 5 ++--- hwproj.front/src/api/CustomFilesApi.ts | 15 ++++----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 6aad40e36..b54a81c6f 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -4,7 +4,6 @@ using HwProj.AuthService.Client; using HwProj.ContentService.Client; using HwProj.Models.ContentService.DTO; -using HwProj.Models.Roles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -45,10 +44,10 @@ public async Task GetStatuses(ScopeDTO filesScope) : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesStatusesResult.Errors); } - [HttpPost("downloadLink")] + [HttpGet("downloadLink")] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] - public async Task GetDownloadLink([FromForm] FileScopeDTO fileScope) + public async Task GetDownloadLink([FromQuery] long fileId) { var result = await _contentServiceClient.GetDownloadLinkAsync(fileScope.FileId); return result.Succeeded diff --git a/hwproj.front/src/api/CustomFilesApi.ts b/hwproj.front/src/api/CustomFilesApi.ts index d8a546994..a6e77870b 100644 --- a/hwproj.front/src/api/CustomFilesApi.ts +++ b/hwproj.front/src/api/CustomFilesApi.ts @@ -35,19 +35,12 @@ export default class CustomFilesApi extends BaseAPI { } } - public getDownloadFileLink = async (fileKey: number, fileScope: ScopeDTO) => { - const formData = new FormData(); - formData.append('FileId', fileKey.toString()); - formData.append('CourseId', fileScope.courseId?.toString() ?? "-1"); - formData.append('CourseUnitType', fileScope.courseUnitType?.toString() ?? "-1"); - formData.append('CourseUnitId', fileScope.courseUnitId?.toString() ?? "-1"); - - const response = await fetch(this.basePath + `/api/Files/downloadLink`, { - method: 'POST', - body: formData, + public getDownloadFileLink = async (fileKey: number) => { + const response = await fetch(this.basePath + `/api/Files/downloadLink?fileId=${fileKey}`, { + method: 'GET', headers: { 'Authorization': this.getApiKeyValue(), - }, + } }); if (response.status >= 200 && response.status < 300) { From 03886d7999e6fbc6fe028cfc21cba8a147b20bc2 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:30 +0300 Subject: [PATCH 51/77] feat [back]: privacy validation --- .../Controllers/FilesController.cs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index b54a81c6f..1ad54cc03 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -15,11 +15,14 @@ namespace HwProj.APIGateway.API.Controllers; public class FilesController : AggregationController { private readonly IContentServiceClient _contentServiceClient; + private readonly FilesPrivacyFilter _privacyFilter; public FilesController(IAuthServiceClient authServiceClient, - IContentServiceClient contentServiceClient) : base(authServiceClient) + IContentServiceClient contentServiceClient, + FilesPrivacyFilter privacyFilter) : base(authServiceClient) { _contentServiceClient = contentServiceClient; + _privacyFilter = privacyFilter; } [HttpPost("process")] @@ -27,6 +30,14 @@ public FilesController(IAuthServiceClient authServiceClient, [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { + var isChecked = await _privacyFilter.CheckRights(User, + processFilesDto.FilesScope.CourseId, + processFilesDto.FilesScope.CourseUnitType, + processFilesDto.FilesScope.CourseUnitId, + FilesPrivacyFilter.Method.Upload + ); + if (!isChecked) return BadRequest(); + var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded ? Ok() @@ -38,6 +49,14 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task GetStatuses(ScopeDTO filesScope) { + var isChecked = await _privacyFilter.CheckRights(User, + filesScope.CourseId, + filesScope.CourseUnitType, + filesScope.CourseUnitId, + FilesPrivacyFilter.Method.Upload + ); + if (!isChecked) return BadRequest(); + var filesStatusesResult = await _contentServiceClient.GetFilesStatuses(filesScope); return filesStatusesResult.Succeeded ? Ok(filesStatusesResult.Value) as IActionResult @@ -49,10 +68,19 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromQuery] long fileId) { - var result = await _contentServiceClient.GetDownloadLinkAsync(fileScope.FileId); - return result.Succeeded - ? Ok(result.Value) - : NotFound(result.Errors); + var linkDto = await _contentServiceClient.GetDownloadLinkAsync(fileId); + if(!linkDto.Succeeded) return NotFound(linkDto.Errors); + + var checkRights = await _privacyFilter.CheckRights(User, + linkDto.Value.CourseId, + linkDto.Value.CourseUnitType, + linkDto.Value.CourseUnitId, + FilesPrivacyFilter.Method.Download + ); + + return checkRights + ? Ok(linkDto.Value.DownloadUrl) + : BadRequest(); } [HttpGet("info/course/{courseId}")] From 26681c0b7c9b92043109a9328433323c2d70c349 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:54 +0300 Subject: [PATCH 52/77] refactor: delete unused validation attributes --- .../CourseMentorOrSolutionStudentAttribute.cs | 102 --------------- .../Filters/SolutionPrivacyAttribute.cs | 118 ------------------ 2 files changed, 220 deletions(-) delete mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs delete mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs deleted file mode 100644 index 2c7b129f3..000000000 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/CourseMentorOrSolutionStudentAttribute.cs +++ /dev/null @@ -1,102 +0,0 @@ -namespace HwProj.APIGateway.API.Filters; -using System.Linq; -using System.Threading.Tasks; -using System.Collections.Generic; -using CoursesService.Client; -using SolutionsService.Client; -using HwProj.Models.ContentService.DTO; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -public class CourseMentorOrSolutionStudentAttribute : ActionFilterAttribute -{ - private readonly ICoursesServiceClient _coursesServiceClient; - private readonly ISolutionsServiceClient _solutionsServiceClient; - - public CourseMentorOrSolutionStudentAttribute(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) - { - _coursesServiceClient = coursesServiceClient; - _solutionsServiceClient = solutionsServiceClient; - } - - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - var userId = context.HttpContext.User.Claims - .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; - if (userId == null) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "В запросе не передан идентификатор пользователя", - ContentType = "application/json" - }; - return; - } - - long courseId = -1; - var courseUnitType = ""; - long courseUnitId = -1; - - if (context.ActionArguments.TryGetValue("processFilesDto", out var processFilesDto) && - processFilesDto is ProcessFilesDTO dto) - { - courseId = dto.FilesScope.CourseId; - courseUnitType = dto.FilesScope.CourseUnitType; - courseUnitId = dto.FilesScope.CourseUnitId; - } - - if (courseUnitType == "Solution") - { - var studentIds = new HashSet(); - if (courseId != -1) - { - var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); - studentIds.Add(solution.StudentId); - var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - } - - if (!studentIds.Contains(userId)) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "Недостаточно прав для работы с файлами: Вы не являетесь студентом, отправляющим задание", - ContentType = "application/json" - }; - return; - } - } else if (courseUnitType == "Homework") - { - string[]? mentorIds = null; - - if (courseId != -1) - mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); - if (mentorIds == null || !mentorIds.Contains(userId)) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "Недостаточно прав для работы с файлами: Вы не являетесь ментором на курсе", - ContentType = "application/json" - }; - return; - } - } - - await next.Invoke(); - } - - private static string? GetValueFromRequest(HttpRequest request, string key) - { - if (request.Query.TryGetValue(key, out var queryValue)) - return queryValue.ToString(); - - if (request.HasFormContentType && request.Form.TryGetValue(key, out var formValue)) - return formValue.ToString(); - - return null; - } -} \ No newline at end of file diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs deleted file mode 100644 index d242eb459..000000000 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/SolutionPrivacyAttribute.cs +++ /dev/null @@ -1,118 +0,0 @@ -namespace HwProj.APIGateway.API.Filters; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using HwProj.CoursesService.Client; -using HwProj.SolutionsService.Client; -using HwProj.Models.ContentService.DTO; -using HwProj.Models.Roles; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -public class SolutionPrivacyAttribute : ActionFilterAttribute -{ - private readonly ICoursesServiceClient _coursesServiceClient; - private readonly ISolutionsServiceClient _solutionsServiceClient; - - public SolutionPrivacyAttribute(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) - { - _coursesServiceClient = coursesServiceClient; - _solutionsServiceClient = solutionsServiceClient; - } - - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - var userId = context.HttpContext.User.Claims - .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; - var userRole = context.HttpContext.User.Claims - .FirstOrDefault(claim => claim.Type.ToString().EndsWith("role"))?.Value; - - if (userId == null || userRole == null) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "В запросе не передан идентификатор пользователя", - ContentType = "application/json" - }; - return; - } - - - long courseId = -1; - var courseUnitType = ""; - long courseUnitId = -1; - - // Для метода GetStatuses (параметр: filesScope) - if (context.ActionArguments.TryGetValue("filesScope", out var filesScope) && - filesScope is ScopeDTO scopeDto) - { - courseId = scopeDto.CourseId; - courseUnitType = scopeDto.CourseUnitType; - courseUnitId = scopeDto.CourseUnitId; - } - // Для метода GetDownloadLink (параметр: fileScope) - else if (context.ActionArguments.TryGetValue("fileScope", out var fileScope) && - fileScope is FileScopeDTO fileScopeDto) - { - courseId = fileScopeDto.CourseId; - courseUnitType = fileScopeDto.CourseUnitType; - courseUnitId = fileScopeDto.CourseUnitId; - } - - if (courseUnitType == "Homework") await next.Invoke(); - - if (userRole == Roles.StudentRole) - { - var studentIds = new HashSet(); - if (courseId != -1) - { - var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); - studentIds.Add(solution.StudentId); - var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - } - - if (!studentIds.Contains(userId)) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "Недостаточно прав для работы с файлами: Вы не являетесь студентом, отправляющим задание", - ContentType = "application/json" - }; - return; - } - } else if (userRole == Roles.LecturerRole) - { - string[]? mentorIds = null; - - if (courseId != -1) - mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); - if (mentorIds == null || !mentorIds.Contains(userId)) - { - context.Result = new ContentResult - { - StatusCode = StatusCodes.Status403Forbidden, - Content = "Недостаточно прав для работы с файлами: Вы не являетесь ментором на курсе", - ContentType = "application/json" - }; - return; - } - } - - await next.Invoke(); - } - - private static string? GetValueFromRequest(HttpRequest request, string key) - { - if (request.Query.TryGetValue(key, out var queryValue)) - return queryValue.ToString(); - - if (request.HasFormContentType && request.Form.TryGetValue(key, out var formValue)) - return formValue.ToString(); - - return null; - } -} From 851a91242ab3b493019d4f0caed640979a75cf3f Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:28:54 +0300 Subject: [PATCH 53/77] refactor [front]: rename files upload waiter --- ...FilesAccessService.ts => FilesUploadWaiter.ts} | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) rename hwproj.front/src/components/Files/{FilesAccessService.ts => FilesUploadWaiter.ts} (94%) diff --git a/hwproj.front/src/components/Files/FilesAccessService.ts b/hwproj.front/src/components/Files/FilesUploadWaiter.ts similarity index 94% rename from hwproj.front/src/components/Files/FilesAccessService.ts rename to hwproj.front/src/components/Files/FilesUploadWaiter.ts index e752582e7..d57ffb411 100644 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ b/hwproj.front/src/components/Files/FilesUploadWaiter.ts @@ -1,5 +1,4 @@ import {useState, useEffect, useRef} from "react"; -import {ICourseFilesState} from "@/components/Courses/Course"; import {FileInfoDTO, ScopeDTO} from "@/api"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; import {enqueueSnackbar} from "notistack"; @@ -7,13 +6,23 @@ import ApiSingleton from "@/api/ApiSingleton"; import {FileStatus} from "@/components/Files/FileStatus"; import ErrorsHandler from "@/components/Utils/ErrorsHandler"; -export const FilesAccessService = (courseId: number, isOwner?: boolean) => { +export interface IUploadFilesState { + processingFilesState: { + [courseUnitId: number]: { + isLoading: boolean; + intervalId?: NodeJS.Timeout; + }; + }; + courseFiles: FileInfoDTO[]; +} + +export const FilesUploadWaiter = (courseId: number, isOwner?: boolean) => { const intervalsRef = useRef>({}); - const [courseFilesState, setCourseFilesState] = useState({ + const [courseFilesState, setCourseFilesState] = useState({ processingFilesState: {}, courseFiles: [] }) From 1aa83bf6a0a4aa57a3282b90f552a1f089ae9e4b Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:54:12 +0300 Subject: [PATCH 54/77] feat [front]: variability for max files count --- hwproj.front/src/components/Files/FilesUploader.tsx | 8 +++++--- .../src/components/Solutions/AddOrEditSolution.tsx | 12 +++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 27b2afe8e..115d155e3 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -10,6 +10,7 @@ import {CourseUnitType} from "./CourseUnitType"; import {FileStatus} from "./FileStatus"; import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import "./filesUploaderOverrides.css"; +import Utils from "@/services/Utils"; interface IFilesUploaderProps { courseUnitType: CourseUnitType @@ -17,6 +18,7 @@ interface IFilesUploaderProps { initialFilesInfo?: IFileInfo[]; onChange: (selectedFiles: IFileInfo[]) => void; isLoading?: boolean; + maxFilesCount?: number; } // Кастомизированный Input для загрузки файла (из примеров MaterialUI) @@ -44,7 +46,6 @@ const FilesUploader: React.FC = (props) => { }, [props.initialFilesInfo]); const maxFileSizeInBytes = 100 * 1024 * 1024; - const maxFilesCount = 5; const forbiddenFileTypes = [ 'application/vnd.microsoft.portable-executable', @@ -57,9 +58,10 @@ const FilesUploader: React.FC = (props) => { ] const validateFiles = (files: File[]): boolean => { - if ((props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > maxFilesCount) { + if (props.maxFilesCount && + (props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > props.maxFilesCount) { setError(`Выбрано слишком много файлов. - Максимально допустимое количество файлов: ${maxFilesCount} штук.`); + Максимально допустимое количество файлов: ${Utils.pluralizeHelper(["штука", "штука", "штук"], props.maxFilesCount)}`); return false; } for (const file of files) { diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 04431df97..d08762c4e 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -47,13 +47,10 @@ const AddOrEditSolution: FC = (props) => { const [disableSend, setDisableSend] = useState(false) - const filesInfo = lastSolution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, lastSolution.id) : [] - const initialFilesInfo = filesInfo.filter(x => x.id !== undefined) - const [filesState, setFilesState] = useState({ - initialFilesInfo: initialFilesInfo, - selectedFilesInfo: filesInfo, - isLoadingInfo: false - }); + const maxFilesCount = 5; + + const filesInfo = lastSolution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, lastSolution.id) : [] + const {filesState, setFilesState, handleFilesChange} = FilesHandler(filesInfo); const handleSubmit = async (e: any) => { e.preventDefault(); @@ -199,6 +196,7 @@ const AddOrEditSolution: FC = (props) => { }} courseUnitType={CourseUnitType.Solution} courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : -1} + maxFilesCount={maxFilesCount} /> From 0f4a812986004a7d09a2ad928962cbc873d48b2a Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 16:02:09 +0300 Subject: [PATCH 55/77] refactor [front]: course files access to upload waiter --- .../src/components/Courses/Course.tsx | 164 +----------------- 1 file changed, 6 insertions(+), 158 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index d436e405d..bb2123976 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -40,6 +40,7 @@ import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {CourseUnitType} from "../Files/CourseUnitType"; import {FileStatus} from "../Files/FileStatus"; +import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; type TabValue = "homeworks" | "stats" | "applications" @@ -58,16 +59,6 @@ interface ICourseState { showQrCode: boolean; } -export interface ICourseFilesState { - processingFilesState: { - [courseUnitId: number]: { - isLoading: boolean; - intervalId?: NodeJS.Timeout; - }; - }; - courseFiles: FileInfoDTO[]; -} - interface IPageState { tabValue: TabValue } @@ -89,135 +80,11 @@ const Course: React.FC = () => { showQrCode: false }) const [studentSolutions, setStudentSolutions] = useState(undefined) - const [courseFilesState, setCourseFilesState] = useState({ - processingFilesState: {}, - courseFiles: [] - }) - - const intervalsRef = React.useRef>({}); - - const updateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - courseFiles: [ - ...prev.courseFiles.filter( - f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), - ...files - ] - })); - }; - - const setCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: true} - } - })); - } - - const unsetCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: false} - } - })); - } - - const stopProcessing = (homeworkId: number) => { - if (intervalsRef.current[homeworkId]) { - const {interval, timeout} = intervalsRef.current[homeworkId]; - clearInterval(interval); - clearTimeout(timeout); - delete intervalsRef.current[homeworkId]; - } - }; - - // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками - const getFilesByInterval = (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => { - // Очищаем предыдущие таймеры - stopProcessing(homeworkId); - - let attempt = 0; - const maxAttempts = 10; - let delay = 1000; // Начальная задержка 1 сек - - const scopeDto: ScopeDTO = { - courseId: +courseId!, - courseUnitType: CourseUnitType.Homework, - courseUnitId: homeworkId - } - - const fetchFiles = async () => { - if (attempt >= maxAttempts) { - stopProcessing(homeworkId); - enqueueSnackbar("Превышено допустимое количество попыток получения информации о файлах", { - variant: "warning", - autoHideDuration: 2000 - }); - return; - } - - attempt++; - try { - const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); - console.log(`Попытка ${attempt}:`, files); - - // Первый вариант для явного отображения всех файлов - if (waitingNewFilesCount === 0 - && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) - } - - // Второй вариант для явного отображения всех файлов - if (waitingNewFilesCount > 0 - && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) - } - - // Условие прекращения отправки запросов на получения записей файлов - if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount - && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { - stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) - } - - } catch (error) { - console.error(`Ошибка (попытка ${attempt}):`, error); - } - } - // Создаем интервал с задержкой - const interval = setInterval(fetchFiles, delay); - - // Создаем таймаут для автоматической остановки - const timeout = setTimeout(() => { - stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) - }, 10000); - - // Сохраняем интервал и таймаут в ref - intervalsRef.current[homeworkId] = {interval, timeout}; - - // Сигнализируем о начале загрузки через состояние - setCommonLoading(homeworkId) - } - - // Останавливаем все активные интевалы при размонтировании - useEffect(() => { - return () => { - Object.values(intervalsRef.current).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - }; - }, []); + const { + courseFilesState, + updCourseUnitFiles, + } = FilesUploadWaiter(+courseId!, true); const [pageState, setPageState] = useState({ tabValue: "homeworks" @@ -284,30 +151,10 @@ const Course: React.FC = () => { })) } - const getCourseFilesInfo = async () => { - let courseFilesInfo = [] as FileInfoDTO[] - try { - courseFilesInfo = isCourseMentor - ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); - } - setCourseFilesState(prevState => ({ - ...prevState, - courseFiles: courseFilesInfo - })) - } - useEffect(() => { setCurrentState() }, []) - useEffect(() => { - getCourseFilesInfo() - }, [isCourseMentor]) - useEffect(() => { ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) .then(res => setStudentSolutions(res)) @@ -499,6 +346,7 @@ const Course: React.FC = () => { processingFiles={courseFilesState.processingFilesState} onStartProcessing={getFilesByInterval} onHomeworkUpdate={({fileInfos, homework, isDeleted}) => { + onStartProcessing={updCourseUnitFiles} const homeworkIndex = courseState.courseHomeworks.findIndex(x => x.id === homework.id) const homeworks = courseState.courseHomeworks From 4b8d7973a0c4f3320acce85058164f73e1d5029b Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 16:10:29 +0300 Subject: [PATCH 56/77] fix [front]: rename usage of upload waiter --- .../src/components/Solutions/StudentSolutionsPage.tsx | 4 ++-- hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index 497708e81..0dad6a072 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -40,7 +40,7 @@ import {getTip} from "../Common/HomeworkTags"; import {appBarStateManager} from "../AppBar"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {RemovedFromCourseTag} from "@/components/Common/StudentTags"; -import {FilesAccessService} from "@/components/Files/FilesAccessService"; +import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; interface IStudentSolutionsPageState { currentTaskId: string @@ -264,7 +264,7 @@ const StudentSolutionsPage: FC = () => {
} - const {courseFilesState} = FilesAccessService(courseId, false); + const {courseFilesState} = FilesUploadWaiter(courseId, false); if (isLoaded) { return ( diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 60d112a4e..7c4b225df 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -29,7 +29,7 @@ import { getTip } from "../Common/HomeworkTags"; import Lodash from "lodash"; import { appBarStateManager } from "../AppBar"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { FilesAccessService } from "@/components/Files/FilesAccessService"; +import { FilesUploadWaiter } from "@/components/Files/FilesUploadWaiter"; interface ITaskSolutionsState { isLoaded: boolean @@ -142,7 +142,7 @@ const TaskSolutionsPage: FC = () => { const { courseFilesState, updCourseUnitFiles, - } = FilesAccessService(courseId, true); + } = FilesUploadWaiter(courseId, true); const currentHomeworksGroup = taskSolutionsWithPreview .find(x => x.homeworkSolutions! From 26dbe909c50c9f1f5924cfd0ad2fe2352af76744 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 16:18:54 +0300 Subject: [PATCH 57/77] fix [front]: rename usages of download link getter --- .../src/components/Homeworks/CourseHomeworkExperimental.tsx | 6 +----- .../src/components/Solutions/TaskSolutionComponent.tsx | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index f16c4b33c..10849fd34 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -531,11 +531,7 @@ const CourseHomeworkExperimental: FC<{ showOkStatus={props.isMentor} filesInfo={filesInfo} onClickFileInfo={async (fileInfo: IFileInfo) => { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { - courseId: homework.courseId, - courseUnitType: CourseUnitType.Homework, - courseUnitId: homework.id, - }) + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!); window.open(url, '_blank'); }} /> diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 35a3d76d5..e560ab803 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1080,11 +1080,7 @@ const TaskSolutionComponent: FC = (props) => { showOkStatus={ !props.forMentor } filesInfo={filesInfo} onClickFileInfo={async (fileInfo: IFileInfo) => { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!, { - courseId: props.courseId, - courseUnitType: fileInfo.courseUnitType, - courseUnitId: fileInfo.courseUnitId - }) + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) window.open(url, '_blank'); }} /> From 45ad3fa3bbeafca0cd0e1becab4d4e378373b82e Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 20:28:49 +0300 Subject: [PATCH 58/77] refactor [front]: unify unit files info getter --- .../src/components/Courses/CourseExperimental.tsx | 3 ++- .../components/Solutions/TaskSolutionComponent.tsx | 5 ++++- .../src/components/Utils/FileInfoConverter.ts | 13 +++---------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index b5d1819fe..44c81ccdf 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -35,6 +35,7 @@ import ErrorIcon from '@mui/icons-material/Error'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; import Lodash from "lodash"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; interface ICourseExperimentalProps { homeworks: HomeworkViewModel[] @@ -410,7 +411,7 @@ export const CourseExperimental: FC = (props) => { } const renderHomework = (homework: HomeworkViewModel & { isModified?: boolean }) => { - const filesInfo = id ? FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, id) : [] + const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] const homeworkEditMode = homework && (homework.id! < 0 || homework.isModified === true) return homework && diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index e560ab803..c3b0559c1 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -43,6 +43,9 @@ import BlurOnIcon from '@mui/icons-material/BlurOn'; import BlurOffIcon from '@mui/icons-material/BlurOff'; import {UserAvatar} from "../Common/UserAvatar"; import FileInfoConverter from "@/components/Utils/FileInfoConverter"; +import {IFileInfo} from "@/components/Files/IFileInfo"; +import FilesPreviewList from "@/components/Files/FilesPreviewList"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; type TaskWithCriteria = HomeworkTaskViewModel & {}; @@ -376,7 +379,7 @@ const TaskSolutionComponent: FC = (props) => { const lecturer = solution?.lecturer const lecturerName = lecturer && (lecturer.surname + " " + lecturer.name) const commitsActuality = solutionActuality?.commitsActuality - const filesInfo = solution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, solution.id) : [] + const filesInfo = solution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, solution.id) : [] const getDatesDiff = (_date1: Date, _date2: Date) => { const truncateToMinutes = (date: Date) => { diff --git a/hwproj.front/src/components/Utils/FileInfoConverter.ts b/hwproj.front/src/components/Utils/FileInfoConverter.ts index e2a70a9b1..4da4db13d 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -25,17 +25,10 @@ export default class FileInfoConverter { return fileInfoDtos.map(fileInfoDto => this.fromFileInfoDTO(fileInfoDto)); } - public static getHomeworkFilesInfo(filesInfo: FileInfoDTO[], homeworkId: number): IFileInfo[] { + public static getCourseUnitFilesInfo(filesInfo: FileInfoDTO[], courseUnitType: CourseUnitType, courseUnitId: number): IFileInfo[] { return FileInfoConverter.fromFileInfoDTOArray( - filesInfo.filter(filesInfo => filesInfo.courseUnitType === CourseUnitType.Homework - && filesInfo.courseUnitId === homeworkId) - ) - } - - public static getSolutionFilesInfo(filesInfo: FileInfoDTO[], solutionId: number): IFileInfo[] { - return FileInfoConverter.fromFileInfoDTOArray( - filesInfo.filter(filesInfo => filesInfo.courseUnitType === CourseUnitType.Solution - && filesInfo.courseUnitId === solutionId) + filesInfo.filter(filesInfo => filesInfo.courseUnitType === courseUnitType + && filesInfo.courseUnitId === courseUnitId) ) } } \ No newline at end of file From 13388c95f7ccb24d199d3637e86c4b3eb007a906 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 20:45:06 +0300 Subject: [PATCH 59/77] feat [front]: delete files saved status text --- hwproj.front/src/components/Files/FilePreview.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/hwproj.front/src/components/Files/FilePreview.tsx b/hwproj.front/src/components/Files/FilePreview.tsx index 17ffb8136..b1d9f7725 100644 --- a/hwproj.front/src/components/Files/FilePreview.tsx +++ b/hwproj.front/src/components/Files/FilePreview.tsx @@ -107,7 +107,6 @@ const FilePreview: React.FC = (props) => { }; case FileStatus.ReadyToUse: return { - text: props.showOkStatus ? "Сохранён" : "", tooltipText: "", icon: props.showOkStatus ? : <>, From e862b6018dbae9ff5772d5ad42f19e481ada7971 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 20:54:56 +0300 Subject: [PATCH 60/77] refactor [front]: delete unused files info array --- .../src/components/Courses/Course.tsx | 3 +-- .../components/Courses/CourseExperimental.tsx | 7 +++---- .../Homeworks/CourseHomeworkExperimental.tsx | 19 +++++++------------ 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index bb2123976..b7fcfee71 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -344,9 +344,8 @@ const Course: React.FC = () => { selectedHomeworkId={searchedHomeworkId == null ? undefined : +searchedHomeworkId} userId={userId!} processingFiles={courseFilesState.processingFilesState} - onStartProcessing={getFilesByInterval} - onHomeworkUpdate={({fileInfos, homework, isDeleted}) => { onStartProcessing={updCourseUnitFiles} + onHomeworkUpdate={({homework, isDeleted}) => { const homeworkIndex = courseState.courseHomeworks.findIndex(x => x.id === homework.id) const homeworks = courseState.courseHomeworks diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 44c81ccdf..20268989e 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -46,7 +46,7 @@ interface ICourseExperimentalProps { isStudentAccepted: boolean userId: string selectedHomeworkId: number | undefined - onHomeworkUpdate: (update: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onHomeworkUpdate: (update: { homework: HomeworkViewModel } & { isDeleted?: boolean }) => void onTaskUpdate: (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => void, @@ -357,8 +357,7 @@ export const CourseExperimental: FC = (props) => { description: "", tasks: [], tags: [] - }, - fileInfos: [] + } }) setState((prevState) => ({ ...prevState, @@ -561,7 +560,7 @@ export const CourseExperimental: FC = (props) => { data: x, isHomework: true, id: x.id, - homeworkFilesInfo: FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, x.id!) + homeworkFilesInfo: FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, x.id!) } })) }}> diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 10849fd34..a456e81e5 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -65,7 +65,7 @@ export interface IEditFilesState { const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, getAllHomeworks: () => HomeworkViewModel[], - onUpdate: (update: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onUpdate: (update: { homework: HomeworkViewModel } & { isDeleted?: boolean, isSaved?: boolean }) => void @@ -179,7 +179,7 @@ const CourseHomeworkEditor: FC<{ isModified: true, } - props.onUpdate({fileInfos: filesState.selectedFilesInfo, homework: update}) + props.onUpdate({homework: update}) }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) useEffect(() => { @@ -210,7 +210,7 @@ const CourseHomeworkEditor: FC<{ newFiles: [] }) - props.onUpdate({homework: loadedHomework, fileInfos: [], isDeleted: true}) + props.onUpdate({homework: loadedHomework, isDeleted: true}) } const getDeleteMessage = (homeworkName: string, filesInfo: IFileInfo[]) => { @@ -308,16 +308,11 @@ const CourseHomeworkEditor: FC<{ enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 4000}); if (isNewHomework) props.onUpdate({ homework: update, - fileInfos: [], isDeleted: true }) // remove fake homework - props.onUpdate({ - homework: updatedHomework.value!, - fileInfos: filesState.selectedFilesInfo, - isSaved: true - }) - } - } + props.onUpdate({homework: updatedHomework.value!, isSaved: true}); + }, + ); } const isDisabled = hasErrors || !isLoaded || taskHasErrors @@ -447,7 +442,7 @@ const CourseHomeworkExperimental: FC<{ isMentor: boolean, initialEditMode: boolean, onMount: () => void, - onUpdate: (x: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onUpdate: (x: { homework: HomeworkViewModel } & { isDeleted?: boolean }) => void onAddTask: (homework: HomeworkViewModel) => void, From bc0292c95ffeb00465b61018f93d9240dbfb3422 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 20:56:22 +0300 Subject: [PATCH 61/77] refactor [front]: separate files handle logic --- .../src/components/Courses/Course.tsx | 2 - .../components/Courses/CourseExperimental.tsx | 9 +- .../src/components/Files/FilesHandler.ts | 85 ++++++++++++++++++ .../Homeworks/CourseHomeworkExperimental.tsx | 90 ++++--------------- .../Solutions/AddOrEditSolution.tsx | 58 +++--------- 5 files changed, 119 insertions(+), 125 deletions(-) create mode 100644 hwproj.front/src/components/Files/FilesHandler.ts diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index b7fcfee71..a495ff6ce 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -38,8 +38,6 @@ import {useSnackbar} from 'notistack'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import {CourseUnitType} from "../Files/CourseUnitType"; -import {FileStatus} from "../Files/FileStatus"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; type TabValue = "homeworks" | "stats" | "applications" diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 20268989e..4ddd2bfdf 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -55,7 +55,11 @@ interface ICourseExperimentalProps { isLoading: boolean; }; }; - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; } interface ICourseExperimentalState { @@ -435,8 +439,7 @@ export const CourseExperimental: FC = (props) => { })) }} isProcessing={props.processingFiles[homework.id!]?.isLoading || false} - onStartProcessing={(homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => - props.onStartProcessing(homeworkId, previouslyExistingFilesCount, waitingNewFilesCount, deletingFilesIds)} + onStartProcessing={props.onStartProcessing} /> diff --git a/hwproj.front/src/components/Files/FilesHandler.ts b/hwproj.front/src/components/Files/FilesHandler.ts new file mode 100644 index 000000000..49791b747 --- /dev/null +++ b/hwproj.front/src/components/Files/FilesHandler.ts @@ -0,0 +1,85 @@ +import {IFileInfo} from "@/components/Files/IFileInfo"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import ApiSingleton from "@/api/ApiSingleton"; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {useState} from "react"; +import {enqueueSnackbar} from "notistack"; + +export interface IEditFilesState { + initialFilesInfo: IFileInfo[] + selectedFilesInfo: IFileInfo[] + isLoadingInfo: boolean +} + +export const FilesHandler = (selectedFilesInfo: IFileInfo[]) => { + const [filesState, setFilesState] = useState({ + initialFilesInfo: selectedFilesInfo.filter(x => x.id !== undefined), + selectedFilesInfo: selectedFilesInfo, + isLoadingInfo: false + }); + + const handleFilesChange = async (courseId: number, + courseUnitType: CourseUnitType, + courseUnitId: number, + onStartProcessing: (courseUnitId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void, + onComplete: () => void, + ) => { + // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить + const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => + initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) + .map(fileInfo => fileInfo.id!) + + // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые + const newFiles = filesState.selectedFilesInfo.filter(selectedFile => + selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) + + // Если требуется, отправляем запрос на обработку файлов + if (deletingFileIds.length + newFiles.length > 0) { + try { + await ApiSingleton.customFilesApi.processFiles({ + courseId: courseId!, + courseUnitType: courseUnitType, + courseUnitId: courseUnitId!, + deletingFileIds, + newFiles, + }); + } catch (e) { + const errors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(errors[0], { + variant: "warning", + autoHideDuration: 2000 + }); + } + } + if (deletingFileIds.length === 0 && newFiles.length === 0) { + onComplete(); + } else { + try { + onComplete(); + onStartProcessing( + courseUnitId!, + courseUnitType, + filesState.initialFilesInfo.length, + newFiles.length, + deletingFileIds, + ); + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(responseErrors[0], { + variant: "warning", + autoHideDuration: 4000 + }); + onComplete(); + } + } + } + return { + filesState, + setFilesState, + handleFilesChange, + } +} diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index a456e81e5..cfd7dbadd 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -18,11 +18,7 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - FileInfoDTO, - HomeworkViewModel, - ActionOptions, - HomeworkTaskViewModel, - PostTaskViewModel + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -33,8 +29,6 @@ import * as React from "react"; import EditIcon from "@mui/icons-material/Edit"; import AddTaskIcon from '@mui/icons-material/AddTask'; import {LoadingButton} from "@mui/lab"; -import ErrorsHandler from "../Utils/ErrorsHandler"; -import {enqueueSnackbar} from "notistack"; import DeletionConfirmation from "../DeletionConfirmation"; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; @@ -42,6 +36,7 @@ import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/compone import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; +import {FilesHandler} from "@/components/Files/FilesHandler"; export interface HomeworkAndFilesInfo { homework: HomeworkViewModel & { isModified?: boolean }, @@ -56,12 +51,6 @@ interface IEditHomeworkState { hasErrors: boolean; } -export interface IEditFilesState { - initialFilesInfo: IFileInfo[] - selectedFilesInfo: IFileInfo[] - isLoadingInfo: boolean -} - const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, getAllHomeworks: () => HomeworkViewModel[], @@ -69,7 +58,11 @@ const CourseHomeworkEditor: FC<{ isDeleted?: boolean, isSaved?: boolean }) => void - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 @@ -88,6 +81,7 @@ const CourseHomeworkEditor: FC<{ const {loadedHomework, isLoaded} = homeworkData + const {filesState, setFilesState, handleFilesChange} = FilesHandler(props.homeworkAndFilesInfo.filesInfo) const initialFilesInfo = props.homeworkAndFilesInfo.filesInfo.filter(x => x.id !== undefined) const homeworkId = loadedHomework.id! @@ -120,11 +114,7 @@ const CourseHomeworkEditor: FC<{ const [title, setTitle] = useState(loadedHomework.title!) const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) - const [filesState, setFilesState] = useState({ - initialFilesInfo: initialFilesInfo, - selectedFilesInfo: props.homeworkAndFilesInfo.filesInfo, - isLoadingInfo: false - }); + const [hasErrors, setHasErrors] = useState(false) const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) @@ -254,58 +244,10 @@ const CourseHomeworkEditor: FC<{ : await ApiSingleton.homeworksApi.homeworksUpdateHomework(+homeworkId!, update) const updatedHomeworkId = updatedHomework.value!.id! - - // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить - const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => - initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) - .map(fileInfo => fileInfo.id!) - - // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые - const newFiles = filesState.selectedFilesInfo.filter(selectedFile => - selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) - - // Если требуется, отправляем запрос на обработку файлов - if (deletingFileIds.length + newFiles.length > 0) { - try { - await ApiSingleton.customFilesApi.processFiles({ - courseId: courseId!, - courseUnitType: CourseUnitType.Homework, - courseUnitId: updatedHomeworkId, - deletingFileIds: deletingFileIds, - newFiles: newFiles, - }); - } catch (e) { - const errors = await ErrorsHandler.getErrorMessages(e as Response); - enqueueSnackbar(`Проблема при обработке файлов. ${errors[0]}`, { - variant: "warning", - autoHideDuration: 2000 - }); - } - } - - if (deletingFileIds.length === 0 && newFiles.length === 0) { - if (isNewHomework) props.onUpdate({ - homework: update, - fileInfos: [], - isDeleted: true - }) // remove fake homework - props.onUpdate({ - homework: updatedHomework.value!, - fileInfos: filesState.selectedFilesInfo, - isSaved: true - }) - } else { - try { - if (isNewHomework) props.onUpdate({ - homework: update, - fileInfos: [], - isDeleted: true - }) // remove fake homework - props.onUpdate({homework: updatedHomework.value!, fileInfos: undefined, isSaved: true}) - props.onStartProcessing(updatedHomework.value!.id!, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds); - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 4000}); + await handleFilesChange( + courseId, CourseUnitType.Homework, updatedHomeworkId, + props.onStartProcessing, + () => { if (isNewHomework) props.onUpdate({ homework: update, isDeleted: true @@ -447,7 +389,11 @@ const CourseHomeworkExperimental: FC<{ }) => void onAddTask: (homework: HomeworkViewModel) => void, isProcessing: boolean; - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index d08762c4e..581d53d8c 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -15,11 +15,11 @@ import {TestTag} from "../Common/HomeworkTags"; import {LoadingButton} from "@mui/lab"; import TextField from "@mui/material/TextField"; import FilesUploader from '../Files/FilesUploader'; -import {IEditFilesState} from '../Homeworks/CourseHomeworkExperimental'; import {CourseUnitType} from '../Files/CourseUnitType'; import ErrorsHandler from "@/components/Utils/ErrorsHandler"; import {enqueueSnackbar} from "notistack"; import FileInfoConverter from "@/components/Utils/FileInfoConverter"; +import {FilesHandler} from "@/components/Files/FilesHandler"; interface IAddSolutionProps { courseId: number @@ -31,7 +31,11 @@ interface IAddSolutionProps { courseFilesInfo: FileInfoDTO[], onAdd: () => void, onCancel: () => void, - onStartProcessing: (solutionId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (solutionId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void, } const AddOrEditSolution: FC = (props) => { @@ -57,52 +61,10 @@ const AddOrEditSolution: FC = (props) => { setDisableSend(true) let solutionId = await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) - - // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить - const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => - initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) - .map(fileInfo => fileInfo.id!) - - // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые - const newFiles = filesState.selectedFilesInfo.filter(selectedFile => - selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) - - // Если требуется, отправляем запрос на обработку файлов - if (deletingFileIds.length + newFiles.length > 0) { - try { - await ApiSingleton.customFilesApi.processFiles({ - courseId: props.courseId!, - courseUnitType: CourseUnitType.Solution, - courseUnitId: solutionId, - deletingFileIds: deletingFileIds, - newFiles: newFiles, - }); - } catch (e) { - const errors = await ErrorsHandler.getErrorMessages(e as Response); - enqueueSnackbar(`Проблема при обработке файлов. ${errors[0]}`, { - variant: "warning", - autoHideDuration: 2000 - }); - } - } - if (deletingFileIds.length === 0 && newFiles.length === 0) { - props.onAdd() - } else { - try { - props.onAdd(); - props.onStartProcessing( - solutionId, - CourseUnitType.Solution, - filesState.initialFilesInfo.length, - newFiles.length, - deletingFileIds - ); - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], { variant: "warning", autoHideDuration: 4000 }); - props.onAdd() - } - } + await handleFilesChange(props.courseId, CourseUnitType.Solution, solutionId, + props.onStartProcessing, + props.onAdd + ); } const { githubUrl } = solution From 65364ee098bc31a8cedeac4b3ea051cfdaf72b87 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 20 Nov 2025 17:01:23 +0300 Subject: [PATCH 62/77] refactor [back]: type validation by foreign library --- .../Attributes/CorrectFileTypeAttribute.cs | 51 ++++++++++--------- .../HwProj.Models/HwProj.Models.csproj | 1 + 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 374b19fae..294f5e37b 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,25 +2,36 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using FileTypeChecker; using Microsoft.AspNetCore.Http; +using FileTypeChecker.Abstracts; +using FileTypeChecker.Types; namespace HwProj.Models.ContentService.Attributes { - [AttributeUsage(AttributeTargets.Property)] - public class CorrectFileTypeAttribute : ValidationAttribute + + public class MachO : FileType { - private static HashSet forbiddenFileSignatures = new HashSet{ - - new byte[] { 0x4d, 0x5a }, // MZ (exe BE) - new byte[] { 0x5a, 0x4d }, // ZM (exe LE) - - new byte[] { 0x7F, 0x45, 0x4C, 0x46 }, // ELF - + public const string TypeName = "MacOS executable"; + public const string TypeExtension = "macho"; + private static readonly byte[][] MagicBytes = + { new byte[] { 0xfe, 0xed, 0xfa, 0xce }, // Mach-O BE 32-bit new byte[] { 0xfe, 0xed, 0xfa, 0xcf }, // Mach-O BE 64-bit new byte[] { 0xce, 0xfa, 0xed, 0xfe }, // Mach-O LE 32-bit new byte[] { 0xcf, 0xfa, 0xed, 0xfe }, // Mach-O LE 64-bit - + }; + + public MachO() : base(TypeName, TypeExtension, MagicBytes) + { + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class CorrectFileTypeAttribute : ValidationAttribute + { + private static readonly HashSet forbiddenFileTypes = new HashSet{ + new MachO(), new Executable(), new ExecutableAndLinkableFormat() }; protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) @@ -34,26 +45,18 @@ public class CorrectFileTypeAttribute : ValidationAttribute if (files == null) return ValidationResult.Success; + FileTypeValidator.RegisterCustomTypes(typeof(MachO).Assembly); foreach (var file in files) { try { - // Первые байты для проверки сигнатуры - var buffer = new byte[4]; - using (var stream = file.OpenReadStream()) + using (var fileContent = file.OpenReadStream()) { - var bytesRead = stream.Read(buffer, 0, buffer.Length); - - if (bytesRead < 2) - return ValidationResult.Success; // Слишком короткий файл, не исполняемый - - foreach (var signature in forbiddenFileSignatures) + if (!FileTypeValidator.IsTypeRecognizable(fileContent) || + forbiddenFileTypes.Any(type => type.DoesMatchWith(fileContent))) { - if (signature.SequenceEqual(buffer.Take(signature.Length))) - { - return new ValidationResult( - $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); - } + return new ValidationResult( + $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); } } } diff --git a/HwProj.Common/HwProj.Models/HwProj.Models.csproj b/HwProj.Common/HwProj.Models/HwProj.Models.csproj index bd95eded7..f1514f75f 100644 --- a/HwProj.Common/HwProj.Models/HwProj.Models.csproj +++ b/HwProj.Common/HwProj.Models/HwProj.Models.csproj @@ -7,6 +7,7 @@ + From 096da5e52fd3396ca52c40d75ba4baaa42d569a6 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 20 Nov 2025 17:09:12 +0300 Subject: [PATCH 63/77] refactor [back]: scope usage in privacy filter --- .../Controllers/FilesController.cs | 12 +++--------- .../Filters/FilesPrivacyFilter.cs | 13 +++++++------ .../HwProj.Models/ContentService/DTO/FileLinkDTO.cs | 4 +--- .../Controllers/FilesController.cs | 4 +--- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 1ad54cc03..11a9445be 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -31,9 +31,7 @@ public FilesController(IAuthServiceClient authServiceClient, public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { var isChecked = await _privacyFilter.CheckRights(User, - processFilesDto.FilesScope.CourseId, - processFilesDto.FilesScope.CourseUnitType, - processFilesDto.FilesScope.CourseUnitId, + processFilesDto.FilesScope, FilesPrivacyFilter.Method.Upload ); if (!isChecked) return BadRequest(); @@ -50,9 +48,7 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles public async Task GetStatuses(ScopeDTO filesScope) { var isChecked = await _privacyFilter.CheckRights(User, - filesScope.CourseId, - filesScope.CourseUnitType, - filesScope.CourseUnitId, + filesScope, FilesPrivacyFilter.Method.Upload ); if (!isChecked) return BadRequest(); @@ -72,9 +68,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) if(!linkDto.Succeeded) return NotFound(linkDto.Errors); var checkRights = await _privacyFilter.CheckRights(User, - linkDto.Value.CourseId, - linkDto.Value.CourseUnitType, - linkDto.Value.CourseUnitId, + linkDto.Value.fileScope, FilesPrivacyFilter.Method.Download ); diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index 458ed3055..7a80650e9 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Threading.Tasks; using HwProj.CoursesService.Client; +using HwProj.Models.ContentService.DTO; using HwProj.Models.Roles; using HwProj.SolutionsService.Client; @@ -25,7 +26,7 @@ public FilesPrivacyFilter(ICoursesServiceClient coursesServiceClient, ISolutions _solutionsServiceClient = solutionsServiceClient; } - public async Task CheckRights(ClaimsPrincipal user, long courseId, string courseUnitType, long courseUnitId, Method method) + public async Task CheckRights(ClaimsPrincipal user, ScopeDTO fileScope, Method method) { var userId = user.Claims .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; @@ -35,25 +36,25 @@ public async Task CheckRights(ClaimsPrincipal user, long courseId, string .FirstOrDefault(claim => claim.Type.ToString().EndsWith("role"))?.Value; if (userRole == null) return false; - if (courseUnitType == "Homework") + if (fileScope.CourseUnitType == "Homework") { if (method == Method.Download) return true; if (method == Method.Upload) { - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); if (!mentorIds.Contains(userId)) { return false; } return true; } - } else if (courseUnitType == "Solution") + } else if (fileScope.CourseUnitType == "Solution") { if (userRole == Roles.StudentRole) { var studentIds = new HashSet(); - var solution = await _solutionsServiceClient.GetSolutionById(courseUnitId); + var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); studentIds.Add(solution.StudentId); var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); @@ -62,7 +63,7 @@ public async Task CheckRights(ClaimsPrincipal user, long courseId, string } else { - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(courseId); + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); if (!mentorIds.Contains(userId)) return false; } diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs index 95337ea61..09ef167bd 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs @@ -4,8 +4,6 @@ namespace HwProj.Models.ContentService.DTO public class FileLinkDTO { public string DownloadUrl { get; set; } - public long CourseId { get; set; } - public string CourseUnitType { get; set; } - public long CourseUnitId { get; set; } + public ScopeDTO fileScope { get; set; } } } diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index 59875982a..f68b6eb1d 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -96,9 +96,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) var result = new FileLinkDTO { DownloadUrl = downloadUrl.Value, - CourseId = fileScope.CourseId, - CourseUnitType = fileScope.CourseUnitType.ToString(), - CourseUnitId = fileScope.CourseUnitId + fileScope = fileScope.ToScopeDTO() }; return Ok(result); From 0804463fc8957cca27f749d9b848b493c36ee2ed Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:18:15 +0300 Subject: [PATCH 64/77] fix [back]: return with privacy error --- .../Controllers/FilesController.cs | 14 +++++++++----- .../Filters/FilesPrivacyFilter.cs | 3 +-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 11a9445be..4e41dc374 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -27,14 +27,16 @@ public FilesController(IAuthServiceClient authServiceClient, [HttpPost("process")] [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { - var isChecked = await _privacyFilter.CheckRights(User, + var checkRights = await _privacyFilter.CheckRights(User, processFilesDto.FilesScope, FilesPrivacyFilter.Method.Upload ); if (!isChecked) return BadRequest(); + if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для загрузки файлов"); var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded @@ -44,14 +46,15 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles [HttpPost("statuses")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task GetStatuses(ScopeDTO filesScope) { - var isChecked = await _privacyFilter.CheckRights(User, + var checkRights = await _privacyFilter.CheckRights(User, filesScope, FilesPrivacyFilter.Method.Upload ); - if (!isChecked) return BadRequest(); + if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для получения информации о файлах"); var filesStatusesResult = await _contentServiceClient.GetFilesStatuses(filesScope); return filesStatusesResult.Succeeded @@ -61,11 +64,12 @@ public async Task GetStatuses(ScopeDTO filesScope) [HttpGet("downloadLink")] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromQuery] long fileId) { var linkDto = await _contentServiceClient.GetDownloadLinkAsync(fileId); - if(!linkDto.Succeeded) return NotFound(linkDto.Errors); + if(!linkDto.Succeeded) return StatusCode((int)HttpStatusCode.ServiceUnavailable, linkDto.Errors); var checkRights = await _privacyFilter.CheckRights(User, linkDto.Value.fileScope, @@ -74,7 +78,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) return checkRights ? Ok(linkDto.Value.DownloadUrl) - : BadRequest(); + : StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для получения ссылки на файл"); } [HttpGet("info/course/{courseId}")] diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index 7a80650e9..f7f4eb114 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -60,8 +60,7 @@ public async Task CheckRights(ClaimsPrincipal user, ScopeDTO fileScope, Me studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); if (!studentIds.Contains(userId)) return false; - } - else + } else if(method == Method.Download) { var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); if (!mentorIds.Contains(userId)) return false; From ffe54a24d7c43ca1338bf3b26093501741122827 Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:19:00 +0300 Subject: [PATCH 65/77] feat [back]: add max files count filter --- .../Controllers/FilesController.cs | 8 +++- .../Filters/FilesCountLimit.cs | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 4e41dc374..0f2754497 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -16,13 +16,15 @@ public class FilesController : AggregationController { private readonly IContentServiceClient _contentServiceClient; private readonly FilesPrivacyFilter _privacyFilter; + private readonly FilesCountLimit _countFilter; public FilesController(IAuthServiceClient authServiceClient, IContentServiceClient contentServiceClient, - FilesPrivacyFilter privacyFilter) : base(authServiceClient) + FilesPrivacyFilter privacyFilter, FilesCountLimit countFilter) : base(authServiceClient) { _contentServiceClient = contentServiceClient; _privacyFilter = privacyFilter; + _countFilter = countFilter; } [HttpPost("process")] @@ -35,8 +37,10 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles processFilesDto.FilesScope, FilesPrivacyFilter.Method.Upload ); - if (!isChecked) return BadRequest(); if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для загрузки файлов"); + + var checkCountLimit = await _countFilter.CheckCountLimit(processFilesDto); + if (!checkCountLimit) return StatusCode((int)HttpStatusCode.Forbidden, "Слишком много файлов в решении"); var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs new file mode 100644 index 000000000..aed7b08b0 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs @@ -0,0 +1,39 @@ +using System.Linq; +using System.Threading.Tasks; +using HwProj.ContentService.Client; +using HwProj.Models.ContentService.DTO; +using HwProj.Models.Result; + +namespace HwProj.APIGateway.API.Filters; + +public class FilesCountLimit +{ + private readonly IContentServiceClient _contentServiceClient; + private readonly long _maxSolutionFiles = 5; + + public FilesCountLimit(IContentServiceClient contentServiceClient) + { + _contentServiceClient = contentServiceClient; + } + + public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) + { + if(processFilesDto.FilesScope.CourseUnitType == "Homework") return true; + + var existingStatuses = await _contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); + if(!existingStatuses.Succeeded) return false; + + var existingIds = existingStatuses.Value.Select(f => f.Id); + if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) + { + return false; + } + + if (existingIds.Count() + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count > _maxSolutionFiles) + { + return false; + } + + return true; + } +} \ No newline at end of file From c29116ad3905941f0deeab8e6577363297c8046a Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 22 Nov 2025 11:18:50 +0300 Subject: [PATCH 66/77] fix [front]: max files count showing --- hwproj.front/src/components/Files/FilesUploader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 115d155e3..5b4b7c59a 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -61,7 +61,7 @@ const FilesUploader: React.FC = (props) => { if (props.maxFilesCount && (props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > props.maxFilesCount) { setError(`Выбрано слишком много файлов. - Максимально допустимое количество файлов: ${Utils.pluralizeHelper(["штука", "штука", "штук"], props.maxFilesCount)}`); + Максимально допустимое количество файлов: ${props.maxFilesCount} ${Utils.pluralizeHelper(["штука", "штука", "штук"], props.maxFilesCount)}`); return false; } for (const file of files) { From 0a25ae85400f303718db12f5d4930d7534212e40 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 22 Nov 2025 11:19:36 +0300 Subject: [PATCH 67/77] fix [back]: add files count limit to start up --- HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index d6ae69cdf..43c47e098 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -81,6 +81,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IHostEnvironment env) From da30fe3b37d933e1ecd4d3cc588be616ce479a38 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 22 Nov 2025 11:53:10 +0300 Subject: [PATCH 68/77] feat [back]: showing max files count on limit exceeding --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 3 ++- .../HwProj.APIGateway.API/Filters/FilesCountLimit.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 0f2754497..c28f182a7 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -40,7 +40,8 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для загрузки файлов"); var checkCountLimit = await _countFilter.CheckCountLimit(processFilesDto); - if (!checkCountLimit) return StatusCode((int)HttpStatusCode.Forbidden, "Слишком много файлов в решении"); + if (!checkCountLimit) return StatusCode((int)HttpStatusCode.Forbidden, "Слишком много файлов в решении." + + $"Максимальное количество файлов - ${_countFilter.maxSolutionFiles}"); var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs index aed7b08b0..00d6fbea6 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs @@ -9,7 +9,7 @@ namespace HwProj.APIGateway.API.Filters; public class FilesCountLimit { private readonly IContentServiceClient _contentServiceClient; - private readonly long _maxSolutionFiles = 5; + public readonly long maxSolutionFiles = 5; public FilesCountLimit(IContentServiceClient contentServiceClient) { @@ -29,7 +29,7 @@ public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) return false; } - if (existingIds.Count() + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count > _maxSolutionFiles) + if (existingIds.Count() + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count > maxSolutionFiles) { return false; } From e58e637c1cae4d43de2804312d8c9be62fef7324 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:20:34 +0300 Subject: [PATCH 69/77] refactor: separate methods in privacy filter --- .../Controllers/FilesController.cs | 43 ++++++----- .../Filters/FilesPrivacyFilter.cs | 76 +++++++++---------- .../ContentService/DTO/FileLinkDTO.cs | 4 +- .../Controllers/FilesController.cs | 8 +- .../Repositories/FileRecordRepository.cs | 4 +- .../Repositories/IFileRecordRepository.cs | 2 +- .../Services/FilesInfoService.cs | 4 +- .../Services/Interfaces/IFilesInfoService.cs | 2 +- 8 files changed, 72 insertions(+), 71 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index c28f182a7..48ce92c1f 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Net; using System.Threading.Tasks; using HwProj.APIGateway.API.Filters; @@ -29,15 +30,14 @@ public FilesController(IAuthServiceClient authServiceClient, [HttpPost("process")] [ProducesResponseType((int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string), (int)HttpStatusCode.Forbidden)] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { - var checkRights = await _privacyFilter.CheckRights(User, - processFilesDto.FilesScope, - FilesPrivacyFilter.Method.Upload - ); - if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для загрузки файлов"); + var checkRights = await _privacyFilter.CheckUploadRights( + User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value, + processFilesDto.FilesScope); + if (!checkRights) return Forbid("Недостаточно прав для загрузки файлов"); var checkCountLimit = await _countFilter.CheckCountLimit(processFilesDto); if (!checkCountLimit) return StatusCode((int)HttpStatusCode.Forbidden, "Слишком много файлов в решении." @@ -50,16 +50,15 @@ public async Task Process([FromForm] ProcessFilesDTO processFiles } [HttpPost("statuses")] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] public async Task GetStatuses(ScopeDTO filesScope) { - var checkRights = await _privacyFilter.CheckRights(User, - filesScope, - FilesPrivacyFilter.Method.Upload - ); - if (!checkRights) return StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для получения информации о файлах"); + var checkRights = await _privacyFilter.CheckUploadRights( + User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value, + filesScope); + if (!checkRights) return Forbid("Недостаточно прав для получения информации о файлах"); var filesStatusesResult = await _contentServiceClient.GetFilesStatuses(filesScope); return filesStatusesResult.Succeeded @@ -68,22 +67,28 @@ public async Task GetStatuses(ScopeDTO filesScope) } [HttpGet("downloadLink")] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromQuery] long fileId) { var linkDto = await _contentServiceClient.GetDownloadLinkAsync(fileId); if(!linkDto.Succeeded) return StatusCode((int)HttpStatusCode.ServiceUnavailable, linkDto.Errors); - var checkRights = await _privacyFilter.CheckRights(User, - linkDto.Value.fileScope, - FilesPrivacyFilter.Method.Download - ); + var userId = User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; + var hasRights = false; + foreach (var scope in linkDto.Value.fileScopes) + { + if (await _privacyFilter.CheckDownloadRights(userId, scope)) + { + hasRights = true; + break; + } + } - return checkRights + return hasRights ? Ok(linkDto.Value.DownloadUrl) - : StatusCode((int)HttpStatusCode.Forbidden, "Недостаточно прав для получения ссылки на файл"); + : Forbid("Недостаточно прав для получения ссылки на файл"); } [HttpGet("info/course/{courseId}")] diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index f7f4eb114..1f0944510 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -1,22 +1,14 @@ using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using HwProj.CoursesService.Client; using HwProj.Models.ContentService.DTO; -using HwProj.Models.Roles; using HwProj.SolutionsService.Client; namespace HwProj.APIGateway.API.Filters; public class FilesPrivacyFilter { - public enum Method - { - Upload, - Download - } - private readonly ICoursesServiceClient _coursesServiceClient; private readonly ISolutionsServiceClient _solutionsServiceClient; @@ -26,45 +18,47 @@ public FilesPrivacyFilter(ICoursesServiceClient coursesServiceClient, ISolutions _solutionsServiceClient = solutionsServiceClient; } - public async Task CheckRights(ClaimsPrincipal user, ScopeDTO fileScope, Method method) + public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) { - var userId = user.Claims - .FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; - if (userId == null) return false; - - var userRole = user.Claims - .FirstOrDefault(claim => claim.Type.ToString().EndsWith("role"))?.Value; - if (userRole == null) return false; + if (fileScope.CourseUnitType == "Homework") return true; + if (fileScope.CourseUnitType == "Solution") + { + if(userId == null) return false; + var studentIds = new HashSet(); + var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); + studentIds.Add(solution.StudentId); + var groupIds = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + if (!mentorIds.Contains(userId)) return false; + studentIds.UnionWith(groupIds.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); + + if (!studentIds.Contains(userId) && !mentorIds.Contains(userId)) return false; + + return true; + } + + return false; + } + public async Task CheckUploadRights(string? userId, ScopeDTO fileScope) + { + if(userId == null) return false; if (fileScope.CourseUnitType == "Homework") { - if (method == Method.Download) return true; - if (method == Method.Upload) - { - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); - if (!mentorIds.Contains(userId)) - { - return false; - } - return true; - } - } else if (fileScope.CourseUnitType == "Solution") + var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + if (!mentorIds.Contains(userId)) return false; + return true; + } + if (fileScope.CourseUnitType == "Solution") { + var studentIds = new HashSet(); + var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); + studentIds.Add(solution.StudentId); + var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - if (userRole == Roles.StudentRole) - { - var studentIds = new HashSet(); - var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); - studentIds.Add(solution.StudentId); - var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - - if (!studentIds.Contains(userId)) return false; - } else if(method == Method.Download) - { - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); - if (!mentorIds.Contains(userId)) return false; - } + if (!studentIds.Contains(userId)) return false; return true; } diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs index 09ef167bd..5d4f703f2 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs @@ -1,9 +1,11 @@ +using System.Collections.Generic; + namespace HwProj.Models.ContentService.DTO { public class FileLinkDTO { public string DownloadUrl { get; set; } - public ScopeDTO fileScope { get; set; } + public List fileScopes { get; set; } } } diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index f68b6eb1d..888ad1b38 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -81,14 +81,14 @@ public async Task GetStatuses(ScopeDTO scopeDto) } [HttpGet("downloadLink")] - [ProducesResponseType(typeof(FileLinkDTO), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(FileLinkDTO[]), (int)HttpStatusCode.OK)] public async Task GetDownloadLink([FromQuery] long fileId) { var externalKey = await _filesInfoService.GetFileExternalKeyAsync(fileId); if (externalKey is null) return Ok(Result.Failed("Файл не найден")); - var fileScope = await _filesInfoService.GetFileScopeAsync(fileId); - if (fileScope is null) return Ok(Result.Failed("Файл не найден")); + var fileScopes = await _filesInfoService.GetFileScopesAsync(fileId); + if (fileScopes is null) return Ok(Result.Failed("Файл не найден")); var downloadUrl = await _s3FilesService.GetDownloadUrl(externalKey); if (!downloadUrl.Succeeded) return Ok(Result.Failed(downloadUrl.Errors)); @@ -96,7 +96,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) var result = new FileLinkDTO { DownloadUrl = downloadUrl.Value, - fileScope = fileScope.ToScopeDTO() + fileScopes = fileScopes.Select(fs => fs.ToScopeDTO()).ToList() }; return Ok(result); diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index fc84f77ae..890e7ad88 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -52,12 +52,12 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat .AsNoTracking() .SingleOrDefaultAsync(fr => fr.Id == fileRecordId); - public async Task GetScopeByRecordIdAsync(long fileRecordId) + public async Task?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits .AsNoTracking() .Where(fr => fr.FileRecordId == fileRecordId) .Select(fc => fc.ToScope()) - .SingleOrDefaultAsync(); + .ToListAsync(); public async Task> GetByScopeAsync(Scope scope) => await _contentContext.FileToCourseUnits diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs index bbdbe8ffa..2dd401a8e 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs @@ -13,7 +13,7 @@ public interface IFileRecordRepository public Task UpdateAsync(long id, Expression, SetPropertyCalls>> setPropertyCalls); public Task GetFileRecordByIdAsync(long fileRecordId); - public Task GetScopeByRecordIdAsync(long fileRecordId); + public Task?> GetScopesAsync(long fileRecordId); public Task> GetByScopeAsync(Scope scope); public Task> GetByCourseIdAsync(long courseId); public Task> GetByCourseIdAndStatusAsync(long courseId, FileStatus filesStatus); diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs index 39312070b..fb5350397 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs @@ -37,9 +37,9 @@ public async Task> GetFilesStatusesAsync(Scope filesScope) return fileRecord?.ExternalKey; } - public async Task GetFileScopeAsync(long fileId) + public async Task?> GetFileScopesAsync(long fileId) { - var fileToCourseUnit = await _fileRecordRepository.GetScopeByRecordIdAsync(fileId); + var fileToCourseUnit = await _fileRecordRepository.GetScopesAsync(fileId); return fileToCourseUnit; } diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs index 1c9a67e31..ecc9d3086 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs @@ -9,7 +9,7 @@ public interface IFilesInfoService { public Task> GetFilesStatusesAsync(Scope filesScope); public Task GetFileExternalKeyAsync(long fileId); - public Task GetFileScopeAsync(long fileId); + public Task?> GetFileScopesAsync(long fileId); public Task> GetFilesInfoAsync(long courseId); public Task> GetFilesInfoAsync(long courseId, FileStatus filesStatus); public Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); From 405cb9278189d4e75efb02544814d66084b10b6c Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:47:39 +0300 Subject: [PATCH 70/77] refactor: create courseUnitType constans --- .../Filters/FilesCountLimit.cs | 6 +++--- .../Filters/FilesPrivacyFilter.cs | 17 +++++++++-------- .../CourseUnitType/CourseUnitType.cs | 9 +++++++++ 3 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs index 00d6fbea6..5b3febe81 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using HwProj.ContentService.Client; using HwProj.Models.ContentService.DTO; -using HwProj.Models.Result; +using HwProj.Models.CourseUnitType; namespace HwProj.APIGateway.API.Filters; @@ -18,10 +18,10 @@ public FilesCountLimit(IContentServiceClient contentServiceClient) public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) { - if(processFilesDto.FilesScope.CourseUnitType == "Homework") return true; + if (processFilesDto.FilesScope.CourseUnitType == CourseUnitType.Homework) return true; var existingStatuses = await _contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); - if(!existingStatuses.Succeeded) return false; + if (!existingStatuses.Succeeded) return false; var existingIds = existingStatuses.Value.Select(f => f.Id); if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index 1f0944510..1ea0705df 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using HwProj.CoursesService.Client; using HwProj.Models.ContentService.DTO; +using HwProj.Models.CourseUnitType; using HwProj.SolutionsService.Client; namespace HwProj.APIGateway.API.Filters; @@ -20,10 +21,10 @@ public FilesPrivacyFilter(ICoursesServiceClient coursesServiceClient, ISolutions public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) { - if (fileScope.CourseUnitType == "Homework") return true; - if (fileScope.CourseUnitType == "Solution") + if (fileScope.CourseUnitType == CourseUnitType.Homework) return true; + if (fileScope.CourseUnitType == CourseUnitType.Solution) { - if(userId == null) return false; + if (userId == null) return false; var studentIds = new HashSet(); var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); studentIds.Add(solution.StudentId); @@ -40,17 +41,17 @@ public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) return false; } - + public async Task CheckUploadRights(string? userId, ScopeDTO fileScope) { - if(userId == null) return false; - if (fileScope.CourseUnitType == "Homework") + if (userId == null) return false; + if (fileScope.CourseUnitType == CourseUnitType.Homework) { var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); if (!mentorIds.Contains(userId)) return false; return true; - } - if (fileScope.CourseUnitType == "Solution") + } + if (fileScope.CourseUnitType == CourseUnitType.Solution) { var studentIds = new HashSet(); var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); diff --git a/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs b/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs new file mode 100644 index 000000000..1561d1b65 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs @@ -0,0 +1,9 @@ +namespace HwProj.Models.CourseUnitType +{ + public static class CourseUnitType + { + public const string Homework = "Homework"; + public const string Solution = "Solution"; + public const string Task = "Task"; + }; +}; \ No newline at end of file From 72081bc27fd989e197c88e125b54ab1ee351d8a5 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 17 Dec 2025 12:16:34 +0300 Subject: [PATCH 71/77] fix: privacy filter --- .../HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index 1ea0705df..f91feb9c5 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -29,10 +29,9 @@ public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); studentIds.Add(solution.StudentId); var groupIds = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); + studentIds.UnionWith(groupIds.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); - if (!mentorIds.Contains(userId)) return false; - studentIds.UnionWith(groupIds.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); if (!studentIds.Contains(userId) && !mentorIds.Contains(userId)) return false; From 39a7e04e12e20989a0a7aa8d7089e0b103e9f60f Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 22:12:18 +0300 Subject: [PATCH 72/77] wip --- .../Controllers/FilesController.cs | 94 ++++++++----------- ...ilesCountLimit.cs => FilesCountLimiter.cs} | 26 ++--- .../Filters/FilesPrivacyFilter.cs | 83 ++++++++-------- .../HwProj.APIGateway.API/Startup.cs | 2 +- .../Attributes/CorrectFileTypeAttribute.cs | 82 +++++++--------- .../Attributes/FileValidationAttribute.cs | 22 +++++ .../Attributes/MaxFileSizeAttribute.cs | 25 ++--- .../ContentService/DTO/FileLinkDTO.cs | 2 +- .../ContentService/DTO/ProcessFilesDTO.cs | 6 +- .../ContentService/DTO/ScopeDTO.cs | 2 +- .../Controllers/FilesController.cs | 2 +- 11 files changed, 159 insertions(+), 187 deletions(-) rename HwProj.APIGateway/HwProj.APIGateway.API/Filters/{FilesCountLimit.cs => FilesCountLimiter.cs} (50%) create mode 100644 HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 48ce92c1f..2a7dc82ed 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net; using System.Threading.Tasks; using HwProj.APIGateway.API.Filters; @@ -13,57 +12,46 @@ namespace HwProj.APIGateway.API.Controllers; [Route("api/[controller]")] [Authorize] [ApiController] -public class FilesController : AggregationController +public class FilesController( + IAuthServiceClient authServiceClient, + IContentServiceClient contentServiceClient, + FilesPrivacyFilter privacyFilter, + FilesCountLimiter filesCountLimiter) + : AggregationController(authServiceClient) { - private readonly IContentServiceClient _contentServiceClient; - private readonly FilesPrivacyFilter _privacyFilter; - private readonly FilesCountLimit _countFilter; - - public FilesController(IAuthServiceClient authServiceClient, - IContentServiceClient contentServiceClient, - FilesPrivacyFilter privacyFilter, FilesCountLimit countFilter) : base(authServiceClient) - { - _contentServiceClient = contentServiceClient; - _privacyFilter = privacyFilter; - _countFilter = countFilter; - } - [HttpPost("process")] [ProducesResponseType((int)HttpStatusCode.OK)] [ProducesResponseType((int)HttpStatusCode.Forbidden)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { - var checkRights = await _privacyFilter.CheckUploadRights( - User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value, - processFilesDto.FilesScope); + var checkRights = await privacyFilter.CheckUploadRights(UserId, processFilesDto.FilesScope); if (!checkRights) return Forbid("Недостаточно прав для загрузки файлов"); - var checkCountLimit = await _countFilter.CheckCountLimit(processFilesDto); - if (!checkCountLimit) return StatusCode((int)HttpStatusCode.Forbidden, "Слишком много файлов в решении." - + $"Максимальное количество файлов - ${_countFilter.maxSolutionFiles}"); - - var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); + var checkCountLimit = await filesCountLimiter.CheckCountLimit(processFilesDto); + if (!checkCountLimit) + return Forbid("Слишком много файлов в решении." + + $"Максимальное количество файлов - ${FilesCountLimiter.MaxSolutionFiles}"); + + var result = await contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded ? Ok() - : StatusCode((int)HttpStatusCode.ServiceUnavailable, result.Errors); + : BadRequest(result.Errors); } [HttpPost("statuses")] [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetStatuses(ScopeDTO filesScope) { - var checkRights = await _privacyFilter.CheckUploadRights( - User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value, - filesScope); + var checkRights = await privacyFilter.CheckUploadRights(UserId, filesScope); if (!checkRights) return Forbid("Недостаточно прав для получения информации о файлах"); - - var filesStatusesResult = await _contentServiceClient.GetFilesStatuses(filesScope); - return filesStatusesResult.Succeeded - ? Ok(filesStatusesResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesStatusesResult.Errors); + + var result = await contentServiceClient.GetFilesStatuses(filesScope); + return result.Succeeded + ? Ok(result.Value) + : BadRequest(result.Errors); } [HttpGet("downloadLink")] @@ -72,44 +60,40 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromQuery] long fileId) { - var linkDto = await _contentServiceClient.GetDownloadLinkAsync(fileId); - if(!linkDto.Succeeded) return StatusCode((int)HttpStatusCode.ServiceUnavailable, linkDto.Errors); + var linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); + if (linkDto.Succeeded) return BadRequest(linkDto.Errors); + + var result = linkDto.Value; + var userId = UserId; - var userId = User.Claims.FirstOrDefault(claim => claim.Type.ToString() == "_id")?.Value; - var hasRights = false; - foreach (var scope in linkDto.Value.fileScopes) + foreach (var scope in result.FileScopes) { - if (await _privacyFilter.CheckDownloadRights(userId, scope)) - { - hasRights = true; - break; - } + if (await privacyFilter.CheckDownloadRights(userId, scope)) + return Ok(result.DownloadUrl); } - return hasRights - ? Ok(linkDto.Value.DownloadUrl) - : Forbid("Недостаточно прав для получения ссылки на файл"); + return Forbid("Недостаточно прав для получения ссылки на файл"); } [HttpGet("info/course/{courseId}")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetFilesInfo(long courseId) { - var filesInfoResult = await _contentServiceClient.GetFilesInfo(courseId); + var filesInfoResult = await contentServiceClient.GetFilesInfo(courseId); return filesInfoResult.Succeeded - ? Ok(filesInfoResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesInfoResult.Errors); + ? Ok(filesInfoResult.Value) + : BadRequest(filesInfoResult.Errors); } [HttpGet("info/course/{courseId}/uploaded")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetUploadedFilesInfo(long courseId) { - var filesInfoResult = await _contentServiceClient.GetUploadedFilesInfo(courseId); + var filesInfoResult = await contentServiceClient.GetUploadedFilesInfo(courseId); return filesInfoResult.Succeeded - ? Ok(filesInfoResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesInfoResult.Errors); + ? Ok(filesInfoResult.Value) + : BadRequest(filesInfoResult.Errors); } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs similarity index 50% rename from HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs rename to HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs index 5b3febe81..2f470677a 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs @@ -6,34 +6,22 @@ namespace HwProj.APIGateway.API.Filters; -public class FilesCountLimit +public class FilesCountLimiter(IContentServiceClient contentServiceClient) { - private readonly IContentServiceClient _contentServiceClient; - public readonly long maxSolutionFiles = 5; - - public FilesCountLimit(IContentServiceClient contentServiceClient) - { - _contentServiceClient = contentServiceClient; - } + public const long MaxSolutionFiles = 5; public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) { if (processFilesDto.FilesScope.CourseUnitType == CourseUnitType.Homework) return true; - var existingStatuses = await _contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); + var existingStatuses = await contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); if (!existingStatuses.Succeeded) return false; - var existingIds = existingStatuses.Value.Select(f => f.Id); + var existingIds = existingStatuses.Value.Select(f => f.Id).ToList(); if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) - { - return false; - } - - if (existingIds.Count() + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count > maxSolutionFiles) - { return false; - } - return true; + return existingIds.Count + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count <= + MaxSolutionFiles; } -} \ No newline at end of file +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs index f91feb9c5..89f979b9b 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -8,61 +8,64 @@ namespace HwProj.APIGateway.API.Filters; -public class FilesPrivacyFilter +public class FilesPrivacyFilter( + ICoursesServiceClient coursesServiceClient, + ISolutionsServiceClient solutionsServiceClient) { - private readonly ICoursesServiceClient _coursesServiceClient; - private readonly ISolutionsServiceClient _solutionsServiceClient; - - public FilesPrivacyFilter(ICoursesServiceClient coursesServiceClient, ISolutionsServiceClient solutionsServiceClient) + private async Task> GetSolutionStudentIds(long solutionId) { - _coursesServiceClient = coursesServiceClient; - _solutionsServiceClient = solutionsServiceClient; + var studentIds = new HashSet(); + var solution = await solutionsServiceClient.GetSolutionById(solutionId); + studentIds.Add(solution.StudentId); + + if (solution.GroupId is { } groupId) + { + var groups = await coursesServiceClient.GetGroupsById(groupId); + if (groups is [var group]) studentIds.UnionWith(group.StudentsIds.ToHashSet()); + } + + return studentIds; } public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) { - if (fileScope.CourseUnitType == CourseUnitType.Homework) return true; - if (fileScope.CourseUnitType == CourseUnitType.Solution) - { - if (userId == null) return false; - var studentIds = new HashSet(); - var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); - studentIds.Add(solution.StudentId); - var groupIds = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds.UnionWith(groupIds.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + if (userId == null) return false; - if (!studentIds.Contains(userId) && !mentorIds.Contains(userId)) return false; + switch (fileScope.CourseUnitType) + { + case CourseUnitType.Homework: + return true; + case CourseUnitType.Solution: + { + var studentIds = await GetSolutionStudentIds(fileScope.CourseUnitId); + if (studentIds.Contains(userId)) return true; - return true; + var mentorIds = await coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + return mentorIds.Contains(userId); + } + default: + return false; } - - return false; } public async Task CheckUploadRights(string? userId, ScopeDTO fileScope) { if (userId == null) return false; - if (fileScope.CourseUnitType == CourseUnitType.Homework) - { - var mentorIds = await _coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); - if (!mentorIds.Contains(userId)) return false; - return true; - } - if (fileScope.CourseUnitType == CourseUnitType.Solution) - { - var studentIds = new HashSet(); - var solution = await _solutionsServiceClient.GetSolutionById(fileScope.CourseUnitId); - studentIds.Add(solution.StudentId); - var group = await _coursesServiceClient.GetGroupsById(solution.GroupId ?? 0); - studentIds.UnionWith(group.FirstOrDefault()?.StudentsIds.ToHashSet() ?? new()); - - if (!studentIds.Contains(userId)) return false; - return true; + switch (fileScope.CourseUnitType) + { + case CourseUnitType.Homework: + { + var mentorIds = await coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + return mentorIds.Contains(userId); + } + case CourseUnitType.Solution: + { + var studentIds = await GetSolutionStudentIds(fileScope.CourseUnitId); + return studentIds.Contains(userId); + } + default: + return false; } - - return false; } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index 43c47e098..ef7c604ad 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -81,7 +81,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IHostEnvironment env) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 294f5e37b..2530197cd 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,72 +2,58 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using FileTypeChecker; using Microsoft.AspNetCore.Http; using FileTypeChecker.Abstracts; using FileTypeChecker.Types; namespace HwProj.Models.ContentService.Attributes { - - public class MachO : FileType - { - public const string TypeName = "MacOS executable"; - public const string TypeExtension = "macho"; - private static readonly byte[][] MagicBytes = - { - new byte[] { 0xfe, 0xed, 0xfa, 0xce }, // Mach-O BE 32-bit - new byte[] { 0xfe, 0xed, 0xfa, 0xcf }, // Mach-O BE 64-bit - new byte[] { 0xce, 0xfa, 0xed, 0xfe }, // Mach-O LE 32-bit - new byte[] { 0xcf, 0xfa, 0xed, 0xfe }, // Mach-O LE 64-bit - }; - - public MachO() : base(TypeName, TypeExtension, MagicBytes) - { - } - } - [AttributeUsage(AttributeTargets.Property)] - public class CorrectFileTypeAttribute : ValidationAttribute + public class CorrectFileTypeAttribute : FileValidationAttribute { - private static readonly HashSet forbiddenFileTypes = new HashSet{ + private static readonly HashSet ForbiddenFileTypes = new HashSet + { new MachO(), new Executable(), new ExecutableAndLinkableFormat() }; - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + protected override ValidationResult Validate(IFormFile file) { - var files = value switch - { - IFormFile singleFile => new[] { singleFile }, - IEnumerable filesCollection => filesCollection, - _ => null - }; - - if (files == null) return ValidationResult.Success; - - FileTypeValidator.RegisterCustomTypes(typeof(MachO).Assembly); - foreach (var file in files) + try { - try - { - using (var fileContent = file.OpenReadStream()) - { - if (!FileTypeValidator.IsTypeRecognizable(fileContent) || - forbiddenFileTypes.Any(type => type.DoesMatchWith(fileContent))) - { - return new ValidationResult( - $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); - } - } - } - catch + using var fileContent = file.OpenReadStream(); + //FileTypeValidator.RegisterCustomTypes(typeof(MachO).Assembly); + if ( //!FileTypeValidator.IsTypeRecognizable(fileContent) || + ForbiddenFileTypes.Any(type => type.DoesMatchWith(fileContent))) { return new ValidationResult( - $"Невозможно прочитать файл `{file.FileName}`"); + $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); } } + catch + { + return new ValidationResult( + $"Невозможно прочитать файл `{file.FileName}`"); + } return ValidationResult.Success; } + + private class MachO : FileType + { + private const string TypeName = "MacOS executable"; + private const string TypeExtension = "macho"; + + private static readonly byte[][] MagicBytes = + { + new byte[] { 0xfe, 0xed, 0xfa, 0xce }, // Mach-O BE 32-bit + new byte[] { 0xfe, 0xed, 0xfa, 0xcf }, // Mach-O BE 64-bit + new byte[] { 0xce, 0xfa, 0xed, 0xfe }, // Mach-O LE 32-bit + new byte[] { 0xcf, 0xfa, 0xed, 0xfe }, // Mach-O LE 64-bit + }; + + public MachO() : base(TypeName, TypeExtension, MagicBytes) + { + } + } } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs new file mode 100644 index 000000000..74d725fd8 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace HwProj.Models.ContentService.Attributes +{ + public abstract class FileValidationAttribute : ValidationAttribute + { + protected abstract ValidationResult Validate(IFormFile file); + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + value switch + { + IFormFile singleFile => Validate(singleFile), + IEnumerable files => files + .Select(Validate) + .FirstOrDefault(x => x != ValidationResult.Success) ?? ValidationResult.Success, + _ => null + }; + } +} diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs index 2a9dea374..2f94eef4e 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs @@ -1,35 +1,24 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http; namespace HwProj.Models.ContentService.Attributes { [AttributeUsage(AttributeTargets.Property)] - public class MaxFileSizeAttribute : ValidationAttribute + public class MaxFileSizeAttribute : FileValidationAttribute { private readonly long _maxFileSizeInBytes; public MaxFileSizeAttribute(long maxFileSizeInBytes) - =>_maxFileSizeInBytes = maxFileSizeInBytes; + => _maxFileSizeInBytes = maxFileSizeInBytes; - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + protected override ValidationResult Validate(IFormFile file) { - var files = value switch - { - IFormFile singleFile => new[] { singleFile }, - IEnumerable filesCollection => filesCollection, - _ => null - }; + if (file.Length > _maxFileSizeInBytes) + return new ValidationResult( + $"Файл `{file.FileName}` превышает лимит в {_maxFileSizeInBytes / 1024 / 1024} MB"); - if (files == null) return ValidationResult.Success; - - foreach (var file in files) - if (file.Length > _maxFileSizeInBytes) - return new ValidationResult( - $"Файл `{file.FileName}` превышает лимит в {_maxFileSizeInBytes / 1024 / 1024} MB"); - return ValidationResult.Success; } } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs index 5d4f703f2..3cd7cd86c 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs @@ -6,6 +6,6 @@ namespace HwProj.Models.ContentService.DTO public class FileLinkDTO { public string DownloadUrl { get; set; } - public List fileScopes { get; set; } + public List FileScopes { get; set; } } } diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs index 39deb7be3..f9d9a16b9 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs @@ -7,11 +7,11 @@ namespace HwProj.Models.ContentService.DTO public class ProcessFilesDTO { public ScopeDTO FilesScope { get; set; } - + public List DeletingFileIds { get; set; } = new List(); - + [CorrectFileType] [MaxFileSize(100 * 1024 * 1024)] public List NewFiles { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs index 6277884dc..ea9d85d45 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs @@ -6,4 +6,4 @@ public class ScopeDTO public string CourseUnitType { get; set; } public long CourseUnitId { get; set; } } -} \ No newline at end of file +} diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index 888ad1b38..e19399c1a 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -96,7 +96,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) var result = new FileLinkDTO { DownloadUrl = downloadUrl.Value, - fileScopes = fileScopes.Select(fs => fs.ToScopeDTO()).ToList() + FileScopes = fileScopes.Select(fs => fs.ToScopeDTO()).ToList() }; return Ok(result); From 8df7cc5eec1a30ad236763598f90e961ee4d52d6 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 22:16:13 +0300 Subject: [PATCH 73/77] wip --- hwproj.front/src/components/Files/FilesUploader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 5b4b7c59a..17662d29b 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -131,7 +131,9 @@ const FilesUploader: React.FC = (props) => { variant={"outlined"}> - Загрузите материалы задания + {props.courseUnitType === CourseUnitType.Solution + ? "Загрузите файлы решения" + : "Загрузите материалы задания"} From 150da6c6315c5036204640a919e09832da71abed Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 23:02:28 +0300 Subject: [PATCH 74/77] fix --- hwproj.front/src/components/Courses/Course.tsx | 15 +++++---------- .../src/components/Files/FilesUploadWaiter.ts | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index a495ff6ce..2b545154a 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -3,9 +3,7 @@ import {useSearchParams} from "react-router-dom"; import { AccountDataDto, CourseViewModel, - FileInfoDTO, HomeworkViewModel, - ScopeDTO, StatisticsCourseMatesModel } from "@/api"; import StudentStats from "./StudentStats"; @@ -33,8 +31,6 @@ import LecturerStatistics from "./Statistics/LecturerStatistics"; import AssessmentIcon from '@mui/icons-material/Assessment'; import NameBuilder from "../Utils/NameBuilder"; import {QRCodeSVG} from 'qrcode.react'; -import ErrorsHandler from "components/Utils/ErrorsHandler"; -import {useSnackbar} from 'notistack'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; @@ -65,7 +61,6 @@ const Course: React.FC = () => { const {courseId, tab} = useParams() const [searchParams] = useSearchParams() const navigate = useNavigate() - const {enqueueSnackbar} = useSnackbar() const [courseState, setCourseState] = useState({ isFound: false, @@ -79,11 +74,6 @@ const Course: React.FC = () => { }) const [studentSolutions, setStudentSolutions] = useState(undefined) - const { - courseFilesState, - updCourseUnitFiles, - } = FilesUploadWaiter(+courseId!, true); - const [pageState, setPageState] = useState({ tabValue: "homeworks" }) @@ -105,6 +95,11 @@ const Course: React.FC = () => { const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) + const { + courseFilesState, + updCourseUnitFiles, + } = FilesUploadWaiter(+courseId!, isCourseMentor); + const isAcceptedStudent = acceptedStudents!.some(cm => cm.userId === userId) const showStatsTab = isCourseMentor || isAcceptedStudent diff --git a/hwproj.front/src/components/Files/FilesUploadWaiter.ts b/hwproj.front/src/components/Files/FilesUploadWaiter.ts index d57ffb411..1b059d661 100644 --- a/hwproj.front/src/components/Files/FilesUploadWaiter.ts +++ b/hwproj.front/src/components/Files/FilesUploadWaiter.ts @@ -16,7 +16,7 @@ export interface IUploadFilesState { courseFiles: FileInfoDTO[]; } -export const FilesUploadWaiter = (courseId: number, isOwner?: boolean) => { +export const FilesUploadWaiter = (courseId: number, isCourseMentor?: boolean) => { const intervalsRef = useRef { const updCourseFiles = async () => { let courseFilesInfo = [] as FileInfoDTO[] try { - courseFilesInfo = isOwner + courseFilesInfo = isCourseMentor ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) } catch (e) { @@ -85,7 +85,7 @@ export const FilesUploadWaiter = (courseId: number, isOwner?: boolean) => { useEffect(() => { updCourseFiles(); - }, [courseId, isOwner]); + }, [courseId, isCourseMentor]); const updateCourseUnitFilesInfo = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { setCourseFilesState(prev => ({ From 57f04989cfaf816748eb174d33aa196ccbda9cbc Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 23:14:23 +0300 Subject: [PATCH 75/77] fix --- hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index 0dad6a072..b67fe930c 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -264,7 +264,7 @@ const StudentSolutionsPage: FC = () => {
} - const {courseFilesState} = FilesUploadWaiter(courseId, false); + const {courseFilesState} = FilesUploadWaiter(courseId, isLoaded); if (isLoaded) { return ( From c5113b796d7fe6affde612cd084e2e66b61ccc57 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sun, 11 Jan 2026 20:10:44 +0300 Subject: [PATCH 76/77] wip --- .../Controllers/FilesController.cs | 18 +-- .../Controllers/FilesController.cs | 18 ++- .../Repositories/FileRecordRepository.cs | 28 ++--- .../Repositories/IFileRecordRepository.cs | 3 +- .../Services/FilesInfoService.cs | 21 +--- .../Services/Interfaces/IFilesInfoService.cs | 3 +- .../ContentServiceClient.cs | 23 +--- .../IContentServiceClient.cs | 3 +- hwproj.front/src/api/api.ts | 104 ++++-------------- .../src/components/Courses/Course.tsx | 29 +++-- .../src/components/Files/FilesUploadWaiter.ts | 18 ++- .../Solutions/StudentSolutionsPage.tsx | 23 ++-- .../Solutions/TaskSolutionsPage.tsx | 79 ++++++------- 13 files changed, 130 insertions(+), 240 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 2a7dc82ed..b802a40c8 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -4,6 +4,7 @@ using HwProj.AuthService.Client; using HwProj.ContentService.Client; using HwProj.Models.ContentService.DTO; +using HwProj.Models.CourseUnitType; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -78,20 +79,11 @@ public async Task GetDownloadLink([FromQuery] long fileId) [HttpGet("info/course/{courseId}")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] - public async Task GetFilesInfo(long courseId) + public async Task GetFilesInfo(long courseId, + [FromQuery] bool uploadedOnly = true, + [FromQuery] string courseUnitType = CourseUnitType.Homework) { - var filesInfoResult = await contentServiceClient.GetFilesInfo(courseId); - return filesInfoResult.Succeeded - ? Ok(filesInfoResult.Value) - : BadRequest(filesInfoResult.Errors); - } - - [HttpGet("info/course/{courseId}/uploaded")] - [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] - public async Task GetUploadedFilesInfo(long courseId) - { - var filesInfoResult = await contentServiceClient.GetUploadedFilesInfo(courseId); + var filesInfoResult = await contentServiceClient.GetFilesInfo(courseId, uploadedOnly, courseUnitType); return filesInfoResult.Succeeded ? Ok(filesInfoResult.Value) : BadRequest(filesInfoResult.Errors); diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index e19399c1a..85b0db5c5 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -89,7 +89,7 @@ public async Task GetDownloadLink([FromQuery] long fileId) var fileScopes = await _filesInfoService.GetFileScopesAsync(fileId); if (fileScopes is null) return Ok(Result.Failed("Файл не найден")); - + var downloadUrl = await _s3FilesService.GetDownloadUrl(externalKey); if (!downloadUrl.Succeeded) return Ok(Result.Failed(downloadUrl.Errors)); @@ -104,17 +104,15 @@ public async Task GetDownloadLink([FromQuery] long fileId) [HttpGet("info/course/{courseId}")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - public async Task GetFilesInfo(long courseId) + public async Task GetFilesInfo(long courseId, bool uploadedOnly, string courseUnitType) { - var filesInfo = await _filesInfoService.GetFilesInfoAsync(courseId); - return Ok(filesInfo); - } + if (!Enum.TryParse(courseUnitType, out var unitType)) + return BadRequest("Неожиданный CourseUnitType: " + courseUnitType); - [HttpGet("info/course/{courseId}/uploaded")] - [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - public async Task GetUploadedFilesInfo(long courseId) - { - var filesInfo = await _filesInfoService.GetFilesInfoAsync(courseId, FileStatus.ReadyToUse); + var filesInfo = await _filesInfoService.GetFilesInfoAsync( + courseId, + uploadedOnly ? FileStatus.ReadyToUse : null, + unitType); return Ok(filesInfo); } diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 890e7ad88..2fe0c92d4 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -44,14 +44,14 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat .AsNoTracking() .Where(fr => fileRecordIds.Contains(fr.Id)) .ExecuteUpdateAsync(setters => - setters.SetProperty(fr => fr!.Status, newStatus) + setters.SetProperty(fr => fr.Status, newStatus) ); public async Task GetFileRecordByIdAsync(long fileRecordId) => await _contentContext.FileRecords .AsNoTracking() .SingleOrDefaultAsync(fr => fr.Id == fileRecordId); - + public async Task?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits .AsNoTracking() @@ -67,20 +67,22 @@ public async Task> GetByScopeAsync(Scope scope) .Select(fc => fc.FileRecord) .ToListAsync(); - public async Task> GetByCourseIdAsync(long courseId) - => await _contentContext.FileToCourseUnits + public async Task> GetAsync(long courseId, FileStatus? filesStatus = null, + CourseUnitType? courseUnitType = null) + { + IQueryable query = _contentContext.FileToCourseUnits .AsNoTracking() .Where(fc => fc.CourseId == courseId) - .Include(fc => fc.FileRecord) - .ToListAsync(); + .Include(fc => fc.FileRecord); - public async Task> GetByCourseIdAndStatusAsync(long courseId, FileStatus filesStatus) - => await _contentContext.FileToCourseUnits - .AsNoTracking() - .Where(fc => fc.CourseId == courseId) - .Include(fc => fc.FileRecord) - .Where(fc => fc.FileRecord.Status == filesStatus) - .ToListAsync(); + if (filesStatus != null) + query = query.Where(fc => fc.FileRecord.Status == filesStatus); + + if (courseUnitType != null) + query = query.Where(fc => fc.CourseUnitType == courseUnitType); + + return await query.ToListAsync(); + } public async Task> GetIdsByStatusAsync(FileStatus status) => await _contentContext.FileRecords diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs index 2dd401a8e..f27630598 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs @@ -15,8 +15,7 @@ public Task UpdateAsync(long id, public Task GetFileRecordByIdAsync(long fileRecordId); public Task?> GetScopesAsync(long fileRecordId); public Task> GetByScopeAsync(Scope scope); - public Task> GetByCourseIdAsync(long courseId); - public Task> GetByCourseIdAndStatusAsync(long courseId, FileStatus filesStatus); + public Task> GetAsync(long courseId, FileStatus? filesStatus = null, CourseUnitType? courseUnitType = null); public Task> GetIdsByStatusAsync(FileStatus status); public Task DeleteWithCourseUnitInfoAsync(long fileRecordId); public Task DeleteWithCourseUnitInfoAsync(List fileRecordIds); diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs index fb5350397..9f7a78cce 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs @@ -43,23 +43,10 @@ public async Task> GetFilesStatusesAsync(Scope filesScope) return fileToCourseUnit; } - public async Task> GetFilesInfoAsync(long courseId) + public async Task> GetFilesInfoAsync(long courseId, FileStatus? filesStatus, + CourseUnitType courseUnitType) { - var filesToCourseUnits = await _fileRecordRepository.GetByCourseIdAsync(courseId); - return filesToCourseUnits.Select(fcu => new FileInfoDTO - { - Id = fcu.FileRecord.Id, - Name = fcu.FileRecord.OriginalName, - Status = fcu.FileRecord.Status.ToString(), - SizeInBytes = fcu.FileRecord.SizeInBytes, - CourseUnitType = fcu.CourseUnitType.ToString(), - CourseUnitId = fcu.CourseUnitId - }).ToList(); - } - - public async Task> GetFilesInfoAsync(long courseId, FileStatus filesStatus) - { - var filesRecords = await _fileRecordRepository.GetByCourseIdAndStatusAsync(courseId, filesStatus); + var filesRecords = await _fileRecordRepository.GetAsync(courseId, filesStatus, courseUnitType); return filesRecords.Select(fcu => new FileInfoDTO { Id = fcu.FileRecord.Id, @@ -78,7 +65,7 @@ public async Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer) x => new Scope(filesTransfer.TargetCourseId, CourseUnitType.Homework, x.Target) ); - var sourceCourseUnits = await _fileRecordRepository.GetByCourseIdAsync(filesTransfer.SourceCourseId); + var sourceCourseUnits = await _fileRecordRepository.GetAsync(filesTransfer.SourceCourseId); var unitsToAdd = sourceCourseUnits .Select(unit => (unit.FileRecord, Scope: unit.ToScope())) .Where(pair => map.ContainsKey(pair.Scope)) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs index ecc9d3086..8c97b3289 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs @@ -10,7 +10,6 @@ public interface IFilesInfoService public Task> GetFilesStatusesAsync(Scope filesScope); public Task GetFileExternalKeyAsync(long fileId); public Task?> GetFileScopesAsync(long fileId); - public Task> GetFilesInfoAsync(long courseId); - public Task> GetFilesInfoAsync(long courseId, FileStatus filesStatus); + public Task> GetFilesInfoAsync(long courseId, FileStatus? filesStatus, CourseUnitType courseUnitType); public Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); } diff --git a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs index 4b012985a..25bc9a29d 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs @@ -112,27 +112,10 @@ public async Task> GetDownloadLinkAsync(long fileId) } } - public async Task> GetFilesInfo(long courseId) + public async Task> GetFilesInfo(long courseId, bool uploadedOnly, string courseUnitType) { - var url = _contentServiceUri + $"api/Files/info/course/{courseId}"; - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); - - try - { - var response = await _httpClient.SendAsync(httpRequest); - var filesInfo = await response.DeserializeAsync(); - return Result.Success(filesInfo); - } - catch (HttpRequestException e) - { - return Result.Failed( - "Пока не можем получить информацию о файлах. \nВсе ваши данные сохранены — попробуйте повторить позже"); - } - } - - public async Task> GetUploadedFilesInfo(long courseId) - { - var url = _contentServiceUri + $"api/Files/info/course/{courseId}/uploaded"; + var url = _contentServiceUri + + $"api/Files/info/course/{courseId}?uploadedOnly={uploadedOnly}&courseUnitType={courseUnitType}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); try diff --git a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs index f480a8781..3a1264862 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs @@ -9,8 +9,7 @@ public interface IContentServiceClient Task ProcessFilesAsync(ProcessFilesDTO processFilesDto); Task> GetFilesStatuses(ScopeDTO scopeDto); Task> GetDownloadLinkAsync(long fileId); - Task> GetFilesInfo(long courseId); - Task> GetUploadedFilesInfo(long courseId); + Task> GetFilesInfo(long courseId, bool uploadedOnly, string courseUnitType); Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); Task Ping(); } diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index b5b4f414d..7a5c048f6 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -6615,10 +6615,12 @@ export const FilesApiFetchParamCreator = function (configuration?: Configuration /** * * @param {number} courseId + * @param {boolean} [uploadedOnly] + * @param {string} [courseUnitType] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - filesGetFilesInfo(courseId: number, options: any = {}): FetchArgs { + filesGetFilesInfo(courseId: number, uploadedOnly?: boolean, courseUnitType?: string, options: any = {}): FetchArgs { // verify required parameter 'courseId' is not null or undefined if (courseId === null || courseId === undefined) { throw new RequiredError('courseId','Required parameter courseId was null or undefined when calling filesGetFilesInfo.'); @@ -6638,6 +6640,14 @@ export const FilesApiFetchParamCreator = function (configuration?: Configuration localVarHeaderParameter["Authorization"] = localVarApiKeyValue; } + if (uploadedOnly !== undefined) { + localVarQueryParameter['uploadedOnly'] = uploadedOnly; + } + + if (courseUnitType !== undefined) { + localVarQueryParameter['courseUnitType'] = courseUnitType; + } + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 localVarUrlObj.search = null; @@ -6683,42 +6693,6 @@ export const FilesApiFetchParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, - /** - * - * @param {number} courseId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - filesGetUploadedFilesInfo(courseId: number, options: any = {}): FetchArgs { - // verify required parameter 'courseId' is not null or undefined - if (courseId === null || courseId === undefined) { - throw new RequiredError('courseId','Required parameter courseId was null or undefined when calling filesGetUploadedFilesInfo.'); - } - const localVarPath = `/api/Files/info/course/{courseId}/uploaded` - .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); - const localVarUrlObj = url.parse(localVarPath, true); - const localVarRequestOptions = Object.assign({ method: 'GET' }, options); - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication Bearer required - if (configuration && configuration.apiKey) { - const localVarApiKeyValue = typeof configuration.apiKey === 'function' - ? configuration.apiKey("Authorization") - : configuration.apiKey; - localVarHeaderParameter["Authorization"] = localVarApiKeyValue; - } - - localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); - // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 - localVarUrlObj.search = null; - localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); - - return { - url: url.format(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * * @param {number} [filesScopeCourseId] @@ -6812,11 +6786,13 @@ export const FilesApiFp = function(configuration?: Configuration) { /** * * @param {number} courseId + * @param {boolean} [uploadedOnly] + * @param {string} [courseUnitType] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - filesGetFilesInfo(courseId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise> { - const localVarFetchArgs = FilesApiFetchParamCreator(configuration).filesGetFilesInfo(courseId, options); + filesGetFilesInfo(courseId: number, uploadedOnly?: boolean, courseUnitType?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise> { + const localVarFetchArgs = FilesApiFetchParamCreator(configuration).filesGetFilesInfo(courseId, uploadedOnly, courseUnitType, options); return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { if (response.status >= 200 && response.status < 300) { @@ -6845,24 +6821,6 @@ export const FilesApiFp = function(configuration?: Configuration) { }); }; }, - /** - * - * @param {number} courseId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - filesGetUploadedFilesInfo(courseId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise> { - const localVarFetchArgs = FilesApiFetchParamCreator(configuration).filesGetUploadedFilesInfo(courseId, options); - return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { - return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { - if (response.status >= 200 && response.status < 300) { - return response.json(); - } else { - throw response; - } - }); - }; - }, /** * * @param {number} [filesScopeCourseId] @@ -6906,11 +6864,13 @@ export const FilesApiFactory = function (configuration?: Configuration, fetch?: /** * * @param {number} courseId + * @param {boolean} [uploadedOnly] + * @param {string} [courseUnitType] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - filesGetFilesInfo(courseId: number, options?: any) { - return FilesApiFp(configuration).filesGetFilesInfo(courseId, options)(fetch, basePath); + filesGetFilesInfo(courseId: number, uploadedOnly?: boolean, courseUnitType?: string, options?: any) { + return FilesApiFp(configuration).filesGetFilesInfo(courseId, uploadedOnly, courseUnitType, options)(fetch, basePath); }, /** * @@ -6921,15 +6881,6 @@ export const FilesApiFactory = function (configuration?: Configuration, fetch?: filesGetStatuses(body?: ScopeDTO, options?: any) { return FilesApiFp(configuration).filesGetStatuses(body, options)(fetch, basePath); }, - /** - * - * @param {number} courseId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - filesGetUploadedFilesInfo(courseId: number, options?: any) { - return FilesApiFp(configuration).filesGetUploadedFilesInfo(courseId, options)(fetch, basePath); - }, /** * * @param {number} [filesScopeCourseId] @@ -6967,12 +6918,14 @@ export class FilesApi extends BaseAPI { /** * * @param {number} courseId + * @param {boolean} [uploadedOnly] + * @param {string} [courseUnitType] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof FilesApi */ - public filesGetFilesInfo(courseId: number, options?: any) { - return FilesApiFp(this.configuration).filesGetFilesInfo(courseId, options)(this.fetch, this.basePath); + public filesGetFilesInfo(courseId: number, uploadedOnly?: boolean, courseUnitType?: string, options?: any) { + return FilesApiFp(this.configuration).filesGetFilesInfo(courseId, uploadedOnly, courseUnitType, options)(this.fetch, this.basePath); } /** @@ -6986,17 +6939,6 @@ export class FilesApi extends BaseAPI { return FilesApiFp(this.configuration).filesGetStatuses(body, options)(this.fetch, this.basePath); } - /** - * - * @param {number} courseId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FilesApi - */ - public filesGetUploadedFilesInfo(courseId: number, options?: any) { - return FilesApiFp(this.configuration).filesGetUploadedFilesInfo(courseId, options)(this.fetch, this.basePath); - } - /** * * @param {number} [filesScopeCourseId] diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 2b545154a..a8744fee3 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,31 +1,29 @@ import * as React from "react"; -import {useSearchParams} from "react-router-dom"; -import { - AccountDataDto, - CourseViewModel, - HomeworkViewModel, - StatisticsCourseMatesModel -} from "@/api"; +import {FC, useEffect, useState} from "react"; +import {useNavigate, useParams, useSearchParams} from "react-router-dom"; +import {AccountDataDto, CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; -import {Button, Tab, Tabs, IconButton} from "@material-ui/core"; +import {Button, IconButton, Tab, Tabs} from "@material-ui/core"; import EditIcon from "@material-ui/icons/Edit"; -import {FC, useEffect, useState} from "react"; import { Alert, - AlertTitle, Box, + AlertTitle, + Box, Chip, Dialog, DialogContent, - DialogTitle, Grid, ListItemIcon, ListItemText, + DialogTitle, + Grid, + ListItemIcon, + ListItemText, Menu, MenuItem, Stack, Typography } from "@mui/material"; import {CourseExperimental} from "./CourseExperimental"; -import {useParams, useNavigate} from 'react-router-dom'; import MentorsList from "../Common/MentorsList"; import LecturerStatistics from "./Statistics/LecturerStatistics"; import AssessmentIcon from '@mui/icons-material/Assessment'; @@ -35,6 +33,7 @@ import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; type TabValue = "homeworks" | "stats" | "applications" @@ -97,8 +96,8 @@ const Course: React.FC = () => { const { courseFilesState, - updCourseUnitFiles, - } = FilesUploadWaiter(+courseId!, isCourseMentor); + updateCourseUnitFiles, + } = FilesUploadWaiter(+courseId!, CourseUnitType.Homework, !isCourseMentor); const isAcceptedStudent = acceptedStudents!.some(cm => cm.userId === userId) @@ -337,7 +336,7 @@ const Course: React.FC = () => { selectedHomeworkId={searchedHomeworkId == null ? undefined : +searchedHomeworkId} userId={userId!} processingFiles={courseFilesState.processingFilesState} - onStartProcessing={updCourseUnitFiles} + onStartProcessing={updateCourseUnitFiles} onHomeworkUpdate={({homework, isDeleted}) => { const homeworkIndex = courseState.courseHomeworks.findIndex(x => x.id === homework.id) const homeworks = courseState.courseHomeworks diff --git a/hwproj.front/src/components/Files/FilesUploadWaiter.ts b/hwproj.front/src/components/Files/FilesUploadWaiter.ts index 1b059d661..e9f007821 100644 --- a/hwproj.front/src/components/Files/FilesUploadWaiter.ts +++ b/hwproj.front/src/components/Files/FilesUploadWaiter.ts @@ -16,7 +16,7 @@ export interface IUploadFilesState { courseFiles: FileInfoDTO[]; } -export const FilesUploadWaiter = (courseId: number, isCourseMentor?: boolean) => { +export const FilesUploadWaiter = (courseId: number, courseUnitType: CourseUnitType, uploadedOnly: boolean) => { const intervalsRef = useRef })); } - const updCourseFiles = async () => { + const updateFiles = async () => { let courseFilesInfo = [] as FileInfoDTO[] try { - courseFilesInfo = isCourseMentor - ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) + courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!, uploadedOnly, courseUnitType) } catch (e) { const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); @@ -84,8 +82,8 @@ export const FilesUploadWaiter = (courseId: number, isCourseMentor?: boolean) => } useEffect(() => { - updCourseFiles(); - }, [courseId, isCourseMentor]); + updateFiles(); + }, [courseId, uploadedOnly, courseUnitType]); const updateCourseUnitFilesInfo = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { setCourseFilesState(prev => ({ @@ -99,7 +97,7 @@ export const FilesUploadWaiter = (courseId: number, isCourseMentor?: boolean) => }; // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками - const updCourseUnitFiles = + const updateCourseUnitFiles = (courseUnitId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, @@ -177,7 +175,7 @@ export const FilesUploadWaiter = (courseId: number, isCourseMentor?: boolean) => return { courseFilesState, - updCourseFiles, - updCourseUnitFiles, + updateFiles, + updateCourseUnitFiles, } } diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index b67fe930c..57a5d7c14 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -1,17 +1,20 @@ import * as React from "react"; import {FC, useEffect, useState} from "react"; import { - GetSolutionModel, HomeworksGroupSolutionStats, + AccountDataDto, + GetSolutionModel, + HomeworksGroupSolutionStats, HomeworkTaskViewModel, Solution, - TaskSolutionsStats, - SolutionState, StudentDataDto, AccountDataDto + SolutionState, + StudentDataDto, + TaskSolutionsStats } from "@/api"; import Typography from "@material-ui/core/Typography"; import Task from "../Tasks/Task"; import TaskSolutions from "./TaskSolutions"; import ApiSingleton from "../../api/ApiSingleton"; -import {Grid, Tabs, Tab} from "@material-ui/core"; +import {Grid, Tab, Tabs} from "@material-ui/core"; import {Link, useNavigate, useParams} from "react-router-dom"; import TaskAltIcon from '@mui/icons-material/TaskAlt'; import EditIcon from '@mui/icons-material/Edit'; @@ -19,17 +22,16 @@ import ThumbUpIcon from '@mui/icons-material/ThumbUp'; import ThumbDownIcon from '@mui/icons-material/ThumbDown'; import { Alert, + Autocomplete, + Checkbox, Chip, List, ListItemButton, ListItemText, SelectChangeEvent, Stack, - Tooltip, - Checkbox, - Autocomplete, - AutocompleteRenderInputParams, - TextField + TextField, + Tooltip } from "@mui/material"; import StudentStatsUtils from "../../services/StudentStatsUtils"; @@ -41,6 +43,7 @@ import {appBarStateManager} from "../AppBar"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {RemovedFromCourseTag} from "@/components/Common/StudentTags"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; interface IStudentSolutionsPageState { currentTaskId: string @@ -264,7 +267,7 @@ const StudentSolutionsPage: FC = () => {
} - const {courseFilesState} = FilesUploadWaiter(courseId, isLoaded); + const {courseFilesState} = FilesUploadWaiter(courseId, CourseUnitType.Solution, false); if (isLoaded) { return ( diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 7c4b225df..865ebe999 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -1,35 +1,24 @@ import * as React from "react"; +import {FC, useEffect, useState} from "react"; import Task from "../Tasks/Task"; import Typography from "@material-ui/core/Typography"; import AddOrEditSolution from "./AddOrEditSolution"; import Button from "@material-ui/core/Button"; import TaskSolutions from "./TaskSolutions"; -import { - AccountDataDto, - HomeworksGroupUserTaskSolutions, - HomeworkTaskViewModel, - Solution, - SolutionState -} from "@/api"; +import {AccountDataDto, HomeworksGroupUserTaskSolutions, HomeworkTaskViewModel, Solution, SolutionState} from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; -import { FC, useEffect, useState } from "react"; -import { Grid, Tab, Tabs } from "@material-ui/core"; -import { - Checkbox, - Chip, - SelectChangeEvent, - Stack, - Tooltip -} from "@mui/material"; -import { useParams, Link, useNavigate } from "react-router-dom"; +import {Grid, Tab, Tabs} from "@material-ui/core"; +import {Checkbox, Chip, SelectChangeEvent, Stack, Tooltip} from "@mui/material"; +import {Link, useNavigate, useParams} from "react-router-dom"; import Step from "@mui/material/Step"; import StepButton from "@mui/material/StepButton"; import StudentStatsUtils from "../../services/StudentStatsUtils"; -import { getTip } from "../Common/HomeworkTags"; +import {getTip} from "../Common/HomeworkTags"; import Lodash from "lodash"; -import { appBarStateManager } from "../AppBar"; -import { DotLottieReact } from "@lottiefiles/dotlottie-react"; -import { FilesUploadWaiter } from "@/components/Files/FilesUploadWaiter"; +import {appBarStateManager} from "../AppBar"; +import {DotLottieReact} from "@lottiefiles/dotlottie-react"; +import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; interface ITaskSolutionsState { isLoaded: boolean @@ -53,7 +42,7 @@ const FilterProps = { } const TaskSolutionsPage: FC = () => { - const { taskId } = useParams() + const {taskId} = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -101,11 +90,11 @@ const TaskSolutionsPage: FC = () => { }) } - const { homeworkGroupedSolutions, courseId, courseMates } = taskSolutionPage + const {homeworkGroupedSolutions, courseId, courseMates} = taskSolutionPage const student = courseMates.find(x => x.userId === userId)! useEffect(() => { - appBarStateManager.setContextAction({ actionName: "К курсу", link: `/courses/${courseId}` }) + appBarStateManager.setContextAction({actionName: "К курсу", link: `/courses/${courseId}`}) return () => appBarStateManager.reset() }, [courseId]) @@ -114,11 +103,11 @@ const TaskSolutionsPage: FC = () => { .map(x => ({ ...x, homeworkSolutions: x.homeworkSolutions!.map(t => - ({ - homeworkTitle: t.homeworkTitle, - previews: t.studentSolutions!.map(y => - ({ ...y, ...StudentStatsUtils.calculateLastRatedSolutionInfo(y.solutions!, y.maxRating!) })) - })) + ({ + homeworkTitle: t.homeworkTitle, + previews: t.studentSolutions!.map(y => + ({...y, ...StudentStatsUtils.calculateLastRatedSolutionInfo(y.solutions!, y.maxRating!)})) + })) })) const taskSolutionsPreview = taskSolutionsWithPreview.flatMap(x => { @@ -141,8 +130,8 @@ const TaskSolutionsPage: FC = () => { const { courseFilesState, - updCourseUnitFiles, - } = FilesUploadWaiter(courseId, true); + updateCourseUnitFiles, + } = FilesUploadWaiter(courseId, CourseUnitType.Solution, false); const currentHomeworksGroup = taskSolutionsWithPreview .find(x => x.homeworkSolutions! @@ -177,19 +166,19 @@ const TaskSolutionsPage: FC = () => { const renderRatingChip = (solutionsDescription: string, color: string, lastRatedSolution: Solution) => { return {solutionsDescription}}> - + style={{whiteSpace: 'pre-line'}}>{solutionsDescription}}> + } - return taskSolutionPage.isLoaded ?
- + return taskSolutionPage.isLoaded ?
+ + style={{overflowY: "hidden", overflowX: "auto", minHeight: 80}}> {taskSolutionsPreviewFiltered.map((t, index) => { const isCurrent = versionsOfCurrentTask.includes(t.taskId!.toString()) const { @@ -198,13 +187,13 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = t return - {index > 0 &&
} + {index > 0 &&
} + style={{color: "black", textDecoration: "none"}}> { - if (isCurrent) ref?.scrollIntoView({ inline: "nearest" }) + if (isCurrent) ref?.scrollIntoView({inline: "nearest"}) }} color={color} icon={renderRatingChip(solutionsDescription, color, lastRatedSolution)}> @@ -222,7 +211,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")}/> Только нерешенные
@@ -259,11 +248,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- } />; + }/>; })} } @@ -306,7 +295,7 @@ const TaskSolutionsPage: FC = () => { students={courseMates} supportsGroup={task.isGroupWork!} courseFilesInfo={courseFilesState.courseFiles} - onStartProcessing={updCourseUnitFiles} + onStartProcessing={updateCourseUnitFiles} />}
: ( From 0cb74b3fcf6b45a423ca6a8b8939a88564f6a400 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sun, 11 Jan 2026 20:24:18 +0300 Subject: [PATCH 77/77] wip --- hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index c3b0559c1..e0f5e93a0 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -41,7 +41,6 @@ import KeyboardCommandKeyIcon from '@mui/icons-material/KeyboardCommandKey'; import MouseOutlinedIcon from '@mui/icons-material/MouseOutlined'; import BlurOnIcon from '@mui/icons-material/BlurOn'; import BlurOffIcon from '@mui/icons-material/BlurOff'; -import {UserAvatar} from "../Common/UserAvatar"; import FileInfoConverter from "@/components/Utils/FileInfoConverter"; import {IFileInfo} from "@/components/Files/IFileInfo"; import FilesPreviewList from "@/components/Files/FilesPreviewList";