diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs index 4d1bbd78f..7225b64d7 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using HwProj.CoursesService.Client; using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.CoursesService.DTO; using HwProj.Models.Roles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -30,6 +31,16 @@ public async Task GetAllCourseGroups(long courseId) : Ok(result); } + [HttpGet("{courseId}/getAllWithNames")] + [ProducesResponseType(typeof(GroupWithNameDTO[]), (int)HttpStatusCode.OK)] + public async Task GetAllCourseGroupsWithNames(long courseId) + { + var result = await _coursesClient.GetAllCourseGroupsWithNames(courseId); + return result == null + ? NotFound() + : Ok(result); + } + [HttpPost("{courseId}/create")] [Authorize(Roles = Roles.LecturerRole)] [ProducesResponseType(typeof(long), (int)HttpStatusCode.OK)] diff --git a/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs b/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs new file mode 100644 index 000000000..89baefaf3 --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs @@ -0,0 +1,9 @@ +namespace HwProj.Models.CoursesService.DTO +{ + public class GroupWithNameDTO + { + public long Id { get; set; } + public string Name { get; set; } + public string[] StudentsIds { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs index 2c7b0a857..1a196e463 100644 --- a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs @@ -27,6 +27,8 @@ public class CreateHomeworkViewModel public List Tasks { get; set; } = new List(); public ActionOptions? ActionOptions { get; set; } + + public long? GroupId { get; set; } } public class HomeworkViewModel @@ -58,5 +60,7 @@ public class HomeworkViewModel public List Tags { get; set; } = new List(); public List Tasks { get; set; } = new List(); + + public long? GroupId { get; set; } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs index 845b0e146..58d5effea 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs @@ -21,6 +21,10 @@ public AutomapperProfile() CreateMap(); CreateMap(); + + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); } } } \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs index f99ce1d42..d46c1a450 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using AutoMapper; using HwProj.CoursesService.API.Filters; using HwProj.CoursesService.API.Models; using HwProj.CoursesService.API.Services; +using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using Microsoft.AspNetCore.Mvc; @@ -36,6 +38,19 @@ public async Task GetAll(long courseId) return result; } + [HttpGet("{courseId}/getAllWithNames")] + public async Task GetAllWithNames(long courseId) + { + var groups = await _groupsService.GetAllAsync(courseId); + var result = groups.Select(t => new GroupWithNameDTO + { + Id = t.Id, + Name = t.Name, + StudentsIds = t.GroupMates.Select(s => s.StudentId).ToArray() + }).ToArray(); + return result; + } + [HttpPost("{courseId}/create")] public async Task CreateGroup([FromBody] CreateGroupViewModel groupViewModel) { diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs index a6c321f66..152bf0410 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs @@ -32,6 +32,7 @@ public static HomeworkViewModel ToHomeworkViewModel(this Homework homework) IsDeferred = DateTime.UtcNow < homework.PublicationDate, Tasks = homework.Tasks.Select(t => t.ToHomeworkTaskViewModel()).ToList(), Tags = tags.ToList(), + GroupId = homework.GroupId, }; } @@ -147,6 +148,7 @@ public static Homework ToHomework(this CreateHomeworkViewModel homework) PublicationDate = homework.PublicationDate, Tasks = homework.Tasks.Select(t => t.ToHomeworkTask()).ToList(), Tags = string.Join(";", homework.Tags), + GroupId = homework.GroupId, }; public static CourseTemplate ToCourseTemplate(this CreateCourseViewModel createCourseViewModel) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs index 2a4dda861..6cade85ea 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs @@ -112,6 +112,11 @@ public static List ValidateHomework(CreateHomeworkViewModel homework, Ho errors.Add("Нельзя изменить дату публикации домашнего задания, если она уже показана студента"); } + if (previousState.GroupId != homework.GroupId) + { + errors.Add("Нельзя изменить группу для домашнего задания, если оно уже опубликовано"); + } + return errors; } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs index 455c411a8..466bacaf2 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs @@ -25,5 +25,7 @@ public class Homework : IEntity public long CourseId { get; set; } public List Tasks { get; set; } + + public long? GroupId { get; set; } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs index aa663be58..6c41b669b 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs @@ -27,6 +27,14 @@ public async Task GetAllWithTasksByCourseAsync(long courseId) .ToArrayAsync(); } + public async Task GetWithTasksAsync(long[] homeworkIds, bool withCriteria = false) + { + var query = Context.Set().AsNoTracking().Include(h => h.Tasks); + return withCriteria + ? await query.ThenInclude(x => x.Criteria).Where(h => homeworkIds.Contains(h.Id)).ToArrayAsync() + : await query.Where(h => homeworkIds.Contains(h.Id)).ToArrayAsync(); + } + public async Task GetWithTasksAsync(long id, bool withCriteria = false) { var query = Context.Set().AsNoTracking().Include(h => h.Tasks); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs index 4c05afdaf..d887b6d21 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs @@ -8,6 +8,7 @@ public interface IHomeworksRepository : ICrudRepository { Task GetAllWithTasksAsync(); Task GetAllWithTasksByCourseAsync(long courseId); + Task GetWithTasksAsync(long[] homeworkIds, bool withCriteria = false); Task GetWithTasksAsync(long id, bool withCriteria = false); } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index b47139ee0..1a059fd93 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -6,17 +6,28 @@ using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using HwProj.Models.Result; +using System.Collections.Generic; +using HwProj.CoursesService.API.Domains; namespace HwProj.CoursesService.API.Services { + public enum ApplyFilterType + { + Intersect, + Union, + Subtract + } public class CourseFilterService : ICourseFilterService { private readonly ICourseFilterRepository _courseFilterRepository; + private readonly IHomeworksService _homeworksService; public CourseFilterService( - ICourseFilterRepository courseFilterRepository) + ICourseFilterRepository courseFilterRepository, + IHomeworksService homeworksService) { _courseFilterRepository = courseFilterRepository; + _homeworksService = homeworksService; } public async Task> CreateOrUpdateCourseFilter(CreateCourseFilterModel courseFilterModel) @@ -59,16 +70,12 @@ public async Task ApplyFiltersToCourses(string userId, CourseDTO[] { var courseIds = courses.Select(c => c.Id).ToArray(); - var filters = (await _courseFilterRepository.GetAsync(userId, courseIds)) - .ToDictionary(x => x.CourseId, x => x.CourseFilter); - - return courses - .Select(course => - { - filters.TryGetValue(course.Id, out var courseFilter); - return ApplyFilterInternal(course, courseFilter); - }) - .ToArray(); + var result = new List(); + foreach (var course in courses) + { + result.Add(await ApplyFilter(course, userId)); + } + return result.ToArray(); } public async Task ApplyFilter(CourseDTO courseDto, string userId) @@ -83,8 +90,21 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) (await _courseFilterRepository.GetAsync(findFiltersFor, courseDto.Id)) .ToDictionary(x => x.UserId, x => x.CourseFilter); + if (!isMentor) + { + var studentCourse = courseDto; + var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); // Глобальный фильтр для вычитания групповых домашних заданий + if (groupFilter != null) + { + studentCourse = await ApplyFilterInternal(courseDto, groupFilter, ApplyFilterType.Subtract); + } + return courseFilters.TryGetValue(userId, out var studentFilter) + ? await ApplyFilterInternal(studentCourse, studentFilter, ApplyFilterType.Union) + : studentCourse; + } + var course = courseFilters.TryGetValue(userId, out var userFilter) - ? ApplyFilterInternal(courseDto, userFilter) + ? await ApplyFilterInternal(courseDto, userFilter, ApplyFilterType.Intersect) : courseDto; if (isMentor || !isCourseStudent) return course; @@ -123,7 +143,7 @@ private async Task AddCourseFilter(Filter filter, long courseId, string us return courseFilterId; } - private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseFilter) + private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseFilter, ApplyFilterType filterType) { var filter = courseFilter?.Filter; @@ -132,6 +152,29 @@ private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseF return courseDto; } + var homeworks = filter.HomeworkIds.Any() + ? filterType switch + { + ApplyFilterType.Intersect => courseDto.Homeworks + .Where(hw => filter.HomeworkIds.Contains(hw.Id)) + .ToArray(), + + ApplyFilterType.Subtract => courseDto.Homeworks + .Where(hw => !filter.HomeworkIds.Contains(hw.Id)) + .ToArray(), + + ApplyFilterType.Union => courseDto.Homeworks + .Concat((await _homeworksService.GetHomeworksAsync(filter.HomeworkIds.ToArray())) + .Where(hw => hw != null) + .Select(hw => hw.ToHomeworkViewModel())) + .GroupBy(hw => hw.Id) + .Select(g => g.First()) + .ToArray(), + + _ => courseDto.Homeworks + } + : courseDto.Homeworks; + return new CourseDTO { Id = courseDto.Id, @@ -164,10 +207,7 @@ private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseF ? courseDto.CourseMates .Where(mate => !mate.IsAccepted || filter.StudentIds.Contains(mate.StudentId)).ToArray() : courseDto.CourseMates, - Homeworks = - filter.HomeworkIds.Any() - ? courseDto.Homeworks.Where(hw => filter.HomeworkIds.Contains(hw.Id)).ToArray() - : courseDto.Homeworks + Homeworks = homeworks.OrderBy(hw => hw.PublicationDate).ToArray() }; } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs index 37b1173d8..ad543f173 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using AutoMapper; using HwProj.CoursesService.API.Models; @@ -28,7 +30,10 @@ public GroupsService(IGroupsRepository groupsRepository, public async Task GetAllAsync(long courseId) { - return await _groupsRepository.GetGroupsWithGroupMatesByCourse(courseId).ToArrayAsync().ConfigureAwait(false); + return await _groupsRepository.GetGroupsWithGroupMatesByCourse(courseId) + .AsNoTracking() + .ToArrayAsync() + .ConfigureAwait(false); } public async Task GetGroupsAsync(params long[] groupIds) @@ -63,19 +68,33 @@ public async Task DeleteGroupAsync(long groupId) public async Task UpdateAsync(long groupId, Group updated) { - var group = await _groupsRepository.GetAsync(groupId); - group.GroupMates.RemoveAll(cm => true); - group.Tasks.RemoveAll(cm => true); + var group = (await _groupsRepository.GetGroupsWithGroupMatesAsync(new[] { groupId })) + .FirstOrDefault() ?? throw new InvalidOperationException($"Group with id {groupId} not found"); - updated.GroupMates.ForEach(cm => cm.GroupId = groupId); - updated.Tasks.ForEach(cm => cm.GroupId = groupId); - var mateTasks = updated.GroupMates.Select(cm => _groupMatesRepository.AddAsync(cm)); - var idTasks = updated.Tasks.Select(cm => _taskModelsRepository.AddAsync(cm)); + foreach (var groupMate in group.GroupMates.ToList()) + { + await _groupMatesRepository.DeleteAsync(groupMate.Id); + } + + foreach (var task in group.Tasks.ToList()) + { + await _taskModelsRepository.DeleteAsync(task.Id); + } + + updated.GroupMates?.ForEach(cm => cm.GroupId = groupId); + updated.Tasks?.ForEach(cm => cm.GroupId = groupId); group.Name = updated.Name; - await Task.WhenAll(mateTasks); - await Task.WhenAll(idTasks); + if (updated.GroupMates != null && updated.GroupMates.Count > 0) + { + await _groupMatesRepository.AddRangeAsync(updated.GroupMates).ConfigureAwait(false); + } + + if (updated.Tasks != null && updated.Tasks.Count > 0) + { + await _taskModelsRepository.AddRangeAsync(updated.Tasks).ConfigureAwait(false); + } } public async Task DeleteGroupMateAsync(long groupId, string studentId) @@ -107,13 +126,14 @@ public async Task GetStudentGroupsAsync(long courseId, s .ToArrayAsync() .ConfigureAwait(false); - var getStudentGroupsTask = studentGroupsIds - .Select(async id => await _groupsRepository.GetAsync(id).ConfigureAwait(false)) - .Where(cm => cm.Result.CourseId == courseId) - .ToArray(); - var studentGroups = await Task.WhenAll(getStudentGroupsTask).ConfigureAwait(false); + var studentGroups = await _groupsRepository + .GetGroupsWithGroupMatesAsync(studentGroupsIds) + .ConfigureAwait(false); - return studentGroups.Select(c => _mapper.Map(c)).ToArray(); + return studentGroups + .Where(g => g.CourseId == courseId) + .Select(c => _mapper.Map(c)) + .ToArray(); } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 76844defc..69e677b88 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -8,6 +8,9 @@ using HwProj.Models; using HwProj.Models.CoursesService.ViewModels; using HwProj.NotificationService.Events.CoursesService; +using HwProj.CoursesService.API.Repositories.Groups; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; namespace HwProj.CoursesService.API.Services { @@ -16,13 +19,17 @@ public class HomeworksService : IHomeworksService private readonly IHomeworksRepository _homeworksRepository; private readonly IEventBus _eventBus; private readonly ICoursesRepository _coursesRepository; + private readonly IGroupMatesRepository _groupMatesRepository; + private readonly ICourseFilterRepository _courseFilterRepository; - public HomeworksService(IHomeworksRepository homeworksRepository, IEventBus eventBus, - ICoursesRepository coursesRepository) + public HomeworksService(IHomeworksRepository homeworksRepository, IEventBus eventBus, ICoursesRepository coursesRepository, + IGroupMatesRepository groupMatesRepository, IGroupsService groupsService, ICourseFilterRepository courseFilterRepository) { _homeworksRepository = homeworksRepository; _eventBus = eventBus; _coursesRepository = coursesRepository; + _groupMatesRepository = groupMatesRepository; + _courseFilterRepository = courseFilterRepository; } public async Task AddHomeworkAsync(long courseId, CreateHomeworkViewModel homeworkViewModel) @@ -32,14 +39,23 @@ public async Task AddHomeworkAsync(long courseId, CreateHomeworkViewMo homework.CourseId = courseId; var course = await _coursesRepository.GetWithCourseMatesAndHomeworksAsync(courseId); - var studentIds = course.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + var notificationStudentIds = course.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + + await _homeworksRepository.AddAsync(homework); + + if(homework.GroupId != null) + { + var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == homework.GroupId).ToListAsync(); + await UpdateGroupFilters(courseId, homework.Id, groupMates); + notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); + } + if (DateTime.UtcNow >= homework.PublicationDate) { - _eventBus.Publish(new NewHomeworkEvent(homework.Title, course.Name, course.Id, studentIds, + _eventBus.Publish(new NewHomeworkEvent(homework.Title, course.Name, course.Id, notificationStudentIds, homework.DeadlineDate)); } - await _homeworksRepository.AddAsync(homework); return await GetHomeworkAsync(homework.Id, withCriteria: true); } @@ -52,6 +68,18 @@ public async Task GetHomeworkAsync(long homeworkId, bool withCriteria return homework; } + public async Task GetHomeworksAsync(long[] homeworkIds, bool withCriteria = false) + { + var homeworks = await _homeworksRepository.GetWithTasksAsync(homeworkIds, withCriteria); + + foreach (var homework in homeworks) + { + CourseDomain.FillTasksInHomework(homework); + } + + return homeworks; + } + public async Task GetForEditingHomeworkAsync(long homeworkId) { var result = await _homeworksRepository.GetWithTasksAsync(homeworkId); @@ -60,6 +88,33 @@ public async Task GetForEditingHomeworkAsync(long homeworkId) public async Task DeleteHomeworkAsync(long homeworkId) { + var homework = await _homeworksRepository.GetAsync(homeworkId); + if (homework == null) return; + + var course = await _coursesRepository.GetWithCourseMates(homework.CourseId); + if (course == null) return; + + var courseUserIds = course.CourseMates.Select(cm => cm.StudentId).ToList(); + courseUserIds.Add(course.MentorIds); + courseUserIds.Add(""); + + // Удаляем homeworkId из фильтров всех участников курса + foreach (var userId in courseUserIds.Distinct()) + { + var userFilter = await _courseFilterRepository.GetAsync(userId, homework.CourseId); + + if (userFilter != null && userFilter.Filter.HomeworkIds.Contains(homeworkId)) + { + userFilter.Filter.HomeworkIds.Remove(homeworkId); + + await _courseFilterRepository.UpdateAsync(userFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = userFilter.Filter }.FilterJson + }); + } + } + await _homeworksRepository.DeleteAsync(homeworkId); } @@ -72,9 +127,20 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV var homework = await _homeworksRepository.GetAsync(homeworkId); var course = await _coursesRepository.GetWithCourseMates(homework.CourseId); var studentIds = course!.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + var notificationStudentIds = studentIds; + + if (update.GroupId != null) + { + var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == update.GroupId).ToListAsync(); + await UpdateGroupFilters(course.Id, homework.Id, groupMates); + + notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); + } if (options.SendNotification && update.PublicationDate <= DateTime.UtcNow) - _eventBus.Publish(new UpdateHomeworkEvent(update.Title, course.Id, course.Name, studentIds)); + { + _eventBus.Publish(new UpdateHomeworkEvent(update.Title, course.Id, course.Name, notificationStudentIds)); + } await _homeworksRepository.UpdateAsync(homeworkId, hw => new Homework() { @@ -84,12 +150,81 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV DeadlineDate = update.DeadlineDate, PublicationDate = update.PublicationDate, IsDeadlineStrict = update.IsDeadlineStrict, - Tags = update.Tags + Tags = update.Tags, + GroupId = update.GroupId }); var updatedHomework = await _homeworksRepository.GetWithTasksAsync(homeworkId); CourseDomain.FillTasksInHomework(updatedHomework); return updatedHomework; } + + private async Task UpdateGroupFilters(long courseId, long homeworkId, List groupMates) + { + // Добавление группового домашнего задания в глобальный фильтр курса + var globalFilter = await _courseFilterRepository.GetAsync("", courseId); + + if (globalFilter != null) + { + var filter = globalFilter.Filter; + if (!filter.HomeworkIds.Contains(homeworkId)) + { + filter.HomeworkIds.Add(homeworkId); + } + + await _courseFilterRepository.UpdateAsync(globalFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = filter }.FilterJson + }); + } + else + { + var newFilter = new Filter + { + StudentIds = new List(), + HomeworkIds = new List { homeworkId }, + MentorIds = new List(), + }; + + await _courseFilterRepository.AddAsync(new CourseFilter { Filter = newFilter }, "", courseId); + } + + // Добавление группового домашнего задания в персональные фильтры участников группы + foreach (var groupMate in groupMates) + { + var studentFilter = await _courseFilterRepository.GetAsync(groupMate.StudentId, courseId); + + if (studentFilter != null) + { + var filter = studentFilter.Filter; + if (!filter.HomeworkIds.Contains(homeworkId)) + { + filter.HomeworkIds.Add(homeworkId); + } + + await _courseFilterRepository.UpdateAsync(studentFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = filter }.FilterJson + }); + } + else + { + var newFilter = new Filter + { + StudentIds = new List(), + HomeworkIds = new List { homeworkId }, + MentorIds = new List() + }; + + await _courseFilterRepository.AddAsync( + new CourseFilter { Filter = newFilter }, + groupMate.StudentId, + courseId + ); + } + } + } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs index 2b2460c37..606fea6d7 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs @@ -10,6 +10,8 @@ public interface IHomeworksService Task GetHomeworkAsync(long homeworkId, bool withCriteria = false); + Task GetHomeworksAsync(long[] homeworkIds, bool withCriteria = false); + Task GetForEditingHomeworkAsync(long homeworkId); Task DeleteHomeworkAsync(long homeworkId); diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs index 2cbd55fd2..68d3c6d39 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs @@ -414,6 +414,16 @@ public async Task GetAllCourseGroups(long courseId) return await response.DeserializeAsync(); } + public async Task GetAllCourseGroupsWithNames(long courseId) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Get, + _coursesServiceUri + $"api/CourseGroups/{courseId}/getAllWithNames"); + + var response = await _httpClient.SendAsync(httpRequest); + return await response.DeserializeAsync(); + } + public async Task CreateCourseGroup(CreateGroupViewModel model, long courseId) { using var httpRequest = new HttpRequestMessage( diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs index da84eb73b..ddcde0582 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs @@ -37,6 +37,7 @@ Task UpdateStudentCharacteristics(long courseId, string studentId, Task DeleteTask(long taskId); Task> UpdateTask(long taskId, PostTaskViewModel taskViewModel); Task GetAllCourseGroups(long courseId); + Task GetAllCourseGroupsWithNames(long courseId); Task CreateCourseGroup(CreateGroupViewModel model, long courseId); Task DeleteCourseGroup(long courseId, long groupId); Task UpdateCourseGroup(UpdateGroupViewModel model, long courseId, long groupId); diff --git a/hwproj.front/src/api/ApiSingleton.ts b/hwproj.front/src/api/ApiSingleton.ts index df3527ba0..1886ef4ce 100644 --- a/hwproj.front/src/api/ApiSingleton.ts +++ b/hwproj.front/src/api/ApiSingleton.ts @@ -8,7 +8,8 @@ import { TasksApi, StatisticsApi, SystemApi, - FilesApi + FilesApi, + CourseGroupsApi } from "."; import AuthService from "../services/AuthService"; import CustomFilesApi from "./CustomFilesApi"; @@ -18,6 +19,7 @@ class Api { readonly accountApi: AccountApi; readonly expertsApi: ExpertsApi; readonly coursesApi: CoursesApi; + readonly courseGroupsApi: CourseGroupsApi; readonly solutionsApi: SolutionsApi; readonly notificationsApi: NotificationsApi; readonly homeworksApi: HomeworksApi; @@ -32,6 +34,7 @@ class Api { accountApi: AccountApi, expertsApi: ExpertsApi, coursesApi: CoursesApi, + courseGroupsApi: CourseGroupsApi, solutionsApi: SolutionsApi, notificationsApi: NotificationsApi, homeworksApi: HomeworksApi, @@ -45,6 +48,7 @@ class Api { this.accountApi = accountApi; this.expertsApi = expertsApi; this.coursesApi = coursesApi; + this.courseGroupsApi = courseGroupsApi; this.solutionsApi = solutionsApi; this.notificationsApi = notificationsApi; this.homeworksApi = homeworksApi; @@ -78,6 +82,7 @@ ApiSingleton = new Api( new AccountApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new ExpertsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new CoursesApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), + new CourseGroupsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new SolutionsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new NotificationsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new HomeworksApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index e198db7c5..3c6582c04 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -1065,6 +1065,31 @@ export interface GroupViewModel { */ studentsIds?: Array; } +/** + * + * @export + * @interface Group + */ +export interface Group { + /** + * + * @type {string} + * @memberof Group + */ + name?: string; + /** + * + * @type {number} + * @memberof Group + */ + id?: number; + /** + * + * @type {Array} + * @memberof Group + */ + studentsIds?: Array; +} /** * * @export @@ -1340,6 +1365,12 @@ export interface HomeworkViewModel { * @memberof HomeworkViewModel */ tasks?: Array; + /** + * + * @type {number} + * @memberof HomeworkViewModel + */ + groupId?: number; } /** * @@ -3969,6 +4000,36 @@ export const CourseGroupsApiFetchParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options: any = {}): FetchArgs { + // verify required parameter 'courseId' is not null or undefined + if (courseId === null || courseId === undefined) { + throw new RequiredError('courseId','Required parameter courseId was null or undefined when calling courseGroupsGetAllCourseGroupsWithNames.'); + } + const localVarPath = `/api/CourseGroups/{courseId}/getAllWithNames` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} courseId @@ -4256,6 +4317,24 @@ export const CourseGroupsApiFp = function(configuration?: Configuration) { }); }; }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise> { + const localVarFetchArgs = CourseGroupsApiFetchParamCreator(configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, /** * * @param {number} courseId @@ -4399,6 +4478,15 @@ export const CourseGroupsApiFactory = function (configuration?: Configuration, f courseGroupsGetAllCourseGroups(courseId: number, options?: any) { return CourseGroupsApiFp(configuration).courseGroupsGetAllCourseGroups(courseId, options)(fetch, basePath); }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any) { + return CourseGroupsApiFp(configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options)(fetch, basePath); + }, /** * * @param {number} courseId @@ -4506,6 +4594,17 @@ export class CourseGroupsApi extends BaseAPI { return CourseGroupsApiFp(this.configuration).courseGroupsGetAllCourseGroups(courseId, options)(this.fetch, this.basePath); } + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CourseGroupsApi + */ + public courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any) { + return CourseGroupsApiFp(this.configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options)(this.fetch, this.basePath); + } + /** * * @param {number} courseId diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx new file mode 100644 index 000000000..b92b916ed --- /dev/null +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -0,0 +1,273 @@ +import {FC, useMemo, useState} from "react"; +import { + Grid, + TextField, + Autocomplete, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Stack, + Alert, + AlertTitle, + CircularProgress, + Chip +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import AddIcon from "@mui/icons-material/Add"; +import ApiSingleton from "../../api/ApiSingleton"; +import { Group, AccountDataDto } from "@/api"; + + +interface GroupSelectorProps { + courseId: number, + courseStudents: AccountDataDto[], + groups: Group[], + onGroupIdChange: (groupId?: number) => void, + onGroupsUpdate: () => void, + selectedGroupId?: number, + choiceDisabled?: boolean, + onCreateNewGroup?: () => void, +} + +const GroupSelector: FC = (props) => { + const groups = props.groups || []; + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formState, setFormState] = useState<{ + name: string, + memberIds: string[] + }>({ + name: "", + memberIds: [] + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isError, setIsError] = useState(false); + + const handleOpenEditDialog = () => { + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); + setFormState({ + name: selectedGroup?.name || "", + memberIds: selectedGroup?.studentsIds || [] + }) + setIsDialogOpen(true) + } + + const handleCloseEditDialog = () => { + if (isSubmitting) return; + setIsDialogOpen(false); + setIsError(false); + } + + const handleSubmitEdit = async () => { + setIsSubmitting(true); + try { + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); + + if (selectedGroup) { + await ApiSingleton.courseGroupsApi.courseGroupsUpdateCourseGroup( + props.courseId, + selectedGroup.id!, + { + name: formState.name, + groupMates: formState.memberIds.map(studentId => ({ studentId })), + } + ); + props.onGroupsUpdate(); + } else { + if (!formState.name.trim() || formState.memberIds.length === 0) { + return; + } + + const groupId = await ApiSingleton.courseGroupsApi.courseGroupsCreateCourseGroup(props.courseId, { + name: formState.name.trim(), + groupMatesIds: formState.memberIds, + courseId: props.courseId, + }); + props.onGroupsUpdate(); + props.onGroupIdChange(groupId); + } + setIsDialogOpen(false); + } catch (error) { + console.error('Failed to update group:', error); + setIsError(true); + } finally { + setIsSubmitting(false); + } + } + + const studentsWithousGroup = useMemo(() => { + const studentsInGroups = groups.flatMap(g => g.studentsIds) + return props.courseStudents.filter((cm) => !studentsInGroups.includes(cm.userId)) + }, [groups, props.courseStudents]); + + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); + + return ( + + {props.choiceDisabled ? ( + + + {selectedGroup && ( + + )} + + ) : ( + + option.name || ""} + value={props.selectedGroupId !== undefined + ? groups.find(g => g.id === props.selectedGroupId) || null + : { id: undefined, name: "Все студенты" }} + onChange={(_, newGroup) => { + props.onGroupIdChange(newGroup?.id) + }} + renderInput={(params) => ( + + )} + /> + {selectedGroup && ( + + )} + {!selectedGroup && ( + + )} + + )} + + + + {selectedGroup ? "Редактировать группу" : "Создать группу"} + + + {isError && ( + + Ошибка + Не удалось {selectedGroup ? "создать" : "обновить"} группу. Попробуйте позже. + + )} + + + { + setFormState(prev => ({ + ...prev, + name: e.target.value + })) + }} + disabled={isSubmitting || props.choiceDisabled} + /> + + + formState.memberIds.includes(s.userId!)) || []} + getOptionLabel={(option) => + `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() + } + filterSelectedOptions + onChange={(_, values) => { + if (selectedGroup) { + // При редактировании выбранной группы можно только добавлять студентов + setFormState(prev => ({ + ...prev, + memberIds: [...formState.memberIds, + ...values.map(x => !formState.memberIds.includes(x.userId!) ? x.userId! : "").filter(Boolean)] + })) + } else { + setFormState(prev => ({ + ...prev, + memberIds: values + .map(x => x.userId!) + .filter(Boolean) + })) + } + }} + disabled={isSubmitting} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + + + + + ) +} + +export default GroupSelector diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 5440ded7c..a10e56164 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import {FC, useEffect, useState} from "react"; +import {FC, useEffect, useState, useMemo} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; -import {AccountDataDto, CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {AccountDataDto, CourseViewModel, Group, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; @@ -34,11 +34,13 @@ import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import CourseGroups from "./CourseGroups"; +import { group } from "@uiw/react-md-editor"; -type TabValue = "homeworks" | "stats" | "applications" +type TabValue = "homeworks" | "stats" | "applications" | "groups" function isAcceptableTabValue(str: string): str is TabValue { - return str === "homeworks" || str === "stats" || str === "applications"; + return str === "homeworks" || str === "stats" || str === "applications" || str === "groups"; } interface ICourseState { @@ -170,6 +172,28 @@ const Course: React.FC = () => { const [lecturerStatsState, setLecturerStatsState] = useState(false); + const [groups, setGroups] = useState([]); + const [groupLoadingError, setGroupLoadingError] = useState(false); + + const studentsWithoutGroup = useMemo(() => { + const inGroupIds = new Set(groups.flatMap(g => g.studentsIds)); + return acceptedStudents.filter(s => !inGroupIds.has(s.userId!)); + }, [groups, acceptedStudents]); + + const loadGroups = async () => { + setGroupLoadingError(false); + try { + const result = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(+courseId!); + setGroups(result.filter(g => g.name && g.name.trim().length > 0)); + } catch { + setGroupLoadingError(true); + } + }; + + useEffect(() => { + loadGroups(); + }, [courseId]); + const CourseMenu: FC = () => { const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); @@ -298,16 +322,22 @@ const Course: React.FC = () => { } + {isCourseMentor && groups.length > 0 && studentsWithoutGroup.length > 0 && !groupLoadingError && + + Студентов, не записанных в группу: {studentsWithoutGroup.length} + + } { if (value === 0 && !isExpert) navigate(`/courses/${courseId}/homeworks`) if (value === 1) navigate(`/courses/${courseId}/stats`) if (value === 2 && !isExpert) navigate(`/courses/${courseId}/applications`) + if (value === 3) navigate(`/courses/${courseId}/groups`) }} > {!isExpert && @@ -325,6 +355,13 @@ const Course: React.FC = () => { }/>} + {isCourseMentor && +
Группы
+ + }/> + }
{tabValue === "homeworks" && { courseHomeworks: homeworks })) }} + onGroupsUpdate={loadGroups} + groups={groups} /> } {tabValue === "stats" && @@ -379,6 +418,7 @@ const Course: React.FC = () => { isMentor={isCourseMentor} course={courseState.course} solutions={studentSolutions} + groups={groups} /> } @@ -390,6 +430,13 @@ const Course: React.FC = () => { courseId={courseId!} /> } + {tabValue === "groups" && isCourseMentor && + + } ); diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 138b4f225..a060359fc 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { FileInfoDTO, + Group, HomeworkTaskViewModel, HomeworkViewModel, SolutionDto, StatisticsCourseMatesModel, } from "@/api"; @@ -60,6 +61,8 @@ interface ICourseExperimentalProps { previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; + groups: Group[]; } interface ICourseExperimentalState { @@ -440,6 +443,8 @@ export const CourseExperimental: FC = (props) => { }} isProcessing={props.processingFiles[homework.id!]?.isLoading || false} onStartProcessing={props.onStartProcessing} + onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} /> diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx new file mode 100644 index 000000000..3d074a16b --- /dev/null +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -0,0 +1,91 @@ +import {FC, useEffect} from "react"; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Grid, + Typography, + Alert, + Stack, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import {AccountDataDto, Group} from "@/api"; + +interface ICourseGroupsProps { + courseStudents: AccountDataDto[]; + groups: Group[]; + onGroupsUpdate: () => void; +} + +const CourseGroups: FC = (props) => { + const {courseStudents, groups, onGroupsUpdate} = props; + + useEffect(() => { + onGroupsUpdate(); + }, []); + + const getStudentName = (userId: string) => { + const student = courseStudents.find(s => s.userId === userId); + if (!student) { + return userId; + } + const nameParts = [student.surname, student.name, student.middleName].filter(Boolean); + return `${nameParts.join(" ") || student.email}`; + }; + + return ( + + + + + Группы курса + + + + + {groups.length === 0 && + + + На курсе пока нет групп. + + + } + + + {groups.map(group => { + const name = group.name!; + const studentsIds = group.studentsIds || []; + + return ( + + + }> + + {name} + + + + {studentsIds.length > 0 ? ( + + {studentsIds.map(id => ( + + {getStudentName(id)} + + ))} + + ) : ( + + В группе пока нет участников. + + )} + + + + ); + })} + + + ); +}; + +export default CourseGroups; diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 61e38becb..36cdd85b4 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useState, useRef} from "react"; -import {CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {CourseViewModel, Group, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import {useNavigate, useParams} from 'react-router-dom'; import {LinearProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core"; import StudentStatsCell from "../Tasks/StudentStatsCell"; @@ -19,6 +19,7 @@ interface IStudentStatsProps { isMentor: boolean; userId: string; solutions: StatisticsCourseMatesModel[] | undefined; + groups: Group[]; } interface IStudentStatsState { @@ -104,13 +105,6 @@ const StudentStats: React.FC = (props) => { const notTests = homeworks.filter(h => !h.tags!.includes(TestTag)) - const homeworksMaxSum = notTests - .filter(h => !h.tags!.includes(BonusTag)) - .flatMap(homework => homework.tasks) - .reduce((sum, task) => { - return sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)); - }, 0) - const testGroups = Lodash(homeworks.filter(h => h.tags!.includes(TestTag))) .groupBy((h: HomeworkViewModel) => { const key = h.tags!.find(t => !DefaultTags.includes(t)) @@ -125,7 +119,10 @@ const StudentStats: React.FC = (props) => { .reduce((sum, task) => sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)), 0) - const hasHomeworks = homeworksMaxSum > 0 + const hasHomeworks = !!notTests + .filter(h => !h.tags!.includes(BonusTag)) + .flatMap(homework => homework.tasks) + .filter(task => !task!.tags!.includes(BonusTag)) const hasTests = testsMaxSum > 0 const showBestSolutions = isMentor && (hasHomeworks || hasTests) @@ -217,7 +214,7 @@ const StudentStats: React.FC = (props) => { paddingRight: 5, borderLeft: borderStyle, }}> - ДЗ ({homeworksMaxSum}) + ДЗ } {hasTests && = (props) => { .flatMap(t => StudentStatsUtils.calculateLastRatedSolution(t.solutions || [])?.rating || 0) || 0 ) .reduce((sum, rating) => sum + rating, 0) + const homeworksMaxSum = notTests + .filter(h => !h.tags!.includes(BonusTag) && + (props.groups.find(g => g.id === h.groupId)?.studentsIds?.includes(cm.id!) || !h.groupId)) + .flatMap(homework => homework.tasks) + .reduce((sum, task) => { + return sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)); + }, 0) const testsSum = testGroups .map(group => { @@ -321,7 +325,7 @@ const StudentStats: React.FC = (props) => { backgroundColor: StudentStatsUtils.getRatingColor(homeworksSum, homeworksMaxSum), fontSize: 16 }} - label={homeworksSum}/> + label={`${homeworksSum} / ${homeworksMaxSum}`}/> } {hasTests && = (props) => { {homeworks.map((homework, idx) => homework.tasks!.map((task, i) => { const additionalStyles = i === 0 && homeworkStyles(homeworks, idx) + const isDisabled = homework.groupId + ? !props.groups.find(g => g.id === homework.groupId)?.studentsIds?.includes(cm.id!) + : false return = (props) => { taskId={task.id!} taskMaxRating={task.maxRating!} isBestSolution={bestTaskSolutions.get(task.id!) === cm.id} + disabled={isDisabled} {...additionalStyles}/>; }) )} diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 512bf8872..add5dbfdd 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -10,7 +10,7 @@ Stack, TextField, Tooltip, - Typography + Typography, } from "@mui/material"; import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; import FilesPreviewList from "components/Files/FilesPreviewList"; @@ -18,7 +18,8 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, AccountDataDto, + Group } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -37,6 +38,7 @@ import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; import {FilesHandler} from "@/components/Files/FilesHandler"; +import GroupSelector from "../Common/GroupSelector"; export interface HomeworkAndFilesInfo { homework: HomeworkViewModel & { isModified?: boolean }, @@ -63,6 +65,8 @@ const CourseHomeworkEditor: FC<{ previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; + groups: Group[]; }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 @@ -114,6 +118,20 @@ const CourseHomeworkEditor: FC<{ const [title, setTitle] = useState(loadedHomework.title!) const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) + const [selectedGroupId, setSelectedGroupId] = useState(loadedHomework.groupId) + const [courseStudents, setCourseStudents] = useState([]) + + useEffect(() => { + const loadCourseStudents = async () => { + try { + const courseData = await ApiSingleton.coursesApi.coursesGetAllCourseData(courseId) + setCourseStudents(courseData.course?.acceptedStudents || []) + } catch (error) { + console.error('Failed to load course students:', error) + } + } + loadCourseStudents() + }, [courseId]) const [hasErrors, setHasErrors] = useState(false) @@ -164,13 +182,14 @@ const CourseHomeworkEditor: FC<{ title: title, description: description, tags: tags, + groupId: selectedGroupId, hasErrors: hasErrors, deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, isModified: true, } props.onUpdate({homework: update}) - }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) + }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo, selectedGroupId]) useEffect(() => { setHasErrors(!title || metadata.hasErrors) @@ -228,6 +247,7 @@ const CourseHomeworkEditor: FC<{ deadlineDate: metadata.deadlineDate, isDeadlineStrict: metadata.isDeadlineStrict, publicationDate: metadata.publicationDate, + groupId: selectedGroupId, actionOptions: editOptions, tasks: isNewHomework ? homework.tasks!.map(t => { const task: PostTaskViewModel = { @@ -287,6 +307,15 @@ const CourseHomeworkEditor: FC<{ + setSelectedGroupId(groupId)} + selectedGroupId={selectedGroupId} + choiceDisabled={!isNewHomework} + onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} + /> {tags.includes(TestTag) && @@ -394,6 +423,8 @@ const CourseHomeworkExperimental: FC<{ previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; + groups: Group[]; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) @@ -414,6 +445,8 @@ const CourseHomeworkExperimental: FC<{ props.onUpdate(update) }} onStartProcessing={props.onStartProcessing} + onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} /> return = (props) => { const navigate = useNavigate() const {solutions, taskMaxRating, forMentor} = props - const cellState = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, taskMaxRating) + const cellState = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, taskMaxRating, props.disabled) const {ratedSolutionsCount, solutionsDescription} = cellState; - const tooltipTitle = ratedSolutionsCount === 0 + const tooltipTitle = ratedSolutionsCount === 0 ? solutionsDescription : solutionsDescription + (props.isBestSolution ? "\n Первое решение с лучшей оценкой" : "") + `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; + const result = cellState.lastRatedSolution === undefined ? "" : @@ -41,6 +43,8 @@ const StudentStatsCell: FC ; const handleCellClick = (e: React.MouseEvent) => { + if(props.disabled) return; + // Формируем URL const url = forMentor ? `/task/${props.taskId}/${props.studentId}` @@ -71,7 +75,7 @@ const StudentStatsCell: FC style={{ backgroundColor: cellState.color, borderLeft: `1px solid ${props.borderLeftColor || grey[300]}`, - cursor: "pointer", + cursor: props.disabled ? "default" : "pointer", }}> {result} diff --git a/hwproj.front/src/services/StudentStatsUtils.ts b/hwproj.front/src/services/StudentStatsUtils.ts index 3597c9948..eefdebebd 100644 --- a/hwproj.front/src/services/StudentStatsUtils.ts +++ b/hwproj.front/src/services/StudentStatsUtils.ts @@ -22,7 +22,7 @@ export default class StudentStatsUtils { return ratedSolutions.slice(-1)[0] } - static calculateLastRatedSolutionInfo(solutions: SolutionDto[], taskMaxRating: number) { + static calculateLastRatedSolutionInfo(solutions: SolutionDto[], taskMaxRating: number, disabled: boolean = false) { const ratedSolutions = solutions!.filter(x => x.state !== SolutionState.NUMBER_0) const ratedSolutionsCount = ratedSolutions.length const isFirstUnratedTry = ratedSolutionsCount === 0 @@ -30,7 +30,9 @@ export default class StudentStatsUtils { const lastRatedSolution = ratedSolutions.slice(-1)[0] let solutionsDescription: string - if (lastSolution === undefined) + if(disabled) + solutionsDescription = "Задача недоступна для этого студента" + else if (lastSolution === undefined) solutionsDescription = "Решение отсутствует" else if (isFirstUnratedTry) solutionsDescription = "Решение ожидает проверки" @@ -38,11 +40,17 @@ export default class StudentStatsUtils { solutionsDescription = `${lastSolution.rating}/${taskMaxRating} ${Utils.pluralizeHelper(["балл", "балла", "баллов"], taskMaxRating)}` else solutionsDescription = "Последняя оценка — " + `${lastRatedSolution.rating}/${taskMaxRating} ${Utils.pluralizeHelper(["балл", "балла", "баллов"], taskMaxRating)}\nНовое решение ожидает проверки` + let color: string + if(disabled) + color = "#d1d1d1" + else if (lastRatedSolution == undefined) + color = "#ffffff" + else + color = StudentStatsUtils.getCellBackgroundColor(lastRatedSolution.state, lastRatedSolution.rating, taskMaxRating, isFirstUnratedTry) + return { lastRatedSolution: lastRatedSolution, - color: lastSolution === undefined - ? "#ffffff" - : StudentStatsUtils.getCellBackgroundColor(lastSolution.state, lastSolution.rating, taskMaxRating, isFirstUnratedTry), + color: color, ratedSolutionsCount, lastSolution, solutionsDescription } }