diff --git a/Docs/sql/homework-hide-ratings-local.sql b/Docs/sql/homework-hide-ratings-local.sql new file mode 100644 index 000000000..27ddca0de --- /dev/null +++ b/Docs/sql/homework-hide-ratings-local.sql @@ -0,0 +1,13 @@ +-- Только локальная база EnsureCreated без истории EF-миграций. +USE [CoursesServiceDB]; +GO +SET XACT_ABORT ON; +BEGIN TRANSACTION; +IF OBJECT_ID(N'dbo.__EFMigrationsHistory', N'U') IS NOT NULL + THROW 50000, 'Use EF migrations for databases with migration history.', 1; +IF OBJECT_ID(N'dbo.Homeworks', N'U') IS NULL + THROW 50000, 'Homeworks table not found.', 1; +IF COL_LENGTH(N'dbo.Homeworks', N'HideRatings') IS NULL + ALTER TABLE dbo.Homeworks ADD HideRatings bit NOT NULL + CONSTRAINT DF_Homeworks_HideRatings DEFAULT (0) WITH VALUES; +COMMIT TRANSACTION; diff --git a/Docs/sql/notification-digest-local.sql b/Docs/sql/notification-digest-local.sql new file mode 100644 index 000000000..bc870baa3 --- /dev/null +++ b/Docs/sql/notification-digest-local.sql @@ -0,0 +1,22 @@ +-- Локальная база EnsureCreated: изменения схемы из upstream NotificationDigest. +-- Для баз с историей миграций следует применять штатную EF-миграцию. +USE [NotificationsServiceDB]; +GO +SET XACT_ABORT ON; +BEGIN TRANSACTION; +IF OBJECT_ID(N'dbo.__EFMigrationsHistory', N'U') IS NOT NULL + THROW 50000, 'Use EF migrations for databases with migration history.', 1; +IF OBJECT_ID(N'dbo.Notifications', N'U') IS NULL + THROW 50000, 'Notifications table not found.', 1; +IF COL_LENGTH(N'dbo.Notifications', N'DigestData') IS NULL + ALTER TABLE dbo.Notifications ADD DigestData nvarchar(max) NULL; +IF COL_LENGTH(N'dbo.Notifications', N'DigestKind') IS NULL + ALTER TABLE dbo.Notifications ADD DigestKind int NULL; +IF COL_LENGTH(N'dbo.Notifications', N'IsDigestOnly') IS NULL + ALTER TABLE dbo.Notifications ADD IsDigestOnly bit NOT NULL + CONSTRAINT DF_Notifications_IsDigestOnly DEFAULT (0) WITH VALUES; +IF NOT EXISTS (SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID(N'dbo.Notifications') AND name = N'IX_Notifications_Date') + EXEC(N'CREATE INDEX IX_Notifications_Date ON dbo.Notifications ([Date]) INCLUDE ([Owner]) + WHERE DigestKind IS NOT NULL'); +COMMIT TRANSACTION; diff --git a/Docs/sql/solution-requires-revision-local.sql b/Docs/sql/solution-requires-revision-local.sql new file mode 100644 index 000000000..e8cdbf70e --- /dev/null +++ b/Docs/sql/solution-requires-revision-local.sql @@ -0,0 +1,22 @@ +-- Только для локальной SolutionsServiceDB, созданной через EnsureCreated +-- без таблицы __EFMigrationsHistory. Для баз с историей применяйте EF-миграцию. +USE [SolutionsServiceDB]; +GO + +SET XACT_ABORT ON; +BEGIN TRANSACTION; + +IF OBJECT_ID(N'dbo.Solutions', N'U') IS NULL + THROW 50000, 'Solutions table not found. Check the target database.', 1; + +IF OBJECT_ID(N'dbo.__EFMigrationsHistory', N'U') IS NOT NULL + THROW 50000, 'Use EF migrations for databases with migration history.', 1; + +IF COL_LENGTH(N'dbo.Solutions', N'RequiresRevision') IS NULL +BEGIN + ALTER TABLE [dbo].[Solutions] + ADD [RequiresRevision] bit NOT NULL + CONSTRAINT [DF_Solutions_RequiresRevision] DEFAULT (0) WITH VALUES; +END; + +COMMIT TRANSACTION; diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs index cf6c21e1f..aaa4bd155 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using HwProj.APIGateway.API.Models; using HwProj.APIGateway.API.Models.Tasks; +using HwProj.APIGateway.API.Services; using HwProj.AuthService.Client; using HwProj.CoursesService.Client; using HwProj.Models.AuthService.DTO; @@ -69,6 +70,21 @@ public async Task GetUserData() var taskDeadlines = await coursesClient.GetTaskDeadlines(); var taskIds = taskDeadlines.Select(t => t.TaskId).ToArray(); var solutions = await solutionsServiceClient.GetLastTaskSolutions(taskIds, UserId); + // Группируем по курсу, чтобы не запрашивать его заново для каждой задачи. + var processedTasks = new System.Collections.Generic.HashSet(); + foreach (var solution in solutions.Where(s => s != null)) + { + if (processedTasks.Contains(solution.TaskId)) continue; + var course = await coursesClient.GetCourseByTask(solution.TaskId); + if (course == null) return Forbid(); + var hiddenTasks = GradeVisibility.HiddenTaskIds(course, UserId); + foreach (var item in solutions.Where(s => s != null && + course.Homeworks.Any(h => h.Tasks.Any(t => t.Id == s.TaskId)))) + { + GradeVisibility.Apply(item, hiddenTasks); + processedTasks.Add(item.TaskId); + } + } var taskDeadlinesInfo = taskDeadlines .Zip(solutions, (deadline, solution) => (deadline, solution)) .Where(t => currentTime <= t.deadline.DeadlineDate || t.solution == null) @@ -78,6 +94,8 @@ public async Task GetUserData() SolutionState = t.solution?.State, MaxRating = t.deadline.MaxRating, Rating = t.solution?.Rating, + IsRatingHidden = t.solution?.IsRatingHidden ?? false, + RequiresRevision = t.solution?.RequiresRevision ?? false, DeadlinePast = currentTime > t.deadline.DeadlineDate }).ToArray(); diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/NotificationsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/NotificationsController.cs index 8f45c00a3..0b66733be 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/NotificationsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/NotificationsController.cs @@ -1,6 +1,11 @@ using System; using System.Net; using System.Threading.Tasks; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using HwProj.CoursesService.Client; +using HwProj.APIGateway.API.Services; using HwProj.Models.NotificationsService; using HwProj.Models.Result; using HwProj.Models.Roles; @@ -16,10 +21,12 @@ namespace HwProj.APIGateway.API.Controllers; public class NotificationsController : AggregationController { private readonly INotificationsServiceClient _notificationsClient; + private readonly ICoursesServiceClient _coursesClient; - public NotificationsController(INotificationsServiceClient notificationsClient) : base(null) + public NotificationsController(INotificationsServiceClient notificationsClient, ICoursesServiceClient coursesClient) : base(null) { _notificationsClient = notificationsClient; + _coursesClient = coursesClient; } [HttpGet("getNewNotificationsCount")] @@ -34,6 +41,30 @@ public async Task GetNewNotificationsCount() public async Task Get() { var result = await _notificationsClient.Get(UserId); + var hiddenTasks = new Dictionary(); + foreach (var notification in result.SelectMany(c => c.SeenNotifications.Concat(c.NotSeenNotifications))) + { + var body = notification.Body ?? ""; + // Старые уведомления могли сохранить оценку до включения скрытия. + if (!body.Contains("оценена на") && + !(notification.Sender == "SolutionService" && + (body.Contains("Решение проверено") || body.Contains("Оценка обновлена")))) continue; + var match = Regex.Match(body, @"taskId=(\d+)"); + if (!match.Success || !long.TryParse(match.Groups[1].Value, out var taskId)) continue; + if (!hiddenTasks.TryGetValue(taskId, out var hidden)) + { + var course = await _coursesClient.GetCourseByTask(taskId); + hidden = course == null || GradeVisibility.HiddenTaskIds(course, UserId).Contains(taskId); + hiddenTasks[taskId] = hidden; + } + if (hidden) + { + var endOfLink = body.IndexOf("", StringComparison.Ordinal); + notification.Body = endOfLink >= 0 + ? body.Substring(0, endOfLink + 4) + " проверена. Результат доступен на странице решения." + : "Решение проверено. Результат доступен на странице решения."; + } + } return Ok(result); } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/SolutionsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/SolutionsController.cs index 51ca4e4f5..61d1e9a03 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/SolutionsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/SolutionsController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using HwProj.APIGateway.API.ExceptionFilters; using HwProj.APIGateway.API.Models.Solutions; +using HwProj.APIGateway.API.Services; using HwProj.AuthService.Client; using HwProj.CoursesService.Client; using HwProj.Models.CoursesService; @@ -37,13 +38,21 @@ public SolutionsController(ISolutionsServiceClient solutionsClient, IAuthService } [HttpGet("{solutionId}")] + [Authorize] [ProducesResponseType(typeof(SolutionDto), (int)HttpStatusCode.OK)] public async Task GetSolutionById(long solutionId) { var result = await _solutionsClient.GetSolutionById(solutionId); - return result == null - ? NotFound() - : Ok(result); + if (result == null) return NotFound(); + var course = await _coursesServiceClient.GetCourseByTask(result.TaskId); + if (course == null) return NotFound(); + var mentor = course.MentorIds.Contains(UserId); + var groupMember = result.GroupId is { } groupId && course.Groups.Any(g => + g.Id == groupId && g.StudentsIds.Contains(UserId)); + if (!mentor && (!course.AcceptedStudents.Any(s => s.StudentId == UserId) || + (result.StudentId != UserId && !groupMember))) return Forbid(); + GradeVisibility.Apply(result, GradeVisibility.HiddenTaskIds(course, UserId)); + return Ok(result); } [HttpGet("taskSolution/{taskId}/{studentId}")] @@ -56,9 +65,11 @@ public async Task GetStudentSolution(long taskId, string studentI var courseMate = course.AcceptedStudents.FirstOrDefault(t => t.StudentId == studentId); if (courseMate == null) return NotFound(); + if (!course.MentorIds.Contains(UserId) && studentId != UserId) return Forbid(); - var studentSolutions = (await _solutionsClient.GetCourseStatistics(course.Id, UserId)) - .Single().Homeworks + var statistics = await _solutionsClient.GetCourseStatistics(course.Id, UserId); + GradeVisibility.Apply(statistics, GradeVisibility.HiddenTaskIds(course, UserId)); + var studentSolutions = statistics.Single(s => s.StudentId == studentId).Homeworks .ToDictionary(x => x.Id); var homeworks = course.Homeworks @@ -516,6 +527,8 @@ public async Task GetSolutionAchievement(long taskId, long soluti course.AcceptedStudents.FirstOrDefault(t => t.StudentId == UserId) == null) return BadRequest($"Студента или преподавателя с id {UserId} не существует"); + if (GradeVisibility.HiddenTaskIds(course, UserId).Contains(taskId)) return Ok(null); + var solutions = await _solutionsClient.GetTaskSolutionStatistics(course.Id, taskId); var lastRatedSolutions = solutions .Select(t => t.Solutions.LastOrDefault(x => x.State != SolutionState.Posted)) @@ -529,11 +542,11 @@ public async Task GetSolutionAchievement(long taskId, long soluti if (lastRatedSolutions.Any(x => x!.GroupId != null)) lastRatedSolutions = lastRatedSolutions.DistinctBy(t => t!.Id).ToArray(); - var score = solution.Rating; + var score = solution.Rating.GetValueOrDefault(); var maxScore = course.Homeworks.SelectMany(x => x.Tasks).First(x => x.Id == taskId).MaxRating; var otherScores = lastRatedSolutions .Where(x => x!.Id != solutionId) - .Select(x => x!.Rating) + .Select(x => x!.Rating.GetValueOrDefault()) .ToArray(); var absolute = (double)score / maxScore; diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs index dfd6c567f..8e3049b94 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/StatisticsController.cs @@ -4,6 +4,7 @@ using System.Net; using System.Threading.Tasks; using HwProj.APIGateway.API.Models.Statistics; +using HwProj.APIGateway.API.Services; using HwProj.AuthService.Client; using HwProj.CoursesService.Client; using HwProj.Models.AuthService.DTO; @@ -53,11 +54,16 @@ public async Task GetLecturersStatistics(long courseId) } [HttpGet("{courseId}")] + [Authorize] [ProducesResponseType(typeof(StatisticsCourseMatesModel[]), (int)HttpStatusCode.OK)] public async Task GetCourseStatistics(long courseId) { + var course = await _coursesClient.GetCourseById(courseId); + if (course == null) return Forbid(); + if (!course.MentorIds.Contains(UserId) && !course.AcceptedStudents.Any(s => s.StudentId == UserId)) return Forbid(); var statistics = await _solutionClient.GetCourseStatistics(courseId, UserId); if (statistics == null) return Forbid(); + GradeVisibility.Apply(statistics, GradeVisibility.HiddenTaskIds(course, UserId)); var studentIds = statistics.Select(t => t.StudentId).ToArray(); var getStudentsTask = AuthServiceClient.GetAccountsData(studentIds); @@ -85,14 +91,18 @@ public async Task GetCourseStatistics(long courseId) } [HttpGet("{courseId}/charts")] + [Authorize] [ProducesResponseType(typeof(AdvancedCourseStatisticsViewModel), (int)HttpStatusCode.OK)] public async Task GetChartStatistics(long courseId) { var course = await _coursesClient.GetCourseById(courseId); if (course == null) return Forbid(); + if (!course.MentorIds.Contains(UserId) && !course.AcceptedStudents.Any(s => s.StudentId == UserId)) return Forbid(); var statistics = await _solutionClient.GetCourseStatistics(courseId, UserId); + var hiddenTasks = GradeVisibility.HiddenTaskIds(course, UserId); + GradeVisibility.Apply(statistics, hiddenTasks); var studentIds = statistics.Select(t => t.StudentId).ToArray(); var studentsData = await AuthServiceClient.GetAccountsData(studentIds); @@ -109,6 +119,7 @@ public async Task GetChartStatistics(long courseId) var result = new AdvancedCourseStatisticsViewModel { + HiddenTaskIds = hiddenTasks.ToArray(), Course = new CoursePreview { Id = course.Id, @@ -117,8 +128,8 @@ public async Task GetChartStatistics(long courseId) }, StudentStatistics = students.ToArray(), Homeworks = course.Homeworks, - AverageStudentSolutions = statisticsMeasure.AverageStudentSolutions, - BestStudentSolutions = statisticsMeasure.BestStudentSolutions + AverageStudentSolutions = statisticsMeasure.AverageStudentSolutions.Where(s => !hiddenTasks.Contains(s.TaskId)).ToArray(), + BestStudentSolutions = statisticsMeasure.BestStudentSolutions.Where(s => !hiddenTasks.Contains(s.TaskId)).ToArray() }; return Ok(result); diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Statistics/AdvancedCourseStatisticsViewModel.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Statistics/AdvancedCourseStatisticsViewModel.cs index 6984237d1..fc992608d 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Statistics/AdvancedCourseStatisticsViewModel.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Statistics/AdvancedCourseStatisticsViewModel.cs @@ -5,6 +5,7 @@ namespace HwProj.APIGateway.API.Models.Statistics; public class AdvancedCourseStatisticsViewModel { + public long[] HiddenTaskIds { get; set; } = System.Array.Empty(); public CoursePreview Course { get; set; } public HomeworkViewModel[] Homeworks { get; set; } public StatisticsCourseMatesModel[] StudentStatistics { get; set; } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs index 015ed4265..07505eeb8 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs @@ -8,6 +8,8 @@ public class TaskDeadlineView public TaskDeadlineDto Deadline { get; set; } public SolutionState? SolutionState { get; set; } public long? Rating { get; set; } + public bool IsRatingHidden { get; set; } + public bool RequiresRevision { get; set; } public long MaxRating { get; set; } public bool DeadlinePast { get; set; } } diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Services/GradeVisibility.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Services/GradeVisibility.cs new file mode 100644 index 000000000..ac6f24039 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Services/GradeVisibility.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.SolutionsService; + +namespace HwProj.APIGateway.API.Services; + +/// Применяется только к ответам Gateway: внутренние сервисы сохраняют точные баллы. +public static class GradeVisibility +{ + public static HashSet HiddenTaskIds(CourseDTO course, string? viewerId) => + course.MentorIds.Contains(viewerId) + ? new HashSet() + : course.Homeworks.Where(h => h.HideRatings).SelectMany(h => h.Tasks).Select(t => t.Id).ToHashSet(); + + public static void Apply(SolutionDto solution, ISet hiddenTaskIds) + { + if (!hiddenTaskIds.Contains(solution.TaskId)) return; + solution.Rating = null; + solution.IsRatingHidden = true; + // Final автоматически выдаётся при максимальном балле и мог бы раскрыть его. + if (solution.State == SolutionState.Final) solution.State = SolutionState.Rated; + solution.LecturerComment = RemoveCriteriaScores(solution.LecturerComment); + } + + public static void Apply(IEnumerable statistics, ISet hiddenTaskIds) + { + foreach (var student in statistics) + foreach (var homework in student.Homeworks) + foreach (var task in homework.Tasks) + foreach (var solution in task.Solutions) + Apply(solution, hiddenTaskIds); + } + + public static string RemoveCriteriaScores(string comment) + { + if (string.IsNullOrEmpty(comment)) return comment; + return SolutionCommentUtils.WithoutCriterionScores(comment); + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/GradeVisibilityTests.cs b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/GradeVisibilityTests.cs new file mode 100644 index 000000000..94902551c --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/GradeVisibilityTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using HwProj.APIGateway.API.Controllers; +using HwProj.APIGateway.API.Services; +using HwProj.APIGateway.API.Models.Statistics; +using HwProj.Models.AuthService.DTO; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.StatisticsService; +using HwProj.Models.NotificationsService; +using HwProj.NotificationsService.Client; +using HwProj.APIGateway.API.Models; +using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.SolutionsService; +using HwProj.SolutionsService.Client; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +namespace HwProj.APIGateway.GradeVisibilityTests; + +public class GradeVisibilityTests +{ + private static ControllerContext Viewer(string id) => new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("_id", id) }, "test")) + } + }; + + private static CourseDTO Course(bool hide) => new CourseDTO + { + Id = 1, MentorIds = new[] { "mentor" }, + CourseMates = new[] { new CourseMateViewModel { StudentId = "student", IsAccepted = true } }, + Homeworks = new[] { new HomeworkViewModel + { + Id = 2, HideRatings = hide, Tasks = new List { new HomeworkTaskViewModel { Id = 3, MaxRating = 10 } } + } } + }; + + [TestCase("student", true, true)] + [TestCase("student", false, false)] + [TestCase("mentor", true, false)] + [TestCase("other-course-teacher", true, true)] + public void PolicyDependsOnHomeworkAndCourseMembership(string viewer, bool setting, bool hidden) + { + Assert.That(GradeVisibility.HiddenTaskIds(Course(setting), viewer).Contains(3), Is.EqualTo(hidden)); + } + + [TestCase(SolutionState.Rated, false)] + [TestCase(SolutionState.Final, true)] + [TestCase(SolutionState.Posted, false)] + public void HiddenResponseKeepsReviewInformationWithoutNumericScore(SolutionState state, bool revision) + { + var solution = new SolutionDto + { + Id = 4, TaskId = 3, Rating = 7, State = state, RequiresRevision = revision, + LecturerComment = "Исправьте обработку ошибок.\n\n| Критерий оценивания | Баллы |\n| --- | --- |\n| Код | 7 / 10 |\n| Доп. оценка | 0 |" + }; + GradeVisibility.Apply(solution, GradeVisibility.HiddenTaskIds(Course(true), "student")); + var response = new GetSolutionModel(solution, null, null); + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var document = JsonDocument.Parse(json); + Assert.That(document.RootElement.GetProperty("rating").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(response.IsRatingHidden, Is.True); + Assert.That(response.RequiresRevision, Is.EqualTo(revision)); + Assert.That(response.State, Is.EqualTo(state == SolutionState.Final ? SolutionState.Rated : state)); + Assert.That(response.LecturerComment, Is.EqualTo("Исправьте обработку ошибок.")); + } + + [Test] + public void NestedStatisticsRedactsAllAttemptsOfHiddenTaskOnly() + { + var hidden = new SolutionDto { TaskId = 3, Rating = 8 }; + var visible = new SolutionDto { TaskId = 9, Rating = 6 }; + var rows = new[] { new StudentSolutionsTableDto { Homeworks = new List + { + new StudentSolutionsTableHomeworkDto { Tasks = new List + { + new StudentSolutionsTableTaskDto { Solutions = new List { hidden, visible } } + } } + } } }; + GradeVisibility.Apply(rows, new HashSet { 3 }); + Assert.That(hidden.Rating, Is.Null); + Assert.That(visible.Rating, Is.EqualTo(6)); + } + + [TestCase("student", true)] + [TestCase("mentor", false)] + public async Task StatisticsAndChartsApplyTheSamePrivacyRule(string viewer, bool hidden) + { + var course = Course(true); + var courses = new Mock(); + courses.Setup(c => c.GetCourseById(1)).ReturnsAsync(course); + courses.Setup(c => c.GetMentorsToAssignedStudents(1)).ReturnsAsync(Array.Empty()); + var auth = new Mock(); + auth.Setup(a => a.GetAccountsData(It.IsAny())).ReturnsAsync(new[] { new AccountDataDto("student", "Test", "Student", "student@example.test", "Student", false) }); + var rows = new[] { new StudentSolutionsTableDto { StudentId = "student", + Homeworks = new List { new StudentSolutionsTableHomeworkDto + { + Id = 2, Tasks = new List { new StudentSolutionsTableTaskDto + { + Id = 3, Solutions = new List { new SolutionDto { Id = 4, TaskId = 3, Rating = 8 } } + } } + } } + } }; + var solutions = new Mock(); + solutions.Setup(s => s.GetCourseStatistics(1, viewer)).ReturnsAsync(rows); + solutions.Setup(s => s.GetBenchmarkStatistics(1)).ReturnsAsync(new StatisticsCourseStudentsBenchmarkDTO + { + AverageStudentSolutions = new[] { new StatisticsCourseMeasureSolutionModel { TaskId = 3, Rating = 8 } }, + BestStudentSolutions = new[] { new StatisticsCourseMeasureSolutionModel { TaskId = 3, Rating = 10 } } + }); + var controller = new StatisticsController(solutions.Object, auth.Object, courses.Object) { ControllerContext = Viewer(viewer) }; + var statsResult = await controller.GetCourseStatistics(1); + Assert.That(statsResult, Is.TypeOf()); + Assert.That(rows[0].Homeworks[0].Tasks[0].Solutions[0].Rating, Is.EqualTo(hidden ? null : (int?)8)); + var chartResult = (OkObjectResult)await controller.GetChartStatistics(1); + var chart = (AdvancedCourseStatisticsViewModel)chartResult.Value; + Assert.That(chart.AverageStudentSolutions.Length, Is.EqualTo(hidden ? 0 : 1)); + Assert.That(chart.BestStudentSolutions.Length, Is.EqualTo(hidden ? 0 : 1)); + Assert.That(chart.HiddenTaskIds.Length, Is.EqualTo(hidden ? 1 : 0)); + } + + [TestCase("student", true)] + [TestCase("mentor", false)] + public async Task AchievementDoesNotDiscloseTheHiddenScore(string viewer, bool hidden) + { + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(Course(true)); + var solutions = new Mock(); + solutions.Setup(s => s.GetTaskSolutionStatistics(1, 3)).ReturnsAsync(new[] { new StudentSolutionsDto + { + StudentId = "student", Solutions = new[] { new SolutionDto + { + Id = 4, TaskId = 3, StudentId = "student", Rating = 7, State = SolutionState.Rated + } } + } }); + var controller = new SolutionsController(solutions.Object, Mock.Of(), courses.Object) + { ControllerContext = Viewer(viewer) }; + var response = (OkObjectResult)await controller.GetSolutionAchievement(3, 4); + Assert.That(response.Value == null, Is.EqualTo(hidden)); + } + + [TestCase("student", true)] + [TestCase("mentor", false)] + public async Task DeadlineResponseDoesNotBypassPrivacy(string viewer, bool hidden) + { + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(Course(true)); + courses.Setup(c => c.GetTaskDeadlines()).ReturnsAsync(new[] { new TaskDeadlineDto + { + TaskId = 3, CourseId = 1, MaxRating = 10, DeadlineDate = DateTime.UtcNow.AddDays(1) + } }); + var auth = new Mock(); + auth.Setup(a => a.GetAccountData(viewer)).ReturnsAsync(new AccountDataDto(viewer, "Test", "User", "user@example.test", "Student", false)); + var solutions = new Mock(); + solutions.Setup(s => s.GetLastTaskSolutions(It.IsAny(), viewer)).ReturnsAsync(new[] + { + new SolutionDto { Id = 4, TaskId = 3, Rating = 7, State = SolutionState.Rated, RequiresRevision = true } + }); + var controller = new AccountController(auth.Object, courses.Object, solutions.Object) { ControllerContext = Viewer(viewer) }; + var response = (UserDataDto)((OkObjectResult)await controller.GetUserData()).Value; + Assert.That(response.TaskDeadlines[0].Rating, Is.EqualTo(hidden ? null : (long?)7)); + Assert.That(response.TaskDeadlines[0].IsRatingHidden, Is.EqualTo(hidden)); + Assert.That(response.TaskDeadlines[0].RequiresRevision, Is.True); + } + + [TestCase("student", true)] + [TestCase("mentor", false)] + [TestCase("student", true, true)] + [TestCase("mentor", false, true)] + public async Task ExistingNotificationCannotRevealPreviouslyStoredScore(string viewer, bool hidden, bool redesigned = false) + { + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(Course(true)); + var note = new NotificationViewModel + { + Sender = "SolutionService", + Body = redesigned + ? "Оценка обновлена
Задача Задача
Оценка: 7/10\n| Код | 7 |" + : "Задача Задача оценена на 7/10.\n| Критерий оценивания | Баллы |\n| Код | 7 |" + }; + var notifications = new Mock(); + notifications.Setup(n => n.Get(viewer)).ReturnsAsync(new[] { new CategorizedNotifications + { + SeenNotifications = new[] { note }, NotSeenNotifications = Array.Empty() + } }); + var controller = new NotificationsController(notifications.Object, courses.Object) { ControllerContext = Viewer(viewer) }; + await controller.Get(); + Assert.That(note.Body.Contains("7/10"), Is.EqualTo(!hidden)); + Assert.That(note.Body.Contains("| Код | 7 |"), Is.EqualTo(!hidden)); + } + + [TestCase("student", false)] + [TestCase("mentor", false)] + [TestCase("other-course-teacher", true)] + public async Task DirectSolutionEndpointCannotBypassPolicy(string viewer, bool forbidden) + { + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(Course(true)); + var solutions = new Mock(); + solutions.Setup(c => c.GetSolutionById(4)).ReturnsAsync(new SolutionDto + { + Id = 4, TaskId = 3, StudentId = "student", Rating = 7, State = SolutionState.Rated + }); + var controller = new SolutionsController(solutions.Object, Mock.Of(), courses.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("_id", viewer) }, "test")) + } } + }; + var result = await controller.GetSolutionById(4); + if (forbidden) Assert.That(result, Is.TypeOf()); + else + { + var returned = (SolutionDto)((OkObjectResult)result).Value; + Assert.That(returned.Rating, Is.EqualTo(viewer == "mentor" ? (int?)7 : null)); + } + } +} diff --git a/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/HwProj.APIGateway.GradeVisibilityTests.csproj b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/HwProj.APIGateway.GradeVisibilityTests.csproj new file mode 100644 index 000000000..91eed2c6a --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/HwProj.APIGateway.GradeVisibilityTests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + false + latest + + + + + + + + + + diff --git a/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/NotificationMergeTests.cs b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/NotificationMergeTests.cs new file mode 100644 index 000000000..9b71d419b --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/NotificationMergeTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.AuthService.DTO; +using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; +using HwProj.Models.SolutionsService; +using HwProj.NotificationService.Events.SolutionsService; +using HwProj.NotificationsService.API.EventHandlers; +using HwProj.NotificationsService.API.Models; +using HwProj.NotificationsService.API.Repositories; +using HwProj.NotificationsService.API.Services.Digest; +using HwProj.NotificationsService.API.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Configuration; +using Moq; +using NUnit.Framework; + +namespace HwProj.APIGateway.GradeVisibilityTests; + +public class NotificationMergeTests +{ + [TestCase(true, "student", true)] + [TestCase(false, "student", false)] + [TestCase(true, "mentor", false)] + public async Task DigestRechecksVisibilityAfterNotificationWasCreated(bool hide, string recipient, bool expectedHidden) + { + var original = new DigestItem + { + Kind = DigestItemKind.SolutionRated, CourseId = 1, TaskId = 3, + TaskTitle = "Task", Rating = 7, MaxRating = 10, PreviousRating = 5, + Comment = "Improve naming\n| Критерий оценивания | Баллы |\n| --- | --- |\n| Code | 7 |" + }; + var repository = new Mock(); + repository.Setup(r => r.GetDigestNotificationsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { original.ToDigestOnlyNotification(recipient, "SolutionService", CategoryState.Homeworks) }); + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(new CourseDTO + { + Id = 1, MentorIds = new[] { "mentor" }, + Homeworks = new[] { new HomeworkViewModel + { + HideRatings = hide, Tasks = new List { new HomeworkTaskViewModel { Id = 3 } } + } } + }); + var auth = new Mock(); + auth.Setup(a => a.GetAccountData(recipient)).ReturnsAsync( + new AccountDataDto(recipient, "Test", "User", "user@example.test", "Student", false)); + IReadOnlyList sent = null; + var email = new Mock(); + email.Setup(e => e.SendDigestsAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((messages, _) => sent = messages) + .ReturnsAsync((IReadOnlyList messages, CancellationToken _) => messages); + using var cache = new MemoryCache(new MemoryCacheOptions()); + var service = new DigestService(repository.Object, auth.Object, courses.Object, email.Object, cache, + Options.Create(new DigestOptions()), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var result = await service.SendRequestedDigestAsync(recipient); + + Assert.That(result.Succeeded, Is.True); + var entry = sent.Single().Message.Sections.Single().Groups.Single().Entries.Single(); + Assert.That(entry.Badge, Is.EqualTo(expectedHidden ? "Проверено" : "7 / 10")); + Assert.That(entry.Criteria.Count, Is.EqualTo(expectedHidden ? 0 : 1)); + Assert.That(entry.Quote, Is.EqualTo("Improve naming")); + if (expectedHidden) Assert.That(entry.Hint, Is.Null); + } + + [TestCase(true, false)] + [TestCase(true, true)] + [TestCase(false, false)] + public async Task RedesignedNotificationAndDigestRespectHiddenScores(bool hidden, bool revision) + { + Notification saved = null; + var repository = new Mock(); + repository.Setup(r => r.AddAsync(It.IsAny())) + .Callback(n => saved = n).ReturnsAsync(1); + var handler = new RateEventHandler(repository.Object, new ConfigurationBuilder().Build()); + await handler.HandleAsync(new RateEvent( + new HomeworkTaskViewModel { Id = 3, Title = "Task", HideRatings = hidden, MaxRating = 10 }, + new SolutionViewModel + { + StudentId = "student", Rating = 7, RequiresRevision = revision, + LecturerComment = "Improve naming\n| Критерий оценивания | Баллы |\n| --- | --- |\n| Code | 7 |" + }, 1, "Course", 5)); + + Assert.That(saved, Is.Not.Null); + Assert.That(saved.Body.Contains("7 / 10"), Is.EqualTo(!hidden)); + Assert.That(saved.Body.Contains("5 / 10"), Is.EqualTo(!hidden)); + Assert.That(saved.Body.Contains("Improve naming"), Is.True); + var item = DigestItem.FromNotification(saved); + Assert.That(item.Rating, Is.EqualTo(hidden ? null : (int?)7)); + Assert.That(item.PreviousRating, Is.EqualTo(hidden ? null : (int?)5)); + Assert.That(item.IsRatingHidden, Is.EqualTo(hidden)); + Assert.That(item.RequiresRevision, Is.EqualTo(revision)); + Assert.That(item.Comment.Contains("| Code | 7 |"), Is.EqualTo(!hidden)); + var entry = DigestBuilder.Build(new[] { item }, DateTime.UtcNow, "https://example.test") + .Sections.Single().Groups.Single().Entries.Single(); + Assert.That(entry.Badge, Is.EqualTo(hidden + ? revision ? "Требуется отправить исправленное решение" : "Проверено" + : "7 / 10")); + Assert.That(entry.Criteria.Count, Is.EqualTo(hidden ? 0 : 1)); + if (hidden) Assert.That(entry.Hint, Is.Null); + } +} diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkTaskViewModels.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkTaskViewModels.cs index f2cc40854..371315837 100644 --- a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkTaskViewModels.cs +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkTaskViewModels.cs @@ -7,6 +7,8 @@ namespace HwProj.Models.CoursesService.ViewModels { public class HomeworkTaskViewModel { + public bool HideRatings { get; set; } + public long Id { get; set; } public string Title { get; set; } diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs index 1a196e463..c9b1d8875 100644 --- a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs @@ -6,6 +6,8 @@ namespace HwProj.Models.CoursesService.ViewModels { public class CreateHomeworkViewModel { + public bool HideRatings { get; set; } + [Required] [RegularExpression(@"^\S+.*", ErrorMessage = "Name shouldn't start with white spaces.")] public string Title { get; set; } @@ -33,6 +35,8 @@ public class CreateHomeworkViewModel public class HomeworkViewModel { + public bool HideRatings { get; set; } + public long Id { get; set; } public string Title { get; set; } diff --git a/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs b/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs index a06a96380..a3733d097 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs @@ -21,6 +21,8 @@ public GetSolutionModel(SolutionDto model, AccountDataDto[]? groupMates, Account PublicationDate = model.PublicationDate; IsModified = model.IsModified; Rating = model.Rating; + IsRatingHidden = model.IsRatingHidden; + RequiresRevision = model.RequiresRevision; StudentId = model.StudentId; TaskId = model.TaskId; State = model.State; @@ -38,7 +40,10 @@ public GetSolutionModel(SolutionDto model, AccountDataDto[]? groupMates, Account public SolutionState State { get; set; } - public int Rating { get; set; } + public int? Rating { get; set; } + public bool IsRatingHidden { get; set; } + + public bool RequiresRevision { get; set; } public string StudentId { get; set; } diff --git a/HwProj.Common/HwProj.Models/SolutionsService/RateSolutionModel.cs b/HwProj.Common/HwProj.Models/SolutionsService/RateSolutionModel.cs index a1ebfb3af..3f4491ac4 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/RateSolutionModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/RateSolutionModel.cs @@ -4,5 +4,6 @@ public class RateSolutionModel { public int Rating { get; set; } public string? LecturerComment { get; set; } + public bool RequiresRevision { get; set; } } } \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/SolutionsService/SolutionCommentUtils.cs b/HwProj.Common/HwProj.Models/SolutionsService/SolutionCommentUtils.cs new file mode 100644 index 000000000..f006849b1 --- /dev/null +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionCommentUtils.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace HwProj.Models.SolutionsService +{ + public static class SolutionCommentUtils + { + public static string WithoutCriterionScores(string comment) + { + if (string.IsNullOrEmpty(comment)) return comment; + var lines = comment.Split('\n'); + var result = new List(); + for (var i = 0; i < lines.Length; i++) + { + if (lines[i].Trim() != "| Критерий оценивания | Баллы |") result.Add(lines[i]); + else while (i + 1 < lines.Length && lines[i + 1].Trim().StartsWith("|") && + lines[i + 1].Trim().EndsWith("|")) i++; + } + return string.Join("\n", result).Trim(); + } + } +} diff --git a/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs b/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs index 6817cc970..a55bff21d 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs @@ -8,7 +8,9 @@ public class SolutionDto public string GithubUrl { get; set; } public string Comment { get; set; } public SolutionState State { get; set; } - public int Rating { get; set; } + public int? Rating { get; set; } + public bool IsRatingHidden { get; set; } + public bool RequiresRevision { get; set; } public string StudentId { get; set; } public string? LecturerId { get; set; } public long? GroupId { get; set; } diff --git a/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs b/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs index 048822f85..2ac14c747 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionViewModel.cs @@ -16,6 +16,8 @@ public class SolutionViewModel public string LecturerComment { get; set; } public int? Rating { get; set; } + + public bool RequiresRevision { get; set; } public DateTime? RatingDate { get; set; } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs index 48ce7a8ea..ba8f09351 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs @@ -19,6 +19,7 @@ public static HomeworkViewModel ToHomeworkViewModel(this Homework homework) return new HomeworkViewModel() { Id = homework.Id, + HideRatings = homework.HideRatings, Title = homework.Title, Description = homework.Description, HasDeadline = homework.HasDeadline, @@ -46,6 +47,7 @@ public static HomeworkTaskViewModel ToHomeworkTaskViewModel(this HomeworkTask ta return new HomeworkTaskViewModel() { Id = task.Id, + HideRatings = task.Homework.HideRatings, Title = task.Title, Description = task.Description, MaxRating = task.MaxRating, @@ -142,6 +144,7 @@ public static HomeworkTask ToHomeworkTask(this PostTaskViewModel postTaskViewMod public static Homework ToHomework(this CreateHomeworkViewModel homework) => new Homework() { + HideRatings = homework.HideRatings, Title = homework.Title, Description = homework.Description, HasDeadline = homework.HasDeadline, @@ -173,6 +176,7 @@ public static CourseTemplate ToCourseTemplate(this Course course) public static HomeworkTemplate ToHomeworkTemplate(this Homework homework) => new HomeworkTemplate() { + HideRatings = homework.HideRatings, Title = homework.Title, Description = homework.Description, HasDeadline = homework.HasDeadline, @@ -205,6 +209,7 @@ public static Course ToCourse(this CourseTemplate courseTemplate) public static Homework ToHomework(this HomeworkTemplate homeworkTemplate, long courseId) => new Homework() { + HideRatings = homeworkTemplate.HideRatings, Title = homeworkTemplate.Title, Description = homeworkTemplate.Description, HasDeadline = homeworkTemplate.HasDeadline, diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.Designer.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.Designer.cs new file mode 100644 index 000000000..96700e95a --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.Designer.cs @@ -0,0 +1,491 @@ +// +using System; +using HwProj.CoursesService.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace HwProj.CoursesService.API.Migrations +{ + [DbContext(typeof(CourseContext))] + [Migration("20261105000100_HomeworkHideRatings")] + partial class HomeworkHideRatings + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("MentorId") + .HasColumnType("nvarchar(max)"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupName") + .HasColumnType("nvarchar(max)"); + + b.Property("InviteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsOpen") + .HasColumnType("bit"); + + b.Property("MentorIds") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FilterJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("CourseFilters"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("CourseMates"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Criterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MaxPoints") + .HasColumnType("int"); + + b.Property("Arguments") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Criteria"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.GroupMate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("StudentId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasAlternateKey("GroupId", "StudentId"); + + b.ToTable("GroupMates"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("HasDeadline") + .HasColumnType("bit"); + + b.Property("HideRatings") + .HasColumnType("bit"); + + b.Property("IsDeadlineStrict") + .HasColumnType("bit"); + + b.Property("PublicationDate") + .HasColumnType("datetime2"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Homeworks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("HasDeadline") + .HasColumnType("bit"); + + b.Property("HomeworkId") + .HasColumnType("bigint"); + + b.Property("IsBonusExplicit") + .HasColumnType("bit"); + + b.Property("IsDeadlineStrict") + .HasColumnType("bit"); + + b.Property("MaxRating") + .HasColumnType("int"); + + b.Property("PublicationDate") + .HasColumnType("datetime2"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("HomeworkId"); + + b.ToTable("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.StudentCharacteristics", b => + { + b.Property("CourseMateId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.HasKey("CourseMateId"); + + b.ToTable("StudentCharacteristics"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("TasksModels"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsPrivate") + .HasColumnType("bit"); + + b.Property("LecturerId") + .HasColumnType("nvarchar(max)"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.Property("Text") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Questions"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.UserToCourseFilter", b => + { + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("CourseFilterId") + .HasColumnType("bigint"); + + b.HasKey("CourseId", "Id"); + + b.HasIndex("CourseFilterId"); + + b.ToTable("UserToCourseFilters"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Assignment", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("Assignments") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("CourseMates") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Criterion", b => + { + b.HasOne("HwProj.CoursesService.API.Models.HomeworkTask", "Task") + .WithMany("Criteria") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.GroupMate", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Group", null) + .WithMany("GroupMates") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("Homeworks") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Homework", "Homework") + .WithMany("Tasks") + .HasForeignKey("HomeworkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Homework"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.StudentCharacteristics", b => + { + b.HasOne("HwProj.CoursesService.API.Models.CourseMate", null) + .WithOne("Characteristics") + .HasForeignKey("HwProj.CoursesService.API.Models.StudentCharacteristics", "CourseMateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskModel", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Group", null) + .WithMany("Tasks") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.UserToCourseFilter", b => + { + b.HasOne("HwProj.CoursesService.API.Models.CourseFilter", "CourseFilter") + .WithMany() + .HasForeignKey("CourseFilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseFilter"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Course", b => + { + b.Navigation("Assignments"); + + b.Navigation("CourseMates"); + + b.Navigation("Homeworks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.Navigation("Characteristics"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Group", b => + { + b.Navigation("GroupMates"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.Navigation("Criteria"); + }); +#pragma warning restore 612, 618 + } + } +} + diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.cs new file mode 100644 index 000000000..ba62d5ed9 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace HwProj.CoursesService.API.Migrations +{ + public partial class HomeworkHideRatings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "HideRatings", table: "Homeworks", + type: "bit", nullable: false, defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "HideRatings", table: "Homeworks"); + } + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs index 199917cf8..18ee5e448 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs @@ -211,6 +211,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HasDeadline") .HasColumnType("bit"); + b.Property("HideRatings") + .HasColumnType("bit"); + b.Property("IsDeadlineStrict") .HasColumnType("bit"); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs index 466bacaf2..004059086 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs @@ -12,6 +12,8 @@ public class Homework : IEntity public string Description { get; set; } + public bool HideRatings { get; set; } + public bool HasDeadline { get; set; } public DateTime? DeadlineDate { get; set; } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/HomeworkTemplate.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/HomeworkTemplate.cs index 66a985096..2b28b4f7b 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Models/HomeworkTemplate.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/HomeworkTemplate.cs @@ -8,6 +8,8 @@ public class HomeworkTemplate public string Description { get; set; } + public bool HideRatings { get; set; } + public bool HasDeadline { get; set; } public bool IsDeadlineStrict { get; set; } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 0f30d7e6f..24d52d23b 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -105,6 +105,7 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV await _homeworksRepository.UpdateAsync(homeworkId, hw => new Homework() { Title = update.Title, + HideRatings = update.HideRatings, Description = update.Description, HasDeadline = update.HasDeadline, DeadlineDate = update.DeadlineDate, diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs index 82d5bfd7f..6cb8af9a8 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using HwProj.EventBus.Client.Interfaces; using HwProj.NotificationService.Events.SolutionsService; +using HwProj.Models.SolutionsService; using HwProj.NotificationsService.API.Models; using HwProj.NotificationsService.API.Repositories; using HwProj.NotificationsService.API.Services.Digest; @@ -26,7 +27,11 @@ public RateEventHandler( public async Task HandleAsync(RateEvent @event) { var taskUrl = $"{_configuration["Url"]}/courses/{@event.CourseId}/homeworks?taskId={@event.Task.Id}"; - var comment = LecturerComment.Parse(@event.Solution.LecturerComment); + var hidden = @event.Task.HideRatings; + var commentText = hidden + ? SolutionCommentUtils.WithoutCriterionScores(@event.Solution.LecturerComment) + : @event.Solution.LecturerComment; + var comment = LecturerComment.Parse(commentText); var rating = @event.Solution.Rating ?? 0; var isUpdate = @event.PreviousRating != null; var taskLink = NotificationHtml.Link(taskUrl, @event.Task.Title); @@ -38,9 +43,13 @@ public async Task HandleAsync(RateEvent @event) : $"Оценка за задачу «{@event.Task.Title}»") .WithText(isUpdate ? $"Преподаватель обновил проверку вашего решения задачи {taskLink}." - : $"Преподаватель проверил ваше решение задачи {taskLink}.") - .WithDetail("Оценка", NotificationHtml.Bold($"{rating} / {@event.Task.MaxRating}")); - if (isUpdate && @event.PreviousRating != rating) + : $"Преподаватель проверил ваше решение задачи {taskLink}."); + if (hidden) + message.WithDetail("Статус", @event.Solution.RequiresRevision + ? "Требуется отправить исправленное решение" : "Проверено"); + else + message.WithDetail("Оценка", NotificationHtml.Bold($"{rating} / {@event.Task.MaxRating}")); + if (!hidden && isUpdate && @event.PreviousRating != rating) message.WithDetail("Было", NotificationHtml.Encode($"{@event.PreviousRating} / {@event.Task.MaxRating}")); message @@ -54,10 +63,12 @@ public async Task HandleAsync(RateEvent @event) CourseName = @event.CourseName, TaskId = @event.Task.Id, TaskTitle = @event.Task.Title, - Rating = rating, - MaxRating = @event.Task.MaxRating, - PreviousRating = @event.PreviousRating, - Comment = @event.Solution.LecturerComment + Rating = hidden ? (int?)null : rating, + MaxRating = hidden ? (int?)null : @event.Task.MaxRating, + PreviousRating = hidden ? null : @event.PreviousRating, + IsRatingHidden = hidden, + RequiresRevision = @event.Solution.RequiresRevision, + Comment = commentText }); var notification = message.ToNotification(@event.Solution.StudentId, "SolutionService", diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/HwProj.NotificationsService.API.csproj b/HwProj.NotificationsService/HwProj.NotificationsService.API/HwProj.NotificationsService.API.csproj index cf6d0176c..c6a8ec96f 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/HwProj.NotificationsService.API.csproj +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/HwProj.NotificationsService.API.csproj @@ -17,6 +17,7 @@ + diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestBuilder.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestBuilder.cs index 960321a3b..454a1f413 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestBuilder.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestBuilder.cs @@ -107,15 +107,19 @@ private static DigestSection BuildRatedSolutions(DigestItem[] items, Links links var comment = LecturerComment.Parse(item.Comment); var entry = new DigestEntry(item.TaskTitle, links.Task(item.CourseId, item.TaskId)) { - Badge = item.MaxRating > 0 ? $"{item.Rating} / {item.MaxRating}" : $"{item.Rating}", - BadgeTone = RatingTone(item.Rating ?? 0, item.MaxRating ?? 0), - Hint = item.PreviousRating is { } previous && previous != item.Rating + Badge = item.IsRatingHidden + ? (item.RequiresRevision ? "Требуется отправить исправленное решение" : "Проверено") + : item.MaxRating > 0 ? $"{item.Rating} / {item.MaxRating}" : $"{item.Rating}", + BadgeTone = item.IsRatingHidden + ? (item.RequiresRevision ? DigestTone.Danger : DigestTone.Success) + : RatingTone(item.Rating ?? 0, item.MaxRating ?? 0), + Hint = !item.IsRatingHidden && item.PreviousRating is { } previous && previous != item.Rating ? $"Оценка исправлена, было {previous}" : null, QuoteLabel = "Комментарий преподавателя", Quote = string.IsNullOrWhiteSpace(comment.Text) ? null : comment.Text }; - entry.Criteria.AddRange(comment.Criteria); + if (!item.IsRatingHidden) entry.Criteria.AddRange(comment.Criteria); return entry; }); section.Groups.Add(Group(course, links, entries, ("задача", "задачи", "задач"))); diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestItem.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestItem.cs index 381664359..89eb75049 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestItem.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestItem.cs @@ -43,6 +43,9 @@ public class DigestItem /// Оценка до исправления; null, если решение проверено впервые public int? PreviousRating { get; set; } + public bool IsRatingHidden { get; set; } + public bool RequiresRevision { get; set; } + public string Question { get; set; } public string Answer { get; set; } diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestService.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestService.cs index 1146742d5..5ba0074da 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestService.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/Services/Digest/DigestService.cs @@ -4,6 +4,9 @@ using System.Threading; using System.Threading.Tasks; using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; +using HwProj.Models.SolutionsService; +using HwProj.NotificationsService.API.Models; using HwProj.Models.Result; using HwProj.NotificationsService.API.Repositories; using HwProj.NotificationsService.API.Services.Messages; @@ -38,6 +41,7 @@ public class DigestService : IDigestService private readonly INotificationsRepository _notificationsRepository; private readonly IAuthServiceClient _authServiceClient; + private readonly ICoursesServiceClient _coursesServiceClient; private readonly IEmailService _emailService; private readonly IMemoryCache _cache; private readonly DigestOptions _options; @@ -47,6 +51,7 @@ public class DigestService : IDigestService public DigestService( INotificationsRepository notificationsRepository, IAuthServiceClient authServiceClient, + ICoursesServiceClient coursesServiceClient, IEmailService emailService, IMemoryCache cache, IOptions options, @@ -55,6 +60,7 @@ public DigestService( { _notificationsRepository = notificationsRepository; _authServiceClient = authServiceClient; + _coursesServiceClient = coursesServiceClient; _emailService = emailService; _cache = cache; _options = options.Value; @@ -117,8 +123,9 @@ private async Task TrySendRequestedDigestAsync(string userId, DateTime u var account = await _authServiceClient.GetAccountData(userId); if (string.IsNullOrEmpty(account?.Email)) return Result.Failed("В профиле не указана почта"); - var message = DigestBuilder.Build(notifications.Select(DigestItem.FromNotification).ToArray(), until, - _siteUrl); + var items = notifications.Select(DigestItem.FromNotification).ToArray(); + await ApplyGradeVisibilityAsync(items, userId); + var message = DigestBuilder.Build(items, until, _siteUrl); message.IsRequested = true; var digest = new DigestEmail(account.Email, account.Name, message); @@ -148,10 +155,32 @@ private async Task SendBatchAsync(string[] recipientIds, DateTime since, DateTim if (!accounts.TryGetValue(recipientId, out var account) || string.IsNullOrEmpty(account.Email)) continue; + await ApplyGradeVisibilityAsync(items, recipientId); digests.Add(new DigestEmail(account.Email, account.Name, DigestBuilder.Build(items, until, _siteUrl))); } await _emailService.SendDigestsAsync(digests, cancellationToken); } + + private async Task ApplyGradeVisibilityAsync(DigestItem[] items, string recipientId) + { + // Настройка могла измениться после создания уведомления, но до отправки письма. + foreach (var taskItems in items.Where(i => i.Kind == DigestItemKind.SolutionRated && i.TaskId.HasValue) + .GroupBy(i => i.TaskId.Value)) + { + var course = await _coursesServiceClient.GetCourseByTask(taskItems.Key); + var hidden = course == null || (!course.MentorIds.Contains(recipientId) && + course.Homeworks.Any(h => h.HideRatings && h.Tasks.Any(t => t.Id == taskItems.Key))); + if (!hidden) continue; + foreach (var item in taskItems) + { + item.IsRatingHidden = true; + item.Rating = null; + item.MaxRating = null; + item.PreviousRating = null; + item.Comment = SolutionCommentUtils.WithoutCriterionScores(item.Comment); + } + } + } } } diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs index 46dfdbc2c..5ae38ca64 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; using HwProj.Common.Net8; using HwProj.NotificationsService.API.EventHandlers; using HwProj.NotificationsService.API.Models; @@ -58,7 +59,9 @@ public void ConfigureServices(IServiceCollection services) services.AddHostedService(); services.AddHttpClient(); + services.AddHttpContextAccessor(); services.AddAuthServiceClient(); + services.AddCoursesServiceClient(); services .AddCors() diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs index 9c4e0554b..027f85405 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Controllers/SolutionsController.cs @@ -91,7 +91,8 @@ public async Task RateSolution(long solutionId, var lecturerId = Request.GetUserIdFromHeader(); if (course != null && lecturerId != null && course.MentorIds.Contains(lecturerId)) { - await _solutionsService.RateSolutionAsync(solutionId, lecturerId, rateSolutionModel.Rating, rateSolutionModel.LecturerComment); + await _solutionsService.RateSolutionAsync(solutionId, lecturerId, rateSolutionModel.Rating, + rateSolutionModel.LecturerComment, rateSolutionModel.RequiresRevision); return Ok(); } @@ -326,4 +327,4 @@ public async Task GetSolutionActuality(long solutionId) return await _solutionsService.GetSolutionActuality(solutionId); } } -} \ No newline at end of file +} diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.Designer.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.Designer.cs new file mode 100644 index 000000000..51c6da9df --- /dev/null +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.Designer.cs @@ -0,0 +1,77 @@ +// +using System; +using HwProj.SolutionsService.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace HwProj.SolutionsService.API.Migrations +{ + [DbContext(typeof(SolutionContext))] + [Migration("20261105000000_SolutionRequiresRevision")] + partial class SolutionRequiresRevision + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("HwProj.SolutionsService.API.Models.Solution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Comment"); + + b.Property("GithubUrl"); + + b.Property("GroupId"); + + b.Property("IsModified"); + + b.Property("LecturerComment"); + + b.Property("LecturerId"); + + b.Property("PublicationDate"); + + b.Property("Rating"); + + b.Property("RatingDate"); + + b.Property("RequiresRevision"); + + b.Property("State"); + + b.Property("StudentId"); + + b.Property("TaskId"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Solutions"); + }); + + modelBuilder.Entity("HwProj.SolutionsService.API.Models.GithubSolutionCommit", b => + { + b.Property("Id"); + + b.Property("CommitHash"); + + b.HasKey("Id"); + + b.ToTable("LastGithubSolutionCommits"); + }); +#pragma warning restore 612, 618 + } + } +} + diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.cs new file mode 100644 index 000000000..45a631ad6 --- /dev/null +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace HwProj.SolutionsService.API.Migrations +{ + public partial class SolutionRequiresRevision : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequiresRevision", + table: "Solutions", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RequiresRevision", + table: "Solutions"); + } + } +} diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/SolutionContextModelSnapshot.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/SolutionContextModelSnapshot.cs index 8a4e07014..08f1891a1 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/SolutionContextModelSnapshot.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/SolutionContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("Relational:MaxIdentifierLength", 128) .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - modelBuilder.Entity("HwProj.Models.SolutionsService.Solution", b => + modelBuilder.Entity("HwProj.SolutionsService.API.Models.Solution", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -43,6 +43,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RatingDate"); + b.Property("RequiresRevision"); + b.Property("State"); b.Property("StudentId"); diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Models/Solution.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Models/Solution.cs index 064a5c083..c85013be4 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Models/Solution.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Models/Solution.cs @@ -16,6 +16,8 @@ public class Solution : IEntity public int Rating { get; set; } + public bool RequiresRevision { get; set; } + public string StudentId { get; set; } public string? LecturerId { get; set; } diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/ISolutionsRepository.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/ISolutionsRepository.cs index 907a19f23..6344e071b 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/ISolutionsRepository.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/ISolutionsRepository.cs @@ -9,7 +9,7 @@ namespace HwProj.SolutionsService.API.Repositories public interface ISolutionsRepository : ICrudRepository { Task RateSolutionAsync(long solutionId, SolutionState newState, string lecturerId, int newRating, - DateTime ratingDate, string lecturerComment); + DateTime ratingDate, string lecturerComment, bool requiresRevision); Task ChangeTaskSolutionsMaxRatingAsync(long solutionId, int newMaxRating); diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/SolutionsRepository.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/SolutionsRepository.cs index 999760b67..ae4e2d356 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/SolutionsRepository.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Repositories/SolutionsRepository.cs @@ -16,12 +16,13 @@ public SolutionsRepository(SolutionContext context) } public async Task RateSolutionAsync(long solutionId, SolutionState newState, string lecturerId, int newRating, - DateTime ratingDate, string lecturerComment) + DateTime ratingDate, string lecturerComment, bool requiresRevision) { await UpdateAsync(solutionId, solution => new Solution { State = newState, Rating = newRating, + RequiresRevision = requiresRevision, RatingDate = ratingDate, LecturerId = lecturerId, LecturerComment = lecturerComment diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/ISolutionsService.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/ISolutionsService.cs index 9f284b30f..350719783 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/ISolutionsService.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/ISolutionsService.cs @@ -19,7 +19,8 @@ public interface ISolutionsService Task PostOrUpdateAsync(long taskId, Solution solution); Task PostEmptySolutionWithRateAsync(long task, Solution solution); - Task RateSolutionAsync(long solutionId, string lecturerId, int newRating, string lecturerComment); + Task RateSolutionAsync(long solutionId, string lecturerId, int newRating, string lecturerComment, + bool requiresRevision); Task DeleteSolutionAsync(long solutionId); diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/SolutionsService.cs b/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/SolutionsService.cs index 18d336a04..f54cd581e 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/SolutionsService.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Services/SolutionsService.cs @@ -104,6 +104,8 @@ public async Task GetTaskSolutionsFromStudentAsync(long taskId, stri public async Task PostOrUpdateAsync(long taskId, Solution solution) { + // Новая попытка ещё не проверена: студент не задаёт требование исправления. + solution.RequiresRevision = false; solution.PublicationDate = DateTime.UtcNow; solution.TaskId = taskId; @@ -127,6 +129,7 @@ await _solutionsRepository GroupId = solution.GroupId, IsModified = isModified, State = SolutionState.Posted, + RequiresRevision = false, }); solutionId = lastSolution.Id; } @@ -160,33 +163,37 @@ public async Task PostEmptySolutionWithRateAsync(long taskId, Solution solution) solution.TaskId = taskId; solution.Id = await _solutionsRepository.AddAsync(solution); if (0 <= solution.Rating) - await RateSolutionAsync(solution, solution.LecturerId!, solution.Rating, solution.LecturerComment, null); + await RateSolutionAsync(solution, solution.LecturerId!, solution.Rating, solution.LecturerComment, null, + solution.RequiresRevision); } - public async Task RateSolutionAsync(long solutionId, string lecturerId, int newRating, string lecturerComment) + public async Task RateSolutionAsync(long solutionId, string lecturerId, int newRating, string lecturerComment, + bool requiresRevision) { if (0 <= newRating) { var solution = await _solutionsRepository.GetAsync(solutionId); var previousRating = solution.State == SolutionState.Posted ? (int?)null : solution.Rating; - await RateSolutionAsync(solution, lecturerId, newRating, lecturerComment, previousRating); + await RateSolutionAsync(solution, lecturerId, newRating, lecturerComment, previousRating, requiresRevision); } } private async Task RateSolutionAsync(Solution solution, string lecturerId, int newRating, - string lecturerComment, int? previousRating) + string lecturerComment, int? previousRating, bool requiresRevision) { var ratingDate = DateTime.UtcNow; var task = await _coursesServiceClient.GetTask(solution.TaskId); var course = await _coursesServiceClient.GetCourseByTask(solution.TaskId); var state = newRating >= task.MaxRating ? SolutionState.Final : SolutionState.Rated; await _solutionsRepository - .RateSolutionAsync(solution.Id, state, lecturerId, newRating, ratingDate, lecturerComment); + .RateSolutionAsync(solution.Id, state, lecturerId, newRating, ratingDate, lecturerComment, + requiresRevision); var solutionModel = _mapper.Map(solution); solutionModel.LecturerComment = lecturerComment; solutionModel.Rating = newRating; solutionModel.RatingDate = ratingDate; + solutionModel.RequiresRevision = requiresRevision; _eventBus.Publish(new RateEvent(task, solutionModel, course?.Id ?? 0, course?.Name, previousRating)); } diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs new file mode 100644 index 000000000..360e12f5b --- /dev/null +++ b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading.Tasks; +using AutoMapper; +using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; +using HwProj.EventBus.Client.Interfaces; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.SolutionsService; +using HwProj.NotificationService.Events.SolutionsService; +using HwProj.SolutionsService.API; +using HwProj.SolutionsService.API.Models; +using HwProj.SolutionsService.API.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using NUnit.Framework; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using SolutionRatingService = HwProj.SolutionsService.API.Services.SolutionsService; + +namespace HwProj.SolutionsService.IntegrationTests +{ + [TestFixture] + public class RequiresRevisionTests + { + [TestCase(true)] + [TestCase(false)] + public void RevisionFlagSurvivesDtoAndGatewayResponse(bool requiresRevision) + { + var mapper = new MapperConfiguration(c => c.AddProfile()).CreateMapper(); + var entity = new Solution + { + Id = 42, Rating = 7, State = SolutionState.Rated, RequiresRevision = requiresRevision + }; + + // Solutions Service использует AutoMapper для списков и статистики. + var dto = mapper.Map(entity); + Assert.That(dto.RequiresRevision, Is.EqualTo(requiresRevision)); + + // Отдельное решение возвращается как Solution, а клиент Gateway читает SolutionDto. + var received = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(entity)) + ?? throw new InvalidOperationException("Solution response was not deserialized"); + Assert.That(received.RequiresRevision, Is.EqualTo(requiresRevision)); + var response = new GetSolutionModel(received, null, null); + var json = JObject.Parse(JsonConvert.SerializeObject(response, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + })); + Assert.That(json["requiresRevision"], Is.Not.Null); + Assert.That(json.Value("requiresRevision"), Is.EqualTo(requiresRevision)); + Assert.That(json.Value("rating"), Is.EqualTo(7)); + Assert.That(response.State, Is.EqualTo(SolutionState.Rated)); + } + + [TestCase(true, false, 7, SolutionState.Rated)] + [TestCase(false, true, 10, SolutionState.Final)] + [TestCase(true, false, 0, SolutionState.Rated)] + public async Task RatingPassesNewFlagToRepositoryAndEvent(bool requested, bool previous, + int rating, SolutionState expectedState) + { + var repository = new Mock(); + repository.Setup(r => r.GetAsync(42)).ReturnsAsync(new Solution + { + Id = 42, TaskId = 5, RequiresRevision = previous + }); + repository.Setup(r => r.RateSolutionAsync(42, expectedState, "lecturer", rating, + It.IsAny(), "comment", requested)).Returns(Task.CompletedTask); + var courses = new Mock(); + courses.Setup(c => c.GetTask(5, false)).ReturnsAsync(new HomeworkTaskViewModel { MaxRating = 10 }); + var events = new Mock(); + var mapper = new MapperConfiguration(c => c.AddProfile()).CreateMapper(); + var service = new SolutionRatingService(repository.Object, events.Object, mapper, courses.Object, + Mock.Of(), Mock.Of(), + Mock.Of()); + + await service.RateSolutionAsync(42, "lecturer", rating, "comment", requested); + + repository.Verify(r => r.RateSolutionAsync(42, expectedState, "lecturer", rating, + It.IsAny(), "comment", requested), Times.Once); + events.Verify(e => e.Publish(It.Is(value => + value.Solution.RequiresRevision == requested && value.Solution.Rating == rating)), Times.Once); + } + + [Test] + public void EmptySolutionMappingPreservesFlag() + { + var mapper = new MapperConfiguration(c => c.AddProfile()).CreateMapper(); + var solution = mapper.Map(new SolutionViewModel { RequiresRevision = true }); + Assert.That(solution.RequiresRevision, Is.True); + } + + [Test, Category("LocalDatabase")] + public async Task RepositoryCanSetAndClearFlagWithoutChangingOtherSolutionData() + { + var options = new DbContextOptionsBuilder().UseSqlServer( + "Server=(localdb)\\mssqllocaldb;Database=SolutionsServiceDB;Trusted_Connection=True;").Options; + using (var context = new SolutionContext(options)) + using (var transaction = await context.Database.BeginTransactionAsync()) + { + // Все изменения теста откатываются, реальные решения не обновляются. + var repository = new SolutionsRepository(context); + var id = await repository.AddAsync(new Solution + { + TaskId = 0, StudentId = "revision-persistence-test", Comment = "original submission", + PublicationDate = DateTime.UtcNow, State = SolutionState.Posted + }); + foreach (var requiresRevision in new[] { true, false }) + { + await repository.RateSolutionAsync(id, SolutionState.Rated, "test-lecturer", 7, + DateTime.UtcNow, "teacher comment", requiresRevision); + var saved = await context.Solutions.AsNoTracking().SingleAsync(s => s.Id == id); + Assert.That(saved.RequiresRevision, Is.EqualTo(requiresRevision)); + Assert.That(saved.Rating, Is.EqualTo(7)); + Assert.That(saved.Comment, Is.EqualTo("original submission")); + Assert.That(saved.LecturerComment, Is.EqualTo("teacher comment")); + } + transaction.Rollback(); + } + } + } +} diff --git a/HwProj.sln b/HwProj.sln index 6b9064aec..b43bda69d 100644 --- a/HwProj.sln +++ b/HwProj.sln @@ -82,12 +82,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HwProj.NotificationService. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HwProj.Repositories.Net8", "HwProj.Common\HwProj.Repositories.Net8\HwProj.Repositories.Net8.csproj", "{9E3A99DA-6975-46A7-90DB-69B708C8FF2C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HwProj.APIGateway.GradeVisibilityTests", "HwProj.APIGateway\HwProj.APIGateway.GradeVisibilityTests\HwProj.APIGateway.GradeVisibilityTests.csproj", "{FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D}.Release|Any CPU.Build.0 = Release|Any CPU {4E5191DC-EC8B-44C8-BD85-198D2C0C16F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E5191DC-EC8B-44C8-BD85-198D2C0C16F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {4E5191DC-EC8B-44C8-BD85-198D2C0C16F7}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -205,6 +211,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {FDD2B7EB-3112-4576-A9FF-CE008DBBDC1D} = {DC0D1EE7-D2F8-4D15-8CC6-69A0A0A938D9} {4E5191DC-EC8B-44C8-BD85-198D2C0C16F7} = {77D857A8-45C6-4432-B4BF-A2F2C9ECA7FE} {D59EB6F0-8220-417A-AB3E-6960E9A15459} = {A85A3030-2878-4923-B450-9683832CDAC1} {EB85EEF8-5860-43A3-AE0D-654DC1325F0F} = {CB37F9AC-88CB-425C-8200-1B40F372F38D} diff --git a/generate-swagger-client.ps1 b/generate-swagger-client.ps1 index 274c42f93..c1546e3d9 100644 --- a/generate-swagger-client.ps1 +++ b/generate-swagger-client.ps1 @@ -79,6 +79,10 @@ if (Test-Path -Path $apiFilePath) { # 2. Делаем конфигурацию опциональной $apiContent = $apiContent -replace 'configuration:\s*Configuration;', 'configuration: Configuration | undefined;' + # Swagger Codegen 3.0.68 некорректно описывает additionalProperties у ProblemDetails. + # Индексная сигнатура внутри интерфейса уже описывает словарь; наследование от null недопустимо. + $apiContent = $apiContent -replace 'export interface ProblemDetails extends null \{', 'export interface ProblemDetails {' + # 3. Форматируем пробелы $apiContent = $apiContent | ForEach {$_.TrimEnd()} $apiContent = $apiContent -replace '\t', (' ' * 4) diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index 2ed1996c9..2a6680021 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -207,6 +207,12 @@ export interface AddTaskQuestionDto { * @interface AdvancedCourseStatisticsViewModel */ export interface AdvancedCourseStatisticsViewModel { + /** + * + * @type {Array} + * @memberof AdvancedCourseStatisticsViewModel + */ + hiddenTaskIds?: Array; /** * * @type {CoursePreview} @@ -582,6 +588,12 @@ export interface CreateGroupViewModel { * @interface CreateHomeworkViewModel */ export interface CreateHomeworkViewModel { + /** + * + * @type {boolean} + * @memberof CreateHomeworkViewModel + */ + hideRatings?: boolean; /** * * @type {string} @@ -940,6 +952,18 @@ export interface GetSolutionModel { * @memberof GetSolutionModel */ rating?: number; + /** + * + * @type {boolean} + * @memberof GetSolutionModel + */ + isRatingHidden?: boolean; + /** + * + * @type {boolean} + * @memberof GetSolutionModel + */ + requiresRevision?: boolean; /** * * @type {string} @@ -1134,6 +1158,12 @@ export interface HomeworkTaskForEditingViewModel { * @interface HomeworkTaskViewModel */ export interface HomeworkTaskViewModel { + /** + * + * @type {boolean} + * @memberof HomeworkTaskViewModel + */ + hideRatings?: boolean; /** * * @type {number} @@ -1281,6 +1311,12 @@ export interface HomeworkUserTaskSolutions { * @interface HomeworkViewModel */ export interface HomeworkViewModel { + /** + * + * @type {boolean} + * @memberof HomeworkViewModel + */ + hideRatings?: boolean; /** * * @type {number} @@ -1732,6 +1768,15 @@ export interface PostTaskViewModel { */ criteria?: Array; } +/** + * + * @export + * @interface ProblemDetails + */ +export interface ProblemDetails { + [key: string]: any; + +} /** * * @export @@ -1788,6 +1833,12 @@ export interface RateSolutionModel { * @memberof RateSolutionModel */ lecturerComment?: string; + /** + * + * @type {boolean} + * @memberof RateSolutionModel + */ + requiresRevision?: boolean; } /** * @@ -2037,6 +2088,18 @@ export interface SolutionDto { * @memberof SolutionDto */ rating?: number; + /** + * + * @type {boolean} + * @memberof SolutionDto + */ + isRatingHidden?: boolean; + /** + * + * @type {boolean} + * @memberof SolutionDto + */ + requiresRevision?: boolean; /** * * @type {string} @@ -2229,6 +2292,12 @@ export interface SolutionViewModel { * @memberof SolutionViewModel */ rating?: number; + /** + * + * @type {boolean} + * @memberof SolutionViewModel + */ + requiresRevision?: boolean; /** * * @type {Date} @@ -2567,6 +2636,18 @@ export interface TaskDeadlineView { * @memberof TaskDeadlineView */ rating?: number; + /** + * + * @type {boolean} + * @memberof TaskDeadlineView + */ + isRatingHidden?: boolean; + /** + * + * @type {boolean} + * @memberof TaskDeadlineView + */ + requiresRevision?: boolean; /** * * @type {number} diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index ae30d0db3..64e227877 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -438,7 +438,8 @@ export const CourseExperimental: FC = (props) => { {solutionsDescription}}> = (props) => { // Студенту показываем набранные за задание баллы, преподавателю — количество задач const renderHomeworkCounter = (homework: HomeworkViewModel) => { + if (!isMentor && homework.hideRatings) return null const tasksCount = homework.tasks!.length if (isMentor) return tasksCount > 0 ? diff --git a/hwproj.front/src/components/Courses/Statistics/StudentStatsChart.tsx b/hwproj.front/src/components/Courses/Statistics/StudentStatsChart.tsx index 30b703bbb..0dc57d407 100644 --- a/hwproj.front/src/components/Courses/Statistics/StudentStatsChart.tsx +++ b/hwproj.front/src/components/Courses/Statistics/StudentStatsChart.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from "react"; import {Link, useParams} from 'react-router-dom'; -import {Grid, Box, Typography, Paper} from "@mui/material"; +import {Grid, Box, Typography, Paper, Alert} from "@mui/material"; import { CourseViewModel, HomeworkTaskViewModel, HomeworkViewModel, StudentSolutionsTableHomeworkDto, @@ -20,6 +20,7 @@ import {appBarStateManager} from "@/components/AppBar"; interface IStudentStatsChartState { isFound: boolean; isSelectionMode: boolean; + hiddenTasksCount: number; course: CourseViewModel; homeworks: HomeworkViewModel[]; tasksWithDeadline: HomeworkTaskViewModel[]; @@ -35,6 +36,7 @@ const StudentStatsChart: React.FC = () => { const [state, setState] = useState({ isFound: false, isSelectionMode: false, + hiddenTasksCount: 0, course: {}, homeworks: [], tasksWithDeadline: [], @@ -67,7 +69,9 @@ const StudentStatsChart: React.FC = () => { const params = await ApiSingleton.statisticsApi.statisticsGetChartStatistics(+courseId!); - const homeworks = params.homeworks!.filter(hw => hw.tasks && hw.tasks.length > 0) + const hiddenTaskIds = new Set(params.hiddenTaskIds ?? []) + const homeworks = params.homeworks!.map(hw => ({...hw, tasks: hw.tasks?.filter(t => !hiddenTaskIds.has(t.id!))})) + .filter(hw => hw.tasks && hw.tasks.length > 0) const tasksWithDeadline = [...new Set(homeworks.map(hw => hw.tasks!).flat())] .filter(t => t.hasDeadline && new Date(t.publicationDate!).getTime() < Date.now()) @@ -77,10 +81,15 @@ const StudentStatsChart: React.FC = () => { setState({ isFound: true, isSelectionMode: params.studentStatistics!.length > 1, + hiddenTasksCount: hiddenTaskIds.size, course: params.course!, homeworks: homeworks, tasksWithDeadline: tasksWithDeadline, - solutions: params.studentStatistics!, + solutions: params.studentStatistics!.map(student => ({...student, + homeworks: student.homeworks?.map(hw => ({...hw, + tasks: hw.tasks?.filter(t => !hiddenTaskIds.has(t.id!)), + })), + })), bestStudent: params.bestStudentSolutions!, averageStudent: params.averageStudentSolutions! }) @@ -110,6 +119,9 @@ const StudentStatsChart: React.FC = () => { if (state.isFound && tasksAmount) { return (
+ {state.hiddenTasksCount > 0 && + Графики учитывают только задачи с открытыми баллами. + } { ) } else if (state.isFound && !tasksAmount) { return
-

На курсе нет задач

+

{state.hiddenTasksCount > 0 ? "Баллы задач скрыты. Числовые графики недоступны." : "На курсе нет задач"}

} diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index a8028f981..8d014f3cf 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -474,6 +474,8 @@ const StudentStats: React.FC = (props) => { {solutions && solutions.map((cm, index) => { + const hasHiddenRatings = cm.homeworks?.some(h => h.tasks?.some(t => + t.solutions?.some(s => s.isRatingHidden))) ?? false const homeworksSum = notTests .flatMap(homework => solutions @@ -559,9 +561,9 @@ const StudentStats: React.FC = (props) => { size={"small"} sx={{ ...sumChipSx, - backgroundColor: StudentStatsUtils.getRatingColor(homeworksSum, studentHomeworksMaxSum), + backgroundColor: hasHiddenRatings ? "#eeeeee" : StudentStatsUtils.getRatingColor(homeworksSum, studentHomeworksMaxSum), }} - label={homeworksWithGroups.length > 0 + label={hasHiddenRatings ? "Баллы скрыты" : homeworksWithGroups.length > 0 ? `${homeworksSum} / ${studentHomeworksMaxSum}` : homeworksSum}/>} } @@ -570,9 +572,9 @@ const StudentStats: React.FC = (props) => { size={"small"} sx={{ ...sumChipSx, - backgroundColor: StudentStatsUtils.getRatingColor(testsSum, studentTestsMaxSum), + backgroundColor: hasHiddenRatings ? "#eeeeee" : StudentStatsUtils.getRatingColor(testsSum, studentTestsMaxSum), }} - label={testsWithGroups.length > 0 + label={hasHiddenRatings ? "Баллы скрыты" : testsWithGroups.length > 0 ? `${testsSum} / ${studentTestsMaxSum}` : testsSum}/>} } diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index b77408174..dad4ea17a 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -3,6 +3,8 @@ Badge, Box, Chip, + Checkbox, + FormControlLabel, CircularProgress, Divider, IconButton, @@ -277,6 +279,7 @@ const CourseHomeworkEditor: FC<{ const [title, setTitle] = useState(loadedHomework.title!) const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) + const [hideRatings, setHideRatings] = useState(loadedHomework.hideRatings ?? false) const [selectedGroupId, setSelectedGroupId] = useState(loadedHomework.groupId) const [courseStudents, setCourseStudents] = useState([]) const [page, setPage] = useState<"homework" | "group">("homework") @@ -343,13 +346,14 @@ const CourseHomeworkEditor: FC<{ description: description, tags: tags, groupId: selectedGroupId, + hideRatings, hasErrors: hasErrors, deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, isModified: true, } props.onUpdate({homework: update}) - }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo, selectedGroupId]) + }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo, selectedGroupId, hideRatings]) useEffect(() => { setHasErrors(!title || metadata.hasErrors) @@ -401,6 +405,7 @@ const CourseHomeworkEditor: FC<{ try { const update = { homeworkId: homeworkId, + hideRatings, title: title!, description: description, tags: tags, @@ -510,6 +515,10 @@ const CourseHomeworkEditor: FC<{ Вы можете сгруппировать контрольные работы и переписывания с помощью дополнительного тега. Например, 'КР 1' } + setHideRatings(checked)}/>} + label="Скрывать баллы от студентов во всех задачах задания" + /> Общее описание задания diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 8665dbc60..a54866401 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -6,8 +6,10 @@ import { Box, Button, Chip, + Checkbox, CircularProgress, Divider, + FormControlLabel, IconButton, LinearProgress, Paper, @@ -266,6 +268,7 @@ interface ISolutionProps { interface ISolutionState { points: number, lecturerComment: string, + requiresRevision: boolean, clickedForRate: boolean, addBonusPoints: boolean } @@ -374,7 +377,7 @@ const TaskSolutionComponent: FC = (props) => { }; const getDefaultState = (): ISolutionState => { - const storageValue = RatingStorage.tryGet(storageKey); + const storageValue = props.forMentor ? RatingStorage.tryGet(storageKey) : null; const storedCriteria = parseStoredCriteria(props.solution?.lecturerComment); const clickedForRate = props.forMentor @@ -384,6 +387,7 @@ const TaskSolutionComponent: FC = (props) => { return { points: storageValue?.points || props.solution?.rating || 0, lecturerComment: storageValue?.comment || props.solution?.lecturerComment || "", + requiresRevision: storageValue?.requiresRevision ?? props.solution?.requiresRevision ?? false, clickedForRate, addBonusPoints: hasCriteria, }; @@ -472,7 +476,7 @@ const TaskSolutionComponent: FC = (props) => { setRateInProgressState(false); getActuality(); setShowOriginalCommentText(false); - }, [props.student.userId, props.task.id, props.solution?.id, props.solution?.rating, props.solution?.lecturerComment]); + }, [props.student.userId, props.task.id, props.solution?.id, props.solution?.rating, props.solution?.lecturerComment, props.solution?.requiresRevision]); useEffect(() => { if (!hasCriteria || !state.addBonusPoints || !state.clickedForRate || !criteriaModified) return; @@ -536,7 +540,8 @@ const TaskSolutionComponent: FC = (props) => { RatingStorage.set(storageKey, { points: state.points, - comment: state.lecturerComment + comment: state.lecturerComment, + requiresRevision: state.requiresRevision, }); const draft: CriteriaDraft = { @@ -551,6 +556,7 @@ const TaskSolutionComponent: FC = (props) => { }, [ state.points, state.lecturerComment, + state.requiresRevision, criterionRatings, extraScore, state.clickedForRate, @@ -570,7 +576,7 @@ const TaskSolutionComponent: FC = (props) => { props.solution.githubUrl && props.solution.githubUrl.startsWith("https://github.com/") - const checkAchievement = props.solution && props.isLastSolution && props.solution.state !== SolutionState.NUMBER_0 + const checkAchievement = props.solution && !props.solution.isRatingHidden && props.isLastSolution && props.solution.state !== SolutionState.NUMBER_0 const getAchievementState = async () => { setAchievementState(undefined) @@ -646,6 +652,7 @@ const TaskSolutionComponent: FC = (props) => { { rating: points, lecturerComment: finalComment, + requiresRevision: state.requiresRevision, } ); } else { @@ -658,6 +665,7 @@ const TaskSolutionComponent: FC = (props) => { publicationDate: undefined, rating: points, studentId: props.student.userId, + requiresRevision: state.requiresRevision, } ); } @@ -1193,21 +1201,28 @@ const TaskSolutionComponent: FC = (props) => { const sentAfterDeadline = solution && task.hasDeadline && getDatesDiff(solution.publicationDate!, task.deadlineDate!) - const showRatingPanel = props.forMentor || isRated + const showRatingPanel = props.forMentor || !!solution // Цвет рамки панели оценки — он же цвет перемычки к решению, чтобы связка читалась как одно целое - const ratingPanelColor = isRated || state.clickedForRate ? scoreColor : "#c4cad2" + const ratingIsHidden = !props.forMentor && !!solution?.isRatingHidden + const ratingPanelColor = !ratingIsHidden && (isRated || state.clickedForRate) ? scoreColor : "#c4cad2" const renderRatingPanel = () => { + // Студент видит статус сохранённой попытки, а не черновика преподавателя. + const studentReviewStatus = !isRated + ? {label: "На проверке", color: "warning" as const} + : solution?.requiresRevision + ? {label: "Требуется отправить исправленное решение", color: "error" as const} + : {label: "Проверено", color: "success" as const}; const percent = maxRating > 0 ? points * 100 / maxRating : 0 const isEditing = props.forMentor && state.clickedForRate - const showRateControlInBody = (!hasCriteria || !state.clickedForRate) && (!isRated || isEditing) - const showRateControlInHeader = (!hasCriteria || !state.clickedForRate) && isRated && !isEditing + const showRateControlInBody = !ratingIsHidden && (props.forMentor || isRated) && (!hasCriteria || !state.clickedForRate) && (!isRated || isEditing) + const showRateControlInHeader = !ratingIsHidden && (!hasCriteria || !state.clickedForRate) && isRated && !isEditing // Тон и цвет рамки берём и во время оценивания — оценка сразу видна по цвету панели const isScored = isRated || state.clickedForRate - const tone = isScored ? gradeTone(percent) : undefined + const tone = isScored && !ratingIsHidden ? gradeTone(percent) : undefined const hasLecturerComment = !!lecturerComment?.trim() const showCriteriaBlock = props.forMentor && hasCriteria && state.addBonusPoints && state.clickedForRate - const showQuickRateHint = !isRated && !state.clickedForRate && maxRating <= 10 && !addBonusPoints + const showQuickRateHint = props.forMentor && !isRated && !state.clickedForRate && maxRating <= 10 && !addBonusPoints const showLastRatingHint = lastRating !== undefined && state.clickedForRate const showLecturerInfo = !!(lecturerName && isRated) const showLecturerComment = (state.clickedForRate && props.forMentor) || (isRated && hasLecturerComment) @@ -1233,17 +1248,60 @@ const TaskSolutionComponent: FC = (props) => { {!isScored - ? solution && + ? props.forMentor && solution && : showRateControlInHeader && renderRateInput(false)} + {!props.forMentor && solution && + } {showRateControlInBody && renderRateInput(false)} {showCriteriaBlock && renderCriteriaBlock()} + {props.forMentor && + setState(prev => ({ + ...prev, + requiresRevision: checked, + clickedForRate: true, + }))} + /> + } + label="Запросить исправленное решение" + />} + {showQuickRateHint && Нажмите{" "} @@ -1358,6 +1416,7 @@ const TaskSolutionComponent: FC = (props) => { ...prevState, points: props.solution?.rating || 0, lecturerComment: storedCriteria.commentWithoutCriteria, + requiresRevision: props.solution?.requiresRevision ?? false, addBonusPoints: hasCriteria, clickedForRate: false, })); diff --git a/hwproj.front/src/components/Storages/RatingStorage.tsx b/hwproj.front/src/components/Storages/RatingStorage.tsx index 995d4260b..20e20987c 100644 --- a/hwproj.front/src/components/Storages/RatingStorage.tsx +++ b/hwproj.front/src/components/Storages/RatingStorage.tsx @@ -6,33 +6,43 @@ export interface RatingStorageKey { export interface RatingStorageValue { points: number, - comment: string + comment: string, + requiresRevision?: boolean } export class RatingStorage { private static getKeyString = ({solutionId, studentId, taskId}: RatingStorageKey) => { const commentKey = solutionId ? `${solutionId}` : `${taskId}/${studentId}` const pointsKey = `${commentKey}/points` - return {pointsKey, commentKey} + const requiresRevisionKey = `${commentKey}/requiresRevision` + return {pointsKey, commentKey, requiresRevisionKey} } static tryGet = (key: RatingStorageKey) => { - const {pointsKey, commentKey} = RatingStorage.getKeyString(key) + const {pointsKey, commentKey, requiresRevisionKey} = RatingStorage.getKeyString(key) const points = localStorage.getItem(pointsKey) const comment = localStorage.getItem(commentKey) - return points !== null || comment !== null - ? {points: Number(points), comment} + const requiresRevision = localStorage.getItem(requiresRevisionKey) + return points != null || comment != null || requiresRevision != null + ? { + points: Number(points), + comment, + requiresRevision: requiresRevision == null ? undefined : requiresRevision === "true" + } : null; } static clean = (key: RatingStorageKey) => { - const {pointsKey, commentKey} = RatingStorage.getKeyString(key) + const {pointsKey, commentKey, requiresRevisionKey} = RatingStorage.getKeyString(key) localStorage.removeItem(pointsKey) localStorage.removeItem(commentKey) + localStorage.removeItem(requiresRevisionKey) } - static set = (key: RatingStorageKey, {comment, points}: RatingStorageValue) => { - const {pointsKey, commentKey} = RatingStorage.getKeyString(key) + static set = (key: RatingStorageKey, {comment, points, requiresRevision}: RatingStorageValue) => { + const {pointsKey, commentKey, requiresRevisionKey} = RatingStorage.getKeyString(key) localStorage.setItem(pointsKey, points.toString()) localStorage.setItem(commentKey, comment) + if (requiresRevision === undefined) localStorage.removeItem(requiresRevisionKey) + else localStorage.setItem(requiresRevisionKey, requiresRevision.toString()) } } diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index 3eb2eb509..b3a8414e7 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -943,7 +943,7 @@ const CourseTaskExperimental: FC<{ } - label={!props.isMentor && props.isStudentAccepted ? (props.rating ?? 0) + " / " + task.maxRating : task.maxRating} + label={!props.isMentor && task.hideRatings ? "Баллы скрыты" : !props.isMentor && props.isStudentAccepted ? (props.rating ?? 0) + " / " + task.maxRating : task.maxRating} sx={accentChipSx} /> diff --git a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx index de5c78cd1..ee48b9beb 100644 --- a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx +++ b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx @@ -36,7 +36,9 @@ const StudentStatsCell: FC + (props.isBestSolution ? "\n Первое решение с лучшей оценкой" : "") + `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; - const result = cellState.lastRatedSolution === undefined + const result = cellState.lastSolution?.isRatingHidden + ? StudentStatsUtils.reviewSymbol(cellState.lastSolution) + : cellState.lastRatedSolution === undefined ? "" : diff --git a/hwproj.front/src/components/Tasks/TaskDeadlines.tsx b/hwproj.front/src/components/Tasks/TaskDeadlines.tsx index a3870c446..d27dfdab8 100644 --- a/hwproj.front/src/components/Tasks/TaskDeadlines.tsx +++ b/hwproj.front/src/components/Tasks/TaskDeadlines.tsx @@ -59,13 +59,16 @@ const getPercent = (startDate: Date, endDate: Date) => { return clamp((currentDateNumber - startDateNumber) * 100 / (endDateNumber - startDateNumber), 0, 100) } -const renderBadge = (solutionState: TaskDeadlineView["solutionState"], rating: number, maxRating: number) => { +const renderBadge = (solutionState: TaskDeadlineView["solutionState"], rating: number, maxRating: number, isRatingHidden?: boolean, requiresRevision?: boolean) => { if (solutionState === null) return if (solutionState === 0) //POSTED return + if (isRatingHidden) return + const color = colorBetween(0xff0000, 0x2cba00, Math.min(rating, maxRating) / maxRating * 100) return {deadline.taskTitle}{getTip(deadline)} - {!deadlinePast && renderBadge(solutionState, rating!, deadline.maxRating!)} + {!deadlinePast && renderBadge(solutionState, rating!, deadline.maxRating!, view.isRatingHidden, view.requiresRevision)} {deadline.courseTitle} diff --git a/hwproj.front/src/services/StudentStatsUtils.ts b/hwproj.front/src/services/StudentStatsUtils.ts index b9d7ff282..51dfe5e73 100644 --- a/hwproj.front/src/services/StudentStatsUtils.ts +++ b/hwproj.front/src/services/StudentStatsUtils.ts @@ -5,6 +5,13 @@ import {grey} from "@mui/material/colors"; export default class StudentStatsUtils { + static reviewLabel = (solution: Pick) => + solution.state === SolutionState.NUMBER_0 ? "На проверке" + : solution.requiresRevision ? "Требуется отправить исправленное решение" : "Проверено" + + static reviewSymbol = (solution: Pick) => + solution.state === SolutionState.NUMBER_0 ? "…" : solution.requiresRevision ? "↻" : "✓" + static getRatingColor = (rating: number, maxRating: number) => colorBetween(0xff0000, 0x2cba00, Math.min(rating, maxRating) / maxRating * 100) @@ -30,6 +37,15 @@ export default class StudentStatsUtils { const lastSolution = solutions!.slice(-1)[0] const lastRatedSolution = ratedSolutions.slice(-1)[0] + if (lastSolution?.isRatingHidden && !disabled && !isDeferred) { + return { + lastRatedSolution, lastSolution, ratedSolutionsCount, + solutionsDescription: StudentStatsUtils.reviewLabel(lastSolution), + color: lastSolution.state === SolutionState.NUMBER_0 ? "#fff8e1" + : lastSolution.requiresRevision ? "#ffebee" : "#edf7ed", + } + } + let solutionsDescription: string if (disabled) solutionsDescription = "Задача недоступна для этого студента"