feat: Добавил синхронизацию преподавателей из лекций
Backend CI / build-and-test (push) Successful in 55s
Frontend CI / build-and-check (push) Failing after 5m9s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12s
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been skipped
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been skipped
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped

This commit is contained in:
2026-05-14 05:47:31 +03:00
parent dab161ef18
commit 6dff7e6ca1
3 changed files with 213 additions and 0 deletions
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Services;
using Xunit; using Xunit;
@@ -123,11 +124,107 @@ public class ScheduleSyncServiceTests
Assert.Equal(48, lecture.MaxEnrollments); 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<ScheduleSyncService>.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() private static AppDbContext CreateDbContext()
{ {
var options = new DbContextOptionsBuilder<AppDbContext>() var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}") .UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
.Options; .Options;
return new AppDbContext(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<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
}
} }
@@ -62,6 +62,11 @@ public class ModeusEventsEmbedded
[JsonPropertyName("event-teams")] [JsonPropertyName("event-teams")]
public List<ModeusEventTeam>? EventTeams { get; init; } public List<ModeusEventTeam>? EventTeams { get; init; }
[JsonPropertyName("event-attendees")]
public List<ModeusEventAttendee>? EventAttendees { get; init; }
public List<ModeusPerson>? Persons { get; init; }
public List<ModeusRoom>? Rooms { get; init; } public List<ModeusRoom>? Rooms { get; init; }
} }
public record ModeusHrefLink(string? Href); public record ModeusHrefLink(string? Href);
@@ -79,6 +84,21 @@ public class ModeusEventRoomLinks
public ModeusHrefLink? Room { get; init; } public ModeusHrefLink? Room { get; init; }
} }
public record ModeusEventTeam(string EventId, int? Size); 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 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 ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms); public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
@@ -4,6 +4,7 @@ using System.Text.Json;
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities; using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Data;
namespace UniVerse.Infrastructure.Services; namespace UniVerse.Infrastructure.Services;
@@ -44,6 +45,7 @@ public class ScheduleSyncService : IScheduleSyncService
var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id;
var courseName = courseUnit?.Name ?? ev.Name; var courseName = courseUnit?.Name ?? ev.Name;
var location = await UpsertEventLocationAsync(events, ev.Id); var location = await UpsertEventLocationAsync(events, ev.Id);
var teacher = await UpsertEventTeacherAsync(events, ev.Id);
var roomId = GetEventRoomId(events, ev.Id); var roomId = GetEventRoomId(events, ev.Id);
var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId);
if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId))
@@ -64,6 +66,7 @@ public class ScheduleSyncService : IScheduleSyncService
existing.StartsAt = startsAt; existing.StartsAt = startsAt;
existing.EndsAt = endsAt; existing.EndsAt = endsAt;
existing.LocationId = location?.Id; existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity; existing.MaxEnrollments = lectureCapacity;
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
updated++; updated++;
@@ -81,6 +84,7 @@ public class ScheduleSyncService : IScheduleSyncService
_db.Lectures.Add(new Lecture _db.Lectures.Add(new Lecture
{ {
CourseId = course.Id, CourseId = course.Id,
TeacherId = teacher?.Id,
LocationId = location?.Id, LocationId = location?.Id,
Title = ev.Name, Title = ev.Name,
Description = ev.Description, Description = ev.Description,
@@ -190,6 +194,79 @@ public class ScheduleSyncService : IScheduleSyncService
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
private async Task<User?> 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<Location?> UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) private async Task<Location?> UpsertEventLocationAsync(ModeusEventsResponse events, string eventId)
{ {
var roomId = GetEventRoomId(events, eventId); var roomId = GetEventRoomId(events, eventId);
@@ -274,6 +351,25 @@ public class ScheduleSyncService : IScheduleSyncService
private static int? NormalizeCapacity(int? capacity) => private static int? NormalizeCapacity(int? capacity) =>
capacity is > 0 ? capacity : null; 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<string> BuildErrorDetails( private static IReadOnlyList<string> BuildErrorDetails(
Exception exception, Exception exception,
string stage, string stage,