Dev #11
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using UniVerse.Application.DTOs.Sync;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
using UniVerse.Infrastructure.Data;
|
||||||
|
using UniVerse.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UniVerse.Api.Tests.Sync;
|
||||||
|
|
||||||
|
public class ScheduleSyncServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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: 60, WorkingCapacity: 42)
|
||||||
|
],
|
||||||
|
EventTeams =
|
||||||
|
[
|
||||||
|
new ModeusEventTeam("event-1", 15)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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(42, lecture.MaxEnrollments);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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: null, WorkingCapacity: null)
|
||||||
|
],
|
||||||
|
EventTeams =
|
||||||
|
[
|
||||||
|
new ModeusEventTeam("event-1", 15)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modeus.SearchRoomsAsync()
|
||||||
|
.Returns(new ModeusRoomsResponse
|
||||||
|
{
|
||||||
|
Rooms =
|
||||||
|
[
|
||||||
|
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
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(48, lecture.MaxEnrollments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var events = await _modeus.SearchEventsAsync(request);
|
var events = await _modeus.SearchEventsAsync(request);
|
||||||
|
var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms);
|
||||||
|
IReadOnlyDictionary<string, int>? syncedRoomCapacityById = null;
|
||||||
|
|
||||||
foreach (var ev in events.EventItems)
|
foreach (var ev in events.EventItems)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name))
|
if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name))
|
||||||
@@ -41,8 +44,15 @@ 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 maxEnrollments = events.Embedded?.EventTeams?
|
var roomId = GetEventRoomId(events, ev.Id);
|
||||||
.FirstOrDefault(team => team.EventId == ev.Id)?.Size ?? 0;
|
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 startsAt = EnsureUtc(ev.StartsAt);
|
||||||
var endsAt = EnsureUtc(ev.EndsAt);
|
var endsAt = EnsureUtc(ev.EndsAt);
|
||||||
|
|
||||||
@@ -54,7 +64,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.MaxEnrollments = maxEnrollments;
|
existing.MaxEnrollments = lectureCapacity;
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
@@ -77,7 +87,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
ExternalId = ev.Id,
|
ExternalId = ev.Id,
|
||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
MaxEnrollments = maxEnrollments
|
MaxEnrollments = lectureCapacity
|
||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
@@ -182,14 +192,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
|
|
||||||
private async Task<Location?> UpsertEventLocationAsync(ModeusEventsResponse events, string eventId)
|
private async Task<Location?> UpsertEventLocationAsync(ModeusEventsResponse events, string eventId)
|
||||||
{
|
{
|
||||||
var roomId = events.Embedded?.EventRooms?
|
var roomId = GetEventRoomId(events, eventId);
|
||||||
.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))
|
if (string.IsNullOrWhiteSpace(roomId))
|
||||||
return null;
|
return null;
|
||||||
@@ -222,6 +225,55 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
return location;
|
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 IReadOnlyList<string> BuildErrorDetails(
|
private static IReadOnlyList<string> BuildErrorDetails(
|
||||||
Exception exception,
|
Exception exception,
|
||||||
string stage,
|
string stage,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function mapApiLecture(lecture: LectureDto): Lecture {
|
|||||||
room: lecture.format === 'Online' ? undefined : locationName,
|
room: lecture.format === 'Online' ? undefined : locationName,
|
||||||
format: lecture.format === 'Online' ? 'online' : 'offline',
|
format: lecture.format === 'Online' ? 'online' : 'offline',
|
||||||
totalSeats,
|
totalSeats,
|
||||||
|
enrolledSeats: enrolled,
|
||||||
freeSeats,
|
freeSeats,
|
||||||
registrationClosed: !lecture.isOpen,
|
registrationClosed: !lecture.isOpen,
|
||||||
tags: lecture.courseName ? [`#${lecture.courseName}`] : [],
|
tags: lecture.courseName ? [`#${lecture.courseName}`] : [],
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ input, textarea, select {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.badge-green { background: rgba(34,197,94,0.15); color: #15803D; border: 1px solid rgba(34,197,94,0.3); }
|
.badge-green { color: #15803D; border: 1.5px solid rgba(34,197,94,0.3); }
|
||||||
.badge-blue { background: rgba(6,182,212,0.15); color: #0E7490; border: 1px solid rgba(6,182,212,0.3); }
|
.badge-blue { background: rgba(6,182,212,0.15); color: #0E7490; border: 1px solid rgba(6,182,212,0.3); }
|
||||||
.badge-orange { background: rgba(251,146,60,0.15); color: #C2410C; border: 1px solid rgba(251,146,60,0.3); }
|
.badge-orange { background: rgba(251,146,60,0.15); color: #C2410C; border: 1px solid rgba(251,146,60,0.3); }
|
||||||
.badge-gray { background: rgba(100,116,139,0.1); color: #64748B; border: 1px solid rgba(100,116,139,0.2); }
|
.badge-gray { background: rgba(100,116,139,0.1); color: #64748B; border: 1px solid rgba(100,116,139,0.2); }
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function goDetail() {
|
|||||||
>
|
>
|
||||||
<template v-if="lecture.registrationClosed">Запись закрыта</template>
|
<template v-if="lecture.registrationClosed">Запись закрыта</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ lecture.freeSeats === 0 ? 'Мест нет' : `${lecture.freeSeats}/${lecture.totalSeats} мест` }}
|
{{ `${lecture.enrolledSeats}/${lecture.totalSeats} записано` }}
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const useLecturesStore = defineStore('lectures', () => {
|
|||||||
await lecturesApi.enroll(lectureId)
|
await lecturesApi.enroll(lectureId)
|
||||||
registered.value.push(lectureId)
|
registered.value.push(lectureId)
|
||||||
lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0)
|
lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0)
|
||||||
|
lecture.enrolledSeats += 1
|
||||||
lecture.registered = true
|
lecture.registered = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export const useLecturesStore = defineStore('lectures', () => {
|
|||||||
const lecture = lectures.value.find(item => item.id === lectureId)
|
const lecture = lectures.value.find(item => item.id === lectureId)
|
||||||
if (lecture) {
|
if (lecture) {
|
||||||
lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats)
|
lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats)
|
||||||
|
lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0)
|
||||||
lecture.registered = false
|
lecture.registered = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface Lecture {
|
|||||||
room?: string
|
room?: string
|
||||||
format: 'online' | 'offline'
|
format: 'online' | 'offline'
|
||||||
totalSeats: number
|
totalSeats: number
|
||||||
|
enrolledSeats: number
|
||||||
freeSeats: number
|
freeSeats: number
|
||||||
registrationClosed?: boolean
|
registrationClosed?: boolean
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ async function registerLecture(id: string) {
|
|||||||
</template>
|
</template>
|
||||||
<template #seats="{ row }">
|
<template #seats="{ row }">
|
||||||
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
|
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
|
||||||
{{ row.registrationClosed ? 'Запись закрыта' : `${row.freeSeats}/${row.totalSeats}` }}
|
{{ row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}` }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<h3>Места</h3>
|
<h3>Места</h3>
|
||||||
<div class="info-value">Свободно {{ lecture.freeSeats }} из {{ lecture.totalSeats }}</div>
|
<div class="info-value">Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}</div>
|
||||||
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
|
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const teacherLectures = computed(() => {
|
|||||||
return owned.length ? owned : lecturesStore.all
|
return owned.length ? owned : lecturesStore.all
|
||||||
})
|
})
|
||||||
const upcoming = computed(() => teacherLectures.value.filter(l => l.status !== 'completed').slice(0, 3))
|
const upcoming = computed(() => teacherLectures.value.filter(l => l.status !== 'completed').slice(0, 3))
|
||||||
const enrolledTotal = computed(() => teacherLectures.value.reduce((sum, l) => sum + (l.totalSeats - l.freeSeats), 0))
|
const enrolledTotal = computed(() => teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0))
|
||||||
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0))
|
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -60,7 +60,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="upcoming-title">{{ l.title }}</div>
|
<div class="upcoming-title">{{ l.title }}</div>
|
||||||
<div class="upcoming-meta">📅 {{ new Date(l.date).toLocaleDateString('ru-RU') }} · {{ l.time }}</div>
|
<div class="upcoming-meta">📅 {{ new Date(l.date).toLocaleDateString('ru-RU') }} · {{ l.time }}</div>
|
||||||
<div class="upcoming-meta">Записалось {{ l.totalSeats - l.freeSeats }} студентов</div>
|
<div class="upcoming-meta">Записалось {{ l.enrolledSeats }} студентов</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
|
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const rows = computed(() => {
|
|||||||
title: l.title,
|
title: l.title,
|
||||||
date: `${new Date(l.date).toLocaleDateString('ru-RU')} · ${l.time}`,
|
date: `${new Date(l.date).toLocaleDateString('ru-RU')} · ${l.time}`,
|
||||||
status: l.status ?? 'upcoming',
|
status: l.status ?? 'upcoming',
|
||||||
stats: `${l.totalSeats - l.freeSeats} / — / ${l.reviewCount}`,
|
stats: `${l.enrolledSeats} / — / ${l.reviewCount}`,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user