diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs index 83247af98..2a7dc82ed 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/FilesController.cs @@ -4,7 +4,6 @@ using HwProj.AuthService.Client; using HwProj.ContentService.Client; using HwProj.Models.ContentService.DTO; -using HwProj.Models.Roles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,72 +12,88 @@ namespace HwProj.APIGateway.API.Controllers; [Route("api/[controller]")] [Authorize] [ApiController] -public class FilesController : AggregationController +public class FilesController( + IAuthServiceClient authServiceClient, + IContentServiceClient contentServiceClient, + FilesPrivacyFilter privacyFilter, + FilesCountLimiter filesCountLimiter) + : AggregationController(authServiceClient) { - private readonly IContentServiceClient _contentServiceClient; - - public FilesController(IAuthServiceClient authServiceClient, - IContentServiceClient contentServiceClient) : base(authServiceClient) - { - _contentServiceClient = contentServiceClient; - } - [HttpPost("process")] - [Authorize(Roles = Roles.LecturerRole)] - [ServiceFilter(typeof(CourseMentorOnlyAttribute))] [ProducesResponseType((int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task Process([FromForm] ProcessFilesDTO processFilesDto) { - var result = await _contentServiceClient.ProcessFilesAsync(processFilesDto); + var checkRights = await privacyFilter.CheckUploadRights(UserId, processFilesDto.FilesScope); + if (!checkRights) return Forbid("Недостаточно прав для загрузки файлов"); + + var checkCountLimit = await filesCountLimiter.CheckCountLimit(processFilesDto); + if (!checkCountLimit) + return Forbid("Слишком много файлов в решении." + + $"Максимальное количество файлов - ${FilesCountLimiter.MaxSolutionFiles}"); + + var result = await contentServiceClient.ProcessFilesAsync(processFilesDto); return result.Succeeded ? Ok() - : StatusCode((int)HttpStatusCode.ServiceUnavailable, result.Errors); + : BadRequest(result.Errors); } [HttpPost("statuses")] - [Authorize(Roles = Roles.LecturerRole)] - [ServiceFilter(typeof(CourseMentorOnlyAttribute))] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetStatuses(ScopeDTO filesScope) { - var filesStatusesResult = await _contentServiceClient.GetFilesStatuses(filesScope); - return filesStatusesResult.Succeeded - ? Ok(filesStatusesResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesStatusesResult.Errors); + var checkRights = await privacyFilter.CheckUploadRights(UserId, filesScope); + if (!checkRights) return Forbid("Недостаточно прав для получения информации о файлах"); + + var result = await contentServiceClient.GetFilesStatuses(filesScope); + return result.Succeeded + ? Ok(result.Value) + : BadRequest(result.Errors); } [HttpGet("downloadLink")] + [ProducesResponseType((int)HttpStatusCode.Forbidden)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.NotFound)] public async Task GetDownloadLink([FromQuery] long fileId) { - var result = await _contentServiceClient.GetDownloadLinkAsync(fileId); - return result.Succeeded - ? Ok(result.Value) - : NotFound(result.Errors); + var linkDto = await contentServiceClient.GetDownloadLinkAsync(fileId); + if (linkDto.Succeeded) return BadRequest(linkDto.Errors); + + var result = linkDto.Value; + var userId = UserId; + + foreach (var scope in result.FileScopes) + { + if (await privacyFilter.CheckDownloadRights(userId, scope)) + return Ok(result.DownloadUrl); + } + + return Forbid("Недостаточно прав для получения ссылки на файл"); } [HttpGet("info/course/{courseId}")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetFilesInfo(long courseId) { - var filesInfoResult = await _contentServiceClient.GetFilesInfo(courseId); + var filesInfoResult = await contentServiceClient.GetFilesInfo(courseId); return filesInfoResult.Succeeded - ? Ok(filesInfoResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesInfoResult.Errors); + ? Ok(filesInfoResult.Value) + : BadRequest(filesInfoResult.Errors); } [HttpGet("info/course/{courseId}/uploaded")] [ProducesResponseType(typeof(FileInfoDTO[]), (int)HttpStatusCode.OK)] - [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.ServiceUnavailable)] + [ProducesResponseType(typeof(string[]), (int)HttpStatusCode.BadRequest)] public async Task GetUploadedFilesInfo(long courseId) { - var filesInfoResult = await _contentServiceClient.GetUploadedFilesInfo(courseId); + var filesInfoResult = await contentServiceClient.GetUploadedFilesInfo(courseId); return filesInfoResult.Succeeded - ? Ok(filesInfoResult.Value) as IActionResult - : StatusCode((int)HttpStatusCode.ServiceUnavailable, filesInfoResult.Errors); + ? Ok(filesInfoResult.Value) + : BadRequest(filesInfoResult.Errors); } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs new file mode 100644 index 000000000..2f470677a --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesCountLimiter.cs @@ -0,0 +1,27 @@ +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 FilesCountLimiter(IContentServiceClient contentServiceClient) +{ + public const long MaxSolutionFiles = 5; + + public async Task CheckCountLimit(ProcessFilesDTO processFilesDto) + { + if (processFilesDto.FilesScope.CourseUnitType == CourseUnitType.Homework) return true; + + var existingStatuses = await contentServiceClient.GetFilesStatuses(processFilesDto.FilesScope); + if (!existingStatuses.Succeeded) return false; + + var existingIds = existingStatuses.Value.Select(f => f.Id).ToList(); + if (processFilesDto.DeletingFileIds.Any(id => !existingIds.Contains(id))) + return false; + + return existingIds.Count + processFilesDto.NewFiles.Count - processFilesDto.DeletingFileIds.Count <= + MaxSolutionFiles; + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs new file mode 100644 index 000000000..89f979b9b --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Filters/FilesPrivacyFilter.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using HwProj.CoursesService.Client; +using HwProj.Models.ContentService.DTO; +using HwProj.Models.CourseUnitType; +using HwProj.SolutionsService.Client; + +namespace HwProj.APIGateway.API.Filters; + +public class FilesPrivacyFilter( + ICoursesServiceClient coursesServiceClient, + ISolutionsServiceClient solutionsServiceClient) +{ + private async Task> GetSolutionStudentIds(long solutionId) + { + var studentIds = new HashSet(); + var solution = await solutionsServiceClient.GetSolutionById(solutionId); + studentIds.Add(solution.StudentId); + + if (solution.GroupId is { } groupId) + { + var groups = await coursesServiceClient.GetGroupsById(groupId); + if (groups is [var group]) studentIds.UnionWith(group.StudentsIds.ToHashSet()); + } + + return studentIds; + } + + public async Task CheckDownloadRights(string? userId, ScopeDTO fileScope) + { + if (userId == null) return false; + + switch (fileScope.CourseUnitType) + { + case CourseUnitType.Homework: + return true; + case CourseUnitType.Solution: + { + var studentIds = await GetSolutionStudentIds(fileScope.CourseUnitId); + if (studentIds.Contains(userId)) return true; + + var mentorIds = await coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + return mentorIds.Contains(userId); + } + default: + return false; + } + } + + public async Task CheckUploadRights(string? userId, ScopeDTO fileScope) + { + if (userId == null) return false; + + switch (fileScope.CourseUnitType) + { + case CourseUnitType.Homework: + { + var mentorIds = await coursesServiceClient.GetCourseLecturersIds(fileScope.CourseId); + return mentorIds.Contains(userId); + } + case CourseUnitType.Solution: + { + var studentIds = await GetSolutionStudentIds(fileScope.CourseUnitId); + return studentIds.Contains(userId); + } + default: + return false; + } + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index ec49a65cd..ef7c604ad 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -80,6 +80,8 @@ public void ConfigureServices(IServiceCollection services) services.AddContentServiceClient(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IHostEnvironment env) diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs new file mode 100644 index 000000000..2530197cd --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/CorrectFileTypeAttribute.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNetCore.Http; +using FileTypeChecker.Abstracts; +using FileTypeChecker.Types; + +namespace HwProj.Models.ContentService.Attributes +{ + [AttributeUsage(AttributeTargets.Property)] + public class CorrectFileTypeAttribute : FileValidationAttribute + { + private static readonly HashSet ForbiddenFileTypes = new HashSet + { + new MachO(), new Executable(), new ExecutableAndLinkableFormat() + }; + + protected override ValidationResult Validate(IFormFile file) + { + try + { + using var fileContent = file.OpenReadStream(); + //FileTypeValidator.RegisterCustomTypes(typeof(MachO).Assembly); + if ( //!FileTypeValidator.IsTypeRecognizable(fileContent) || + ForbiddenFileTypes.Any(type => type.DoesMatchWith(fileContent))) + { + return new ValidationResult( + $"Файл `{file.FileName}` имеет недопустимый тип ${file.ContentType}"); + } + } + catch + { + return new ValidationResult( + $"Невозможно прочитать файл `{file.FileName}`"); + } + + return ValidationResult.Success; + } + + private class MachO : FileType + { + private const string TypeName = "MacOS executable"; + private const string TypeExtension = "macho"; + + private static readonly byte[][] MagicBytes = + { + new byte[] { 0xfe, 0xed, 0xfa, 0xce }, // Mach-O BE 32-bit + new byte[] { 0xfe, 0xed, 0xfa, 0xcf }, // Mach-O BE 64-bit + new byte[] { 0xce, 0xfa, 0xed, 0xfe }, // Mach-O LE 32-bit + new byte[] { 0xcf, 0xfa, 0xed, 0xfe }, // Mach-O LE 64-bit + }; + + public MachO() : base(TypeName, TypeExtension, MagicBytes) + { + } + } + } +} diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs new file mode 100644 index 000000000..74d725fd8 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/FileValidationAttribute.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace HwProj.Models.ContentService.Attributes +{ + public abstract class FileValidationAttribute : ValidationAttribute + { + protected abstract ValidationResult Validate(IFormFile file); + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + value switch + { + IFormFile singleFile => Validate(singleFile), + IEnumerable files => files + .Select(Validate) + .FirstOrDefault(x => x != ValidationResult.Success) ?? ValidationResult.Success, + _ => null + }; + } +} diff --git a/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs b/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs index 2a9dea374..2f94eef4e 100644 --- a/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs +++ b/HwProj.Common/HwProj.Models/ContentService/Attributes/MaxFileSizeAttribute.cs @@ -1,35 +1,24 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http; namespace HwProj.Models.ContentService.Attributes { [AttributeUsage(AttributeTargets.Property)] - public class MaxFileSizeAttribute : ValidationAttribute + public class MaxFileSizeAttribute : FileValidationAttribute { private readonly long _maxFileSizeInBytes; public MaxFileSizeAttribute(long maxFileSizeInBytes) - =>_maxFileSizeInBytes = maxFileSizeInBytes; + => _maxFileSizeInBytes = maxFileSizeInBytes; - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + protected override ValidationResult Validate(IFormFile file) { - var files = value switch - { - IFormFile singleFile => new[] { singleFile }, - IEnumerable filesCollection => filesCollection, - _ => null - }; + if (file.Length > _maxFileSizeInBytes) + return new ValidationResult( + $"Файл `{file.FileName}` превышает лимит в {_maxFileSizeInBytes / 1024 / 1024} MB"); - if (files == null) return ValidationResult.Success; - - foreach (var file in files) - if (file.Length > _maxFileSizeInBytes) - return new ValidationResult( - $"Файл `{file.FileName}` превышает лимит в {_maxFileSizeInBytes / 1024 / 1024} MB"); - return ValidationResult.Success; } } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs b/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs new file mode 100644 index 000000000..1561d1b65 --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/CourseUnitType/CourseUnitType.cs @@ -0,0 +1,9 @@ +namespace HwProj.Models.CourseUnitType +{ + public static class CourseUnitType + { + public const string Homework = "Homework"; + public const string Solution = "Solution"; + public const string Task = "Task"; + }; +}; \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs new file mode 100644 index 000000000..3cd7cd86c --- /dev/null +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/FileLinkDTO.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace HwProj.Models.ContentService.DTO +{ + + public class FileLinkDTO + { + public string DownloadUrl { get; set; } + public List FileScopes { get; set; } + } +} diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs index 36616f013..f9d9a16b9 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/ProcessFilesDTO.cs @@ -7,10 +7,11 @@ namespace HwProj.Models.ContentService.DTO public class ProcessFilesDTO { public ScopeDTO FilesScope { get; set; } - + public List DeletingFileIds { get; set; } = new List(); - + + [CorrectFileType] [MaxFileSize(100 * 1024 * 1024)] public List NewFiles { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs b/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs index 6277884dc..ea9d85d45 100644 --- a/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs +++ b/HwProj.Common/HwProj.Models/ContentService/DTO/ScopeDTO.cs @@ -6,4 +6,4 @@ public class ScopeDTO public string CourseUnitType { get; set; } public long CourseUnitId { get; set; } } -} \ No newline at end of file +} diff --git a/HwProj.Common/HwProj.Models/HwProj.Models.csproj b/HwProj.Common/HwProj.Models/HwProj.Models.csproj index bd95eded7..f1514f75f 100644 --- a/HwProj.Common/HwProj.Models/HwProj.Models.csproj +++ b/HwProj.Common/HwProj.Models/HwProj.Models.csproj @@ -7,6 +7,7 @@ + diff --git a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs index b1a1ae2d3..e19399c1a 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Controllers/FilesController.cs @@ -81,14 +81,25 @@ public async Task GetStatuses(ScopeDTO scopeDto) } [HttpGet("downloadLink")] - [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(FileLinkDTO[]), (int)HttpStatusCode.OK)] public async Task GetDownloadLink([FromQuery] long fileId) { var externalKey = await _filesInfoService.GetFileExternalKeyAsync(fileId); - if (externalKey is null) return Ok(Result.Failed("Файл не найден")); + if (externalKey is null) return Ok(Result.Failed("Файл не найден")); - var downloadUrlResult = await _s3FilesService.GetDownloadUrl(externalKey); - return Ok(downloadUrlResult); + var fileScopes = await _filesInfoService.GetFileScopesAsync(fileId); + if (fileScopes is null) return Ok(Result.Failed("Файл не найден")); + + var downloadUrl = await _s3FilesService.GetDownloadUrl(externalKey); + if (!downloadUrl.Succeeded) return Ok(Result.Failed(downloadUrl.Errors)); + + var result = new FileLinkDTO + { + DownloadUrl = downloadUrl.Value, + FileScopes = fileScopes.Select(fs => fs.ToScopeDTO()).ToList() + }; + + return Ok(result); } [HttpGet("info/course/{courseId}")] diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/FileRecordRepository.cs index 987cb0a35..890e7ad88 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?> 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 diff --git a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs index b9522b2fb..2dd401a8e 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Repositories/IFileRecordRepository.cs @@ -13,6 +13,7 @@ public interface IFileRecordRepository public Task UpdateAsync(long id, Expression, SetPropertyCalls>> setPropertyCalls); public Task GetFileRecordByIdAsync(long fileRecordId); + public Task?> GetScopesAsync(long fileRecordId); public Task> GetByScopeAsync(Scope scope); public Task> GetByCourseIdAsync(long courseId); public Task> GetByCourseIdAndStatusAsync(long courseId, FileStatus filesStatus); diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs index 0310b1866..fb5350397 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/FilesInfoService.cs @@ -36,6 +36,12 @@ public async Task> GetFilesStatusesAsync(Scope filesScope) var fileRecord = await _fileRecordRepository.GetFileRecordByIdAsync(fileId); return fileRecord?.ExternalKey; } + + public async Task?> GetFileScopesAsync(long fileId) + { + var fileToCourseUnit = await _fileRecordRepository.GetScopesAsync(fileId); + return fileToCourseUnit; + } public async Task> GetFilesInfoAsync(long courseId) { diff --git a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs index 51417c164..ecc9d3086 100644 --- a/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs +++ b/HwProj.ContentService/HwProj.ContentService.API/Services/Interfaces/IFilesInfoService.cs @@ -1,4 +1,5 @@ using HwProj.ContentService.API.Models; +using HwProj.ContentService.API.Models.Database; using HwProj.ContentService.API.Models.Enums; using HwProj.Models.ContentService.DTO; @@ -8,6 +9,7 @@ public interface IFilesInfoService { public Task> GetFilesStatusesAsync(Scope filesScope); public Task GetFileExternalKeyAsync(long fileId); + public Task?> GetFileScopesAsync(long fileId); public Task> GetFilesInfoAsync(long courseId); public Task> GetFilesInfoAsync(long courseId, FileStatus filesStatus); public Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); diff --git a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs index c659d0356..4b012985a 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/ContentServiceClient.cs @@ -92,7 +92,7 @@ public async Task> GetFilesStatuses(ScopeDTO scopeDto) } } - public async Task> GetDownloadLinkAsync(long fileId) + public async Task> GetDownloadLinkAsync(long fileId) { using var httpRequest = new HttpRequestMessage( HttpMethod.Get, @@ -101,11 +101,13 @@ public async Task> GetDownloadLinkAsync(long fileId) try { var response = await _httpClient.SendAsync(httpRequest); - return await response.DeserializeAsync>(); + var result = await response.DeserializeAsync(); + + return Result.Success(result); } catch (HttpRequestException e) { - return Result.Failed( + return Result.Failed( "Пока не можем открыть файл. \nВсе ваши данные сохранены — попробуйте повторить позже"); } } diff --git a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs index 162c44978..f480a8781 100644 --- a/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs +++ b/HwProj.ContentService/HwProj.ContentService.Client/IContentServiceClient.cs @@ -8,7 +8,7 @@ public interface IContentServiceClient { Task ProcessFilesAsync(ProcessFilesDTO processFilesDto); Task> GetFilesStatuses(ScopeDTO scopeDto); - Task> GetDownloadLinkAsync(long fileId); + Task> GetDownloadLinkAsync(long fileId); Task> GetFilesInfo(long courseId); Task> GetUploadedFilesInfo(long courseId); Task TransferFilesFromCourse(CourseFilesTransferDto filesTransfer); diff --git a/hwproj.front/src/api/CustomFilesApi.ts b/hwproj.front/src/api/CustomFilesApi.ts index 7ba971a66..a6e77870b 100644 --- a/hwproj.front/src/api/CustomFilesApi.ts +++ b/hwproj.front/src/api/CustomFilesApi.ts @@ -1,4 +1,4 @@ -import {BaseAPI} from "./api"; +import {BaseAPI, ScopeDTO} from "./api"; import { IProcessFilesDto } from "../components/Files/IProcessFilesDto"; export default class CustomFilesApi extends BaseAPI { @@ -36,12 +36,11 @@ export default class CustomFilesApi extends BaseAPI { } public getDownloadFileLink = async (fileKey: number) => { - // Необходимо, чтобы символы & и др. не влияли на обработку запроса на бэкенде const response = await fetch(this.basePath + `/api/Files/downloadLink?fileId=${fileKey}`, { method: 'GET', headers: { 'Authorization': this.getApiKeyValue(), - }, + } }); if (response.status >= 200 && response.status < 300) { @@ -75,4 +74,4 @@ export default class CustomFilesApi extends BaseAPI { ? this.configuration.apiKey('Authorization') : this.configuration.apiKey; } -} \ No newline at end of file +} diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index c2309e0fe..2b545154a 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -3,9 +3,7 @@ import {useSearchParams} from "react-router-dom"; import { AccountDataDto, CourseViewModel, - FileInfoDTO, HomeworkViewModel, - ScopeDTO, StatisticsCourseMatesModel } from "@/api"; import StudentStats from "./StudentStats"; @@ -33,13 +31,10 @@ import LecturerStatistics from "./Statistics/LecturerStatistics"; import AssessmentIcon from '@mui/icons-material/Assessment'; import NameBuilder from "../Utils/NameBuilder"; import {QRCodeSVG} from 'qrcode.react'; -import ErrorsHandler from "components/Utils/ErrorsHandler"; -import {useSnackbar} from 'notistack'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import {CourseUnitType} from "../Files/CourseUnitType"; -import {FileStatus} from "../Files/FileStatus"; +import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; type TabValue = "homeworks" | "stats" | "applications" @@ -58,16 +53,6 @@ interface ICourseState { showQrCode: boolean; } -interface ICourseFilesState { - processingFilesState: { - [homeworkId: number]: { - isLoading: boolean; - intervalId?: NodeJS.Timeout; - }; - }; - courseFiles: FileInfoDTO[]; -} - interface IPageState { tabValue: TabValue } @@ -76,7 +61,6 @@ const Course: React.FC = () => { const {courseId, tab} = useParams() const [searchParams] = useSearchParams() const navigate = useNavigate() - const {enqueueSnackbar} = useSnackbar() const [courseState, setCourseState] = useState({ isFound: false, @@ -89,135 +73,6 @@ const Course: React.FC = () => { showQrCode: false }) const [studentSolutions, setStudentSolutions] = useState(undefined) - const [courseFilesState, setCourseFilesState] = useState({ - processingFilesState: {}, - courseFiles: [] - }) - - const intervalsRef = React.useRef>({}); - - const updateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - courseFiles: [ - ...prev.courseFiles.filter( - f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), - ...files - ] - })); - }; - - const setCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: true} - } - })); - } - - const unsetCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: false} - } - })); - } - - const stopProcessing = (homeworkId: number) => { - if (intervalsRef.current[homeworkId]) { - const {interval, timeout} = intervalsRef.current[homeworkId]; - clearInterval(interval); - clearTimeout(timeout); - delete intervalsRef.current[homeworkId]; - } - }; - - // Запускает получение информации о файлах элемента курса с интервалом в 1 секунду и 5 попытками - const getFilesByInterval = (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => { - // Очищаем предыдущие таймеры - stopProcessing(homeworkId); - - let attempt = 0; - const maxAttempts = 10; - let delay = 1000; // Начальная задержка 1 сек - - const scopeDto: ScopeDTO = { - courseId: +courseId!, - courseUnitType: CourseUnitType.Homework, - courseUnitId: homeworkId - } - - const fetchFiles = async () => { - if (attempt >= maxAttempts) { - stopProcessing(homeworkId); - enqueueSnackbar("Превышено допустимое количество попыток получения информации о файлах", { - variant: "warning", - autoHideDuration: 2000 - }); - return; - } - - attempt++; - try { - const files = await ApiSingleton.filesApi.filesGetStatuses(scopeDto); - console.log(`Попытка ${attempt}:`, files); - - // Первый вариант для явного отображения всех файлов - if (waitingNewFilesCount === 0 - && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) - } - - // Второй вариант для явного отображения всех файлов - if (waitingNewFilesCount > 0 - && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) - } - - // Условие прекращения отправки запросов на получения записей файлов - if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount - && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { - stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) - } - - } catch (error) { - console.error(`Ошибка (попытка ${attempt}):`, error); - } - } - - // Создаем интервал с задержкой - const interval = setInterval(fetchFiles, delay); - - // Создаем таймаут для автоматической остановки - const timeout = setTimeout(() => { - stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) - }, 10000); - - // Сохраняем интервал и таймаут в ref - intervalsRef.current[homeworkId] = {interval, timeout}; - - // Сигнализируем о начале загрузки через состояние - setCommonLoading(homeworkId) - } - - // Останавливаем все активные интевалы при размонтировании - useEffect(() => { - return () => { - Object.values(intervalsRef.current).forEach(({interval, timeout}) => { - clearInterval(interval); - clearTimeout(timeout); - }); - intervalsRef.current = {}; - }; - }, []); const [pageState, setPageState] = useState({ tabValue: "homeworks" @@ -240,6 +95,11 @@ const Course: React.FC = () => { const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) + const { + courseFilesState, + updCourseUnitFiles, + } = FilesUploadWaiter(+courseId!, isCourseMentor); + const isAcceptedStudent = acceptedStudents!.some(cm => cm.userId === userId) const showStatsTab = isCourseMentor || isAcceptedStudent @@ -284,30 +144,10 @@ const Course: React.FC = () => { })) } - const getCourseFilesInfo = async () => { - let courseFilesInfo = [] as FileInfoDTO[] - try { - courseFilesInfo = isCourseMentor - ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); - } - setCourseFilesState(prevState => ({ - ...prevState, - courseFiles: courseFilesInfo - })) - } - useEffect(() => { setCurrentState() }, []) - useEffect(() => { - getCourseFilesInfo() - }, [isCourseMentor]) - useEffect(() => { ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) .then(res => setStudentSolutions(res)) @@ -497,8 +337,8 @@ const Course: React.FC = () => { selectedHomeworkId={searchedHomeworkId == null ? undefined : +searchedHomeworkId} userId={userId!} processingFiles={courseFilesState.processingFilesState} - onStartProcessing={getFilesByInterval} - onHomeworkUpdate={({fileInfos, homework, isDeleted}) => { + onStartProcessing={updCourseUnitFiles} + onHomeworkUpdate={({homework, isDeleted}) => { const homeworkIndex = courseState.courseHomeworks.findIndex(x => x.id === homework.id) const homeworks = courseState.courseHomeworks diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index b5d1819fe..4ddd2bfdf 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -35,6 +35,7 @@ import ErrorIcon from '@mui/icons-material/Error'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; import Lodash from "lodash"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; interface ICourseExperimentalProps { homeworks: HomeworkViewModel[] @@ -45,7 +46,7 @@ interface ICourseExperimentalProps { isStudentAccepted: boolean userId: string selectedHomeworkId: number | undefined - onHomeworkUpdate: (update: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onHomeworkUpdate: (update: { homework: HomeworkViewModel } & { isDeleted?: boolean }) => void onTaskUpdate: (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => void, @@ -54,7 +55,11 @@ interface ICourseExperimentalProps { isLoading: boolean; }; }; - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; } interface ICourseExperimentalState { @@ -356,8 +361,7 @@ export const CourseExperimental: FC = (props) => { description: "", tasks: [], tags: [] - }, - fileInfos: [] + } }) setState((prevState) => ({ ...prevState, @@ -410,7 +414,7 @@ export const CourseExperimental: FC = (props) => { } const renderHomework = (homework: HomeworkViewModel & { isModified?: boolean }) => { - const filesInfo = id ? FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, id) : [] + const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] const homeworkEditMode = homework && (homework.id! < 0 || homework.isModified === true) return homework && @@ -435,8 +439,7 @@ export const CourseExperimental: FC = (props) => { })) }} isProcessing={props.processingFiles[homework.id!]?.isLoading || false} - onStartProcessing={(homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => - props.onStartProcessing(homeworkId, previouslyExistingFilesCount, waitingNewFilesCount, deletingFilesIds)} + onStartProcessing={props.onStartProcessing} /> @@ -560,7 +563,7 @@ export const CourseExperimental: FC = (props) => { data: x, isHomework: true, id: x.id, - homeworkFilesInfo: FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, x.id!) + homeworkFilesInfo: FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, x.id!) } })) }}> diff --git a/hwproj.front/src/components/Files/FilePreview.tsx b/hwproj.front/src/components/Files/FilePreview.tsx index 17ffb8136..b1d9f7725 100644 --- a/hwproj.front/src/components/Files/FilePreview.tsx +++ b/hwproj.front/src/components/Files/FilePreview.tsx @@ -107,7 +107,6 @@ const FilePreview: React.FC = (props) => { }; case FileStatus.ReadyToUse: return { - text: props.showOkStatus ? "Сохранён" : "", tooltipText: "", icon: props.showOkStatus ? : <>, diff --git a/hwproj.front/src/components/Files/FilesHandler.ts b/hwproj.front/src/components/Files/FilesHandler.ts new file mode 100644 index 000000000..49791b747 --- /dev/null +++ b/hwproj.front/src/components/Files/FilesHandler.ts @@ -0,0 +1,85 @@ +import {IFileInfo} from "@/components/Files/IFileInfo"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import ApiSingleton from "@/api/ApiSingleton"; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {useState} from "react"; +import {enqueueSnackbar} from "notistack"; + +export interface IEditFilesState { + initialFilesInfo: IFileInfo[] + selectedFilesInfo: IFileInfo[] + isLoadingInfo: boolean +} + +export const FilesHandler = (selectedFilesInfo: IFileInfo[]) => { + const [filesState, setFilesState] = useState({ + initialFilesInfo: selectedFilesInfo.filter(x => x.id !== undefined), + selectedFilesInfo: selectedFilesInfo, + isLoadingInfo: false + }); + + const handleFilesChange = async (courseId: number, + courseUnitType: CourseUnitType, + courseUnitId: number, + onStartProcessing: (courseUnitId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void, + onComplete: () => void, + ) => { + // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить + const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => + initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) + .map(fileInfo => fileInfo.id!) + + // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые + const newFiles = filesState.selectedFilesInfo.filter(selectedFile => + selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) + + // Если требуется, отправляем запрос на обработку файлов + if (deletingFileIds.length + newFiles.length > 0) { + try { + await ApiSingleton.customFilesApi.processFiles({ + courseId: courseId!, + courseUnitType: courseUnitType, + courseUnitId: courseUnitId!, + deletingFileIds, + newFiles, + }); + } catch (e) { + const errors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(errors[0], { + variant: "warning", + autoHideDuration: 2000 + }); + } + } + if (deletingFileIds.length === 0 && newFiles.length === 0) { + onComplete(); + } else { + try { + onComplete(); + onStartProcessing( + courseUnitId!, + courseUnitType, + filesState.initialFilesInfo.length, + newFiles.length, + deletingFileIds, + ); + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(responseErrors[0], { + variant: "warning", + autoHideDuration: 4000 + }); + onComplete(); + } + } + } + return { + filesState, + setFilesState, + handleFilesChange, + } +} diff --git a/hwproj.front/src/components/Files/FilesUploadWaiter.ts b/hwproj.front/src/components/Files/FilesUploadWaiter.ts new file mode 100644 index 000000000..1b059d661 --- /dev/null +++ b/hwproj.front/src/components/Files/FilesUploadWaiter.ts @@ -0,0 +1,183 @@ +import {useState, useEffect, useRef} from "react"; +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 interface IUploadFilesState { + processingFilesState: { + [courseUnitId: number]: { + isLoading: boolean; + intervalId?: NodeJS.Timeout; + }; + }; + courseFiles: FileInfoDTO[]; +} + +export const FilesUploadWaiter = (courseId: number, isCourseMentor?: 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 = isCourseMentor + ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) + : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) + } catch (e) { + const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) + enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); + } + setCourseFilesState(prevState => ({ + ...prevState, + courseFiles: courseFilesInfo + })) + } + + useEffect(() => { + updCourseFiles(); + }, [courseId, isCourseMentor]); + + 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, + } +} diff --git a/hwproj.front/src/components/Files/FilesUploader.tsx b/hwproj.front/src/components/Files/FilesUploader.tsx index 28334ef80..17662d29b 100644 --- a/hwproj.front/src/components/Files/FilesUploader.tsx +++ b/hwproj.front/src/components/Files/FilesUploader.tsx @@ -10,6 +10,7 @@ import {CourseUnitType} from "./CourseUnitType"; import {FileStatus} from "./FileStatus"; import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import "./filesUploaderOverrides.css"; +import Utils from "@/services/Utils"; interface IFilesUploaderProps { courseUnitType: CourseUnitType @@ -17,6 +18,7 @@ interface IFilesUploaderProps { initialFilesInfo?: IFileInfo[]; onChange: (selectedFiles: IFileInfo[]) => void; isLoading?: boolean; + maxFilesCount?: number; } // Кастомизированный Input для загрузки файла (из примеров MaterialUI) @@ -45,13 +47,33 @@ const FilesUploader: React.FC = (props) => { const maxFileSizeInBytes = 100 * 1024 * 1024; + const forbiddenFileTypes = [ + 'application/vnd.microsoft.portable-executable', + 'application/x-msdownload', + 'application/x-ms-installer', + 'application/x-ms-dos-executable', + 'application/x-dosexec', + 'application/x-msdos-program', + 'application/octet-stream', // если тип двоичного файла не определен, отбрасывать + ] + const validateFiles = (files: File[]): boolean => { + if (props.maxFilesCount && + (props.initialFilesInfo ? props.initialFilesInfo.length : 0) + files.length > props.maxFilesCount) { + setError(`Выбрано слишком много файлов. + Максимально допустимое количество файлов: ${props.maxFilesCount} ${Utils.pluralizeHelper(["штука", "штука", "штук"], props.maxFilesCount)}`); + return false; + } for (const file of files) { if (file.size > maxFileSizeInBytes) { setError(`Файл "${file.name}" слишком большой. Максимальный допустимый размер: ${(maxFileSizeInBytes / 1024 / 1024).toFixed(1)} MB.`); return false; } + if (forbiddenFileTypes.includes(file.type)) { + setError(`Файл "${file.name}" имеет недопустимый тип "${file.type}`); + return false; + } } return true @@ -109,7 +131,9 @@ const FilesUploader: React.FC = (props) => { variant={"outlined"}> - Загрузите материалы задания + {props.courseUnitType === CourseUnitType.Solution + ? "Загрузите файлы решения" + : "Загрузите материалы задания"} diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 9ee3efdaa..58b3c886b 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -17,7 +17,7 @@ import FilesPreviewList from "components/Files/FilesPreviewList"; import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; -import {FileInfoDTO, HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, CreateTaskViewModel} from "@/api"; +import {HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, CreateTaskViewModel} from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; import apiSingleton from "../../api/ApiSingleton"; @@ -36,6 +36,7 @@ import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/compone import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; +import {FilesHandler} from "@/components/Files/FilesHandler"; export interface HomeworkAndFilesInfo { homework: HomeworkViewModel & { isModified?: boolean }, @@ -50,20 +51,18 @@ interface IEditHomeworkState { hasErrors: boolean; } -interface IEditFilesState { - initialFilesInfo: IFileInfo[] - selectedFilesInfo: IFileInfo[] - isLoadingInfo: boolean -} - const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, getAllHomeworks: () => HomeworkViewModel[], - onUpdate: (update: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onUpdate: (update: { homework: HomeworkViewModel } & { isDeleted?: boolean, isSaved?: boolean }) => void - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 @@ -82,6 +81,7 @@ const CourseHomeworkEditor: FC<{ const {loadedHomework, isLoaded} = homeworkData + const {filesState, setFilesState, handleFilesChange} = FilesHandler(props.homeworkAndFilesInfo.filesInfo) const initialFilesInfo = props.homeworkAndFilesInfo.filesInfo.filter(x => x.id !== undefined) const homeworkId = loadedHomework.id! @@ -114,11 +114,7 @@ const CourseHomeworkEditor: FC<{ const [title, setTitle] = useState(loadedHomework.title!) const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) - const [filesState, setFilesState] = useState({ - initialFilesInfo: initialFilesInfo, - selectedFilesInfo: props.homeworkAndFilesInfo.filesInfo, - isLoadingInfo: false - }); + const [hasErrors, setHasErrors] = useState(false) const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) @@ -173,7 +169,7 @@ const CourseHomeworkEditor: FC<{ isModified: true, } - props.onUpdate({fileInfos: filesState.selectedFilesInfo, homework: update}) + props.onUpdate({homework: update}) }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) useEffect(() => { @@ -204,7 +200,7 @@ const CourseHomeworkEditor: FC<{ newFiles: [] }) - props.onUpdate({homework: loadedHomework, fileInfos: [], isDeleted: true}) + props.onUpdate({homework: loadedHomework, isDeleted: true}) } const getDeleteMessage = (homeworkName: string, filesInfo: IFileInfo[]) => { @@ -248,70 +244,17 @@ const CourseHomeworkEditor: FC<{ : await ApiSingleton.homeworksApi.homeworksUpdateHomework(+homeworkId!, update) const updatedHomeworkId = updatedHomework.value!.id! - - // Если какие-то файлы из ранее добавленных больше не выбраны, их потребуется удалить - const deletingFileIds = filesState.initialFilesInfo.filter(initialFile => - initialFile.id && !filesState.selectedFilesInfo.some(sf => sf.id === initialFile.id)) - .map(fileInfo => fileInfo.id!) - - // Если какие-то файлы из выбранных сейчас не были добавлены раньше, они новые - const newFiles = filesState.selectedFilesInfo.filter(selectedFile => - selectedFile.file && selectedFile.id == undefined).map(fileInfo => fileInfo.file!) - - // Если требуется, отправляем запрос на обработку файлов - if (deletingFileIds.length + newFiles.length > 0) { - try { - await ApiSingleton.customFilesApi.processFiles({ - courseId: courseId!, - courseUnitType: CourseUnitType.Homework, - courseUnitId: updatedHomeworkId, - deletingFileIds: deletingFileIds, - newFiles: newFiles, - }); - } catch (e) { - const errors = await ErrorsHandler.getErrorMessages(e as Response); - enqueueSnackbar(`Проблема при обработке файлов. ${errors[0]}`, { - variant: "warning", - autoHideDuration: 2000 - }); - } - } - - if (deletingFileIds.length === 0 && newFiles.length === 0) { - if (isNewHomework) props.onUpdate({ - homework: update, - fileInfos: [], - isDeleted: true - }) // remove fake homework - props.onUpdate({ - homework: updatedHomework.value!, - fileInfos: filesState.selectedFilesInfo, - isSaved: true - }) - } else { - try { - if (isNewHomework) props.onUpdate({ - homework: update, - fileInfos: [], - isDeleted: true - }) // remove fake homework - props.onUpdate({homework: updatedHomework.value!, fileInfos: undefined, isSaved: true}) - props.onStartProcessing(updatedHomework.value!.id!, filesState.initialFilesInfo.length, newFiles.length, deletingFileIds); - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 4000}); + await handleFilesChange( + courseId, CourseUnitType.Homework, updatedHomeworkId, + props.onStartProcessing, + () => { if (isNewHomework) props.onUpdate({ homework: update, - fileInfos: [], isDeleted: true }) // remove fake homework - props.onUpdate({ - homework: updatedHomework.value!, - fileInfos: filesState.selectedFilesInfo, - isSaved: true - }) - } - } + props.onUpdate({homework: updatedHomework.value!, isSaved: true}); + }, + ); } const isDisabled = hasErrors || !isLoaded || taskHasErrors @@ -441,12 +384,16 @@ const CourseHomeworkExperimental: FC<{ isMentor: boolean, initialEditMode: boolean, onMount: () => void, - onUpdate: (x: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { + onUpdate: (x: { homework: HomeworkViewModel } & { isDeleted?: boolean }) => void onAddTask: (homework: HomeworkViewModel) => void, isProcessing: boolean; - onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onStartProcessing: (homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) @@ -525,7 +472,7 @@ const CourseHomeworkExperimental: FC<{ showOkStatus={props.isMentor} filesInfo={filesInfo} onClickFileInfo={async (fileInfo: IFileInfo) => { - const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!); window.open(url, '_blank'); }} /> diff --git a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx index d4f553115..581d53d8c 100644 --- a/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx +++ b/hwproj.front/src/components/Solutions/AddOrEditSolution.tsx @@ -1,31 +1,45 @@ import * as React from 'react'; +import {FC, useState} from 'react'; import ApiSingleton from "../../api/ApiSingleton"; import { AccountDataDto, + FileInfoDTO, GetSolutionModel, HomeworkTaskViewModel, PostSolutionModel, - SolutionState + SolutionState, } from "@/api"; -import {FC, useState} from "react"; -import {Alert, Autocomplete, Grid, DialogContent, Dialog, DialogTitle, DialogActions, Button} from "@mui/material"; +import {Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid} from "@mui/material"; import {MarkdownEditor} from "../Common/MarkdownEditor"; import {TestTag} from "../Common/HomeworkTags"; import {LoadingButton} from "@mui/lab"; import TextField from "@mui/material/TextField"; +import FilesUploader from '../Files/FilesUploader'; +import {CourseUnitType} from '../Files/CourseUnitType'; +import ErrorsHandler from "@/components/Utils/ErrorsHandler"; +import {enqueueSnackbar} from "notistack"; +import FileInfoConverter from "@/components/Utils/FileInfoConverter"; +import {FilesHandler} from "@/components/Files/FilesHandler"; interface IAddSolutionProps { + courseId: number userId: string lastSolution: GetSolutionModel | undefined, task: HomeworkTaskViewModel, supportsGroup: boolean, - students: AccountDataDto[] + students: AccountDataDto[], + courseFilesInfo: FileInfoDTO[], onAdd: () => void, onCancel: () => void, + onStartProcessing: (solutionId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[]) => void, } const AddOrEditSolution: FC = (props) => { - const {lastSolution} = props + const { lastSolution } = props const isEdit = lastSolution?.state === SolutionState.NUMBER_0 const lastGroup = lastSolution?.groupMates?.map(x => x.userId!) || [] @@ -37,28 +51,37 @@ const AddOrEditSolution: FC = (props) => { const [disableSend, setDisableSend] = useState(false) + const maxFilesCount = 5; + + const filesInfo = lastSolution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, lastSolution.id) : [] + const {filesState, setFilesState, handleFilesChange} = FilesHandler(filesInfo); + const handleSubmit = async (e: any) => { e.preventDefault(); setDisableSend(true) - await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) - props.onAdd() + + let solutionId = await ApiSingleton.solutionsApi.solutionsPostSolution(props.task.id!, solution) + await handleFilesChange(props.courseId, CourseUnitType.Solution, solutionId, + props.onStartProcessing, + props.onAdd + ); } - const {githubUrl} = solution + const { githubUrl } = solution const isTest = props.task.tags?.includes(TestTag) const showTestGithubInfo = isTest && githubUrl?.startsWith("https://github") && githubUrl.includes("/pull/") const courseMates = props.students.filter(s => props.userId !== s.userId) return ( props.onCancel()} aria-labelledby="form-dialog-title"> + maxWidth="md" + open={true} + onClose={() => props.onCancel()} aria-labelledby="form-dialog-title"> Отправить новое решение - + = (props) => { }} /> {showTestGithubInfo && - + Для данного решения будет сохранена информация о коммитах на момент отправки. -
+
Убедитесь, что работа закончена, и отправьте решение в конце.
} {!isEdit && githubUrl === lastSolution?.githubUrl && !showTestGithubInfo && - Ссылка + Ссылка взята из предыдущего решения}
- {props.supportsGroup && + {props.supportsGroup && = (props) => { )} /> {!isEdit && lastGroup?.length > 0 && solution.groupMateIds === lastGroup && - Команда + Команда взята из предыдущего решения} } - + = (props) => { })) }} /> + { + setFilesState((prevState) => ({ + ...prevState, + selectedFilesInfo: filesInfo + })); + }} + courseUnitType={CourseUnitType.Solution} + courseUnitId={lastSolution?.id !== undefined ? lastSolution.id : -1} + maxFilesCount={maxFilesCount} + />
diff --git a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx index 6b93229f0..09f8029f4 100644 --- a/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/StudentSolutionsPage.tsx @@ -40,7 +40,7 @@ import {getTip} from "../Common/HomeworkTags"; import {appBarStateManager} from "../AppBar"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {RemovedFromCourseTag} from "@/components/Common/StudentTags"; -import AuthService from "@/services/AuthService"; +import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; interface IStudentSolutionsPageState { currentTaskId: string @@ -264,6 +264,8 @@ const StudentSolutionsPage: FC = () => { } + const {courseFilesState} = FilesUploadWaiter(courseId, isLoaded); + if (isLoaded) { return (
@@ -420,6 +422,8 @@ const StudentSolutionsPage: FC = () => { await getTaskData(currentTaskId, secondMentorId, true) //else navigate(`/task/${currentTaskId}/${studentSolutionsPreview[nextStudentIndex].student.userId}`) }} + courseFiles={courseFilesState.courseFiles} + processingFiles={courseFilesState.processingFilesState} /> diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 59b69e552..46725c0e7 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -8,7 +8,7 @@ import { HomeworkTaskViewModel, SolutionState, SolutionActualityDto, - SolutionActualityPart, StudentDataDto + SolutionActualityPart, StudentDataDto, FileInfoDTO } from '@/api' import ApiSingleton from "../../api/ApiSingleton"; import {Alert, Rating, Stack, Card, CardContent, CardActions, IconButton, Chip, Tooltip, Avatar} from "@mui/material"; @@ -29,6 +29,10 @@ import MouseOutlinedIcon from '@mui/icons-material/MouseOutlined'; import BlurOnIcon from '@mui/icons-material/BlurOn'; import BlurOffIcon from '@mui/icons-material/BlurOff'; import {UserAvatar} from "../Common/UserAvatar"; +import FileInfoConverter from "@/components/Utils/FileInfoConverter"; +import {IFileInfo} from "@/components/Files/IFileInfo"; +import FilesPreviewList from "@/components/Files/FilesPreviewList"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; interface ISolutionProps { courseId: number, @@ -39,6 +43,8 @@ interface ISolutionProps { lastRating?: number, onRateSolutionClick?: () => void, isLastSolution: boolean, + courseFilesInfo: FileInfoDTO[], + isProcessing: boolean, } interface ISolutionState { @@ -183,6 +189,7 @@ const TaskSolutionComponent: FC = (props) => { const lecturer = solution?.lecturer const lecturerName = lecturer && (lecturer.surname + " " + lecturer.name) const commitsActuality = solutionActuality?.commitsActuality + const filesInfo = solution?.id ? FileInfoConverter.getCourseUnitFilesInfo(props.courseFilesInfo, CourseUnitType.Solution, solution.id) : [] const getDatesDiff = (_date1: Date, _date2: Date) => { const truncateToMinutes = (date: Date) => { @@ -500,14 +507,33 @@ const TaskSolutionComponent: FC = (props) => { + {solution.comment && - + {showOriginalCommentText ? {solution.comment} : } } + {props.isProcessing ? ( +
+ +   Обрабатываем файлы... +
+ ) : filesInfo.length > 0 && ( +
+ { + const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!) + window.open(url, '_blank'); + }} + /> +
+ )} +
} {props.forMentor && props.isLastSolution && student && diff --git a/hwproj.front/src/components/Solutions/TaskSolutions.tsx b/hwproj.front/src/components/Solutions/TaskSolutions.tsx index dd4296d88..9bf84c16c 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutions.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutions.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import {FC, useEffect, useState} from 'react'; import TaskSolutionComponent from "./TaskSolutionComponent"; import { + FileInfoDTO, GetSolutionModel, GetTaskQuestionDto, HomeworkTaskViewModel, @@ -17,13 +18,19 @@ import ApiSingleton from "../../api/ApiSingleton"; import {DotLottieReact} from '@lottiefiles/dotlottie-react'; interface ITaskSolutionsProps { - courseId: number, + courseId: number task: HomeworkTaskViewModel solutions: GetSolutionModel[] student: StudentDataDto | undefined courseStudents: StudentDataDto[] forMentor: boolean onSolutionRateClick?: () => void + courseFiles: FileInfoDTO[] + processingFiles: { + [solutionId: number]: { + isLoading: boolean; + } + }; } interface ITaskSolutionsState { @@ -186,7 +193,10 @@ const TaskSolutions: FC = (props) => { lastRating={lastRating} onRateSolutionClick={onSolutionRateClick} isLastSolution={true} - courseId={props.courseId}/> + courseId={props.courseId} + courseFilesInfo={props.courseFiles} + isProcessing={props.processingFiles[lastSolution.id!]?.isLoading || false} + /> :
Студент не отправил ни одного решения. = (props) => { student={student!} onRateSolutionClick={onSolutionRateClick} isLastSolution={false} - courseId={props.courseId}/> + courseId={props.courseId} + courseFilesInfo={props.courseFiles} + isProcessing={props.processingFiles[x.id!]?.isLoading || false} + /> {i < arrayOfRatedSolutions.length - 1 ? : null} )} diff --git a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx index 581129625..7c4b225df 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionsPage.tsx @@ -12,8 +12,8 @@ import { SolutionState } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; -import {FC, useEffect, useState} from "react"; -import {Grid, Tab, Tabs} from "@material-ui/core"; +import { FC, useEffect, useState } from "react"; +import { Grid, Tab, Tabs } from "@material-ui/core"; import { Checkbox, Chip, @@ -21,14 +21,15 @@ import { Stack, Tooltip } from "@mui/material"; -import {useParams, Link, useNavigate} from "react-router-dom"; +import { useParams, Link, useNavigate } from "react-router-dom"; import Step from "@mui/material/Step"; import StepButton from "@mui/material/StepButton"; import StudentStatsUtils from "../../services/StudentStatsUtils"; -import {getTip} from "../Common/HomeworkTags"; +import { getTip } from "../Common/HomeworkTags"; import Lodash from "lodash"; -import {appBarStateManager} from "../AppBar"; -import {DotLottieReact} from "@lottiefiles/dotlottie-react"; +import { appBarStateManager } from "../AppBar"; +import { DotLottieReact } from "@lottiefiles/dotlottie-react"; +import { FilesUploadWaiter } from "@/components/Files/FilesUploadWaiter"; interface ITaskSolutionsState { isLoaded: boolean @@ -52,7 +53,7 @@ const FilterProps = { } const TaskSolutionsPage: FC = () => { - const {taskId} = useParams() + const { taskId } = useParams() const navigate = useNavigate() const userId = ApiSingleton.authService.getUserId() @@ -100,11 +101,11 @@ const TaskSolutionsPage: FC = () => { }) } - const {homeworkGroupedSolutions, courseId, courseMates} = taskSolutionPage + const { homeworkGroupedSolutions, courseId, courseMates } = taskSolutionPage const student = courseMates.find(x => x.userId === userId)! useEffect(() => { - appBarStateManager.setContextAction({actionName: "К курсу", link: `/courses/${courseId}`}) + appBarStateManager.setContextAction({ actionName: "К курсу", link: `/courses/${courseId}` }) return () => appBarStateManager.reset() }, [courseId]) @@ -113,11 +114,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 => { @@ -138,6 +139,11 @@ const TaskSolutionsPage: FC = () => { }); }) + const { + courseFilesState, + updCourseUnitFiles, + } = FilesUploadWaiter(courseId, true); + const currentHomeworksGroup = taskSolutionsWithPreview .find(x => x.homeworkSolutions! .some(h => h.previews! @@ -171,19 +177,19 @@ const TaskSolutionsPage: FC = () => { const renderRatingChip = (solutionsDescription: string, color: string, lastRatedSolution: Solution) => { return {solutionsDescription}}> - + style={{ whiteSpace: 'pre-line' }}>{solutionsDescription}}> + } - return taskSolutionPage.isLoaded ?
- + return taskSolutionPage.isLoaded ?
+ + style={{ overflowY: "hidden", overflowX: "auto", minHeight: 80 }}> {taskSolutionsPreviewFiltered.map((t, index) => { const isCurrent = versionsOfCurrentTask.includes(t.taskId!.toString()) const { @@ -192,13 +198,13 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = t return - {index > 0 &&
} + {index > 0 &&
} + style={{ color: "black", textDecoration: "none" }}> { - if (isCurrent) ref?.scrollIntoView({inline: "nearest"}) + if (isCurrent) ref?.scrollIntoView({ inline: "nearest" }) }} color={color} icon={renderRatingChip(solutionsDescription, color, lastRatedSolution)}> @@ -216,7 +222,7 @@ const TaskSolutionsPage: FC = () => { + checked={filterState.includes("Только нерешенные")} /> Только нерешенные
@@ -253,11 +259,11 @@ const TaskSolutionsPage: FC = () => { solutionsDescription } = h.previews[taskIndexInHomework]! return {renderRatingChip(color, solutionsDescription, lastRatedSolution)}
{h.homeworkTitle}
- }/>; + } />; })} } @@ -281,20 +287,27 @@ const TaskSolutionsPage: FC = () => { forMentor={false} student={student} courseStudents={[student]} - solutions={currentTaskSolutions}/> + solutions={currentTaskSolutions} + courseFiles={courseFilesState.courseFiles} + processingFiles={courseFilesState.processingFilesState} + />
)}
{taskSolutionPage.addSolution && } + supportsGroup={task.isGroupWork!} + courseFilesInfo={courseFilesState.courseFiles} + onStartProcessing={updCourseUnitFiles} + />}
: (
diff --git a/hwproj.front/src/components/Utils/FileInfoConverter.ts b/hwproj.front/src/components/Utils/FileInfoConverter.ts index 0c8e534fe..4da4db13d 100644 --- a/hwproj.front/src/components/Utils/FileInfoConverter.ts +++ b/hwproj.front/src/components/Utils/FileInfoConverter.ts @@ -25,10 +25,10 @@ export default class FileInfoConverter { return fileInfoDtos.map(fileInfoDto => this.fromFileInfoDTO(fileInfoDto)); } - public static getHomeworkFilesInfo(filesInfo: FileInfoDTO[], homeworkId: number): IFileInfo[] { + public static getCourseUnitFilesInfo(filesInfo: FileInfoDTO[], courseUnitType: CourseUnitType, courseUnitId: number): IFileInfo[] { return FileInfoConverter.fromFileInfoDTOArray( - filesInfo.filter(filesInfo => filesInfo.courseUnitType === CourseUnitType.Homework - && filesInfo.courseUnitId === homeworkId) + filesInfo.filter(filesInfo => filesInfo.courseUnitType === courseUnitType + && filesInfo.courseUnitId === courseUnitId) ) } } \ No newline at end of file