fix: синхронизации лекций
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m3s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 25s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s

This commit is contained in:
2026-05-12 00:44:27 +03:00
parent 9b28a09253
commit 860964e3c2
4 changed files with 272 additions and 19 deletions
@@ -19,8 +19,66 @@ public interface IModeusApiClient
} }
// Modeus API response models // Modeus API response models
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId); public class ModeusEvent
public record ModeusEventsResponse(List<ModeusEvent> Events); {
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string? NameShort { get; init; }
public string? Description { get; init; }
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
public ModeusHrefLink? CourseUnitRealization { get; init; }
}
public class ModeusEventsResponse
{
[JsonPropertyName("_embedded")]
public ModeusEventsEmbedded? Embedded { get; init; }
public List<ModeusEvent>? Events { get; init; }
public ModeusPage? Page { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusEvent> EventItems => Embedded?.Events ?? Events ?? [];
}
public class ModeusEventsEmbedded
{
public List<ModeusEvent>? Events { get; init; }
[JsonPropertyName("course-unit-realizations")]
public List<ModeusCourseUnitRealization>? CourseUnitRealizations { get; init; }
[JsonPropertyName("event-rooms")]
public List<ModeusEventRoom>? EventRooms { get; init; }
[JsonPropertyName("event-teams")]
public List<ModeusEventTeam>? EventTeams { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
}
public record ModeusHrefLink(string? Href);
public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort);
public class ModeusEventRoom
{
public string Id { get; init; } = string.Empty;
[JsonPropertyName("_links")]
public ModeusEventRoomLinks? Links { get; init; }
}
public class ModeusEventRoomLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Room { get; init; }
}
public record ModeusEventTeam(string EventId, int? Size);
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);
@@ -1,4 +1,5 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UniVerse.Application.DTOs.Sync; using UniVerse.Application.DTOs.Sync;
@@ -21,11 +22,30 @@ public class ModeusApiClient : IModeusApiClient
public async Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) public async Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request)
{ {
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId }; const int pageSize = 900;
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var typeIds = request.TypeId ?? [];
var body = new Dictionary<string, object?>
{
["size"] = pageSize,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax,
["specialtyCode"] = specialtyCodes
};
if (typeIds.Count > 0)
body["typeId"] = typeIds;
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body);
await EnsureSuccessAsync(response, "Modeus events search", await EnsureSuccessAsync(response, "Modeus events search",
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}, timeMin={request.TimeMin:O}, timeMax={request.TimeMax:O}, typeId=[{string.Join(", ", request.TypeId ?? [])}]"); BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson));
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new()); return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson))
?? new ModeusEventsResponse();
} }
public async Task<ModeusRoomsResponse> SearchRoomsAsync() public async Task<ModeusRoomsResponse> SearchRoomsAsync()
@@ -50,7 +70,9 @@ public class ModeusApiClient : IModeusApiClient
await EnsureSuccessAsync(response, "Modeus rooms search", await EnsureSuccessAsync(response, "Modeus rooms search",
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false"); $"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false");
var payload = await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new ModeusRoomsResponse(); var payload = await ReadJsonAsync<ModeusRoomsResponse>(response, "Modeus rooms search",
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false")
?? new ModeusRoomsResponse();
allRooms.AddRange(payload.RoomItems); allRooms.AddRange(payload.RoomItems);
totalPages = payload.Page?.TotalPages ?? page + 1; totalPages = payload.Page?.TotalPages ?? page + 1;
@@ -66,15 +88,54 @@ public class ModeusApiClient : IModeusApiClient
if (response.IsSuccessStatusCode) return; if (response.IsSuccessStatusCode) return;
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
if (responseBody.Length > 2000)
responseBody = string.Concat(responseBody.AsSpan(0, 2000), "...<truncated>");
throw new HttpRequestException( throw new HttpRequestException(
$"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {responseBody}", $"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {Truncate(responseBody)}",
null, null,
response.StatusCode); response.StatusCode);
} }
private static string BuildEventsRequestSummary(
int size,
IReadOnlyList<string> specialtyCodes,
DateTime? timeMin,
DateTime? timeMax,
IReadOnlyList<string> typeIds,
string requestJson)
{
var typeFilter = typeIds.Count > 0 ? $"typeId=[{string.Join(", ", typeIds)}]" : "typeId=<omitted>";
return $"size={size}, specialtyCode=[{string.Join(", ", specialtyCodes)}], timeMin={timeMin:O}, timeMax={timeMax:O}, {typeFilter}. Request JSON: {requestJson}";
}
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, string operation, string requestSummary)
{
var responseBody = await response.Content.ReadAsStringAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "<empty>";
var contentLength = response.Content.Headers.ContentLength?.ToString() ?? "<unknown>";
if (string.IsNullOrWhiteSpace(responseBody))
{
throw new HttpRequestException(
$"{operation} returned HTTP {(int)response.StatusCode} {response.ReasonPhrase} with an empty response body. Request: {requestSummary}. Content-Type: {contentType}. Content-Length: {contentLength}.",
null,
response.StatusCode);
}
try
{
return JsonSerializer.Deserialize<T>(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"{operation} returned invalid JSON. Request: {requestSummary}. Content-Type: {contentType}. Response body: {Truncate(responseBody)}",
ex);
}
}
private static string Truncate(string value) =>
value.Length > 2000 ? string.Concat(value.AsSpan(0, 2000), "...<truncated>") : value;
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
{ {
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>( var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
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;
@@ -26,15 +27,58 @@ public class ScheduleSyncService : IScheduleSyncService
try try
{ {
var events = await _modeus.SearchEventsAsync(request); var events = await _modeus.SearchEventsAsync(request);
foreach (var ev in events.Events) 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 maxEnrollments = events.Embedded?.EventTeams?
.FirstOrDefault(team => team.EventId == ev.Id)?.Size ?? 0;
var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt);
var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id); var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id);
if (existing != null) { updated++; existing.StartsAt = ev.StartsAt; existing.EndsAt = ev.EndsAt; existing.UpdatedAt = DateTime.UtcNow; } if (existing != null)
{
existing.Title = ev.Name;
existing.Description = ev.Description;
existing.StartsAt = startsAt;
existing.EndsAt = endsAt;
existing.LocationId = location?.Id;
existing.MaxEnrollments = maxEnrollments;
existing.UpdatedAt = DateTime.UtcNow;
updated++;
}
else else
{ {
var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == ev.TypeId); var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == courseExternalId);
if (course == null) { course = new Course { Name = ev.Name, ExternalId = ev.TypeId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } if (course == null)
_db.Lectures.Add(new Lecture { CourseId = course.Id, Title = ev.Name, ExternalId = ev.Id, StartsAt = ev.StartsAt, EndsAt = ev.EndsAt }); {
course = new Course { Name = courseName, ExternalId = courseExternalId, IsSynced = true };
_db.Courses.Add(course);
await _db.SaveChangesAsync();
}
_db.Lectures.Add(new Lecture
{
CourseId = course.Id,
LocationId = location?.Id,
Title = ev.Name,
Description = ev.Description,
ExternalId = ev.Id,
StartsAt = startsAt,
EndsAt = endsAt,
MaxEnrollments = maxEnrollments
});
created++; created++;
} }
} }
@@ -46,6 +90,9 @@ public class ScheduleSyncService : IScheduleSyncService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Schedule sync failed"); _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( var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex, ex,
stage, stage,
@@ -53,10 +100,14 @@ public class ScheduleSyncService : IScheduleSyncService
updated, updated,
skipped, skipped,
[ [
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}", "size=900",
$"requestJson={BuildScheduleRequestJson(request)}",
$"specialtyCode=[{string.Join(", ", specialtyCodes)}]",
$"timeMin={request.TimeMin:O}", $"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}", $"timeMax={request.TimeMax:O}",
$"typeId=[{string.Join(", ", request.TypeId ?? [])}]" request.TypeId is { Count: > 0 }
? $"typeId=[{string.Join(", ", request.TypeId)}]"
: "typeId=<omitted>"
])); ]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result; return result;
@@ -129,6 +180,48 @@ public class ScheduleSyncService : IScheduleSyncService
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
private async Task<Location?> UpsertEventLocationAsync(ModeusEventsResponse events, string eventId)
{
var roomId = events.Embedded?.EventRooms?
.Select(eventRoom => new
{
EventId = GetHrefId(eventRoom.Links?.Event?.Href),
RoomId = GetHrefId(eventRoom.Links?.Room?.Href)
})
.FirstOrDefault(link => link.EventId == eventId)
?.RoomId;
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 IReadOnlyList<string> BuildErrorDetails( private static IReadOnlyList<string> BuildErrorDetails(
Exception exception, Exception exception,
string stage, string stage,
@@ -154,4 +247,43 @@ public class ScheduleSyncService : IScheduleSyncService
details.AddRange(context); details.AddRange(context);
return details; 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()
};
} }
@@ -51,14 +51,16 @@ function toInputDateTime(date: Date) {
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16) return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
} }
const now = new Date() const todayStart = new Date()
const inTwoWeeks = new Date(now) todayStart.setHours(0, 0, 0, 0)
const inTwoWeeks = new Date(todayStart)
inTwoWeeks.setDate(inTwoWeeks.getDate() + 14) inTwoWeeks.setDate(inTwoWeeks.getDate() + 14)
inTwoWeeks.setHours(23, 59, 0, 0)
const syncForm = ref({ const syncForm = ref({
specialtyCode: '', specialtyCode: '',
typeIds: [] as ApiScheduleTypeId[], typeIds: [] as ApiScheduleTypeId[],
timeMin: toInputDateTime(now), timeMin: toInputDateTime(todayStart),
timeMax: toInputDateTime(inTwoWeeks), timeMax: toInputDateTime(inTwoWeeks),
}) })