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
🚀 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:
@@ -19,8 +19,66 @@ public interface IModeusApiClient
|
||||
}
|
||||
|
||||
// Modeus API response models
|
||||
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
|
||||
public record ModeusEventsResponse(List<ModeusEvent> Events);
|
||||
public class ModeusEvent
|
||||
{
|
||||
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 ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
|
||||
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
@@ -21,11 +22,30 @@ public class ModeusApiClient : IModeusApiClient
|
||||
|
||||
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 requestJson = JsonSerializer.Serialize(body);
|
||||
await EnsureSuccessAsync(response, "Modeus events search",
|
||||
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}, timeMin={request.TimeMin:O}, timeMax={request.TimeMax:O}, typeId=[{string.Join(", ", request.TypeId ?? [])}]");
|
||||
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
|
||||
BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson));
|
||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
|
||||
BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson))
|
||||
?? new ModeusEventsResponse();
|
||||
}
|
||||
|
||||
public async Task<ModeusRoomsResponse> SearchRoomsAsync()
|
||||
@@ -50,7 +70,9 @@ public class ModeusApiClient : IModeusApiClient
|
||||
await EnsureSuccessAsync(response, "Modeus rooms search",
|
||||
$"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);
|
||||
|
||||
totalPages = payload.Page?.TotalPages ?? page + 1;
|
||||
@@ -66,15 +88,54 @@ public class ModeusApiClient : IModeusApiClient
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
if (responseBody.Length > 2000)
|
||||
responseBody = string.Concat(responseBody.AsSpan(0, 2000), "...<truncated>");
|
||||
|
||||
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,
|
||||
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)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
@@ -26,15 +27,58 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
try
|
||||
{
|
||||
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);
|
||||
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
|
||||
{
|
||||
var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == ev.TypeId);
|
||||
if (course == null) { course = new Course { Name = ev.Name, ExternalId = ev.TypeId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); }
|
||||
_db.Lectures.Add(new Lecture { CourseId = course.Id, Title = ev.Name, ExternalId = ev.Id, StartsAt = ev.StartsAt, EndsAt = ev.EndsAt });
|
||||
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,
|
||||
LocationId = location?.Id,
|
||||
Title = ev.Name,
|
||||
Description = ev.Description,
|
||||
ExternalId = ev.Id,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
MaxEnrollments = maxEnrollments
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +90,9 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
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,
|
||||
@@ -53,10 +100,14 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
updated,
|
||||
skipped,
|
||||
[
|
||||
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}",
|
||||
"size=900",
|
||||
$"requestJson={BuildScheduleRequestJson(request)}",
|
||||
$"specialtyCode=[{string.Join(", ", specialtyCodes)}]",
|
||||
$"timeMin={request.TimeMin: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);
|
||||
return result;
|
||||
@@ -129,6 +180,48 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
|
||||
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(
|
||||
Exception exception,
|
||||
string stage,
|
||||
@@ -154,4 +247,43 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user