From 6dff7e6ca16382d97f94ccaa0a3e447945402bb0 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 14 May 2026 05:47:31 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BB=D0=B5=D0=BA=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sync/ScheduleSyncServiceTests.cs | 97 +++++++++++++++++++ .../Interfaces/IScheduleSyncService.cs | 20 ++++ .../Services/ScheduleSyncService.cs | 96 ++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs index d5ca62e..35589be 100644 --- a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; @@ -123,11 +124,107 @@ public class ScheduleSyncServiceTests Assert.Equal(48, lecture.MaxEnrollments); } + [Fact] + public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher() + { + await using var db = CreateDbContext(); + var modeus = new FakeModeusApiClient(BuildEventsResponse()); + var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); + + var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); + + Assert.Null(result.Error); + Assert.Equal(1, result.Created); + + var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync(); + Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName); + Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email); + + var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync(); + Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId); + Assert.Equal(teacherProfile.UserId, lecture.TeacherId); + + var teacherRole = await db.UserRoles.SingleAsync(); + Assert.Equal(lecture.TeacherId, teacherRole.UserId); + Assert.Equal(UserRole.Teacher, teacherRole.Role); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}") .Options; + return new AppDbContext(options); } + + private static ModeusEventsResponse BuildEventsResponse() + { + const string eventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a"; + const string courseId = "73aa6226-adbb-4e15-b264-e16fee19fd73"; + const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7"; + const string personId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440"; + + return new ModeusEventsResponse + { + Embedded = new ModeusEventsEmbedded + { + Events = + [ + new ModeusEvent + { + Id = eventId, + Name = "Тема 20. Управление ресурсами проекта. Часть 2.", + TypeId = "LAB", + StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc), + Links = new ModeusEventLinks + { + CourseUnitRealization = new ModeusHrefLink($"/{courseId}") + } + } + ], + CourseUnitRealizations = + [ + new ModeusCourseUnitRealization( + courseId, + "Управление проектами разработки программного обеспечения", + "УПРПО") + ], + EventTeams = [new ModeusEventTeam(eventId, 25)], + EventAttendees = + [ + new ModeusEventAttendee + { + Id = attendeeId, + RoleId = "TEACH", + RoleName = "Преподаватель", + Links = new ModeusEventAttendeeLinks + { + Event = new ModeusHrefLink($"/{eventId}"), + Person = new ModeusHrefLink($"/{personId}") + } + } + ], + Persons = + [ + new ModeusPerson( + personId, + "Иванов", + "Иван", + "Иванович", + "Иванов Иван Иванович") + ] + } + }; + } + + private sealed class FakeModeusApiClient(ModeusEventsResponse events) : IModeusApiClient + { + public Task SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events); + + public Task SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse()); + + public Task> SearchEmployeeAsync(string fullname) => Task.FromResult(new List()); + } } diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index 5e14dab..e4f4cf2 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -62,6 +62,11 @@ public class ModeusEventsEmbedded [JsonPropertyName("event-teams")] public List? EventTeams { get; init; } + [JsonPropertyName("event-attendees")] + public List? EventAttendees { get; init; } + + public List? Persons { get; init; } + public List? Rooms { get; init; } } public record ModeusHrefLink(string? Href); @@ -79,6 +84,21 @@ public class ModeusEventRoomLinks public ModeusHrefLink? Room { get; init; } } public record ModeusEventTeam(string EventId, int? Size); +public class ModeusEventAttendee +{ + public string Id { get; init; } = string.Empty; + public string? RoleId { get; init; } + public string? RoleName { get; init; } + + [JsonPropertyName("_links")] + public ModeusEventAttendeeLinks? Links { get; init; } +} +public class ModeusEventAttendeeLinks +{ + public ModeusHrefLink? Event { get; init; } + public ModeusHrefLink? Person { get; init; } +} +public record ModeusPerson(string Id, string? LastName, string? FirstName, string? MiddleName, string? FullName); public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address); public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity); public record ModeusRoomsEmbedded(List? Rooms); diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 7165000..3f86252 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -4,6 +4,7 @@ using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -44,6 +45,7 @@ public class ScheduleSyncService : IScheduleSyncService var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; var courseName = courseUnit?.Name ?? ev.Name; var location = await UpsertEventLocationAsync(events, ev.Id); + var teacher = await UpsertEventTeacherAsync(events, ev.Id); var roomId = GetEventRoomId(events, ev.Id); var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) @@ -64,6 +66,7 @@ public class ScheduleSyncService : IScheduleSyncService existing.StartsAt = startsAt; existing.EndsAt = endsAt; existing.LocationId = location?.Id; + existing.TeacherId = teacher?.Id; existing.MaxEnrollments = lectureCapacity; existing.UpdatedAt = DateTime.UtcNow; updated++; @@ -81,6 +84,7 @@ public class ScheduleSyncService : IScheduleSyncService _db.Lectures.Add(new Lecture { CourseId = course.Id, + TeacherId = teacher?.Id, LocationId = location?.Id, Title = ev.Name, Description = ev.Description, @@ -190,6 +194,79 @@ public class ScheduleSyncService : IScheduleSyncService public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); + private async Task UpsertEventTeacherAsync(ModeusEventsResponse events, string eventId) + { + var personId = events.Embedded?.EventAttendees? + .Where(attendee => string.Equals(attendee.RoleId, "TEACH", StringComparison.OrdinalIgnoreCase)) + .Select(attendee => new + { + EventId = GetHrefId(attendee.Links?.Event?.Href), + PersonId = GetHrefId(attendee.Links?.Person?.Href) + }) + .FirstOrDefault(link => link.EventId == eventId) + ?.PersonId; + + if (string.IsNullOrWhiteSpace(personId)) + return null; + + var person = events.Embedded?.Persons?.FirstOrDefault(item => item.Id == personId); + var fullName = BuildPersonFullName(person); + if (string.IsNullOrWhiteSpace(fullName)) + return null; + + var existingProfile = await _db.TeacherProfiles + .Include(profile => profile.User) + .ThenInclude(user => user.Roles) + .FirstOrDefaultAsync(profile => profile.ModeusId == personId); + + if (existingProfile != null) + { + existingProfile.User.DisplayName = fullName; + existingProfile.User.UpdatedAt = DateTime.UtcNow; + EnsureTeacherRole(existingProfile.User); + return existingProfile.User; + } + + var email = BuildModeusTeacherEmail(personId); + var user = await _db.Users + .Include(item => item.Roles) + .Include(item => item.TeacherProfile) + .FirstOrDefaultAsync(item => item.Email == email); + + if (user == null) + { + user = new User + { + Email = email, + DisplayName = fullName, + IsActive = true, + TeacherProfile = new TeacherProfile { ModeusId = personId } + }; + user.Roles.Add(new UserRoleAssignment { User = user, Role = UserRole.Teacher }); + _db.Users.Add(user); + await _db.SaveChangesAsync(); + return user; + } + + user.DisplayName = fullName; + user.UpdatedAt = DateTime.UtcNow; + if (user.TeacherProfile == null) + user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = personId }; + else + user.TeacherProfile.ModeusId = personId; + + EnsureTeacherRole(user); + + await _db.SaveChangesAsync(); + return user; + } + + private static void EnsureTeacherRole(User user) + { + if (!user.Roles.Any(role => role.Role == UserRole.Teacher)) + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Teacher }); + } + private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) { var roomId = GetEventRoomId(events, eventId); @@ -274,6 +351,25 @@ public class ScheduleSyncService : IScheduleSyncService private static int? NormalizeCapacity(int? capacity) => capacity is > 0 ? capacity : null; + private static string BuildModeusTeacherEmail(string personId) => + $"modeus-{personId}@modeus.local".ToLowerInvariant(); + + private static string? BuildPersonFullName(ModeusPerson? person) + { + if (person == null) + return null; + + if (!string.IsNullOrWhiteSpace(person.FullName)) + return person.FullName.Trim(); + + var parts = new[] { person.LastName, person.FirstName, person.MiddleName } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => part!.Trim()); + + var fullName = string.Join(" ", parts); + return string.IsNullOrWhiteSpace(fullName) ? null : fullName; + } + private static IReadOnlyList BuildErrorDetails( Exception exception, string stage,