From cd491e77f5eba1460899af5c26b414759a3c90d0 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 17:21:52 +0300 Subject: [PATCH 1/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=84=D0=BB=D0=B0=D0=B6=D0=BA=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/sql/solution-requires-revision-local.sql | 22 +++++ .../SolutionsService/RateSolutionModel.cs | 1 + .../SolutionsService/SolutionViewModel.cs | 2 + .../Controllers/SolutionsController.cs | 5 +- ...00000_SolutionRequiresRevision.Designer.cs | 77 ++++++++++++++++ ...20261105000000_SolutionRequiresRevision.cs | 23 +++++ .../SolutionContextModelSnapshot.cs | 4 +- .../Models/Solution.cs | 2 + .../Repositories/ISolutionsRepository.cs | 2 +- .../Repositories/SolutionsRepository.cs | 3 +- .../Services/ISolutionsService.cs | 3 +- .../Services/SolutionsService.cs | 13 ++- .../RequiresRevisionTests.cs | 90 +++++++++++++++++++ 13 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 Docs/sql/solution-requires-revision-local.sql create mode 100644 HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.Designer.cs create mode 100644 HwProj.SolutionsService/HwProj.SolutionsService.API/Migrations/20261105000000_SolutionRequiresRevision.cs create mode 100644 HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs 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.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/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.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 e457d0150..a8789ef59 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; } @@ -159,10 +162,12 @@ public async Task PostEmptySolutionWithRateAsync(long taskId, Solution solution) solution.RatingDate = currentTime; solution.TaskId = taskId; var id = await _solutionsRepository.AddAsync(solution); - await RateSolutionAsync(id, solution.LecturerId!, solution.Rating, solution.LecturerComment); + await RateSolutionAsync(id, solution.LecturerId!, solution.Rating, solution.LecturerComment, + 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) { @@ -172,11 +177,13 @@ public async Task RateSolutionAsync(long solutionId, string lecturerId, int newR var course = await _coursesServiceClient.GetCourseByTask(solution.TaskId); var state = newRating >= task.MaxRating ? SolutionState.Final : SolutionState.Rated; await _solutionsRepository - .RateSolutionAsync(solutionId, state, lecturerId, newRating, ratingDate, lecturerComment); + .RateSolutionAsync(solutionId, state, lecturerId, newRating, ratingDate, lecturerComment, + requiresRevision); var solutionModel = _mapper.Map(solution); solutionModel.LecturerComment = lecturerComment; solutionModel.Rating = newRating; + solutionModel.RequiresRevision = requiresRevision; _eventBus.Publish(new RateEvent(task, solutionModel, course?.Id ?? 0)); } } diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs new file mode 100644 index 000000000..7635f7891 --- /dev/null +++ b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs @@ -0,0 +1,90 @@ +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 SolutionRatingService = HwProj.SolutionsService.API.Services.SolutionsService; + +namespace HwProj.SolutionsService.IntegrationTests +{ + [TestFixture] + public class RequiresRevisionTests + { + [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(); + } + } + } +} From fbd14c11a65ce3acda7c213e44de335e1e321c0b Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 17:29:22 +0300 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BB=D0=B0=D0=B6=D0=BA=D0=B0=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Controllers/AccountController.cs | 1 + .../Models/Tasks/TaskDeadlineView.cs | 1 + .../SolutionsService/GetSolutionModel.cs | 3 ++ .../SolutionsService/SolutionDto.cs | 1 + .../RequiresRevisionTests.cs | 32 +++++++++++++++++++ 6 files changed, 39 insertions(+) diff --git a/.gitignore b/.gitignore index dae2af57f..3a75088e0 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,4 @@ swagger-codegen hwproj.front/static_dist/ hwproj.front/dist/ .DS_Store +Docs/revision-status-progress.md diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs index cf6c21e1f..14626440c 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs @@ -78,6 +78,7 @@ public async Task GetUserData() SolutionState = t.solution?.State, MaxRating = t.deadline.MaxRating, Rating = t.solution?.Rating, + RequiresRevision = t.solution?.RequiresRevision ?? false, DeadlinePast = currentTime > t.deadline.DeadlineDate }).ToArray(); diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs index 015ed4265..2fd19acf1 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Models/Tasks/TaskDeadlineView.cs @@ -8,6 +8,7 @@ public class TaskDeadlineView public TaskDeadlineDto Deadline { get; set; } public SolutionState? SolutionState { get; set; } public long? Rating { get; set; } + public bool RequiresRevision { get; set; } public long MaxRating { get; set; } public bool DeadlinePast { get; set; } } diff --git a/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs b/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs index a06a96380..cb029ce6b 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs @@ -21,6 +21,7 @@ public GetSolutionModel(SolutionDto model, AccountDataDto[]? groupMates, Account PublicationDate = model.PublicationDate; IsModified = model.IsModified; Rating = model.Rating; + RequiresRevision = model.RequiresRevision; StudentId = model.StudentId; TaskId = model.TaskId; State = model.State; @@ -40,6 +41,8 @@ public GetSolutionModel(SolutionDto model, AccountDataDto[]? groupMates, Account public int Rating { get; set; } + public bool RequiresRevision { get; set; } + public string StudentId { get; set; } public long TaskId { get; set; } diff --git a/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs b/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs index 6817cc970..bd545af87 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs @@ -9,6 +9,7 @@ public class SolutionDto public string Comment { get; set; } public SolutionState State { get; set; } public int Rating { 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.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs index 7635f7891..360e12f5b 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs +++ b/HwProj.SolutionsService/HwProj.SolutionsService.IntegrationTests/RequiresRevisionTests.cs @@ -14,6 +14,9 @@ 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 @@ -21,6 +24,35 @@ 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)] From bfe631cb31ad5f86b693250200b7044a89e58a77 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 17:37:01 +0300 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20TypeScript-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BB=D0=B0=D0=B6=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate-swagger-client.ps1 | 4 ++++ hwproj.front/src/api/api.ts | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) 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 e42934f1c..91c16fda7 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -940,6 +940,12 @@ export interface GetSolutionModel { * @memberof GetSolutionModel */ rating?: number; + /** + * + * @type {boolean} + * @memberof GetSolutionModel + */ + requiresRevision?: boolean; /** * * @type {string} @@ -1732,6 +1738,15 @@ export interface PostTaskViewModel { */ criteria?: Array; } +/** + * + * @export + * @interface ProblemDetails + */ +export interface ProblemDetails { + [key: string]: any; + +} /** * * @export @@ -1788,6 +1803,12 @@ export interface RateSolutionModel { * @memberof RateSolutionModel */ lecturerComment?: string; + /** + * + * @type {boolean} + * @memberof RateSolutionModel + */ + requiresRevision?: boolean; } /** * @@ -2037,6 +2058,12 @@ export interface SolutionDto { * @memberof SolutionDto */ rating?: number; + /** + * + * @type {boolean} + * @memberof SolutionDto + */ + requiresRevision?: boolean; /** * * @type {string} @@ -2229,6 +2256,12 @@ export interface SolutionViewModel { * @memberof SolutionViewModel */ rating?: number; + /** + * + * @type {boolean} + * @memberof SolutionViewModel + */ + requiresRevision?: boolean; /** * * @type {Date} @@ -2567,6 +2600,12 @@ export interface TaskDeadlineView { * @memberof TaskDeadlineView */ rating?: number; + /** + * + * @type {boolean} + * @memberof TaskDeadlineView + */ + requiresRevision?: boolean; /** * * @type {number} From ec32f7e22037631faba658f84d3bf84174add613 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 17:50:42 +0300 Subject: [PATCH 4/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=84=D0=BB=D0=B0=D0=B6=D0=BE=D0=BA=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83=20=D0=BE=D1=86=D0=B5=D0=BD=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Solutions/TaskSolutionComponent.tsx | 30 +++++++++++++++++-- .../src/components/Storages/RatingStorage.tsx | 26 +++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 9c3a7b9f1..86189936b 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 } @@ -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, @@ -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, } ); } @@ -1244,6 +1252,23 @@ const TaskSolutionComponent: FC = (props) => { {showCriteriaBlock && renderCriteriaBlock()} + {props.forMentor && + setState(prev => ({ + ...prev, + requiresRevision: checked, + clickedForRate: true, + }))} + /> + } + label="Требуется исправление решения" + />} + {showQuickRateHint && Нажмите{" "} @@ -1358,6 +1383,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()) } } From 9b24d25bb7af9610dd3ff033ec02f5cdc9febe02 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 18:09:08 +0300 Subject: [PATCH 5/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=83?= =?UTF-8?q?=20=D1=81=D1=82=D1=83=D0=B4=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Solutions/TaskSolutionComponent.tsx | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 86189936b..4bdc95120 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1201,21 +1201,27 @@ 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 renderRatingPanel = () => { + // Студент видит статус сохранённой попытки, а не черновика преподавателя. + const studentReviewStatus = !isRated + ? {label: "На проверке", color: "warning" as const} + : solution?.requiresRevision + ? {label: "Требуется отправить исправленное решение", color: "warning" 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 showRateControlInBody = (props.forMentor || isRated) && (!hasCriteria || !state.clickedForRate) && (!isRated || isEditing) const showRateControlInHeader = (!hasCriteria || !state.clickedForRate) && isRated && !isEditing // Тон и цвет рамки берём и во время оценивания — оценка сразу видна по цвету панели const isScored = isRated || state.clickedForRate const tone = isScored ? 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) @@ -1241,13 +1247,31 @@ const TaskSolutionComponent: FC = (props) => { {!isScored - ? solution && + ? props.forMentor && solution && : showRateControlInHeader && renderRateInput(false)} + {!props.forMentor && solution && + } {showRateControlInBody && renderRateInput(false)} {showCriteriaBlock && renderCriteriaBlock()} @@ -1266,7 +1290,7 @@ const TaskSolutionComponent: FC = (props) => { }))} /> } - label="Требуется исправление решения" + label="Запросить исправленное решение" />} {showQuickRateHint && From db283f1414503f56dba88770c9a1b4f77ea0a882 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Thu, 5 Nov 2026 18:16:57 +0300 Subject: [PATCH 6/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=86=D0=B2=D0=B5=D1=82=D0=BD=D1=8B=D0=B5=20=D1=84?= =?UTF-8?q?=D0=BE=D0=BD=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Solutions/TaskSolutionComponent.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx index 4bdc95120..7faa66d23 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -1210,7 +1210,7 @@ const TaskSolutionComponent: FC = (props) => { const studentReviewStatus = !isRated ? {label: "На проверке", color: "warning" as const} : solution?.requiresRevision - ? {label: "Требуется отправить исправленное решение", color: "warning" as const} + ? {label: "Требуется отправить исправленное решение", color: "error" as const} : {label: "Проверено", color: "success" as const}; const percent = maxRating > 0 ? points * 100 / maxRating : 0 const isEditing = props.forMentor && state.clickedForRate @@ -1264,10 +1264,18 @@ const TaskSolutionComponent: FC = (props) => { alignSelf: "flex-start", maxWidth: "100%", height: "auto", - ...(!isRated && { + ...(!isRated ? { backgroundColor: "#fff8e1", borderColor: "#f0cf65", color: "#795500", + } : solution.requiresRevision ? { + backgroundColor: "#ffebee", + borderColor: "#e57373", + color: "#b71c1c", + } : { + backgroundColor: "#edf7ed", + borderColor: "#b7d9b9", + color: "#255d2a", }), "& .MuiChip-label": {whiteSpace: "normal", py: 0.5}, }} From cf38b149816a27e006be3b3a6ee80844149055f2 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Fri, 6 Nov 2026 17:33:39 +0300 Subject: [PATCH 7/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8E=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=BB=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=83=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Docs/sql/homework-hide-ratings-local.sql | 13 + .../Controllers/AccountController.cs | 17 + .../Controllers/NotificationsController.cs | 31 +- .../Controllers/SolutionsController.cs | 27 +- .../Controllers/StatisticsController.cs | 15 +- .../AdvancedCourseStatisticsViewModel.cs | 1 + .../Models/Tasks/TaskDeadlineView.cs | 1 + .../Services/GradeVisibility.cs | 40 ++ .../GradeVisibilityTests.cs | 223 ++++++++ ...roj.APIGateway.GradeVisibilityTests.csproj | 14 + .../ViewModels/HomeworkTaskViewModels.cs | 2 + .../ViewModels/HomeworkViewModels.cs | 4 + .../SolutionsService/GetSolutionModel.cs | 4 +- .../SolutionsService/SolutionCommentUtils.cs | 21 + .../SolutionsService/SolutionDto.cs | 3 +- .../Domains/MappingExtensions.cs | 5 + ...1105000100_HomeworkHideRatings.Designer.cs | 491 ++++++++++++++++++ .../20261105000100_HomeworkHideRatings.cs | 18 + .../Migrations/CourseContextModelSnapshot.cs | 3 + .../Models/Homework.cs | 2 + .../Models/HomeworkTemplate.cs | 2 + .../Services/HomeworksService.cs | 1 + .../EventHandlers/RateEventHandler.cs | 14 +- HwProj.sln | 7 + hwproj.front/src/api/api.ts | 42 ++ .../components/Courses/CourseExperimental.tsx | 4 +- .../Courses/Statistics/StudentStatsChart.tsx | 20 +- .../src/components/Courses/StudentStats.tsx | 10 +- .../Homeworks/CourseHomeworkExperimental.tsx | 11 +- .../Solutions/TaskSolutionComponent.tsx | 13 +- .../Tasks/CourseTaskExperimental.tsx | 2 +- .../src/components/Tasks/StudentStatsCell.tsx | 4 +- .../src/components/Tasks/TaskDeadlines.tsx | 7 +- .../src/services/StudentStatsUtils.ts | 16 + 35 files changed, 1053 insertions(+), 36 deletions(-) create mode 100644 Docs/sql/homework-hide-ratings-local.sql create mode 100644 HwProj.APIGateway/HwProj.APIGateway.API/Services/GradeVisibility.cs create mode 100644 HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/GradeVisibilityTests.cs create mode 100644 HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/HwProj.APIGateway.GradeVisibilityTests.csproj create mode 100644 HwProj.Common/HwProj.Models/SolutionsService/SolutionCommentUtils.cs create mode 100644 HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.Designer.cs create mode 100644 HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20261105000100_HomeworkHideRatings.cs diff --git a/.gitignore b/.gitignore index 3a75088e0..0770b5c08 100644 --- a/.gitignore +++ b/.gitignore @@ -364,3 +364,4 @@ hwproj.front/static_dist/ hwproj.front/dist/ .DS_Store Docs/revision-status-progress.md +Docs/hidden-grades-implementation.md 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/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs index 14626440c..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,7 @@ 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 8a4dc0a89..ca44428e8 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.Roles; using HwProj.NotificationsService.Client; @@ -15,10 +20,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")] @@ -33,6 +40,28 @@ 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("оценена на")) 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 2fd19acf1..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,7 @@ 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..9684beaff --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/GradeVisibilityTests.cs @@ -0,0 +1,223 @@ +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)] + public async Task ExistingNotificationCannotRevealPreviouslyStoredScore(string viewer, bool hidden) + { + var courses = new Mock(); + courses.Setup(c => c.GetCourseByTask(3)).ReturnsAsync(Course(true)); + var note = new NotificationViewModel + { + Body = "Задача Задача оценена на 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..b630dad71 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.GradeVisibilityTests/HwProj.APIGateway.GradeVisibilityTests.csproj @@ -0,0 +1,14 @@ + + + net8.0 + false + latest + + + + + + + + + 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 cb029ce6b..a3733d097 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/GetSolutionModel.cs @@ -21,6 +21,7 @@ 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; @@ -39,7 +40,8 @@ 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; } 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 bd545af87..a55bff21d 100644 --- a/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs +++ b/HwProj.Common/HwProj.Models/SolutionsService/SolutionDto.cs @@ -8,7 +8,8 @@ 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; } 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 914d4ea4c..0d4305bf7 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 d3e1ee168..b40665fc3 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RateEventHandler.cs @@ -3,6 +3,7 @@ using HwProj.AuthService.Client; 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; @@ -31,17 +32,22 @@ public RateEventHandler( public override async Task HandleAsync(RateEvent @event) { - var commentBody = string.IsNullOrWhiteSpace(@event.Solution.LecturerComment) + var comment = @event.Task.HideRatings + ? SolutionCommentUtils.WithoutCriterionScores(@event.Solution.LecturerComment) + : @event.Solution.LecturerComment; + var outcome = @event.Task.HideRatings + ? (@event.Solution.RequiresRevision ? "требует отправки исправленного решения." : "проверена.") + : $"оценена на {@event.Solution.Rating}/{@event.Task.MaxRating}."; + var commentBody = string.IsNullOrWhiteSpace(comment) ? "" : "

Комментарий преподавателя:" + - $"
{@event.Solution.LecturerComment}"; + $"
{comment}"; var notification = new Notification { Sender = "SolutionService", Body = - $"Задача {@event.Task.Title} оценена на " + - $"{@event.Solution.Rating}/{@event.Task.MaxRating}." + + $"Задача {@event.Task.Title} {outcome}" + $"{commentBody}", Category = CategoryState.Homeworks, Date = @event.Solution.RatingDate ?? DateTime.UtcNow, 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/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index 91c16fda7..972a2be74 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,12 @@ export interface GetSolutionModel { * @memberof GetSolutionModel */ rating?: number; + /** + * + * @type {boolean} + * @memberof GetSolutionModel + */ + isRatingHidden?: boolean; /** * * @type {boolean} @@ -1140,6 +1158,12 @@ export interface HomeworkTaskForEditingViewModel { * @interface HomeworkTaskViewModel */ export interface HomeworkTaskViewModel { + /** + * + * @type {boolean} + * @memberof HomeworkTaskViewModel + */ + hideRatings?: boolean; /** * * @type {number} @@ -1287,6 +1311,12 @@ export interface HomeworkUserTaskSolutions { * @interface HomeworkViewModel */ export interface HomeworkViewModel { + /** + * + * @type {boolean} + * @memberof HomeworkViewModel + */ + hideRatings?: boolean; /** * * @type {number} @@ -2058,6 +2088,12 @@ export interface SolutionDto { * @memberof SolutionDto */ rating?: number; + /** + * + * @type {boolean} + * @memberof SolutionDto + */ + isRatingHidden?: boolean; /** * * @type {boolean} @@ -2600,6 +2636,12 @@ export interface TaskDeadlineView { * @memberof TaskDeadlineView */ rating?: number; + /** + * + * @type {boolean} + * @memberof TaskDeadlineView + */ + isRatingHidden?: boolean; /** * * @type {boolean} 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 2f51c4c62..ec47f3ad6 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -465,6 +465,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 @@ -550,9 +552,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}/>} } @@ -561,9 +563,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 7abdfc7a2..30e6cb054 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 7faa66d23..c7037a632 100644 --- a/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx +++ b/hwproj.front/src/components/Solutions/TaskSolutionComponent.tsx @@ -377,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 @@ -576,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) @@ -1203,7 +1203,8 @@ const TaskSolutionComponent: FC = (props) => { 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 = () => { // Студент видит статус сохранённой попытки, а не черновика преподавателя. @@ -1214,11 +1215,11 @@ const TaskSolutionComponent: FC = (props) => { : {label: "Проверено", color: "success" as const}; const percent = maxRating > 0 ? points * 100 / maxRating : 0 const isEditing = props.forMentor && state.clickedForRate - const showRateControlInBody = (props.forMentor || isRated) && (!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 = props.forMentor && !isRated && !state.clickedForRate && maxRating <= 10 && !addBonusPoints diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index e87d0aa83..7261af0d4 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 = "Задача недоступна для этого студента" From b8bf2fa12daed3aea3df4ff441940d4765ed0930 Mon Sep 17 00:00:00 2001 From: aleksandrgarin Date: Wed, 11 Nov 2026 16:29:50 +0300 Subject: [PATCH 8/8] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B8=D0=B7=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0770b5c08..dae2af57f 100644 --- a/.gitignore +++ b/.gitignore @@ -363,5 +363,3 @@ swagger-codegen hwproj.front/static_dist/ hwproj.front/dist/ .DS_Store -Docs/revision-status-progress.md -Docs/hidden-grades-implementation.md