diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/ApplicationProfile.cs b/HwProj.APIGateway/HwProj.APIGateway.API/ApplicationProfile.cs index 1df12835c..138c760c7 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/ApplicationProfile.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/ApplicationProfile.cs @@ -1,9 +1,6 @@ using AutoMapper; -using HwProj.Models.AuthService.DTO; using HwProj.Models.AuthService.ViewModels; -using HwProj.Models.CoursesService; using HwProj.Models.CoursesService.DTO; -using HwProj.Models.CoursesService.ViewModels; namespace HwProj.APIGateway.API { @@ -15,4 +12,4 @@ public ApplicationProfile() CreateMap(); } } -} \ No newline at end of file +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs index 0f0617a02..8dc9abbb7 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs @@ -1,15 +1,18 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; +using HwProj.APIGateway.API.ExportServices; using HwProj.APIGateway.API.Models.Statistics; +using HwProj.APIGateway.API.TableGenerators; using HwProj.AuthService.Client; using HwProj.CoursesService.Client; using HwProj.Models.AuthService.DTO; using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using HwProj.Models.Roles; +using HwProj.Models.Result; using HwProj.SolutionsService.Client; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,13 +25,18 @@ public class StatisticsController : AggregationController { private readonly ISolutionsServiceClient _solutionClient; private readonly ICoursesServiceClient _coursesClient; - - public StatisticsController(ISolutionsServiceClient solutionClient, IAuthServiceClient authServiceClient, - ICoursesServiceClient coursesServiceClient) : - base(authServiceClient) + private readonly GoogleService _googleService; + + public StatisticsController( + ISolutionsServiceClient solutionClient, + ICoursesServiceClient coursesServiceClient, + IAuthServiceClient authServiceClient, + GoogleService googleService) + : base(authServiceClient) { _solutionClient = solutionClient; _coursesClient = coursesServiceClient; + _googleService = googleService; } [HttpGet("{courseId}/lecturers")] @@ -55,9 +63,20 @@ public async Task GetLecturersStatistics(long courseId) [HttpGet("{courseId}")] [ProducesResponseType(typeof(StatisticsCourseMatesModel[]), (int)HttpStatusCode.OK)] public async Task GetCourseStatistics(long courseId) + { + var result = await GetStatistics(courseId); + if (result == null) + { + return Forbid(); + } + + return Ok(result); + } + + private async Task?> GetStatistics(long courseId) { var statistics = await _solutionClient.GetCourseStatistics(courseId, UserId); - if (statistics == null) return Forbid(); + if (statistics == null) return null; var studentIds = statistics.Select(t => t.StudentId).ToArray(); var getStudentsTask = AuthServiceClient.GetAccountsData(studentIds); @@ -81,7 +100,7 @@ public async Task GetCourseStatistics(long courseId) }; }).OrderBy(t => t.Surname).ThenBy(t => t.Name); - return Ok(result); + return result; } [HttpGet("{courseId}/charts")] @@ -123,7 +142,55 @@ public async Task GetChartStatistics(long courseId) return Ok(result); } - + + /// + /// Implements file download. + /// + /// The course Id the report is based on. + /// Name of the sheet on which the report will be generated. + /// File download process. + [HttpGet("getFile")] + public async Task GetFile(long courseId, string sheetName) + { + var course = await _coursesClient.GetCourseById(courseId); + var statistics = await GetStatistics(courseId); + if (statistics == null || course == null) return Forbid(); + + var statisticStream = + await ExcelGenerator.Generate(statistics.ToList(), course, sheetName).GetAsByteArrayAsync(); + return new FileContentResult(statisticStream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + } + + [HttpGet("getSheetTitles")] + public async Task> GetSheetTitles(string sheetUrl) + => await _googleService.GetSheetTitles(sheetUrl); + + [HttpPost("processLink")] + public Result ProcessLink(string? sheetUrl) + { + if (sheetUrl == null) return Result.Failed("Некорректная ссылка"); + if (GoogleService.ParseLink(sheetUrl).Succeeded) return Result.Success(); + return Result.Failed("Некорректная ссылка"); + } + + /// + /// Implements sending a report to the Google Sheets. + /// + /// The course Id the report is based on. + /// Sheet Url parameter, required to make requests to the Google Sheets. + /// Sheet Name parameter, required to make requests to the Google Sheets. + /// Operation status. + [HttpGet("exportToSheet")] + public async Task ExportToGoogleSheets( + long courseId, string sheetUrl, string sheetName) + { + var course = await _coursesClient.GetCourseById(courseId); + var statistics = await GetStatistics(courseId); + if (course == null || statistics == null) return Result.Failed("Ошибка при получении статистики"); + var result = await _googleService.Export(course, statistics, sheetUrl, sheetName); + return result; + } + private async Task> GetStudentsToMentorsDictionary( MentorToAssignedStudentsDTO[] mentorsToStudents) { @@ -153,4 +220,4 @@ private async Task> GetStudentsToMentorsDic ); } } -} \ No newline at end of file +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/ExportServices/GoogleService.cs b/HwProj.APIGateway/HwProj.APIGateway.API/ExportServices/GoogleService.cs new file mode 100644 index 000000000..64243666b --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/ExportServices/GoogleService.cs @@ -0,0 +1,490 @@ +using System; +using HwProj.APIGateway.API.TableGenerators; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Google.Apis.Sheets.v4; +using Google.Apis.Sheets.v4.Data; +using HwProj.APIGateway.API.Models.Statistics; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.Result; +using OfficeOpenXml; +using OfficeOpenXml.Style; +using Google; +using System.Net; + +namespace HwProj.APIGateway.API.ExportServices +{ + public class GoogleService + { + private readonly SheetsService _internalGoogleSheetsService; + + public GoogleService(SheetsService internalGoogleSheetsService) + { + _internalGoogleSheetsService = internalGoogleSheetsService; + } + + private static int SeparationColumnPixelWidth { get; set; } = 20; + + public async Task Export( + CourseDTO course, + IOrderedEnumerable statistics, + string sheetUrl, + string sheetName) + { + if (sheetName == string.Empty || sheetUrl == string.Empty) + return Result.Failed("Ошибка при получении данных о гугл-документе"); + + var gettingSpreadsheetIdResult = ParseLink(sheetUrl); + if (!gettingSpreadsheetIdResult.Succeeded) return Result.Failed(gettingSpreadsheetIdResult.Errors); + var spreadsheetId = gettingSpreadsheetIdResult.Value; + Result result; + try + { + var sheetProperties = await GetSheetProperties(spreadsheetId, sheetName); + if (sheetProperties?.SheetId == null || sheetProperties.GridProperties.RowCount == null || + sheetProperties.GridProperties.ColumnCount == null) + return Result.Failed("Лист с таким названием не найден"); + + var (valueRange, range, updateStyleRequestBody) = Generate( + statistics.ToList(), course, sheetName, (int)sheetProperties.SheetId); + + var rowDifference = valueRange.Values.Count - (int)sheetProperties.GridProperties.RowCount; + var columnDifference = valueRange.Values.First().Count - (int)sheetProperties.GridProperties.ColumnCount; + + if (rowDifference > 0 || columnDifference > 0) + { + var appendBatchRequest = GetAppendDimensionBatchRequest( + (int)sheetProperties.SheetId, rowDifference, columnDifference); + var appendDimensionRequest = _internalGoogleSheetsService.Spreadsheets. + BatchUpdate(appendBatchRequest, spreadsheetId); + await appendDimensionRequest.ExecuteAsync(); + } + + var clearRequest = _internalGoogleSheetsService.Spreadsheets.Values.Clear(new ClearValuesRequest(), spreadsheetId, range); + await clearRequest.ExecuteAsync(); + var updateStyleRequest = _internalGoogleSheetsService.Spreadsheets.BatchUpdate(updateStyleRequestBody, spreadsheetId); + await updateStyleRequest.ExecuteAsync(); + var updateRequest = _internalGoogleSheetsService.Spreadsheets.Values.Update(valueRange, spreadsheetId, range); + updateRequest.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED; + await updateRequest.ExecuteAsync(); + result = Result.Success(); + } + catch (GoogleApiException ex) + { + var message = $"Ошибка при обращении к Google Sheets: {ex.Message}"; + if (ex.Error.Code == (int)HttpStatusCode.NotFound) + { + message = "Таблица не найдена, проверьте корректность ссылки"; + } + else if (ex.Error.Code == (int)HttpStatusCode.Forbidden) + { + message = "Нет прав не редактирование таблицы, проверьте настройки доступа"; + } + + return Result.Failed(message); + } + + return result; + } + + public async Task> GetSheetTitles(string sheetUrl) + { + var processingResult = ParseLink(sheetUrl); + if (!processingResult.Succeeded) return Result.Failed(processingResult.Errors); + + var spreadsheetId = processingResult.Value; + try + { + var spreadsheet = await _internalGoogleSheetsService.Spreadsheets.Get(spreadsheetId).ExecuteAsync(); + return Result.Success(spreadsheet.Sheets.Select(t => t.Properties.Title).ToArray()); + } + catch (GoogleApiException ex) + { + var message = $"Ошибка при обращении к Google Sheets: {ex.Message}"; + if (ex.Error.Code == (int)HttpStatusCode.NotFound) + { + message = "Таблица не найдена, проверьте корректность ссылки"; + } + else if (ex.Error.Code == (int)HttpStatusCode.Forbidden) + { + message = "Нет прав не получение данных о таблице, проверьте настройки доступа"; + } + + return Result.Failed(message); + } + } + + public static Result ParseLink(string sheetUrl) + { + var match = Regex.Match(sheetUrl, "https://docs\\.google\\.com/spreadsheets/d/(?.+)/"); + return match.Success ? Result.Success(match.Groups["id"].Value) + : Result.Failed("Некорректная ссылка на страницу Google Docs"); + } + + private static BatchUpdateSpreadsheetRequest GetAppendDimensionBatchRequest( + int sheetId, + int rowDifference, + int columnDifference) + { + var batchUpdateRequest = new BatchUpdateSpreadsheetRequest(); + batchUpdateRequest.Requests = new List(); + + if (rowDifference > 0) + { + var appendRowsRequest = new AppendDimensionRequest(); + appendRowsRequest.SheetId = sheetId; + appendRowsRequest.Dimension = "ROWS"; + appendRowsRequest.Length = rowDifference; + var request = new Request(); + request.AppendDimension = appendRowsRequest; + batchUpdateRequest.Requests.Add(request); + } + + if (columnDifference > 0) + { + var appendColumnsRequest = new AppendDimensionRequest(); + appendColumnsRequest.SheetId = sheetId; + appendColumnsRequest.Dimension = "COLUMNS"; + appendColumnsRequest.Length = columnDifference; + var request = new Request(); + request.AppendDimension = appendColumnsRequest; + batchUpdateRequest.Requests.Add(request); + } + + return batchUpdateRequest; + } + + /// + /// Generates query data to create a report in Google Sheets. + /// + /// Information about the success of the course participants. + /// Course information. + /// Building sheet name. + /// Building sheet Id. + /// Data for executing queries to the Google Sheets. + private static (ValueRange ValueRange, string Range, BatchUpdateSpreadsheetRequest UpdateStyleRequest) Generate + (List courseMatesModels, + CourseDTO course, + string sheetName, + int sheetId) + { + var package = ExcelGenerator.Generate(courseMatesModels, course, sheetName); + var worksheet = package.Workbook.Worksheets[sheetName]; + var rangeWithSheetTitle = worksheet.Dimension.FullAddress; + var range = worksheet.Dimension.LocalAddress; + + var headersFieldEndAddress = string.Empty; + var whiteForegroundAddresses = new List(); + var blueCellsAddresses = new List(); + var grayCellsAddresses = new List(); + var testHeaderCellsAddresses = new List(); + var cellsWithBorderAddresses = new List<(string CellAddress, string BorderType)>(); + + var valueRange = new ValueRange() + { + Values = new List>(), + }; + for (var i = 1; i <= worksheet.Dimension.End.Row; ++i) + { + var row = new List(); + for (var j = 1; j <= worksheet.Dimension.End.Column; ++j) + { + var cell = worksheet.Cells[i, j]; + row.Add(cell.Value); + if (cell.Style.Font.Bold) + { + headersFieldEndAddress = cell.LocalAddress; + } + if (cell.Style.Font.Color.Rgb == ExcelGenerator.WhiteArgbColor) + { + whiteForegroundAddresses.Add(cell.LocalAddress); + } + + if (cell.Style.Fill.BackgroundColor.Rgb == ExcelGenerator.CyanArgbColor) + { + blueCellsAddresses.Add(cell.LocalAddress); + } + else if (cell.Style.Fill.BackgroundColor.Rgb == ExcelGenerator.GrayArgbColor) + { + grayCellsAddresses.Add(cell.LocalAddress); + } + else if (cell.Style.Fill.BackgroundColor.Rgb == ExcelGenerator.TestHeaderArgbColor) + { + testHeaderCellsAddresses.Add(cell.LocalAddress); + } + + if (cell.Style.Border.Top.Style != ExcelBorderStyle.None) + { + cellsWithBorderAddresses.Add((cell.LocalAddress, "top")); + } + if (cell.Style.Border.Bottom.Style != ExcelBorderStyle.None) + { + cellsWithBorderAddresses.Add((cell.LocalAddress, "bottom")); + } + if (cell.Style.Border.Left.Style != ExcelBorderStyle.None) + { + cellsWithBorderAddresses.Add((cell.LocalAddress, "left")); + } + if (cell.Style.Border.Right.Style != ExcelBorderStyle.None) + { + cellsWithBorderAddresses.Add((cell.LocalAddress, "right")); + } + } + + valueRange.Values.Add(row); + } + + var batchUpdateRequest = new BatchUpdateSpreadsheetRequest(); + batchUpdateRequest.Requests = new List(); + AddClearStylesRequest(batchUpdateRequest, worksheet, sheetId, range); + AddColoredCellsRequests(batchUpdateRequest, worksheet, sheetId, blueCellsAddresses, ExcelGenerator.CyanFloatColor); + AddColoredCellsRequests(batchUpdateRequest, worksheet, sheetId, grayCellsAddresses, ExcelGenerator.GrayFloatColor); + AddColoredCellsRequests(batchUpdateRequest, worksheet, sheetId, testHeaderCellsAddresses, ExcelGenerator.TestHeaderFloatColor); + AddColoredFontRequest(batchUpdateRequest, worksheet, sheetId, whiteForegroundAddresses, ExcelGenerator.WhiteFloatColor); + AddUpdateCellsWidthRequest(batchUpdateRequest, worksheet, sheetId, grayCellsAddresses, SeparationColumnPixelWidth); + AddCellsFormattingRequest(batchUpdateRequest, worksheet, sheetId, range); + AddHeadersFormattingRequest(batchUpdateRequest, worksheet, sheetId, $"{sheetName}!A1:{headersFieldEndAddress}"); + AddBordersFormattingRequest(batchUpdateRequest, worksheet, sheetId, cellsWithBorderAddresses, ExcelGenerator.EquivalentBorderStyle); + AddMergeRequests(batchUpdateRequest, worksheet, sheetId, worksheet.MergedCells); + return (valueRange, rangeWithSheetTitle, batchUpdateRequest); + } + + private static GridRange FillGridRange(ExcelWorksheet worksheet, string rangeAddress, int sheetId) + { + var gridRange = new GridRange(); + var rangeInfo = worksheet.Cells[rangeAddress]; + gridRange.SheetId = sheetId; + gridRange.StartRowIndex = rangeInfo.Start.Row - 1; + gridRange.StartColumnIndex = rangeInfo.Start.Column - 1; + gridRange.EndRowIndex = rangeInfo.End.Row; + gridRange.EndColumnIndex = rangeInfo.End.Column; + return gridRange; + } + + private static void AddClearStylesRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + string range) + { + var clearStylesRequest = new RepeatCellRequest(); + clearStylesRequest.Range = FillGridRange(worksheet, range, sheetId); + var cell = new CellData(); + cell.UserEnteredFormat = new CellFormat(); + clearStylesRequest.Cell = cell; + clearStylesRequest.Fields = "userEnteredFormat"; + + var request = new Request(); + request.RepeatCell = clearStylesRequest; + batchUpdateRequest.Requests.Add(request); + } + + private static void AddBordersFormattingRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + List<(string CellAddress, string BorderType)> cellsAddresses, + string bordersStyle) + { + foreach (var cell in cellsAddresses) + { + var updateBorderRequest = new UpdateBordersRequest(); + var gridRange = FillGridRange(worksheet, cell.CellAddress, sheetId); + updateBorderRequest.Range = gridRange; + var border = new Google.Apis.Sheets.v4.Data.Border(); + border.Style = bordersStyle; + switch (cell.BorderType) + { + case "top": + updateBorderRequest.Top = border; + break; + case "bottom": + updateBorderRequest.Bottom = border; + break; + case "left": + updateBorderRequest.Left = border; + break; + case "right": + updateBorderRequest.Right = border; + break; + } + + var request = new Request(); + request.UpdateBorders = updateBorderRequest; + batchUpdateRequest.Requests.Add(request); + } + } + + private static void AddHeadersFormattingRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + string range) + { + var styleBoldCellsRequest = new RepeatCellRequest(); + styleBoldCellsRequest.Range = FillGridRange(worksheet, range, sheetId); + var cell = new CellData(); + cell.UserEnteredFormat = new CellFormat(); + cell.UserEnteredFormat.TextFormat = new TextFormat(); + cell.UserEnteredFormat.TextFormat.Bold = true; + styleBoldCellsRequest.Cell = cell; + styleBoldCellsRequest.Fields = "userEnteredFormat(textFormat.bold)"; + + var request = new Request(); + request.RepeatCell = styleBoldCellsRequest; + batchUpdateRequest.Requests.Add(request); + } + + private static void AddCellsFormattingRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + string range) + { + var cellsFormatRequest = new RepeatCellRequest(); + cellsFormatRequest.Range = FillGridRange(worksheet, range, sheetId); + var cell = new CellData(); + cell.UserEnteredFormat = new CellFormat(); + cell.UserEnteredFormat.HorizontalAlignment = "CENTER"; + cell.UserEnteredFormat.VerticalAlignment = "MIDDLE"; + cell.UserEnteredFormat.TextFormat = new TextFormat(); + cell.UserEnteredFormat.TextFormat.FontSize = ExcelGenerator.FontSize; + cell.UserEnteredFormat.TextFormat.FontFamily = ExcelGenerator.FontFamily; + cellsFormatRequest.Cell = cell; + cellsFormatRequest.Fields = "userEnteredFormat(horizontalAlignment,verticalAlignment,textFormat.fontSize,textFormat.fontFamily)"; + + var request = new Request(); + request.RepeatCell = cellsFormatRequest; + batchUpdateRequest.Requests.Add(request); + } + + private static void AddMergeRequests( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + ExcelWorksheet.MergeCellsCollection mergeCellsCollection) + { + for (var i = 0; i < mergeCellsCollection.Count; ++i) + { + var mergedCellsAddress = mergeCellsCollection[i]; + var gridRange = FillGridRange(worksheet, mergedCellsAddress, sheetId); + var mergeCellsRequest = new MergeCellsRequest(); + mergeCellsRequest.MergeType = "MERGE_ALL"; + mergeCellsRequest.Range = gridRange; + + var request = new Request(); + request.MergeCells = mergeCellsRequest; + batchUpdateRequest.Requests.Add(request); + } + } + + private static void AddUpdateCellsWidthRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + List cellsAddresses, + int cellsPixelWidth) + { + for (var i = 0; i < cellsAddresses.Count; ++i) + { + var cellAddress = cellsAddresses[i]; + var rangeInfo = worksheet.Cells[cellAddress]; + var updateWidthRequest = new UpdateDimensionPropertiesRequest(); + updateWidthRequest.Range = new DimensionRange() + { + SheetId = sheetId, + Dimension = "COLUMNS", + StartIndex = rangeInfo.Start.Column - 1, + EndIndex = rangeInfo.End.Column, + }; + updateWidthRequest.Fields = "*"; + updateWidthRequest.Properties = new DimensionProperties(); + updateWidthRequest.Properties.PixelSize = cellsPixelWidth; + + var request = new Request(); + request.UpdateDimensionProperties = updateWidthRequest; + batchUpdateRequest.Requests.Add(request); + } + } + + private static void AddColoredCellsRequests( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + List coloredCellsAddresses, + (float Alpha, float Red, float Green, float Blue) fillColor) + { + for (var i = 0; i < coloredCellsAddresses.Count; ++i) + { + var cellAddress = coloredCellsAddresses[i]; + var colorCellRequest = new RepeatCellRequest(); + colorCellRequest.Range = FillGridRange(worksheet, cellAddress, sheetId); + var cell = new CellData(); + cell.UserEnteredFormat = new CellFormat(); + cell.UserEnteredFormat.BackgroundColor = new Color() + { + Alpha = fillColor.Alpha, + Red = fillColor.Red, + Green = fillColor.Green, + Blue = fillColor.Blue, + }; + + colorCellRequest.Fields = $"userEnteredFormat(backgroundColor)"; + colorCellRequest.Cell = cell; + + var request = new Request(); + request.RepeatCell = colorCellRequest; + batchUpdateRequest.Requests.Add(request); + } + } + + private static void AddColoredFontRequest( + BatchUpdateSpreadsheetRequest batchUpdateRequest, + ExcelWorksheet worksheet, + int sheetId, + List cellsAddresses, + (float Alpha, float Red, float Green, float Blue) fontColor) + { + for (var i = 0; i < cellsAddresses.Count; ++i) + { + var cellAddress = cellsAddresses[i]; + var colorFontRequest = new RepeatCellRequest(); + colorFontRequest.Range = FillGridRange(worksheet, cellAddress, sheetId); + var cell = new CellData(); + cell.UserEnteredFormat = new CellFormat(); + cell.UserEnteredFormat.TextFormat = new TextFormat(); + cell.UserEnteredFormat.TextFormat.ForegroundColor = new Color() + { + Alpha = fontColor.Alpha, + Red = fontColor.Red, + Green = fontColor.Green, + Blue = fontColor.Blue, + }; + + colorFontRequest.Fields = $"userEnteredFormat(textFormat.foregroundColor)"; + colorFontRequest.Cell = cell; + + var request = new Request(); + request.RepeatCell = colorFontRequest; + batchUpdateRequest.Requests.Add(request); + } + } + + private async Task GetSheetProperties(string spreadsheetId, string sheetName) + { + var spreadsheetGetRequest = _internalGoogleSheetsService.Spreadsheets.Get(spreadsheetId); + spreadsheetGetRequest.IncludeGridData = true; + try + { + var spreadsheetResponse = await spreadsheetGetRequest.ExecuteAsync(); + return spreadsheetResponse.Sheets.First(sheet => sheet.Properties.Title == sheetName).Properties; + } + catch (Exception) + { + return null; + } + } + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/HwProj.APIGateway.API.csproj b/HwProj.APIGateway/HwProj.APIGateway.API/HwProj.APIGateway.API.csproj index 7238b2966..d27fb272e 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/HwProj.APIGateway.API.csproj +++ b/HwProj.APIGateway/HwProj.APIGateway.API/HwProj.APIGateway.API.csproj @@ -10,8 +10,10 @@ + + diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs index cf053dec6..556f4dc9b 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Startup.cs @@ -1,4 +1,8 @@ -using HwProj.AuthService.Client; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; +using Google.Apis.Sheets.v4; +using HwProj.APIGateway.API.ExportServices; +using HwProj.AuthService.Client; using HwProj.ContentService.Client; using HwProj.CoursesService.Client; using HwProj.NotificationsService.Client; @@ -14,13 +18,18 @@ using Microsoft.IdentityModel.Tokens; using IStudentsInfo; using StudentsInfo; +using Newtonsoft.Json.Linq; +using System; namespace HwProj.APIGateway.API { public class Startup { + private readonly IConfigurationSection _sheetsConfiguration; + public Startup(IConfiguration configuration) { + _sheetsConfiguration = configuration.GetSection("GoogleSheets"); Configuration = configuration; } @@ -28,6 +37,7 @@ public Startup(IConfiguration configuration) public void ConfigureServices(IServiceCollection services) { + services.AddCors(); services.Configure(options => { options.MultipartBodyLengthLimit = 200 * 1024 * 1024; }); services.ConfigureHwProjServices("API Gateway"); services.AddSingleton(provider => @@ -54,6 +64,8 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpClient(); services.AddHttpContextAccessor(); + services.AddSingleton(_ => ConfigureGoogleSheets(_sheetsConfiguration)); + services.AddSingleton(); services.AddAuthServiceClient(); services.AddCoursesServiceClient(); @@ -68,5 +80,35 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.ConfigureHwProj(env, "API Gateway"); } + + private static JToken Serialize(IConfigurationSection configurationSection) + { + JObject obj = new JObject(); + foreach (var child in configurationSection.GetChildren()) + { + obj.Add(child.Key, child.Value); + } + + return obj; + } + + private static SheetsService ConfigureGoogleSheets(IConfigurationSection _sheetsConfiguration) + { + var jsonObject = Serialize(_sheetsConfiguration); + GoogleCredential? credential = null; + + try + { + credential = GoogleCredential.FromJson(jsonObject.ToString()) + .CreateScoped(SheetsService.Scope.Spreadsheets); + } + catch (Exception) {} + + return new SheetsService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "HwProjSheets" + }); + } } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/TableGenerators/ExcelGenerator.cs b/HwProj.APIGateway/HwProj.APIGateway.API/TableGenerators/ExcelGenerator.cs new file mode 100644 index 000000000..8f4318c59 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/TableGenerators/ExcelGenerator.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using HwProj.APIGateway.API.Models.Statistics; +using HwProj.Models.CoursesService; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.SolutionsService; +using Microsoft.EntityFrameworkCore.Internal; +using OfficeOpenXml; +using OfficeOpenXml.Style; + +namespace HwProj.APIGateway.API.TableGenerators +{ + /// + /// Implements course report generation. + /// + public static class ExcelGenerator + { + /// + /// Font used in the reports. + /// + public static string FontFamily { get; set; } = "Calibri"; + + /// + /// Font size used in the reports. + /// + public static int FontSize { get; set; } = 11; + + /// + /// Color for font to use in test headers. + /// + private static Color WhiteColor { get; set; } = Color.White; + public static string WhiteArgbColor = "FFFFFFFF"; + public static (float Alpha, float Red, float Green, float Blue) WhiteFloatColor { get; set; } = + (1, 1, 1, 1); + + /// + /// Cyan color used to indicate unrated solutions. + /// + private static Color CyanColor { get; set; } = Color.Cyan; + public static string CyanArgbColor { get; set; } = "FF00FFFF"; + public static (float Alpha, float Red, float Green, float Blue) CyanFloatColor { get; set; } = + (1, 0, 1, 1); + + /// + /// Gray color used with separation columns. + /// + private static Color GrayColor { get; set; } = Color.FromArgb(255, 80, 80, 80); + public static string GrayArgbColor { get; set; } = "FF505050"; + public static (float Alpha, float Red, float Green, float Blue) GrayFloatColor { get; set; } = + (1, (float)0.3137, (float)0.3137, (float)0.3137); + + /// + /// Header color for tests. + /// + private static Color TestHeaderColor { get; set; } = Color.FromArgb(255, 63, 81, 181); + public static string TestHeaderArgbColor = "FF3F51B5"; + public static (float Alpha, float Red, float Green, float Blue) TestHeaderFloatColor { get; set; } = + (1, (float)0.2471, (float)0.3176, (float)0.7098); + + private static ExcelBorderStyle BorderStyle { get; set; } = ExcelBorderStyle.Thin; + + public static string EquivalentBorderStyle { get; set; } = "SOLID"; + + private static int SeparationColumnWidth { get; set; } = 2; + + private static string GetTagLabel(string tag) + { + return tag switch + { + HomeworkTags.Test => "Тест", + HomeworkTags.BonusTask => "Бонус", + HomeworkTags.GroupWork => "Командное", + _ => tag, + }; + } + + /// + /// Generates course statistics file based on the model from HwProj.APIGateway.Tests.Test.xlsx file. + /// + /// Information about the success of the course participants. + /// Course information. + /// Name of the building sheet. + /// generated package. + public static ExcelPackage Generate( + List courseMatesModels, + CourseDTO course, + string sheetName) + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + var excelPackage = new ExcelPackage(); + var worksheet = excelPackage.Workbook.Worksheets.Add(sheetName); + + var rowsNumber = 3 + courseMatesModels.Count; + + var position = new Position(1, 1); + + worksheet.Cells[position.Row, position.Column, position.Row + 2, position.Column].Merge = true; + ++position.Column; + + AddHomeworksHeaders(worksheet, course, position, rowsNumber, SeparationColumnWidth); + var columnsNumber = position.Column - 1; + position.ToNextRow(2); + + AddTasksHeaders(worksheet, course, position, rowsNumber); + position.ToNextRow(2); + + AddRatingHeadersWithBottomBorder(worksheet, course, position); + position.ToNextRow(1); + + var maxFieldPosition = new Position(position.Row, 3); + var (maxRatingForHw, maxRatingForTests) = AddTasksMaxRatingInfo( + worksheet, course, rowsNumber, maxFieldPosition); + + var totalRatings = AddCourseMatesInfo(course, worksheet, courseMatesModels, position); + + columnsNumber += AddSummary( + worksheet, maxRatingForHw, maxRatingForTests, totalRatings, rowsNumber, SeparationColumnWidth); + + var headersRange = worksheet.Cells[1, 1, 3, columnsNumber]; + headersRange.Style.Font.Bold = true; + + var range = worksheet.Cells[1, 1, rowsNumber, columnsNumber]; + range.Style.Font.Size = FontSize; + range.Style.Font.Name = FontFamily; + range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; + range.Style.VerticalAlignment = ExcelVerticalAlignment.Center; + + return excelPackage; + } + + private static void AddBorderedSeparationColumn(ExcelWorksheet worksheet, Position position, int heightInCells, int columnWidth) + { + var range = worksheet.Cells[1, position.Column, heightInCells, position.Column]; + range.Style.Fill.PatternType = ExcelFillStyle.Solid; + range.Style.Fill.BackgroundColor.SetColor(GrayColor); + worksheet.Column(position.Column).Width = columnWidth; + ++position.Column; + } + + private static void AddHomeworksHeaders(ExcelWorksheet worksheet, CourseDTO course, Position position, + int heightInCells, int separationColumnWidth) + { + for (var i = 0; i < course.Homeworks.Length; ++i) + { + var numberCellsToMerge = course.Homeworks[i].Tasks.Count * 3; + if (numberCellsToMerge == 0) continue; + + var title = course.Homeworks[i].Title; + var publicationDate = course.Homeworks[i].PublicationDate; + var tags = course.Homeworks[i].Tags; + var isTest = tags.Contains(HomeworkTags.Test); + var tagsToShow = tags.Where(t => t != HomeworkTags.Test).ToList(); + var tagsStr = $" ({tagsToShow.Select(GetTagLabel).Join(", ")})"; + + worksheet.Cells[position.Row, position.Column].Value + = $"h/w {i + 1}: {title}, {publicationDate:dd.MM}{(tagsToShow.Count > 0 ? tagsStr : "")}"; + worksheet.Cells[position.Row, position.Column, position.Row, position.Column + numberCellsToMerge - 1] + .Merge = true; + if (isTest) + { + var range = worksheet.Cells[ + position.Row, position.Column, position.Row + 2, position.Column + numberCellsToMerge - 1]; + range.Style.Fill.PatternType = ExcelFillStyle.Solid; + range.Style.Fill.BackgroundColor.SetColor(TestHeaderColor); + range.Style.Font.Color.SetColor(WhiteColor); + } + + position.Column += numberCellsToMerge; + AddBorderedSeparationColumn(worksheet, position, heightInCells, separationColumnWidth); + } + } + + private static void AddTasksHeaders(ExcelWorksheet worksheet, CourseDTO course, Position position, + int heightInCells) + { + for (var i = 0; i < course.Homeworks.Length; ++i) + { + var numberOfTasks = course.Homeworks[i].Tasks.Count; + if (numberOfTasks == 0) continue; + + for (var j = 0; j < numberOfTasks; ++j) + { + if (j > 0) + { + var rangeForBordering = + worksheet.Cells[position.Row, position.Column, heightInCells, position.Column]; + rangeForBordering.Style.Border.Left.Style = BorderStyle; + } + + worksheet.Cells[position.Row, position.Column].Value + = $"{j + 1}. {course.Homeworks[i].Tasks[j].Title}"; + worksheet.Cells[position.Row, position.Column, position.Row, position.Column + 2].Merge = true; + position.Column += 3; + } + + ++position.Column; + } + } + + private static void AddRatingHeadersWithBottomBorder(ExcelWorksheet worksheet, CourseDTO course, + Position position) + { + for (var i = 0; i < course.Homeworks.Length; ++i) + { + var lengthInCells = course.Homeworks[i].Tasks.Count * 3; + if (lengthInCells == 0) continue; + + for (var j = position.Column; j < position.Column + lengthInCells; j += 3) + { + worksheet.Cells[position.Row, j].Value = "оценка"; + worksheet.Cells[position.Row, j + 1].Value = "макс. балл"; + worksheet.Cells[position.Row, j + 2].Value = "попытки"; + worksheet.Cells[position.Row, j, position.Row, j + 2].Style.Border.Bottom.Style = BorderStyle; + } + + position.Column += lengthInCells; + ++position.Column; + } + } + + private static (int MaxRatingForHw, int MaxRatingForTests) AddTasksMaxRatingInfo( + ExcelWorksheet worksheet, + CourseDTO course, + int heightInCells, + Position firstMaxFieldPosition) + { + var maxRatingForHw = 0; + var maxRatingForTests = 0; + + for (var i = 0; i < course.Homeworks.Length; ++i) + { + var numberOfTasks = course.Homeworks[i].Tasks.Count; + if (numberOfTasks == 0) continue; + + for (var j = 0; j < numberOfTasks; ++j) + { + var maxRating = course.Homeworks[i].Tasks[j].MaxRating; + var isTest = course.Homeworks[i].Tasks[j].Tags.Contains(HomeworkTags.Test); + var isBonus = course.Homeworks[i].Tasks[j].Tags.Contains(HomeworkTags.BonusTask); + + for (var k = firstMaxFieldPosition.Row; k <= heightInCells; ++k) + { + worksheet.Cells[k, firstMaxFieldPosition.Column].Value = maxRating; + } + + firstMaxFieldPosition.Column += 3; + if (isBonus) continue; + if (isTest) maxRatingForTests += maxRating; + else maxRatingForHw += maxRating; + } + + ++firstMaxFieldPosition.Column; + } + + return (maxRatingForHw, maxRatingForTests); + } + + private static List<(int HwRating, int TestRating)> AddCourseMatesInfo( + CourseDTO course, + ExcelWorksheet worksheet, + List courseMatesModels, + Position position) + { + var totalRatings = new List<(int, int)>(); + + for (var i = 0; i < courseMatesModels.Count; ++i) + { + var (hwRating, testRating) = (0, 0); + worksheet.Cells[position.Row, position.Column].Value + = $"{courseMatesModels[i].Name} {courseMatesModels[i].Surname}"; + ++position.Column; + + for (var j = 0; j < courseMatesModels[i].Homeworks.Count; ++j) + { + var homeworkModel = course.Homeworks.FirstOrDefault(h => h.Id == courseMatesModels[i].Homeworks[j].Id); + var isTest = homeworkModel.Tags.Contains(HomeworkTags.Test); + + for (var k = 0; k < courseMatesModels[i].Homeworks[j].Tasks.Count; ++k) + { + var allSolutions = courseMatesModels[i].Homeworks[j].Tasks[k].Solution; + var solutions = allSolutions + .Where(solution => + solution.State == SolutionState.Rated || solution.State == SolutionState.Final); + var current = solutions.Any() ? solutions.Last().Rating : 0; + var count = solutions.Count(); + worksheet.Cells[position.Row, position.Column].Value = current; + worksheet.Cells[position.Row, position.Column + 2].Value = count; + if (count != allSolutions.Count) + { + worksheet.Cells[position.Row, position.Column + 2] + .Style.Fill.PatternType = ExcelFillStyle.Solid; + worksheet.Cells[position.Row, position.Column + 2] + .Style.Fill.BackgroundColor.SetColor(CyanColor); + } + + if (isTest) testRating += current; + else hwRating += current; + position.Column += 3; + } + + ++position.Column; + } + + totalRatings.Add((hwRating, testRating)); + position.ToNextRow(1); + } + + return totalRatings; + } + + private static int AddSummary(ExcelWorksheet worksheet, + int maxRatingForHw, + int maxRatingForTests, + List<(int HwRating, int TestRating)> totalRatings, + int heightInCells, + int separationColumnWidth) + { + if (totalRatings.Count == 0) return 0; + var hasHomework = maxRatingForHw > 0; + var hasTests = maxRatingForTests > 0; + + if (hasTests) + { + worksheet.Cells[1, 2].Insert(eShiftTypeInsert.EntireColumn); + worksheet.Cells[1, 2].Value = "Итоговые баллы"; + worksheet.Cells[2, 2].Value = $"КР ({maxRatingForTests})"; + worksheet.Cells[2, 2, 3, 2].Merge = true; + worksheet.Cells[4, 2, 4 + totalRatings.Count - 1, 2].FillList(totalRatings.Select(p => p.TestRating)); + } + if (hasHomework) + { + worksheet.Cells[1, 2].Insert(eShiftTypeInsert.EntireColumn); + worksheet.Cells[1, 2].Value = "Итоговые баллы"; + worksheet.Cells[2, 2].Value = $"ДЗ ({maxRatingForHw})"; + worksheet.Cells[2, 2, 3, 2].Merge = true; + worksheet.Cells[4, 2, 4 + totalRatings.Count - 1, 2].FillList(totalRatings.Select(p => p.HwRating)); + } + + var cellsToMerge = (hasHomework ? 1 : 0) + (hasTests ? 1 : 0); + if (cellsToMerge > 0) + { + worksheet.Cells[1, 2, 1, 1 + cellsToMerge].Merge = true; + worksheet.Cells[1, 2 + cellsToMerge].Insert(eShiftTypeInsert.EntireColumn); + AddBorderedSeparationColumn( + worksheet, new Position(1, 2 + cellsToMerge), heightInCells, separationColumnWidth); + worksheet.Cells[2, 2, 3, 1 + cellsToMerge].Style.Border.Bottom.Style = BorderStyle; + } + + return cellsToMerge; + } + + private class Position + { + /// + /// Initializes a new instance of the class. + /// + /// The row number at the current position. + /// The column number at the current position. + public Position(int rowPosition, int columnPosition) + { + this.Row = rowPosition; + this.Column = columnPosition; + } + + /// + /// Gets or sets the row number at the current position. + /// + public int Row { get; set; } + + /// + /// Gets or sets the column number at the current position. + /// + public int Column { get; set; } + + /// + /// Moves position to the next row optionally changing column component. + /// + /// New column component of the position. + public void ToNextRow(int nextRowColumnPosition) + => (this.Row, this.Column) = (this.Row + 1, nextRowColumnPosition); + } + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/appsettings.json b/HwProj.APIGateway/HwProj.APIGateway.API/appsettings.json index 92453a812..c55cba850 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/appsettings.json +++ b/HwProj.APIGateway/HwProj.APIGateway.API/appsettings.json @@ -12,5 +12,10 @@ "LdapHost": "ad.pu.ru", "LdapPort": 389, "SearchBase": "DC=ad,DC=pu,DC=ru" + }, + "EPPlus": { + "ExcelPackage": { + "LicenseContext": "NonCommercial" + } } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.Tests/ExcelGeneratorTests.cs b/HwProj.APIGateway/HwProj.APIGateway.Tests/ExcelGeneratorTests.cs new file mode 100644 index 000000000..aa08c11b4 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.Tests/ExcelGeneratorTests.cs @@ -0,0 +1,274 @@ +using NUnit.Framework; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.StatisticsService; +using HwProj.APIGateway.API.Models.Statistics; +using HwProj.APIGateway.API.TableGenerators; +using System.Collections.Generic; +using OfficeOpenXml; +using System.IO; +using System; +using HwProj.Models.SolutionsService; +using NUnit.Framework.Interfaces; + +namespace HwProj.APIGateway.Tests +{ + [TestFixture] + public class ExcelGeneratorTests + { + private enum CellProperty + { + Value, + Style, + IsMerge, + } + + private static readonly string GoldFile = "GoldFile.xlsx"; + private static readonly string TestFile = "TestFile.xlsx"; + private static readonly string TestFileSheetName = "ТестЛист"; + private static readonly CourseMateViewModel[] CourseMates = + { + new CourseMateViewModel(), + new CourseMateViewModel() + }; + + private static readonly HomeworkViewModel[] Homeworks = + { + new HomeworkViewModel() + { + Title = "TestHomework1", + PublicationDate = new DateTime(2023, 6, 4), + Tasks = new List() + { + new HomeworkTaskViewModel() + { + Title = "Task1.1", + PublicationDate = new System.DateTime(2023, 6, 4, 14, 0, 0), + MaxRating = 8 + }, + new HomeworkTaskViewModel() + { + Title = "Task1.2", + PublicationDate = new System.DateTime(2023, 6, 4, 15, 0, 0), + MaxRating = 8 + } + } + }, + new HomeworkViewModel() + { + Title = "TestHomework2", + PublicationDate = new System.DateTime(2023, 6, 5), + Tasks = new List + { + new HomeworkTaskViewModel() + { + Title = "Task2.1", + PublicationDate = new System.DateTime(2023, 6, 5, 14, 0, 0), + MaxRating = 8 + }, + new HomeworkTaskViewModel() + { + Title = "Task2.2", + PublicationDate = new System.DateTime(2023, 6, 5, 15, 0, 0), + MaxRating = 8 + } + } + }, + }; + + private static readonly CourseDTO Course = new CourseDTO() + { + CourseMates = CourseMates, + Homeworks = Homeworks, + }; + + private static readonly List CourseMatesModels = new List + { + new StatisticsCourseMatesModel() + { + Name = "Иван", Surname = "Иванов", + Homeworks = new List + { + new StatisticsCourseHomeworksModel() + { + Tasks = new List + { + new StatisticsCourseTasksModel() + { + Solution = new List + { + new Solution { State = SolutionState.Rated, Rating = 4 }, + new Solution() { State = SolutionState.Posted } + } + }, + new StatisticsCourseTasksModel() + { + Solution = new List + { + new Solution() { State = SolutionState.Posted } + } + } + } + }, + new StatisticsCourseHomeworksModel() + { + Tasks = new List + { + new StatisticsCourseTasksModel() + { + Solution = new List() + }, + new StatisticsCourseTasksModel() + { + Solution = new List() + } + } + } + } + }, + new StatisticsCourseMatesModel() + { + Name = "Петр", Surname = "Петров", + Homeworks = new List + { + new StatisticsCourseHomeworksModel() + { + Tasks = new List + { + new StatisticsCourseTasksModel() + { + Solution = new List() + }, + new StatisticsCourseTasksModel() + { + Solution = new List + { + new Solution() { State = SolutionState.Rated, Rating = 5 }, + new Solution() { State = SolutionState.Rated, Rating = 7 } + } + } + } + }, + new StatisticsCourseHomeworksModel() + { + Tasks = new List + { + new StatisticsCourseTasksModel() + { + Solution = new List() + }, + new StatisticsCourseTasksModel() + { + Solution = new List() + } + } + } + } + } + }; + + [OneTimeSetUp] + public void GenerateFile() + { + var testPackage = ExcelGenerator.Generate(CourseMatesModels, Course, TestFileSheetName); + var testFileInfo = new FileInfo(TestFile); + testPackage.SaveAs(testFileInfo); + } + + [Test] + public void CheckTheEquivalenceOfTwoSheetsValues() + { + using (var testPackage = new ExcelPackage(TestFile)) + { + var testSheet = testPackage.Workbook.Worksheets[TestFileSheetName]; + var goldFile = new FileInfo(GoldFile); + using (var goldPackage = new ExcelPackage(goldFile)) + { + var goldSheet = goldPackage.Workbook.Worksheets[TestFileSheetName]; + Assert.That(IsTwoExcelWorksheetsEquals(goldSheet, testSheet, 5, 14, CellProperty.Value)); + } + } + } + + [Test] + public void CheckTheEquivalenceOfTwoSheetsStructure() + { + using (var testPackage = new ExcelPackage(TestFile)) + { + var testSheet = testPackage.Workbook.Worksheets[TestFileSheetName]; + var goldFile = new FileInfo(GoldFile); + using (var goldPackage = new ExcelPackage(goldFile)) + { + var goldSheet = goldPackage.Workbook.Worksheets[TestFileSheetName]; + Assert.That(IsTwoExcelWorksheetsEquals(goldSheet, testSheet, 5, 14, CellProperty.IsMerge)); + } + } + } + + [Test] + public void CheckTheEquivalenceOfTwoSheetsStyle() + { + using (var testPackage = new ExcelPackage(TestFile)) + { + var testSheet = testPackage.Workbook.Worksheets[TestFileSheetName]; + var goldFile = new FileInfo(GoldFile); + using (var goldPackage = new ExcelPackage(goldFile)) + { + var goldSheet = goldPackage.Workbook.Worksheets[TestFileSheetName]; + Assert.That(IsTwoExcelWorksheetsEquals(goldSheet, testSheet, 5, 14, CellProperty.Style)); + } + } + } + + [OneTimeTearDown] + public void DeleteFileIfTestsArePassed() + { + if (TestContext.CurrentContext.Result.Outcome == ResultState.Success) + { + File.Delete(TestFile); + } + } + + private static bool IsTwoExcelWorksheetsEquals(ExcelWorksheet firstSheet, ExcelWorksheet secondSheet, + int lastRow, int lastCol, CellProperty cellPropertyToCompare) + { + var comparer = new Func (Equals); + switch (cellPropertyToCompare) + { + case CellProperty.Value: + comparer = ((firstCell, secondCell) => + Equals(firstCell.Value, secondCell.Value)); + break; + case CellProperty.Style: + comparer = ((firstCell, secondCell) => + firstCell.Style.Font.Bold == secondCell.Style.Font.Bold + && firstCell.Style.Font.Name == secondCell.Style.Font.Name + && Math.Abs(firstCell.Style.Font.Size - secondCell.Style.Font.Size) < 0.1 + && firstCell.Style.Fill.BackgroundColor.Rgb == secondCell.Style.Fill.BackgroundColor.Rgb + && firstCell.Style.VerticalAlignment == secondCell.Style.VerticalAlignment + && firstCell.Style.HorizontalAlignment == secondCell.Style.HorizontalAlignment + && firstCell.Style.Border.Left.Style == secondCell.Style.Border.Left.Style + && firstCell.Style.Border.Right.Style == secondCell.Style.Border.Right.Style + && firstCell.Style.Border.Bottom.Style == secondCell.Style.Border.Bottom.Style + && firstCell.Style.Border.Top.Style == secondCell.Style.Border.Top.Style); + break; + case CellProperty.IsMerge: + comparer = ((firstCell, secondCell) => + firstCell.Merge == secondCell.Merge); + break; + } + + for (var i = 1; i <= lastRow; ++i) + { + for (var j = 1; j <= lastCol; ++j) + { + if (!comparer(firstSheet.Cells[i, j], secondSheet.Cells[i, j])) + { + return false; + } + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/HwProj.APIGateway/HwProj.APIGateway.Tests/GoldFile.xlsx b/HwProj.APIGateway/HwProj.APIGateway.Tests/GoldFile.xlsx new file mode 100644 index 000000000..5b06e6f5c Binary files /dev/null and b/HwProj.APIGateway/HwProj.APIGateway.Tests/GoldFile.xlsx differ diff --git a/HwProj.APIGateway/HwProj.APIGateway.Tests/HwProj.APIGateway.Tests.csproj b/HwProj.APIGateway/HwProj.APIGateway.Tests/HwProj.APIGateway.Tests.csproj index 89dd6ef3f..117822415 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.Tests/HwProj.APIGateway.Tests.csproj +++ b/HwProj.APIGateway/HwProj.APIGateway.Tests/HwProj.APIGateway.Tests.csproj @@ -8,17 +8,36 @@ + + Always + Never + + PreserveNewest + - + + + + + + + + + + + Always + + + diff --git a/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs b/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs index 048822f85..ae330b8fe 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs @@ -9,14 +9,15 @@ public class SolutionViewModel public string Comment { get; set; } public string StudentId { get; set; } - + public string[]? GroupMateIds { get; set; } + public DateTime PublicationDate { get; set; } public string LecturerComment { get; set; } public int? Rating { get; set; } - + public DateTime? RatingDate { get; set; } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.Tests/CoursesServiceTests.cs b/HwProj.CoursesService/HwProj.CoursesService.Tests/CoursesServiceTests.cs index 85ddd92e9..c8bca21fe 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Tests/CoursesServiceTests.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Tests/CoursesServiceTests.cs @@ -179,7 +179,7 @@ public async Task NullTaskPropertiesAfterShouldBeInheritedFromHomework() var homeworkResult = await client.AddHomeworkToCourse(homework, courseId); - var actualResult = (await client.GetHomework(homeworkResult.Value.Id)).Tasks.FirstOrDefault(); + var actualResult = (await client.GetHomework(homeworkResult.Value.Id)).Value.Tasks.FirstOrDefault(); homeworkResult.Succeeded.Should().BeTrue(); homeworkResult.Errors.Should().BeNull(); @@ -345,7 +345,7 @@ public async Task AddTaskByMentorNotFromThisCourseShouldReturnFailedResult() var homeworkFromDb = await client.GetHomework(homeworkResult.Value.Id); addHomeworkResult.Succeeded.Should().BeFalse(); - homeworkFromDb.Tasks.Should().BeEmpty(); + homeworkFromDb.Value.Tasks.Should().BeEmpty(); } [Test] diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs index 49422b339..d66ac39c0 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -205,6 +206,12 @@ public async Task GetCourseStat(long courseId) var course = await _coursesClient.GetCourseById(courseId); if (course == null) return NotFound(); + course.Homeworks = course.Homeworks.OrderBy(homework => homework.PublicationDate).ToArray(); + for (var i = 0; i < course.Homeworks.Length; ++i) + { + course.Homeworks[i].Tasks = course.Homeworks[i].Tasks.OrderBy(task => task.PublicationDate).ToList(); + } + var taskIds = course.Homeworks .SelectMany(t => t.Tasks) .Select(t => t.Id) diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/SolutionsStatsDomainTests.cs b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/SolutionsStatsDomainTests.cs index 3637e03ec..0cb10b37f 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/SolutionsStatsDomainTests.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/SolutionsStatsDomainTests.cs @@ -58,13 +58,13 @@ private List GenerateTestSolutionsForTask(int amount, long taskId) => }) .ToList(); - private GroupViewModel GenerateGroupViewModel(long id, string[] studentIds) => new() + private GroupViewModel GenerateGroupViewModel(long id, string[] studentIds) => new GroupViewModel { Id = id, StudentsIds = studentIds }; - private HomeworkViewModel GenerateHomeworkViewModel(long id, long courseId, int taskAmount) => new() + private HomeworkViewModel GenerateHomeworkViewModel(long id, long courseId, int taskAmount) => new HomeworkViewModel { Id = id, Title = "Test", @@ -85,7 +85,7 @@ private List MakeTestSolutions(CourseMateViewModel[] courseMates, Grou solutions[2].StudentId = courseMates[1].StudentId; solutions[0].GroupId = groups[0].Id; solutions[1].GroupId = groups[1].Id; - + return solutions; } @@ -130,7 +130,7 @@ public async Task GetCourseStatisticsTest() thirdStudentSolutions.Should().HaveCount(1); thirdStudentSolutions[0].Id.Should().Be(1); } - + [Test] public async Task GetCourseTaskStatisticsTest() { @@ -138,7 +138,7 @@ public async Task GetCourseTaskStatisticsTest() var group1 = GenerateGroupViewModel(1, new[] { courseMates[0].StudentId, courseMates[1].StudentId }); var group2 = GenerateGroupViewModel(2, new[] { courseMates[1].StudentId, courseMates[2].StudentId }); var groups = new[] { group1, group2 }; - + var solutions = GenerateTestSolutionsForTask(3, 1); solutions[0].StudentId = courseMates[0].StudentId; solutions[0].GroupId = group1.Id; diff --git a/HwProj.sln b/HwProj.sln index 61dabe1e8..e0ef544e4 100644 --- a/HwProj.sln +++ b/HwProj.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29001.49 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33403.182 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.Repositories", "HwProj.Common\HwProj.Repositories\HwProj.Repositories.csproj", "{4E5191DC-EC8B-44C8-BD85-198D2C0C16F7}" EndProject @@ -58,9 +58,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.SolutionsService.Cli EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.Exceptions", "HwProj.Common\HwProj.Exceptions\HwProj.Exceptions.csproj", "{51463655-7668-4C7D-9FDE-D4D7CDAA82B8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HwProj.SolutionsService.IntegrationTests", "HwProj.SolutionsService\HwProj.SolutionsService.IntegrationTests\HwProj.SolutionsService.IntegrationTests.csproj", "{9751B4E3-50A6-4678-A3AF-BE5CD828B151}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.SolutionsService.IntegrationTests", "HwProj.SolutionsService\HwProj.SolutionsService.IntegrationTests\HwProj.SolutionsService.IntegrationTests.csproj", "{9751B4E3-50A6-4678-A3AF-BE5CD828B151}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HwProj.AuthService.IntegrationTests", "HwProj.AuthService\HwProj.AuthService.IntegrationTests\HwProj.AuthService.IntegrationTests.csproj", "{EA822D2F-88C2-4B82-AA20-DD07FF0A9A1E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.AuthService.IntegrationTests", "HwProj.AuthService\HwProj.AuthService.IntegrationTests\HwProj.AuthService.IntegrationTests.csproj", "{EA822D2F-88C2-4B82-AA20-DD07FF0A9A1E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HwProj.APIGateway.Tests", "HwProj.APIGateway\HwProj.APIGateway.Tests\HwProj.APIGateway.Tests.csproj", "{E1D02140-1F92-47CF-9EF0-93FC5A007AAA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{7F52CAA6-CDDF-42DD-9C5F-100A3400B0D0}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HwProj.ContentService", "HwProj.ContentService", "{3C318420-6DC8-4AF7-9966-9D7E6C8956B8}" EndProject @@ -182,6 +189,10 @@ Global {8DE955D7-FD97-4F11-B6D4-414E631B9F83}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DE955D7-FD97-4F11-B6D4-414E631B9F83}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DE955D7-FD97-4F11-B6D4-414E631B9F83}.Release|Any CPU.Build.0 = Release|Any CPU + {E1D02140-1F92-47CF-9EF0-93FC5A007AAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1D02140-1F92-47CF-9EF0-93FC5A007AAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1D02140-1F92-47CF-9EF0-93FC5A007AAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1D02140-1F92-47CF-9EF0-93FC5A007AAA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -212,6 +223,7 @@ Global {886E6A4F-9F11-482D-AADF-CCB1049B6C14} = {CCA598FB-F8A9-4F20-BCE5-BE21725156BD} {72A4C047-1F47-45DA-9BD6-54E62E7CFB78} = {CCA598FB-F8A9-4F20-BCE5-BE21725156BD} {8DE955D7-FD97-4F11-B6D4-414E631B9F83} = {CCA598FB-F8A9-4F20-BCE5-BE21725156BD} + {E1D02140-1F92-47CF-9EF0-93FC5A007AAA} = {DC0D1EE7-D2F8-4D15-8CC6-69A0A0A938D9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C03BF138-4A5B-4261-9495-6D3AC6CE9779} diff --git a/hwproj.front/.env b/hwproj.front/.env index 9d3484989..027f7f917 100644 --- a/hwproj.front/.env +++ b/hwproj.front/.env @@ -1,4 +1,4 @@ # Переменные, необходимые для локального запуска в режиме разработки (npm run dev) VITE_BASE_PATH=http://localhost:5000 VITE_YANDEX_METRICA_ID=101061418 -WDS_SOCKET_PORT=0 \ No newline at end of file +WDS_SOCKET_PORT=0 diff --git a/hwproj.front/package-lock.json b/hwproj.front/package-lock.json index 37a13e471..ae2dfc708 100644 --- a/hwproj.front/package-lock.json +++ b/hwproj.front/package-lock.json @@ -42,6 +42,7 @@ "isomorphic-fetch": "^3.0.0", "jwt-decode": "^3.1.2", "lowdb": "^1.0.0", + "mui-nested-menu": "^4.0.1", "notistack": "^3.0.2", "portable-fetch": "^3.0.0", "qrcode.react": "^4.1.0", @@ -19921,6 +19922,31 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mui-nested-menu": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/mui-nested-menu/-/mui-nested-menu-4.0.1.tgz", + "integrity": "sha512-o/UaG3oXvHI+phKZzTJdX/fAqgJXQC5xjo/KjMrJq8XtShs+n+JmVYCqD6ATIyoTamEt7+5LAjcIy4iyARcKdg==", + "license": "MIT", + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": "^5.0.0 || ^6.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", diff --git a/hwproj.front/package.json b/hwproj.front/package.json index d09d9ae66..6a4bf5d84 100644 --- a/hwproj.front/package.json +++ b/hwproj.front/package.json @@ -38,6 +38,7 @@ "isomorphic-fetch": "^3.0.0", "jwt-decode": "^3.1.2", "lowdb": "^1.0.0", + "mui-nested-menu": "^4.0.1", "notistack": "^3.0.2", "portable-fetch": "^3.0.0", "qrcode.react": "^4.1.0", diff --git a/hwproj.front/src/App.tsx b/hwproj.front/src/App.tsx index 359f29fa3..e8ab9305e 100644 --- a/hwproj.front/src/App.tsx +++ b/hwproj.front/src/App.tsx @@ -117,6 +117,7 @@ class App extends Component<{ navigate: any }, AppState> { }/> }/> }/> + }/> }/> }/> @@ -135,4 +136,4 @@ class App extends Component<{ navigate: any }, AppState> { } } -export default withRouter(App); +export default withRouter(App); \ No newline at end of file diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index fcc4dd749..136573ff3 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -2124,6 +2124,31 @@ export interface StatisticsLecturersModel { */ numberOfCheckedUniqueSolutions?: number; } +/** + * + * @export + * @interface StringArrayResult + */ +export interface StringArrayResult { + /** + * + * @type {Array} + * @memberof StringArrayResult + */ + value?: Array; + /** + * + * @type {boolean} + * @memberof StringArrayResult + */ + succeeded?: boolean; + /** + * + * @type {Array} + * @memberof StringArrayResult + */ + errors?: Array; +} /** * * @export @@ -8547,6 +8572,51 @@ export class SolutionsApi extends BaseAPI { */ export const StatisticsApiFetchParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {number} [courseId] + * @param {string} [sheetUrl] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsExportToGoogleSheets(courseId?: number, sheetUrl?: string, sheetName?: string, options: any = {}): FetchArgs { + const localVarPath = `/api/Statistics/exportToSheet`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + if (courseId !== undefined) { + localVarQueryParameter['courseId'] = courseId; + } + + if (sheetUrl !== undefined) { + localVarQueryParameter['sheetUrl'] = sheetUrl; + } + + if (sheetName !== undefined) { + localVarQueryParameter['sheetName'] = sheetName; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} courseId @@ -8619,6 +8689,46 @@ export const StatisticsApiFetchParamCreator = function (configuration?: Configur options: localVarRequestOptions, }; }, + /** + * + * @param {number} [courseId] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetFile(courseId?: number, sheetName?: string, options: any = {}): FetchArgs { + const localVarPath = `/api/Statistics/getFile`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + if (courseId !== undefined) { + localVarQueryParameter['courseId'] = courseId; + } + + if (sheetName !== undefined) { + localVarQueryParameter['sheetName'] = sheetName; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} courseId @@ -8650,6 +8760,76 @@ export const StatisticsApiFetchParamCreator = function (configuration?: Configur localVarUrlObj.search = null; localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetSheetTitles(sheetUrl?: string, options: any = {}): FetchArgs { + const localVarPath = `/api/Statistics/getSheetTitles`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + if (sheetUrl !== undefined) { + localVarQueryParameter['sheetUrl'] = sheetUrl; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsProcessLink(sheetUrl?: string, options: any = {}): FetchArgs { + const localVarPath = `/api/Statistics/processLink`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + if (sheetUrl !== undefined) { + localVarQueryParameter['sheetUrl'] = sheetUrl; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + return { url: url.format(localVarUrlObj), options: localVarRequestOptions, @@ -8664,6 +8844,26 @@ export const StatisticsApiFetchParamCreator = function (configuration?: Configur */ export const StatisticsApiFp = function(configuration?: Configuration) { return { + /** + * + * @param {number} [courseId] + * @param {string} [sheetUrl] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsExportToGoogleSheets(courseId?: number, sheetUrl?: string, sheetName?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = StatisticsApiFetchParamCreator(configuration).statisticsExportToGoogleSheets(courseId, sheetUrl, sheetName, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, /** * * @param {number} courseId @@ -8700,6 +8900,25 @@ export const StatisticsApiFp = function(configuration?: Configuration) { }); }; }, + /** + * + * @param {number} [courseId] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetFile(courseId?: number, sheetName?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = StatisticsApiFetchParamCreator(configuration).statisticsGetFile(courseId, sheetName, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, /** * * @param {number} courseId @@ -8718,6 +8937,42 @@ export const StatisticsApiFp = function(configuration?: Configuration) { }); }; }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetSheetTitles(sheetUrl?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = StatisticsApiFetchParamCreator(configuration).statisticsGetSheetTitles(sheetUrl, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsProcessLink(sheetUrl?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = StatisticsApiFetchParamCreator(configuration).statisticsProcessLink(sheetUrl, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, } }; @@ -8727,6 +8982,17 @@ export const StatisticsApiFp = function(configuration?: Configuration) { */ export const StatisticsApiFactory = function (configuration?: Configuration, fetch?: FetchAPI, basePath?: string) { return { + /** + * + * @param {number} [courseId] + * @param {string} [sheetUrl] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsExportToGoogleSheets(courseId?: number, sheetUrl?: string, sheetName?: string, options?: any) { + return StatisticsApiFp(configuration).statisticsExportToGoogleSheets(courseId, sheetUrl, sheetName, options)(fetch, basePath); + }, /** * * @param {number} courseId @@ -8745,6 +9011,16 @@ export const StatisticsApiFactory = function (configuration?: Configuration, fet statisticsGetCourseStatistics(courseId: number, options?: any) { return StatisticsApiFp(configuration).statisticsGetCourseStatistics(courseId, options)(fetch, basePath); }, + /** + * + * @param {number} [courseId] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetFile(courseId?: number, sheetName?: string, options?: any) { + return StatisticsApiFp(configuration).statisticsGetFile(courseId, sheetName, options)(fetch, basePath); + }, /** * * @param {number} courseId @@ -8754,6 +9030,24 @@ export const StatisticsApiFactory = function (configuration?: Configuration, fet statisticsGetLecturersStatistics(courseId: number, options?: any) { return StatisticsApiFp(configuration).statisticsGetLecturersStatistics(courseId, options)(fetch, basePath); }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsGetSheetTitles(sheetUrl?: string, options?: any) { + return StatisticsApiFp(configuration).statisticsGetSheetTitles(sheetUrl, options)(fetch, basePath); + }, + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statisticsProcessLink(sheetUrl?: string, options?: any) { + return StatisticsApiFp(configuration).statisticsProcessLink(sheetUrl, options)(fetch, basePath); + }, }; }; @@ -8764,6 +9058,19 @@ export const StatisticsApiFactory = function (configuration?: Configuration, fet * @extends {BaseAPI} */ export class StatisticsApi extends BaseAPI { + /** + * + * @param {number} [courseId] + * @param {string} [sheetUrl] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public statisticsExportToGoogleSheets(courseId?: number, sheetUrl?: string, sheetName?: string, options?: any) { + return StatisticsApiFp(this.configuration).statisticsExportToGoogleSheets(courseId, sheetUrl, sheetName, options)(this.fetch, this.basePath); + } + /** * * @param {number} courseId @@ -8786,6 +9093,18 @@ export class StatisticsApi extends BaseAPI { return StatisticsApiFp(this.configuration).statisticsGetCourseStatistics(courseId, options)(this.fetch, this.basePath); } + /** + * + * @param {number} [courseId] + * @param {string} [sheetName] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public statisticsGetFile(courseId?: number, sheetName?: string, options?: any) { + return StatisticsApiFp(this.configuration).statisticsGetFile(courseId, sheetName, options)(this.fetch, this.basePath); + } + /** * * @param {number} courseId @@ -8797,6 +9116,28 @@ export class StatisticsApi extends BaseAPI { return StatisticsApiFp(this.configuration).statisticsGetLecturersStatistics(courseId, options)(this.fetch, this.basePath); } + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public statisticsGetSheetTitles(sheetUrl?: string, options?: any) { + return StatisticsApiFp(this.configuration).statisticsGetSheetTitles(sheetUrl, options)(this.fetch, this.basePath); + } + + /** + * + * @param {string} [sheetUrl] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public statisticsProcessLink(sheetUrl?: string, options?: any) { + return StatisticsApiFp(this.configuration).statisticsProcessLink(sheetUrl, options)(this.fetch, this.basePath); + } + } /** * SystemApi - fetch parameter creator diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index a482c4139..24c416c00 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -72,9 +72,24 @@ interface IPageState { tabValue: TabValue } +const getLastViewedCourseId = () => +{ + const sessionStorageCourseId = sessionStorage.getItem("courseId") + return sessionStorageCourseId === null ? "-1" : sessionStorageCourseId +} + +const updatedLastViewedCourseId = (courseId : string) => +{ + sessionStorage.setItem("courseId", courseId) +} + const Course: React.FC = () => { const {courseId, tab} = useParams() const [searchParams] = useSearchParams() + + const isFromYandex = !courseId || courseId === "yandex" + const validatedCourseId = isFromYandex ? getLastViewedCourseId() : courseId + const navigate = useNavigate() const {enqueueSnackbar} = useSnackbar() @@ -146,7 +161,7 @@ const Course: React.FC = () => { let delay = 1000; // Начальная задержка 1 сек const scopeDto: ScopeDTO = { - courseId: +courseId!, + courseId: +validatedCourseId!, courseUnitType: CourseUnitType.Homework, courseUnitId: homeworkId } @@ -220,7 +235,7 @@ const Course: React.FC = () => { }, []); const [pageState, setPageState] = useState({ - tabValue: "homeworks" + tabValue: isFromYandex ? "stats" : "homeworks" }) const { @@ -248,19 +263,22 @@ const Course: React.FC = () => { const hasAccessToMaterials = course.isOpen || isCourseMentor || isAcceptedStudent const changeTab = (newTab: string) => { - if (isAcceptableTabValue(newTab) && newTab !== pageState.tabValue) { - if (newTab === "stats" && !showStatsTab) return; - if (newTab === "applications" && !showApplicationsTab) return; - - setPageState(prevState => ({ - ...prevState, - tabValue: newTab - })); + if (!isFromYandex) { + if (isAcceptableTabValue(newTab) && newTab !== pageState.tabValue) { + if (newTab === "stats" && !showStatsTab) return; + if (newTab === "applications" && !showApplicationsTab) return; + + setPageState(prevState => ({ + ...prevState, + tabValue: newTab + })); + } } } const setCurrentState = async () => { - const course = await ApiSingleton.coursesApi.coursesGetCourseData(+courseId!) + updatedLastViewedCourseId(validatedCourseId) + const course = await ApiSingleton.coursesApi.coursesGetCourseData(+validatedCourseId!) // У пользователя изменилась роль (иначе он не может стать лектором в курсе), // однако он все ещё использует токен с прежней ролью @@ -274,6 +292,8 @@ const Course: React.FC = () => { return } + const solutions = await ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+validatedCourseId!) + setCourseState(prevState => ({ ...prevState, isFound: true, @@ -283,15 +303,20 @@ const Course: React.FC = () => { mentors: course.mentors!, acceptedStudents: course.acceptedStudents!, newStudents: course.newStudents!, + studentSolutions: solutions, + tabValue: isFromYandex ? "stats" : "homeworks" })) + if (isFromYandex) { + window.history.replaceState(null, "", `/courses/${validatedCourseId}/stats`) + } } const getCourseFilesInfo = async () => { let courseFilesInfo = [] as FileInfoDTO[] try { courseFilesInfo = isCourseMentor - ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) + ? await ApiSingleton.filesApi.filesGetFilesInfo(+validatedCourseId!) + : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+validatedCourseId!) } catch (e) { const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); @@ -311,14 +336,16 @@ const Course: React.FC = () => { }, [isCourseMentor]) useEffect(() => { - ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) + ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+validatedCourseId!) .then(res => setStudentSolutions(res)) - }, [courseId]) - - useEffect(() => changeTab(tab || "homeworks"), [tab, courseId, isFound]) + }, [validatedCourseId]) + + useEffect(() => changeTab(tab || "homeworks"), [tab, validatedCourseId, isFound]) + + const yandexCode = new URLSearchParams(window.location.search).get("code") const joinCourse = async () => { - await ApiSingleton.coursesApi.coursesSignInCourse(+courseId!) + await ApiSingleton.coursesApi.coursesSignInCourse(+validatedCourseId!) .then(() => setCurrentState()); } @@ -363,7 +390,7 @@ const Course: React.FC = () => { onClose={handleClose} > {isCourseMentor && isLecturer && - navigate(`/courses/${courseId}/editInfo`)}> + navigate(`/courses/${validatedCourseId}/editInfo`)}> @@ -436,7 +463,7 @@ const Course: React.FC = () => { {lecturerStatsState && setLecturerStatsState(false)} /> } @@ -474,9 +501,9 @@ const Course: React.FC = () => { value={tabValue === "homeworks" ? 0 : tabValue === "stats" ? 1 : 2} indicatorColor="primary" onChange={(event, value) => { - if (value === 0 && !isExpert) navigate(`/courses/${courseId}/homeworks`) - if (value === 1) navigate(`/courses/${courseId}/stats`) - if (value === 2 && !isExpert) navigate(`/courses/${courseId}/applications`) + if (value === 0 && !isExpert) navigate(`/courses/${validatedCourseId}/homeworks`) + if (value === 1) navigate(`/courses/${validatedCourseId}/stats`) + if (value === 2 && !isExpert) navigate(`/courses/${validatedCourseId}/applications`) }} > {hasAccessToMaterials && !isExpert && @@ -496,7 +523,7 @@ const Course: React.FC = () => { }/>} {tabValue === "homeworks" && { isMentor={isCourseMentor} course={courseState.course} solutions={studentSolutions} + yandexCode={yandexCode} /> } @@ -556,7 +584,7 @@ const Course: React.FC = () => { onUpdate={() => setCurrentState()} course={courseState.course} students={courseState.newStudents} - courseId={courseId!} + courseId={validatedCourseId!} /> } diff --git a/hwproj.front/src/components/Courses/StatsMenu.tsx b/hwproj.front/src/components/Courses/StatsMenu.tsx new file mode 100644 index 000000000..88b93270f --- /dev/null +++ b/hwproj.front/src/components/Courses/StatsMenu.tsx @@ -0,0 +1,189 @@ +import { FC, useState, useEffect } from "react"; +import { + Button, + Menu, + MenuItem, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { NestedMenuItem } from "mui-nested-menu"; +import { Download, ShowChart } from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import DownloadStats from "../Solutions/DownloadStats"; +import ExportToGoogle from "../Solutions/ExportToGoogle"; +import ExportToYandex from "../Solutions/ExportToYandex"; +import SaveIcon from '@mui/icons-material/Save'; +import GoogleIcon from '@mui/icons-material/Google'; +import YandexLogo from './YandexLogo.svg'; + +enum SaveStatsAction { + Download, + ShareWithGoogle, + ShareWithYandex, +} + +const actions = [SaveStatsAction.Download, SaveStatsAction.ShareWithGoogle, SaveStatsAction.ShareWithYandex] + +interface StatsMenuProps { + courseId: number | undefined; + userId: string; + yandexCode: string | null; + onActionOpening: () => void; + onActionClosing: () => void; +} + +interface StatsMenuState { + anchorEl: HTMLElement | null; + saveStatsAction: SaveStatsAction | null; +} + +const StatsMenu: FC = props => { + const {courseId, userId, yandexCode, onActionOpening, onActionClosing} = props + + const [menuState, setMenuState] = useState({ + anchorEl: null, + saveStatsAction: yandexCode !== null ? SaveStatsAction.ShareWithYandex : null, + }) + + const {anchorEl, saveStatsAction} = menuState + const showMenu = anchorEl !== null + const openAction = saveStatsAction !== null + + useEffect(() => { + if (saveStatsAction !== null) + onActionOpening() + else + onActionClosing() + }, [saveStatsAction]); + + const navigate = useNavigate(); + + const goToCharts = () => navigate(`/statistics/${courseId}/charts`) + + const handleOpen = (event: React.MouseEvent) => + setMenuState ({ + anchorEl: event.currentTarget, + saveStatsAction: null, + }) + + const handleClose = () => + setMenuState ({ + anchorEl: null, + saveStatsAction: null, + }) + + const handleSelectAction = (action: SaveStatsAction | null) => + setMenuState({ + anchorEl: null, + saveStatsAction: action, + }) + + const getActionIcon = (action: SaveStatsAction | null) => { + switch (action) { + case SaveStatsAction.Download: + return + case SaveStatsAction.ShareWithGoogle: + return + case SaveStatsAction.ShareWithYandex: + return Y + default: + return null + } + } + + const getActionLabel = (action: SaveStatsAction | null) => { + switch (action) { + case SaveStatsAction.Download: + return "На диск" + case SaveStatsAction.ShareWithGoogle: + return "В Google Docs" + case SaveStatsAction.ShareWithYandex: + return "На Яндекс Диск" + default: + return "" + } + } + + const getActionContent = (action: SaveStatsAction | null) => { + switch (action) { + case SaveStatsAction.Download: + return + case SaveStatsAction.ShareWithGoogle: + return + case SaveStatsAction.ShareWithYandex: + return + default: + return null + } + } + + return ( +
+ + + + + + + + Графики успеваемости + + + + + + } + renderLabel={() => + + Выгрузить таблицу + + } + > + {actions.map(action => + handleSelectAction(action)}> + + {getActionIcon(action)} + + + {getActionLabel(action)} + + + )} + + + {getActionContent(saveStatsAction)} +
+ ) +} + +export default StatsMenu; diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 50d55c772..6e122fa58 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -1,57 +1,55 @@ -import React, {useEffect, useState} from "react"; +import {useEffect, useState, CSSProperties} from "react"; import {CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "../../api/"; -import {useNavigate, useParams} from 'react-router-dom'; import {Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core"; import StudentStatsCell from "../Tasks/StudentStatsCell"; -import {Alert, Button, Chip, Typography} from "@mui/material"; +import {Alert, Chip, Typography} from "@mui/material"; import {grey} from "@material-ui/core/colors"; import StudentStatsUtils from "../../services/StudentStatsUtils"; -import ShowChartIcon from "@mui/icons-material/ShowChart"; import {BonusTag, DefaultTags, TestTag} from "../Common/HomeworkTags"; import Lodash from "lodash" +import StatsMenu from "./StatsMenu"; interface IStudentStatsProps { course: CourseViewModel; homeworks: HomeworkViewModel[]; isMentor: boolean; userId: string; + yandexCode: string | null; solutions: StatisticsCourseMatesModel[]; } interface IStudentStatsState { searched: string + isSaveStatsActionOpened: boolean } const greyBorder = grey[300] -const StudentStats: React.FC = (props) => { +const StudentStats: React.FC = (props) => { const [state, setSearched] = useState({ - searched: "" + searched: "", + isSaveStatsActionOpened: false }); - const {courseId} = useParams(); - const navigate = useNavigate(); - const handleClick = () => { - navigate(`/statistics/${courseId}/charts`) - } - const {searched} = state + const {searched, isSaveStatsActionOpened} = state useEffect(() => { const keyDownHandler = (event: KeyboardEvent) => { + if (isSaveStatsActionOpened) return if (event.ctrlKey || event.altKey) return if (searched && event.key === "Escape") { - setSearched({searched: ""}); + setSearched({...state, searched: ""}); } else if (searched && event.key === "Backspace") { - setSearched({searched: searched.slice(0, -1)}) + setSearched({...state, searched: searched.slice(0, -1)}) } else if (event.key.length === 1 && event.key.match(/[a-zA-Zа-яА-Я\s]/i) ) { - setSearched({searched: searched + event.key}) + setSearched({...state, searched: searched + event.key}) } }; document.addEventListener('keydown', keyDownHandler); return () => document.removeEventListener('keydown', keyDownHandler); - }, [searched]); + }, [searched, isSaveStatsActionOpened]); const homeworks = props.homeworks.filter(h => h.tasks && h.tasks.length > 0) const solutions = searched @@ -65,7 +63,7 @@ const StudentStats: React.FC = (props) => { color: "white", } - const homeworkStyles = (homeworks: HomeworkViewModel[], idx: number): React.CSSProperties | undefined => { + const homeworkStyles = (homeworks: HomeworkViewModel[], idx: number): CSSProperties | undefined => { if (homeworks[idx].tags?.includes(TestTag)) return testHomeworkStyle if (idx !== 0 && homeworks[idx - 1].tags?.includes(TestTag)) @@ -141,14 +139,15 @@ const StudentStats: React.FC = (props) => { )} - + {solutions.length > 0 && - + setSearched({searched, isSaveStatsActionOpened: true})} + onActionClosing={() => setSearched({searched, isSaveStatsActionOpened: false})} + /> } {hasHomeworks && + + +Created with Fabric.js 5.2.4 + + + + + + + + + + + + \ No newline at end of file diff --git a/hwproj.front/src/components/Notifications.tsx b/hwproj.front/src/components/Notifications.tsx index 684f623e0..3abdb3024 100644 --- a/hwproj.front/src/components/Notifications.tsx +++ b/hwproj.front/src/components/Notifications.tsx @@ -276,4 +276,4 @@ const Notifications: FC = (props) => { } -export default Notifications +export default Notifications \ No newline at end of file diff --git a/hwproj.front/src/components/Solutions/DownloadStats.tsx b/hwproj.front/src/components/Solutions/DownloadStats.tsx new file mode 100644 index 000000000..578a41a08 --- /dev/null +++ b/hwproj.front/src/components/Solutions/DownloadStats.tsx @@ -0,0 +1,43 @@ +import { FC, useEffect } from "react"; +import { useSnackbar } from "notistack"; +import apiSingleton from "../../api/ApiSingleton"; +import Utils from "@/services/Utils"; + +interface DownloadStatsProps { + courseId: number | undefined + userId: string + onClose: () => void +} + +const DownloadStats: FC = (props: DownloadStatsProps) => { + const {courseId, userId, onClose} = props + const {enqueueSnackbar} = useSnackbar() + + useEffect(() => { + const downloadStats = async () => { + try { + const statsDatetime = Utils.toStringForFileName(new Date()) + const response = await apiSingleton.statisticsApi.statisticsGetFile(courseId, userId, "Лист 1") + const blob = await response.blob() + const fileName = `StatsReport_${statsDatetime}` + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${fileName}.xlsx`); + document.body.appendChild(link); + link.click(); + link.parentNode!.removeChild(link); + } catch (e) { + console.error("Ошибка при загрузке статистики:", e) + enqueueSnackbar("Не удалось загрузить файл со статистикой, попробуйте позже", {variant: "error"}) + } + } + + downloadStats() + onClose() + }) + + return null +} + +export default DownloadStats; diff --git a/hwproj.front/src/components/Solutions/ExportToGoogle.tsx b/hwproj.front/src/components/Solutions/ExportToGoogle.tsx new file mode 100644 index 000000000..8ca6c84d5 --- /dev/null +++ b/hwproj.front/src/components/Solutions/ExportToGoogle.tsx @@ -0,0 +1,185 @@ +import { FC, useState } from "react"; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, + MenuItem, + TextField, +} from "@mui/material"; +import apiSingleton from "../../api/ApiSingleton"; +import { green, red } from "@material-ui/core/colors"; +import { StringArrayResult } from "@/api"; +import { LoadingButton } from "@mui/lab"; + +enum LoadingStatus { + None, + Loading, + Success, + Error, +} + +interface ExportToGoogleProps { + courseId: number | undefined + open: boolean + onClose: () => void +} + +interface ExportToGoogleState { + url: string + googleSheetTitles: StringArrayResult | undefined + selectedSheet: number + loadingSheets: boolean + exportStatus: LoadingStatus + error: string | null +} + +const ExportToGoogle: FC = (props: ExportToGoogleProps) => { + const {courseId, open, onClose} = props + + const [state, setState] = useState({ + url: "", + googleSheetTitles: undefined, + selectedSheet: 0, + loadingSheets: false, + exportStatus: LoadingStatus.None, + error: null + }) + + const {url, googleSheetTitles, selectedSheet, loadingSheets, exportStatus, error } = state + + const handleGoogleDocUrlChange = (value: string) => { + setState(prevState => ({ ...prevState, url: value, loadingSheets: true })) + if (value) + apiSingleton.statisticsApi.statisticsGetSheetTitles(value) + .then(response => setState(prevState => ({ + ...prevState, + googleSheetTitles: response, + loadingSheets: false, + }))) + else + setState(prevState => ({ + ...prevState, + googleSheetTitles: undefined, + loadingSheets: false, + })) + } + + const getGoogleSheetName = () => { + return (googleSheetTitles && googleSheetTitles.value + && googleSheetTitles.value.length > state.selectedSheet) + ? googleSheetTitles.value[state.selectedSheet] : ""; + } + + const buttonSx = { + ...(exportStatus === LoadingStatus.Success && { + color: green[600], + }), + ...(exportStatus === LoadingStatus.Error && { + color: red[600], + }), + }; + + return ( + + + Выгрузить таблицу в Google Docs + + + + + {(googleSheetTitles && !googleSheetTitles.succeeded && + + {googleSheetTitles!.errors![0]} + + ) || (exportStatus === LoadingStatus.Error && + + {error} + + ) || ( + + Для загрузки таблицы необходимо разрешить доступ + на редактирование по ссылке для Google Sheets + + )} + + + + + { + event.persist() + handleGoogleDocUrlChange(event.target.value) + }} + /> + + {loadingSheets && + + + + } + {!loadingSheets && googleSheetTitles && googleSheetTitles.value && googleSheetTitles.value.length > 0 && + + setState(prevState => ({ ...prevState, selectedSheet: +v.target.value }))} + > + {googleSheetTitles.value.map((title, i) => {title})} + + + } + {!loadingSheets && googleSheetTitles && googleSheetTitles.succeeded && + + { + setState((prevState) => ({...prevState, exportStatus: LoadingStatus.Loading})) + const result = await apiSingleton.statisticsApi.statisticsExportToGoogleSheets( + courseId, + url, + getGoogleSheetName()) + setState((prevState) => + ({...prevState, + exportStatus: result.succeeded ? LoadingStatus.Success : LoadingStatus.Error, + error: result.errors === undefined + || result.errors === null + || result.errors.length === 0 + ? null : result.errors[0] + })) + } + } + > + Сохранить + + + } + + + + + + + ) +} + +export default ExportToGoogle; diff --git a/hwproj.front/src/components/Solutions/ExportToYandex.tsx b/hwproj.front/src/components/Solutions/ExportToYandex.tsx new file mode 100644 index 000000000..1b153fe12 --- /dev/null +++ b/hwproj.front/src/components/Solutions/ExportToYandex.tsx @@ -0,0 +1,232 @@ +import { FC, useState, useEffect } from "react"; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, + Link, + TextField, +} from "@mui/material"; +import apiSingleton from "../../api/ApiSingleton"; +import { green, red } from "@material-ui/core/colors"; +import { LoadingButton } from "@mui/lab"; + +enum LoadingStatus { + None, + Loading, + Success, + Error, +} + +interface LocalStorageKey { + name: string + userId: string +} + +interface ExportToYandexProps { + courseId: number | undefined + userId: string + userCode: string | null + open: boolean + onClose: () => void +} + +interface ExportToYandexState { + fileName: string + userToken: string | null + loadingStatus: LoadingStatus + isAuthorizationError: boolean +} + +const ExportToYandex: FC = (props: ExportToYandexProps) => { + const {courseId, userId, userCode, open, onClose} = props + + const [state, setState] = useState({ + fileName: "", + userToken: localStorage.getItem( + JSON.stringify({name: "yandexAccessToken", userId: `${userId}`})), + loadingStatus: LoadingStatus.None, + isAuthorizationError: false, + }) + + const { fileName, userToken, loadingStatus, isAuthorizationError } = state + + const setUserYandexToken = async (userConfirmationCode: string, userId: string) : Promise => { + const fetchBody = `grant_type=authorization_code&code=${userConfirmationCode}` + + `&client_id=${import.meta.env.VITE_YANDEX_CLIENT_ID}&client_secret=${import.meta.env.VITE_YANDEX_CLIENT_SECRET}`; + + const response = await fetch(`https://oauth.yandex.ru/token`, { + method: "post", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; Charset=utf-8', + 'Host': 'https://oauth.yandex.ru/' + }, + body: fetchBody + }) + if (response.status === 200) { + const jsonResponse = await response.json(); + const token = jsonResponse.access_token; + if (token !== null && userId !== undefined) { + const localStorageKey : LocalStorageKey = { + name: 'yandexAccessToken', + userId: userId + } + localStorage.setItem(JSON.stringify(localStorageKey), token); + return token; + } + } + return "error"; + } + + const setCurrentState = async () => + { + if (userToken === null && userCode !== null) + { + const token = await setUserYandexToken(userCode, userId) + setState((prevState) => + ({...prevState, userToken: token === 'error' ? null : token, isAuthorizationError: token === 'error'})) + } + } + + useEffect(() => { + setCurrentState() + }, []) + + const handleExportClick = async () => + { + fetch(`https://cloud-api.yandex.net/v1/disk/resources/upload?path=app:/${fileName}.xlsx&overwrite=true`, + { + method: "get", + headers: { + 'Authorization': `${import.meta.env.VITE_YANDEX_AUTHORIZATION_TOKEN}`, + }}) + .then( async (response) => { + if (response.status >= 200 && response.status < 300) { + const jsonResponse = await response.json(); + const url = jsonResponse.href; + const fileData = await apiSingleton.statisticsApi.statisticsGetFile(courseId, userId, "Лист 1"); + const data = await fileData.blob(); + const fileExportResponse = await fetch(url, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': `${data.size}` + }, + body: data + }) + if (fileExportResponse.status >= 200 && fileExportResponse.status < 300) + { + setState((prevState) => ({...prevState, loadingStatus: LoadingStatus.Success})) + return; + } + } + + setState((prevState) => ({...prevState, loadingStatus: LoadingStatus.Error})) + }) + } + + const yacRequestLink = `https://oauth.yandex.ru/authorize?response_type=code&client_id=${import.meta.env.VITE_YANDEX_CLIENT_ID}` + + const buttonSx = { + ...(loadingStatus === LoadingStatus.Success && { + color: green[600], + }), + ...(loadingStatus === LoadingStatus.Error && { + color: red[600], + }), + }; + + return ( + + + Выгрузить таблицу на Яндекс Диск + + {userToken === null ? ( + + + + {isAuthorizationError ? ( + + Авторизация не пройдена. Попробуйте{" "} + + еще раз + + + ) : ( + + Для загрузки таблицы необходимо пройти{" "} + + авторизацию + + + )} + + + + + + + + + ) : ( + + + + + Авторизация успешно пройдена. Файл будет загружен на диск по адресу + "Приложения/{import.meta.env.VITE_YANDEX_APPLICATION_NAME}/{fileName}.xlsx" + + + + + + { + event.persist() + setState((prevState) => + ({...prevState, fileName: event.target.value, loadingStatus: LoadingStatus.None}) + ) + }} + /> + + + { + setState((prevState) => ({...prevState, loadingStatus: LoadingStatus.Loading})) + handleExportClick() + }} + > + Сохранить + + + + + + + + )} + + ) +} + +export default ExportToYandex; diff --git a/hwproj.front/src/services/StudentStatsUtils.ts b/hwproj.front/src/services/StudentStatsUtils.ts index bcb6708ad..2145ea591 100644 --- a/hwproj.front/src/services/StudentStatsUtils.ts +++ b/hwproj.front/src/services/StudentStatsUtils.ts @@ -1,4 +1,4 @@ -import {Solution, SolutionState} from "../api"; +import {Solution, SolutionState} from "../api"; import {colorBetween} from "./JsUtils"; import Utils from "./Utils"; diff --git a/hwproj.front/src/services/Utils.ts b/hwproj.front/src/services/Utils.ts index d0a718c76..c1a8048a7 100644 --- a/hwproj.front/src/services/Utils.ts +++ b/hwproj.front/src/services/Utils.ts @@ -21,6 +21,18 @@ export default class Utils { ':' + pad(date.getSeconds()) } + static toStringForFileName(date: Date | undefined) { + if (date == null) return undefined + + const pad = (num: number) => (num < 10 ? '0' : '') + num + + return date.getFullYear() + + '-' + pad(date.getMonth() + 1) + + '-' + pad(date.getDate()) + + '_' + pad(date.getHours()) + + '-' + pad(date.getMinutes()) + } + static pluralizeDateTime(milliseconds: number) { const diffHours = milliseconds / (1000 * 60 * 60) const diffHoursInt = Math.trunc(diffHours)