feat: подтянул занятость из Modeus
Backend CI / build-and-test (push) Successful in 53s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 6m26s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-30 14:08:49 +03:00
parent a8d51df3f1
commit 09d3d2778d
16 changed files with 1349 additions and 48 deletions
@@ -53,6 +53,7 @@ public class MappingExtensionsTests
EndsAt = startsAt.AddHours(2), EndsAt = startsAt.AddHours(2),
IsOpen = true, IsOpen = true,
MaxEnrollments = 25, MaxEnrollments = 25,
MandatoryAttendeesCount = 30,
Enrollments = Enrollments =
[ [
new LectureEnrollment { UserId = 1 }, new LectureEnrollment { UserId = 1 },
@@ -66,7 +67,7 @@ public class MappingExtensionsTests
Assert.Equal("", dto.CourseName); Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName); Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName); Assert.Null(dto.LocationName);
Assert.Equal(2, dto.EnrollmentsCount); Assert.Equal(32, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled); Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled); Assert.False(detail.IsEnrolled);
} }
@@ -166,6 +166,29 @@ public class LectureServiceTests
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1)); Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
} }
[Fact]
public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.MaxEnrollments = 31;
lecture.MandatoryAttendeesCount = 30;
db.Users.AddRange(
new User { Id = 1, Email = "first@test.local" },
new User { Id = 2, Email = "second@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
}
[Fact] [Fact]
public async Task UnenrollAsync_CancelsLectureReminders() public async Task UnenrollAsync_CancelsLectureReminders()
{ {
@@ -0,0 +1,62 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Infrastructure.ExternalServices;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ModeusApiClientTests
{
[Fact]
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://schedule.test")
};
var config = new ConfigurationBuilder().Build();
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
await client.SearchEventsAsync(new SyncScheduleRequest(
SpecialtyCode: ["09.03.04"],
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
TypeId: ["LECT"],
Size: 50));
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
Assert.NotNull(handler.RequestBody);
using var body = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
}
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpMethod? RequestMethod { get; private set; }
public string? RequestPathAndQuery { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestMethod = request.Method;
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"events":[]}""")
};
}
}
}
@@ -129,6 +129,56 @@ public class ScheduleSyncServiceTests
Assert.Equal(48, lecture.MaxEnrollments); Assert.Equal(48, lecture.MaxEnrollments);
} }
[Fact]
public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc),
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 120, WorkingCapacity: 120)
]
}
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(120, lecture.MaxEnrollments);
Assert.Equal(31, lecture.MandatoryAttendeesCount);
}
[Fact] [Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher() public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{ {
+1 -1
View File
@@ -136,7 +136,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client => builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{ {
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru"); client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
client.Timeout = TimeSpan.FromSeconds(30); client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
}); });
// --- Background Services --- // --- Background Services ---
+2 -1
View File
@@ -23,7 +23,8 @@
}, },
"ModeusApi": { "ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru", "BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": "" "ApiKey": "",
"TimeoutSeconds": 180
}, },
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
@@ -29,11 +29,14 @@ public class ModeusEvent
public string? TypeId { get; init; } public string? TypeId { get; init; }
public DateTime StartsAt { get; init; } public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; } public DateTime EndsAt { get; init; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")] [JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; } public ModeusEventLinks? Links { get; init; }
} }
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
public class ModeusEventLinks public class ModeusEventLinks
{ {
[JsonPropertyName("course-unit-realization")] [JsonPropertyName("course-unit-realization")]
@@ -13,6 +13,9 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions public static class MappingExtensions
{ {
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- User --- // --- User ---
public static UserDto ToDto(this User user, int level) => new( public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl, user.Id, user.Email, user.DisplayName, user.AvatarUrl,
@@ -57,7 +60,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name, lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format, lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen, lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count, lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
); );
@@ -67,7 +70,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name, lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format, lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen, lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count, lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
); );
@@ -15,6 +15,7 @@ public class Lecture
public DateTime EndsAt { get; set; } public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true; public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; } public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; } public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; } public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -22,6 +22,7 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at"); builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true); builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0); builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255); builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500); builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -42,12 +42,11 @@ public class ModeusApiClient : IModeusApiClient
AddNonEmpty(body, "curriculumId", request.CurriculumId); AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId); AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body); var requestJson = JsonSerializer.Serialize(body);
await EnsureSuccessAsync(response, "Modeus events search", var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
BuildEventsRequestSummary(requestJson)); var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search", await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
BuildEventsRequestSummary(requestJson)) return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
?? new ModeusEventsResponse(); ?? new ModeusEventsResponse();
} }
@@ -98,8 +97,6 @@ public class ModeusApiClient : IModeusApiClient
response.StatusCode); response.StatusCode);
} }
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>( private static void AddNonEmpty<T>(
IDictionary<string, object?> body, IDictionary<string, object?> body,
string key, string key,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MandatoryAttendeesCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "mandatory_attendees_count",
table: "lectures",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "mandatory_attendees_count",
table: "lectures");
}
}
}
@@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
@@ -250,6 +250,12 @@ namespace UniVerse.Infrastructure.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("location_id"); .HasColumnName("location_id");
b.Property<int>("MandatoryAttendeesCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("mandatory_attendees_count");
b.Property<int>("MaxEnrollments") b.Property<int>("MaxEnrollments")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("integer") .HasColumnType("integer")
@@ -122,7 +122,8 @@ public class LectureService : ILectureService
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId); var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments) var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
throw new ConflictException("Lecture is full."); throw new ConflictException("Lecture is full.");
if (lecture.Enrollments.Any(e => e.UserId == userId)) if (lecture.Enrollments.Any(e => e.UserId == userId))
throw new ConflictException("Already enrolled."); throw new ConflictException("Already enrolled.");
@@ -1,6 +1,5 @@
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;
@@ -55,6 +54,7 @@ public class ScheduleSyncService : IScheduleSyncService
} }
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
var startsAt = EnsureUtc(ev.StartsAt); var startsAt = EnsureUtc(ev.StartsAt);
var endsAt = EnsureUtc(ev.EndsAt); var endsAt = EnsureUtc(ev.EndsAt);
@@ -68,6 +68,7 @@ public class ScheduleSyncService : IScheduleSyncService
existing.LocationId = location?.Id; existing.LocationId = location?.Id;
existing.TeacherId = teacher?.Id; existing.TeacherId = teacher?.Id;
existing.MaxEnrollments = lectureCapacity; existing.MaxEnrollments = lectureCapacity;
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
updated++; updated++;
} }
@@ -91,7 +92,8 @@ public class ScheduleSyncService : IScheduleSyncService
ExternalId = ev.Id, ExternalId = ev.Id,
StartsAt = startsAt, StartsAt = startsAt,
EndsAt = endsAt, EndsAt = endsAt,
MaxEnrollments = lectureCapacity MaxEnrollments = lectureCapacity,
MandatoryAttendeesCount = mandatoryAttendeesCount
}); });
created++; created++;
} }
@@ -111,7 +113,7 @@ public class ScheduleSyncService : IScheduleSyncService
updated, updated,
skipped, skipped,
[ [
$"requestJson={BuildScheduleRequestJson(request)}", "endpoint=POST /api/ictis?includeCounts=true",
$"timeMin={request.TimeMin:O}", $"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}" $"timeMax={request.TimeMax:O}"
])); ]));
@@ -443,6 +445,9 @@ 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 int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
private static string BuildModeusTeacherEmail(string personId) => private static string BuildModeusTeacherEmail(string personId) =>
$"modeus-{personId}@modeus.local".ToLowerInvariant(); $"modeus-{personId}@modeus.local".ToLowerInvariant();
@@ -488,37 +493,6 @@ public class ScheduleSyncService : IScheduleSyncService
return details; return details;
} }
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var body = new Dictionary<string, object?>
{
["size"] = request.Size is > 0 ? request.Size.Value : 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax
};
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
return JsonSerializer.Serialize(body);
}
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static string? GetHrefId(string? href) private static string? GetHrefId(string? href)
{ {
if (string.IsNullOrWhiteSpace(href)) if (string.IsNullOrWhiteSpace(href))