From 3481901274bb8e89609b287ad5dfaf0b702acc8a Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 29 Sep 2025 13:19:22 +0300 Subject: [PATCH 01/76] add: file uploader element to add solution page --- .../Homeworks/CourseHomeworkExperimental.tsx | 11 ++--- .../Solutions/AddOrEditSolution.tsx | 9 +++- .../Solutions/TaskSolutionsPage.tsx | 44 +++++++++---------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 512bf8872..1b51ff1be 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -32,7 +32,7 @@ import {LoadingButton} from "@mui/lab"; 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"; @@ -396,7 +396,7 @@ const CourseHomeworkExperimental: FC<{ 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) @@ -427,11 +427,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 581d53d8c..8d38e5070 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -43,7 +43,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 @@ -72,6 +72,13 @@ 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 [filesState, setFilesState] = useState({ + initialFilesInfo: initialFilesInfo, + selectedFilesInfo: props.filesInfo, + isLoadingInfo: false + }); + return ( { - const {taskId} = useParams() + const { taskId } = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -90,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]) @@ -103,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 => { @@ -166,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 { @@ -187,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)}> @@ -211,7 +211,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")} /> Только нерешенные
@@ -248,11 +248,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- }/>; + } />; })} } From 383d594a28a73ae14cdd979453ab1a336d268505 Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 30 Sep 2025 12:02:38 +0300 Subject: [PATCH 02/76] add: files count validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 190543a9a..386f18af5 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -46,6 +46,7 @@ const FilesUploader: React.FC = (props) => { }, [props.initialFilesInfo]); const maxFileSizeInBytes = 100 * 1024 * 1024; + const maxFilesCount = 5; const forbiddenFileTypes = [ 'application/vnd.microsoft.portable-executable', From d63504f6e7b931f45a2621f619a19c60f96151e3 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:38:12 +0300 Subject: [PATCH 03/76] 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 57a5d7c14..37d455271 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -100,6 +100,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 af8e93a00fa04ae81492c93ece77e1c436f661a1 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:50:07 +0300 Subject: [PATCH 04/76] 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 4da4db13d..88b32bb22 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 === courseUnitId) ) } + + 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 cdb659c6e8355e4ba8999c863aa2cc55a0cc750d Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:55:02 +0300 Subject: [PATCH 05/76] feat: processing files after adding solution --- hwproj.front/src/components/Solutions/AddOrEditSolution.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 8d38e5070..11b1e5e42 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -50,6 +50,7 @@ const AddOrEditSolution: FC = (props) => { }) const [disableSend, setDisableSend] = useState(false) + const filesInfo = lastSolution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, lastSolution.id) : [] const maxFilesCount = 5; @@ -72,10 +73,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 }); From 883c0cc4ab15b37e7a439b0710b906b6372d1eb7 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:08 +0300 Subject: [PATCH 06/76] 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 3178a3db39fa870913e5710e62a0a995099cbf75 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:36 +0300 Subject: [PATCH 07/76] 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 41c6df29e1a7df0427a90e57ce86465ab61426df Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:36:14 +0300 Subject: [PATCH 08/76] 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 62dbff87e28f8e300575a8c8ff54a00ca3703846 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:38:15 +0300 Subject: [PATCH 09/76] feat: change download link validation (back) --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 6ee73f136..65ed3fb99 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -59,7 +59,7 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType((int)HttpStatusCode.Forbidden)] [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 linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); if (!linkDto.Succeeded) return BadRequest(linkDto.Errors); From b47ec51643f4e2a7371ef790b8c14bcb65ec7001 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:39:53 +0300 Subject: [PATCH 10/76] 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 43b7aca96aa9d232869568a32dcfacf6b21cb404 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:41:29 +0300 Subject: [PATCH 11/76] feat: change download link api call (front) --- .../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 1b51ff1be..15e48fba1 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -32,7 +32,7 @@ import {LoadingButton} from "@mui/lab"; 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"; From f92fc7f848c602fde43c1f10a8054a0a4061bb7f Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 20 Oct 2025 18:53:32 +0300 Subject: [PATCH 12/76] 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 284f0dca08bc5a826bc58db1cefa3954945cfae3 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:32:51 +0300 Subject: [PATCH 13/76] 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 3f50d01d96a43d43f94fc2ee9e1433440452bb7f Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:33:57 +0300 Subject: [PATCH 14/76] 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 73691ca9e8b087a64e4bfc0d6e79236bb36ef78a Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 17:23:24 +0300 Subject: [PATCH 15/76] 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 1b9a1de1ed28804a4609ed91b53e61c00c82f26e Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 18:58:59 +0300 Subject: [PATCH 16/76] 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 cda3b8c5253ae41eb579db2ee8fb78be2765c864 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:00:33 +0300 Subject: [PATCH 17/76] 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 6dc598af146bfa70cf42d4014d3067971974a5a4 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:01:39 +0300 Subject: [PATCH 18/76] 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 91345ee51354434703a633cd875e84f2119b6bbc Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:11:13 +0300 Subject: [PATCH 19/76] feat: update solutions components for files accessor --- .../Solutions/AddOrEditSolution.tsx | 14 ++++++------ .../Solutions/StudentSolutionsPage.tsx | 22 ------------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 11b1e5e42..3155f16bf 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -50,7 +50,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 maxFilesCount = 5; @@ -73,13 +80,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 ( { 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 c788abe0837402388876924771002d3a2d1a9273 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 26 Oct 2025 13:31:32 +0300 Subject: [PATCH 20/76] fix: return alien code --- .../components/Homeworks/CourseHomeworkExperimental.tsx | 9 ++++----- .../src/components/Solutions/AddOrEditSolution.tsx | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 15e48fba1..512bf8872 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -396,7 +396,7 @@ const CourseHomeworkExperimental: FC<{ 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) @@ -427,12 +427,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 3155f16bf..ffdce17b2 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -43,7 +43,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 0e8281d09cba2206233fafc3ee09fba553c60e33 Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 28 Oct 2025 11:58:34 +0300 Subject: [PATCH 21/76] 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 6fb426a42d5c85611746e06d9506eff34bcd8199 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:25:45 +0300 Subject: [PATCH 22/76] feat [back]: method to get file scope --- .../Repositories/FileRecordRepository.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 2fe0c92d4..bfcdce9ab 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?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits From 2cf714686d4628debbbee2a402a1b3f0d12e4b9c Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:41:46 +0300 Subject: [PATCH 23/76] 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 af36e5d9ccfbb388302ae7d4119a36d9bb9596e4 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:54:45 +0300 Subject: [PATCH 24/76] feat [back]: file link dto --- .../HwProj.Models/ContentService/DTO/FileScopeDTO.cs | 10 ---------- 1 file changed, 10 deletions(-) delete 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 deleted file mode 100644 index ac89e4ac9..000000000 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 05eca024b4b6eafa9d337ff629616ac24c83d5c8 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:09 +0300 Subject: [PATCH 25/76] fix: download link request --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 65ed3fb99..6ee73f136 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -59,7 +59,7 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType((int)HttpStatusCode.Forbidden)] [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 linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); if (!linkDto.Succeeded) return BadRequest(linkDto.Errors); From 9ffd3b815a19f88d961a0e8b51c7fc32eb154dfa Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:54 +0300 Subject: [PATCH 26/76] 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 e96d2d2b18050941d2117971ff998572d9690e41 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:28:54 +0300 Subject: [PATCH 27/76] refactor [front]: rename files upload waiter --- .../components/Files/FilesAccessService.ts | 174 ------------------ 1 file changed, 174 deletions(-) delete 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 deleted file mode 100644 index e752582e7..000000000 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ /dev/null @@ -1,174 +0,0 @@ -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: [] - }) - - // Останавливаем все активные интервалы при размонтировании - useEffect(() => { - return () => { - Object.values(intervalsRef.current).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - }; - }, []); - - const stopProcessing = (courseUnitId: number) => { - if (intervalsRef.current[courseUnitId]) { - const {interval, timeout} = intervalsRef.current[courseUnitId]; - clearInterval(interval); - clearTimeout(timeout); - delete intervalsRef.current[courseUnitId]; - } - }; - - const setCommonLoading = (courseUnitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [courseUnitId]: {isLoading: true} - } - })); - } - - const unsetCommonLoading = (courseUnitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [courseUnitId]: {isLoading: false} - } - })); - } - - 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(); - }, [courseId, isOwner]); - - 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.current[courseUnitId] = {interval, timeout}; - - // Сигнализируем о начале загрузки через состояние - setCommonLoading(courseUnitId); - } - - return { - courseFilesState, - updCourseFiles, - updCourseUnitFiles, - } -} From ba52fa60e9bec5b13a82e70d8b649a2848239dc0 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:54:12 +0300 Subject: [PATCH 28/76] feat [front]: variability for max files count --- hwproj.front/src/components/Files/FilesUploader.tsx | 1 - .../src/components/Solutions/AddOrEditSolution.tsx | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 386f18af5..190543a9a 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -46,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', diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index ffdce17b2..aa49d8e0d 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -51,13 +51,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 maxFilesCount = 5; From 1d49d30334db27f459fb84ecb2149817c3d8993b Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 20 Nov 2025 17:01:23 +0300 Subject: [PATCH 29/76] refactor [back]: type validation by foreign library --- .../ContentService/Attributes/CorrectFileTypeAttribute.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 2530197cd..1b6334fc2 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using FileTypeChecker; using Microsoft.AspNetCore.Http; using FileTypeChecker.Abstracts; using FileTypeChecker.Types; From ad978fbbca761696c56c977fecac2d890f47218c Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:18:15 +0300 Subject: [PATCH 30/76] fix [back]: return with privacy error --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 6ee73f136..2c78a2966 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -58,6 +58,7 @@ 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) { From bdb9956bd194609c9258e2d8e137d506cabcba2d Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:19:00 +0300 Subject: [PATCH 31/76] feat [back]: add max files count filter --- .../Filters/FilesCountLimit.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs 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 b90a95c5c2f6f32988c8a0ec3d2e87cff2a04ae1 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 22 Nov 2025 11:53:10 +0300 Subject: [PATCH 32/76] feat [back]: showing max files count on limit exceeding --- .../HwProj.APIGateway.API/Filters/FilesCountLimit.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7bad8b1cf367d366ab9fa09ba2fc5c6b2ef5767c Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:20:34 +0300 Subject: [PATCH 33/76] refactor: separate methods in privacy filter --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- .../Repositories/FileRecordRepository.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 2c78a2966..4312d40a9 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; @@ -58,7 +59,6 @@ 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) { diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index bfcdce9ab..53cc8651a 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?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits From 7653cda5797aeee825ed7b6074ac66aad91f9a3e Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:47:39 +0300 Subject: [PATCH 34/76] refactor: create courseUnitType constans --- .../HwProj.APIGateway.API/Filters/FilesCountLimit.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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))) From f78bb492917efe8d4c1695e70a48eb6fe7aee123 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 22:12:18 +0300 Subject: [PATCH 35/76] wip --- .../Controllers/FilesController.cs | 1 - .../Filters/FilesCountLimit.cs | 39 ------------------- .../Attributes/CorrectFileTypeAttribute.cs | 1 - 3 files changed, 41 deletions(-) delete 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 4312d40a9..6ee73f136 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; diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs deleted file mode 100644 index 5b3febe81..000000000 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using HwProj.ContentService.Client; -using HwProj.Models.ContentService.DTO; -using HwProj.Models.CourseUnitType; - -namespace HwProj.APIGateway.API.Filters; - -public class FilesCountLimit -{ - private readonly IContentServiceClient _contentServiceClient; - public readonly long maxSolutionFiles = 5; - - public FilesCountLimit(IContentServiceClient contentServiceClient) - { - _contentServiceClient = contentServiceClient; - } - - public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) - { - if (processFilesDto.FilesScope.CourseUnitType == 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 diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 1b6334fc2..2530197cd 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using FileTypeChecker; using Microsoft.AspNetCore.Http; using FileTypeChecker.Abstracts; using FileTypeChecker.Types; From 10f591b243abe977b359a70dfaa00e042f583e68 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sun, 11 Jan 2026 20:10:44 +0300 Subject: [PATCH 36/76] wip --- .../Repositories/FileRecordRepository.cs | 2 +- .../Solutions/TaskSolutionsPage.tsx | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 53cc8651a..1d3348813 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -51,7 +51,7 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat => await _contentContext.FileRecords .AsNoTracking() .SingleOrDefaultAsync(fr => fr.Id == fileRecordId); - + public async Task?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits .AsNoTracking() diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index c74991f8e..865ebe999 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -13,7 +13,7 @@ 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"; @@ -42,7 +42,7 @@ const FilterProps = { } const TaskSolutionsPage: FC = () => { - const { taskId } = useParams() + const {taskId} = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -90,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]) @@ -103,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 => { @@ -166,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 { @@ -187,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)}> @@ -211,7 +211,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")}/> Только нерешенные
@@ -248,11 +248,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- } />; + }/>; })} } From 4a163127dfb21d242bd30332ee8bb9833e535912 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 29 Sep 2025 13:19:22 +0300 Subject: [PATCH 37/76] add: file uploader element to add solution page --- .../Homeworks/CourseHomeworkExperimental.tsx | 11 ++--- .../Solutions/AddOrEditSolution.tsx | 9 +++- .../Solutions/TaskSolutionsPage.tsx | 44 +++++++++---------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 512bf8872..1b51ff1be 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -32,7 +32,7 @@ import {LoadingButton} from "@mui/lab"; 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"; @@ -396,7 +396,7 @@ const CourseHomeworkExperimental: FC<{ 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) @@ -427,11 +427,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 aa49d8e0d..4e3ab89c3 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -43,7 +43,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 @@ -77,6 +77,13 @@ 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 [filesState, setFilesState] = useState({ + initialFilesInfo: initialFilesInfo, + selectedFilesInfo: props.filesInfo, + isLoadingInfo: false + }); + return ( { - const {taskId} = useParams() + const { taskId } = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -90,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]) @@ -103,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 => { @@ -166,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 { @@ -187,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)}> @@ -211,7 +211,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")} /> Только нерешенные
@@ -248,11 +248,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- }/>; + } />; })} } From 4ab35e91c61788d639ff184a8888d82bd96a6549 Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 30 Sep 2025 12:02:38 +0300 Subject: [PATCH 38/76] add: files count validation --- hwproj.front/src/components/Files/FilesUploader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 190543a9a..386f18af5 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -46,6 +46,7 @@ const FilesUploader: React.FC = (props) => { }, [props.initialFilesInfo]); const maxFileSizeInBytes = 100 * 1024 * 1024; + const maxFilesCount = 5; const forbiddenFileTypes = [ 'application/vnd.microsoft.portable-executable', From 67ed67bfa0ed98420500282e1a9129aa0c7766ac Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:38:12 +0300 Subject: [PATCH 39/76] 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 57a5d7c14..37d455271 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -100,6 +100,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 f769f6d34b1948dac6430dd0785b13efaaa02fa1 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:50:07 +0300 Subject: [PATCH 40/76] 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 88b32bb22..498f2be89 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -38,4 +38,11 @@ export default class FileInfoConverter { && filesInfo.courseUnitId === solutionId) ) } + + 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 5d0470887f31051d67377af1b486045e7e35b97e Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 6 Oct 2025 19:55:02 +0300 Subject: [PATCH 41/76] feat: processing files after adding solution --- hwproj.front/src/components/Solutions/AddOrEditSolution.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 4e3ab89c3..3e68f249b 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -50,6 +50,7 @@ const AddOrEditSolution: FC = (props) => { }) const [disableSend, setDisableSend] = useState(false) + const filesInfo = lastSolution?.id ? FileInfoConverter.getSolutionFilesInfo(props.courseFilesInfo, lastSolution.id) : [] const maxFilesCount = 5; @@ -77,10 +78,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 }); From 7ba3505fe7dc32965c5777d296e9631c190ba787 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:08 +0300 Subject: [PATCH 42/76] 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 c40eb4c89f87b12731ff2a55ea61382fbdfb490e Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:34:36 +0300 Subject: [PATCH 43/76] 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 44fbc2fe180f907de7f00bb19d10dea616845793 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:36:14 +0300 Subject: [PATCH 44/76] 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 7bceb6bd3400c6e2c00e7fea70f52110eed2cc42 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:38:15 +0300 Subject: [PATCH 45/76] feat: change download link validation (back) --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 6ee73f136..65ed3fb99 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -59,7 +59,7 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType((int)HttpStatusCode.Forbidden)] [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 linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); if (!linkDto.Succeeded) return BadRequest(linkDto.Errors); From eb93453572e322d51b52307a945dae029ae33701 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:39:53 +0300 Subject: [PATCH 46/76] 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 0f988d438fc188cf9a5bbe4b18f424c8c0a58534 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 19 Oct 2025 14:41:29 +0300 Subject: [PATCH 47/76] feat: change download link api call (front) --- .../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 1b51ff1be..15e48fba1 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -32,7 +32,7 @@ import {LoadingButton} from "@mui/lab"; 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"; From 72c38900f53632e4c1a82be5960d5c6d20c9d8f5 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 20 Oct 2025 18:53:32 +0300 Subject: [PATCH 48/76] 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 30c521db80d9c671caf1b507cc85f1a18a816073 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:32:51 +0300 Subject: [PATCH 49/76] 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 8db58f78cad62d22cbc80426b32ee062815db20d Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 23 Oct 2025 14:33:57 +0300 Subject: [PATCH 50/76] 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 9cf2324a315bad5573d7e49e963eb6ef682c4b1d Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 17:23:24 +0300 Subject: [PATCH 51/76] 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 50e82da108fbe9195034f73a8aa25e7e580d967b Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 18:58:59 +0300 Subject: [PATCH 52/76] 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 52c6365cc247cdac30e3213452f8cd3475eb22b2 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:00:33 +0300 Subject: [PATCH 53/76] 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 11f8b5651121e84dd4ca89a7f01fb7d131397474 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:01:39 +0300 Subject: [PATCH 54/76] 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 d739ff866f7257b14042db2a0a16d9638f79761f Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 25 Oct 2025 19:11:13 +0300 Subject: [PATCH 55/76] feat: update solutions components for files accessor --- .../Solutions/AddOrEditSolution.tsx | 14 ++++++------ .../Solutions/StudentSolutionsPage.tsx | 22 ------------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index 3e68f249b..615c4c12c 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -50,7 +50,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 maxFilesCount = 5; @@ -78,13 +85,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 ( { 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 4f3d4fde4c0489a89df028e142d5c438ad0bcf41 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sun, 26 Oct 2025 13:31:32 +0300 Subject: [PATCH 56/76] fix: return alien code --- .../components/Homeworks/CourseHomeworkExperimental.tsx | 9 ++++----- .../src/components/Solutions/AddOrEditSolution.tsx | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 15e48fba1..512bf8872 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -396,7 +396,7 @@ const CourseHomeworkExperimental: FC<{ 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) @@ -427,12 +427,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 615c4c12c..b6df3c04b 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -43,7 +43,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 4eb54632411b3921e74927655996533f32f6d02d Mon Sep 17 00:00:00 2001 From: Semyon Date: Tue, 28 Oct 2025 11:58:34 +0300 Subject: [PATCH 57/76] 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 cabb158337d155b8554340f719028b0a3cbdf9ed Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:25:45 +0300 Subject: [PATCH 58/76] feat [back]: method to get file scope --- .../Repositories/FileRecordRepository.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 1d3348813..67eb53816 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?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits From 6a7bc5e43b9d77575a79ddc437e775eaea45742f Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 11:41:46 +0300 Subject: [PATCH 59/76] 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 f19a14b03011369fc6d41e299dfcc905281333a3 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 13:54:45 +0300 Subject: [PATCH 60/76] feat [back]: file link dto --- .../HwProj.Models/ContentService/DTO/FileScopeDTO.cs | 10 ---------- 1 file changed, 10 deletions(-) delete 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 deleted file mode 100644 index ac89e4ac9..000000000 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/FileScopeDTO.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 34f0a5e1d377d895b8fbb47ae4f854455b4083c3 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:09 +0300 Subject: [PATCH 61/76] fix: download link request --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 65ed3fb99..6ee73f136 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -59,7 +59,7 @@ public async Task GetStatuses(ScopeDTO filesScope) [ProducesResponseType((int)HttpStatusCode.Forbidden)] [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 linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); if (!linkDto.Succeeded) return BadRequest(linkDto.Errors); From de1763115eb5c1d260487f32611e4745e6ad6ec5 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:26:54 +0300 Subject: [PATCH 62/76] 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 90bb68ac03ff215f854104bcc307437472763225 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:28:54 +0300 Subject: [PATCH 63/76] refactor [front]: rename files upload waiter --- .../components/Files/FilesAccessService.ts | 174 ------------------ 1 file changed, 174 deletions(-) delete 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 deleted file mode 100644 index e752582e7..000000000 --- a/hwproj.front/src/components/Files/FilesAccessService.ts +++ /dev/null @@ -1,174 +0,0 @@ -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: [] - }) - - // Останавливаем все активные интервалы при размонтировании - useEffect(() => { - return () => { - Object.values(intervalsRef.current).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - }; - }, []); - - const stopProcessing = (courseUnitId: number) => { - if (intervalsRef.current[courseUnitId]) { - const {interval, timeout} = intervalsRef.current[courseUnitId]; - clearInterval(interval); - clearTimeout(timeout); - delete intervalsRef.current[courseUnitId]; - } - }; - - const setCommonLoading = (courseUnitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [courseUnitId]: {isLoading: true} - } - })); - } - - const unsetCommonLoading = (courseUnitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [courseUnitId]: {isLoading: false} - } - })); - } - - 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(); - }, [courseId, isOwner]); - - 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.current[courseUnitId] = {interval, timeout}; - - // Сигнализируем о начале загрузки через состояние - setCommonLoading(courseUnitId); - } - - return { - courseFilesState, - updCourseFiles, - updCourseUnitFiles, - } -} From c0339cfcf8227774d8a415f71b377182a8200539 Mon Sep 17 00:00:00 2001 From: Semyon Date: Wed, 19 Nov 2025 15:54:12 +0300 Subject: [PATCH 64/76] feat [front]: variability for max files count --- hwproj.front/src/components/Files/FilesUploader.tsx | 1 - .../src/components/Solutions/AddOrEditSolution.tsx | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 386f18af5..190543a9a 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -46,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', diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index b6df3c04b..cb7ce4154 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -51,13 +51,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 maxFilesCount = 5; From 031740476a5e4bdd113ca912cebf70af2192fc69 Mon Sep 17 00:00:00 2001 From: Semyon Date: Thu, 20 Nov 2025 17:01:23 +0300 Subject: [PATCH 65/76] refactor [back]: type validation by foreign library --- .../ContentService/Attributes/CorrectFileTypeAttribute.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 2530197cd..1b6334fc2 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using FileTypeChecker; using Microsoft.AspNetCore.Http; using FileTypeChecker.Abstracts; using FileTypeChecker.Types; From ef20dd8e77b52a9187a17783dabea770d63b8158 Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:18:15 +0300 Subject: [PATCH 66/76] fix [back]: return with privacy error --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 6ee73f136..2c78a2966 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -58,6 +58,7 @@ 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) { From 8cd5a2d83cdf623f077ee9684688e0101614b9de Mon Sep 17 00:00:00 2001 From: Semyon Date: Fri, 21 Nov 2025 20:19:00 +0300 Subject: [PATCH 67/76] feat [back]: add max files count filter --- .../Filters/FilesCountLimit.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs 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 d3d2aed18804e1028ed8ab8ab87df979dae03fe2 Mon Sep 17 00:00:00 2001 From: Semyon Date: Sat, 22 Nov 2025 11:53:10 +0300 Subject: [PATCH 68/76] feat [back]: showing max files count on limit exceeding --- .../HwProj.APIGateway.API/Filters/FilesCountLimit.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 17e5908376dfc9f32faaf89a5c9aae48e0311928 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:20:34 +0300 Subject: [PATCH 69/76] refactor: separate methods in privacy filter --- .../HwProj.APIGateway.API/Controllers/FilesController.cs | 2 +- .../Repositories/FileRecordRepository.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 2c78a2966..4312d40a9 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; @@ -58,7 +59,6 @@ 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) { diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 67eb53816..aa1194944 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?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits From 3b1e8d87cdbb9052d5babf5885ecdd96d10c55b6 Mon Sep 17 00:00:00 2001 From: Semyon Date: Mon, 15 Dec 2025 23:47:39 +0300 Subject: [PATCH 70/76] refactor: create courseUnitType constans --- .../HwProj.APIGateway.API/Filters/FilesCountLimit.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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))) From 0ef5390822c9067277de7ed1d5fb62842af813e5 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sat, 10 Jan 2026 22:12:18 +0300 Subject: [PATCH 71/76] wip --- .../Controllers/FilesController.cs | 1 - .../Filters/FilesCountLimit.cs | 39 ------------------- .../Attributes/CorrectFileTypeAttribute.cs | 1 - 3 files changed, 41 deletions(-) delete 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 4312d40a9..6ee73f136 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; diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs deleted file mode 100644 index 5b3febe81..000000000 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimit.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using HwProj.ContentService.Client; -using HwProj.Models.ContentService.DTO; -using HwProj.Models.CourseUnitType; - -namespace HwProj.APIGateway.API.Filters; - -public class FilesCountLimit -{ - private readonly IContentServiceClient _contentServiceClient; - public readonly long maxSolutionFiles = 5; - - public FilesCountLimit(IContentServiceClient contentServiceClient) - { - _contentServiceClient = contentServiceClient; - } - - public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) - { - if (processFilesDto.FilesScope.CourseUnitType == 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 diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs index 1b6334fc2..2530197cd 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using FileTypeChecker; using Microsoft.AspNetCore.Http; using FileTypeChecker.Abstracts; using FileTypeChecker.Types; From 8d89252e9dc66f69b1c7da5ec2eec026c4e36f76 Mon Sep 17 00:00:00 2001 From: "Alexey.Berezhnykh" Date: Sun, 11 Jan 2026 20:10:44 +0300 Subject: [PATCH 72/76] wip --- .../Repositories/FileRecordRepository.cs | 2 +- .../Solutions/TaskSolutionsPage.tsx | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index aa1194944..703fb9217 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -51,7 +51,7 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat => await _contentContext.FileRecords .AsNoTracking() .SingleOrDefaultAsync(fr => fr.Id == fileRecordId); - + public async Task?> GetScopesAsync(long fileRecordId) => await _contentContext.FileToCourseUnits .AsNoTracking() diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index c74991f8e..865ebe999 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -13,7 +13,7 @@ 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"; @@ -42,7 +42,7 @@ const FilterProps = { } const TaskSolutionsPage: FC = () => { - const { taskId } = useParams() + const {taskId} = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -90,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]) @@ -103,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 => { @@ -166,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 { @@ -187,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)}> @@ -211,7 +211,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")}/> Только нерешенные
@@ -248,11 +248,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- } />; + }/>; })} } From 21fc69ea0b3d21a800954faf421a92e829b18e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Thu, 5 Mar 2026 20:46:48 +0300 Subject: [PATCH 73/76] fix: bug of accounting deleted files in files limiter --- .../HwProj.APIGateway.API/Filters/FilesCountLimiter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs index 2f470677a..40c72b83c 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs @@ -17,7 +17,7 @@ public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) var existingStatuses = await contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); if (!existingStatuses.Succeeded) return false; - var existingIds = existingStatuses.Value.Select(f => f.Id).ToList(); + var existingIds = existingStatuses.Value.Where(f => f.Status == "ReadyToUse").Select(f => f.Id).ToList(); if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) return false; From b7d0840b2593eed0d76829d8fc3c8209d4dab3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Thu, 5 Mar 2026 20:48:53 +0300 Subject: [PATCH 74/76] wip --- .../Repositories/FileRecordRepository.cs | 14 -------------- .../src/components/Solutions/AddOrEditSolution.tsx | 10 ---------- .../src/components/Utils/FileInfoConverter.ts | 7 ------- 3 files changed, 31 deletions(-) diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 703fb9217..2fe0c92d4 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs @@ -59,20 +59,6 @@ public async Task UpdateStatusAsync(List fileRecordIds, FileStatus newStat .Select(fc => fc.ToScope()) .ToListAsync(); - public async Task?> GetScopesAsync(long fileRecordId) - => await _contentContext.FileToCourseUnits - .AsNoTracking() - .Where(fr => fr.FileRecordId == fileRecordId) - .Select(fc => fc.ToScope()) - .ToListAsync(); - - public async Task?> GetScopesAsync(long fileRecordId) - => await _contentContext.FileToCourseUnits - .AsNoTracking() - .Where(fr => fr.FileRecordId == fileRecordId) - .Select(fc => fc.ToScope()) - .ToListAsync(); - public async Task> GetByScopeAsync(Scope scope) => await _contentContext.FileToCourseUnits .AsNoTracking() diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index cb7ce4154..581d53d8c 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -56,16 +56,6 @@ const AddOrEditSolution: FC = (props) => { const filesInfo = lastSolution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, lastSolution.id) : [] const {filesState, setFilesState, handleFilesChange} = FilesHandler(filesInfo); - const maxFilesCount = 5; - - const filesInfo = lastSolution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, lastSolution.id) : [] - const {filesState, setFilesState, handleFilesChange} = FilesHandler(filesInfo); - - 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(); setDisableSend(true) diff --git a/hwproj.front/src/components/Utils/FileInfoConverter.ts b/hwproj.front/src/components/Utils/FileInfoConverter.ts index 498f2be89..88b32bb22 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -38,11 +38,4 @@ export default class FileInfoConverter { && filesInfo.courseUnitId === solutionId) ) } - - 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 1889ba88e0067097f53e5b53d83662356a9db290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Thu, 5 Mar 2026 20:49:44 +0300 Subject: [PATCH 75/76] wip --- hwproj.front/src/components/Utils/FileInfoConverter.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/hwproj.front/src/components/Utils/FileInfoConverter.ts b/hwproj.front/src/components/Utils/FileInfoConverter.ts index 88b32bb22..4da4db13d 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -31,11 +31,4 @@ export default class FileInfoConverter { && filesInfo.courseUnitId === courseUnitId) ) } - - 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 47c6e8f91e740421f5b3a1a836d18d940b9290e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Mon, 9 Mar 2026 13:54:14 +0300 Subject: [PATCH 76/76] fix: count uploading and deleting error files in limiter --- .../HwProj.APIGateway.API/Filters/FilesCountLimiter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs index 40c72b83c..d0d2a3258 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs @@ -17,7 +17,9 @@ public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) var existingStatuses = await contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); if (!existingStatuses.Succeeded) return false; - var existingIds = existingStatuses.Value.Where(f => f.Status == "ReadyToUse").Select(f => f.Id).ToList(); + var existingIds = existingStatuses.Value + .Where(f => f.Status == "ReadyToUse" || f.Status == "Uploading" || f.Status == "DeletingError") + .Select(f => f.Id).ToList(); if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) return false;