Files
UniVerse/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs
T
serega404 6dff7e6ca1
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
feat: Добавил синхронизацию преподавателей из лекций
2026-05-14 05:47:31 +03:00

438 lines
17 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
public class ScheduleSyncService : IScheduleSyncService
{
private readonly AppDbContext _db;
private readonly IModeusApiClient _modeus;
private readonly ILogger<ScheduleSyncService> _logger;
private static SyncStatusDto _lastStatus = new(null, "idle", null);
public ScheduleSyncService(AppDbContext db, IModeusApiClient modeus, ILogger<ScheduleSyncService> logger)
{
_db = db; _modeus = modeus; _logger = logger;
}
public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request)
{
const string stage = "schedule";
int created = 0, updated = 0, skipped = 0;
try
{
var events = await _modeus.SearchEventsAsync(request);
var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms);
IReadOnlyDictionary<string, int>? syncedRoomCapacityById = null;
foreach (var ev in events.EventItems)
{
if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name))
{
skipped++;
continue;
}
var courseUnitId = GetHrefId(ev.Links?.CourseUnitRealization?.Href);
var courseUnit = events.Embedded?.CourseUnitRealizations?
.FirstOrDefault(c => c.Id == courseUnitId);
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))
{
syncedRoomCapacityById ??= await LoadRoomCapacityLookupAsync();
maxEnrollments = GetRoomCapacity(syncedRoomCapacityById, roomId);
}
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt);
var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id);
if (existing != null)
{
existing.Title = ev.Name;
existing.Description = ev.Description;
existing.StartsAt = startsAt;
existing.EndsAt = endsAt;
existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity;
existing.UpdatedAt = DateTime.UtcNow;
updated++;
}
else
{
var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == courseExternalId);
if (course == null)
{
course = new Course { Name = courseName, ExternalId = courseExternalId, IsSynced = true };
_db.Courses.Add(course);
await _db.SaveChangesAsync();
}
_db.Lectures.Add(new Lecture
{
CourseId = course.Id,
TeacherId = teacher?.Id,
LocationId = location?.Id,
Title = ev.Name,
Description = ev.Description,
ExternalId = ev.Id,
StartsAt = startsAt,
EndsAt = endsAt,
MaxEnrollments = lectureCapacity
});
created++;
}
}
await _db.SaveChangesAsync();
var result = new SyncResultDto(created, updated, skipped, null);
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Schedule sync failed");
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex,
stage,
created,
updated,
skipped,
[
"size=900",
$"requestJson={BuildScheduleRequestJson(request)}",
$"specialtyCode=[{string.Join(", ", specialtyCodes)}]",
$"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}",
request.TypeId is { Count: > 0 }
? $"typeId=[{string.Join(", ", request.TypeId)}]"
: "typeId=<omitted>"
]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result;
}
}
public async Task<SyncResultDto> SyncRoomsAsync()
{
const string stage = "rooms";
int created = 0, updated = 0, skipped = 0;
try
{
var rooms = await _modeus.SearchRoomsAsync();
foreach (var room in rooms?.RoomItems ?? [])
{
if (room is null || string.IsNullOrWhiteSpace(room.Id) || string.IsNullOrWhiteSpace(room.Name))
{
skipped++;
continue;
}
var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id);
if (existing != null)
{
existing.Name = room.Name;
existing.Room = room.NameShort;
existing.Building = room.Building?.Name ?? room.Building?.NameShort;
existing.Address = room.Building?.Address;
updated++;
}
else
{
_db.Locations.Add(new Location
{
Name = room.Name,
Room = room.NameShort,
Building = room.Building?.Name ?? room.Building?.NameShort,
Address = room.Building?.Address,
ExternalId = room.Id
});
created++;
}
}
await _db.SaveChangesAsync();
var result = new SyncResultDto(created, updated, skipped, null);
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Rooms sync failed");
var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex,
stage,
created,
updated,
skipped,
["request=name:<empty>, sort:+building.name,+name, deleted:false, page size:100"]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result;
}
}
public async Task<List<EmployeeDto>> SearchEmployeesAsync(string fullname)
{
var employees = await _modeus.SearchEmployeeAsync(fullname);
return employees.Select(e => new EmployeeDto(e.Id, e.FullName, e.Department)).ToList();
}
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)
{
var roomId = GetEventRoomId(events, eventId);
if (string.IsNullOrWhiteSpace(roomId))
return null;
var room = events.Embedded?.Rooms?.FirstOrDefault(item => item.Id == roomId);
if (room == null || string.IsNullOrWhiteSpace(room.Name))
return null;
var existing = await _db.Locations.FirstOrDefaultAsync(location => location.ExternalId == room.Id);
if (existing != null)
{
existing.Name = room.Name;
existing.Room = room.NameShort;
existing.Building = room.Building?.Name ?? room.Building?.NameShort;
existing.Address = room.Building?.Address;
return existing;
}
var location = new Location
{
Name = room.Name,
Room = room.NameShort,
Building = room.Building?.Name ?? room.Building?.NameShort,
Address = room.Building?.Address,
ExternalId = room.Id
};
_db.Locations.Add(location);
await _db.SaveChangesAsync();
return location;
}
private static string? GetEventRoomId(ModeusEventsResponse events, string eventId) =>
events.Embedded?.EventRooms?
.Select(eventRoom => new
{
EventId = GetHrefId(eventRoom.Links?.Event?.Href),
RoomId = GetHrefId(eventRoom.Links?.Room?.Href)
})
.FirstOrDefault(link => link.EventId == eventId)
?.RoomId;
private async Task<IReadOnlyDictionary<string, int>> LoadRoomCapacityLookupAsync()
{
try
{
var rooms = await _modeus.SearchRoomsAsync();
return BuildRoomCapacityLookup(rooms.RoomItems);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load room capacities from Modeus rooms search.");
return new Dictionary<string, int>();
}
}
private static IReadOnlyDictionary<string, int> BuildRoomCapacityLookup(IEnumerable<ModeusRoom>? rooms)
{
var result = new Dictionary<string, int>();
foreach (var room in rooms ?? [])
{
var capacity = NormalizeCapacity(room.WorkingCapacity) ?? NormalizeCapacity(room.TotalCapacity);
if (!string.IsNullOrWhiteSpace(room.Id) && capacity.HasValue)
result.TryAdd(room.Id, capacity.Value);
}
return result;
}
private static int? GetRoomCapacity(IReadOnlyDictionary<string, int> roomCapacityById, string? roomId) =>
!string.IsNullOrWhiteSpace(roomId) && roomCapacityById.TryGetValue(roomId, out var capacity)
? capacity
: null;
private static int? GetEventTeamSize(ModeusEventsResponse events, string eventId) =>
NormalizeCapacity(events.Embedded?.EventTeams?
.FirstOrDefault(team => team.EventId == eventId)?.Size);
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<string> BuildErrorDetails(
Exception exception,
string stage,
int created,
int updated,
int skipped,
IReadOnlyList<string> context)
{
var details = new List<string>
{
$"stage={stage}",
$"exceptionType={exception.GetType().FullName}",
$"message={exception.Message}",
$"partialResult=created:{created}, updated:{updated}, skipped:{skipped}"
};
if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue)
details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}");
if (exception.InnerException != null)
details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}");
details.AddRange(context);
return details;
}
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var body = new Dictionary<string, object?>
{
["size"] = 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax,
["specialtyCode"] = specialtyCodes
};
if (request.TypeId is { Count: > 0 })
body["typeId"] = request.TypeId;
return JsonSerializer.Serialize(body);
}
private static string? GetHrefId(string? href)
{
if (string.IsNullOrWhiteSpace(href))
return null;
var index = href.LastIndexOf('/');
return index >= 0 && index < href.Length - 1
? href[(index + 1)..]
: href;
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Local).ToUniversalTime()
};
}