Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c49a821e7 | |||
| 4cc1478e86 | |||
| 6287661ca5 | |||
| 8223697bd3 | |||
| 3106f0ef61 | |||
| a220afd078 | |||
| cd3f2c53b7 |
@@ -53,7 +53,6 @@ public class MappingExtensionsTests
|
||||
EndsAt = startsAt.AddHours(2),
|
||||
IsOpen = true,
|
||||
MaxEnrollments = 25,
|
||||
MandatoryAttendeesCount = 30,
|
||||
Enrollments =
|
||||
[
|
||||
new LectureEnrollment { UserId = 1 },
|
||||
@@ -67,7 +66,7 @@ public class MappingExtensionsTests
|
||||
Assert.Equal("", dto.CourseName);
|
||||
Assert.Null(dto.TeacherName);
|
||||
Assert.Null(dto.LocationName);
|
||||
Assert.Equal(32, dto.EnrollmentsCount);
|
||||
Assert.Equal(2, dto.EnrollmentsCount);
|
||||
Assert.True(dto.IsEnrolled);
|
||||
Assert.False(detail.IsEnrolled);
|
||||
}
|
||||
|
||||
@@ -166,29 +166,6 @@ public class LectureServiceTests
|
||||
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]
|
||||
public async Task UnenrollAsync_CancelsLectureReminders()
|
||||
{
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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,56 +129,6 @@ public class ScheduleSyncServiceTests
|
||||
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]
|
||||
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -159,19 +159,6 @@ public class UsersController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||
|
||||
/// <summary>Получить статистику для админского дашборда.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <response code="200">Агрегированная статистика дашборда.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("admin/stats")]
|
||||
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
|
||||
Ok(await _users.GetAdminDashboardStatsAsync());
|
||||
|
||||
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace UniVerse.Api.Middleware;
|
||||
|
||||
public sealed class LocalNetworksOnlyMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
|
||||
|
||||
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var remoteIpAddress = context.Connection.RemoteIpAddress;
|
||||
|
||||
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
|
||||
{
|
||||
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static bool IsLocalNetwork(IPAddress ipAddress)
|
||||
{
|
||||
if (IPAddress.IsLoopback(ipAddress))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ipAddress.IsIPv4MappedToIPv6)
|
||||
{
|
||||
ipAddress = ipAddress.MapToIPv4();
|
||||
}
|
||||
|
||||
return ipAddress.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
|
||||
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
|
||||
{
|
||||
var bytes = ipAddress.GetAddressBytes();
|
||||
|
||||
return bytes[0] == 10
|
||||
|| bytes[0] == 127
|
||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| (bytes[0] == 169 && bytes[1] == 254);
|
||||
}
|
||||
|
||||
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
|
||||
{
|
||||
var bytes = ipAddress.GetAddressBytes();
|
||||
|
||||
return ipAddress.IsIPv6LinkLocal
|
||||
|| ipAddress.IsIPv6SiteLocal
|
||||
|| (bytes[0] & 0xfe) == 0xfc;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Prometheus;
|
||||
using Quartz;
|
||||
using Serilog;
|
||||
using UniVerse.Api.BackgroundServices;
|
||||
@@ -136,7 +135,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
||||
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
|
||||
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// --- Background Services ---
|
||||
@@ -222,7 +221,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseHttpMetrics();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseAntiforgery();
|
||||
@@ -230,10 +228,4 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
app.MapControllers();
|
||||
|
||||
// Restrict Prometheus scrape endpoint to local and private networks.
|
||||
app.UseWhen(
|
||||
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
|
||||
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
|
||||
app.MapMetrics();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
},
|
||||
"ModeusApi": {
|
||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||
"ApiKey": "",
|
||||
"TimeoutSeconds": 180
|
||||
"ApiKey": ""
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
|
||||
@@ -4158,52 +4158,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/admin/stats": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Получить статистику для админского дашборда.",
|
||||
"description": "Только Admin.\n\n**Required roles:** Admin",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Агрегированная статистика дашборда.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminDashboardStatsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Требуется аутентификация.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Требуется роль Admin.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/enrollments": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4804,28 +4758,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminDashboardStatsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"usersCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"lecturesCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"enrollmentsCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"pendingReviewsCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
@@ -42,13 +42,6 @@ public record UserStatsDto(
|
||||
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
||||
);
|
||||
|
||||
public record AdminDashboardStatsDto(
|
||||
int UsersCount,
|
||||
int LecturesCount,
|
||||
int EnrollmentsCount,
|
||||
int PendingReviewsCount
|
||||
);
|
||||
|
||||
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||
|
||||
public record UpdateUserRequest(
|
||||
|
||||
@@ -29,14 +29,11 @@ public class ModeusEvent
|
||||
public string? TypeId { get; init; }
|
||||
public DateTime StartsAt { get; init; }
|
||||
public DateTime EndsAt { get; init; }
|
||||
public ModeusIctisStats? IctisStats { get; init; }
|
||||
|
||||
[JsonPropertyName("_links")]
|
||||
public ModeusEventLinks? Links { get; init; }
|
||||
}
|
||||
|
||||
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
|
||||
|
||||
public class ModeusEventLinks
|
||||
{
|
||||
[JsonPropertyName("course-unit-realization")]
|
||||
|
||||
@@ -10,7 +10,6 @@ public interface IUserService
|
||||
Task<UserDto> GetByIdAsync(int id);
|
||||
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
||||
Task<UserStatsDto> GetStatsAsync(int id);
|
||||
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
|
||||
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
||||
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||
|
||||
@@ -13,9 +13,6 @@ namespace UniVerse.Application.Mappings;
|
||||
|
||||
public static class MappingExtensions
|
||||
{
|
||||
private static int OccupiedSeatsCount(this Lecture lecture) =>
|
||||
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
||||
|
||||
// --- User ---
|
||||
public static UserDto ToDto(this User user, int level) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||
@@ -60,7 +57,7 @@ public static class MappingExtensions
|
||||
lecture.LocationId, lecture.Location?.Name,
|
||||
lecture.Title, lecture.Description, lecture.Format,
|
||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
||||
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||
);
|
||||
|
||||
@@ -70,7 +67,7 @@ public static class MappingExtensions
|
||||
lecture.LocationId, lecture.Location?.Name,
|
||||
lecture.Title, lecture.Description, lecture.Format,
|
||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
||||
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ public class Lecture
|
||||
public DateTime EndsAt { get; set; }
|
||||
public bool IsOpen { get; set; } = true;
|
||||
public int MaxEnrollments { get; set; }
|
||||
public int MandatoryAttendeesCount { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public string? OnlineUrl { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -22,7 +22,6 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
|
||||
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
|
||||
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
|
||||
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.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
|
||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
@@ -42,11 +42,12 @@ public class ModeusApiClient : IModeusApiClient
|
||||
AddNonEmpty(body, "curriculumId", request.CurriculumId);
|
||||
AddNonEmpty(body, "typeId", request.TypeId);
|
||||
|
||||
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
|
||||
var requestJson = JsonSerializer.Serialize(body);
|
||||
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
|
||||
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
|
||||
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
|
||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
|
||||
await EnsureSuccessAsync(response, "Modeus events search",
|
||||
BuildEventsRequestSummary(requestJson));
|
||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
|
||||
BuildEventsRequestSummary(requestJson))
|
||||
?? new ModeusEventsResponse();
|
||||
}
|
||||
|
||||
@@ -97,6 +98,8 @@ public class ModeusApiClient : IModeusApiClient
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
|
||||
|
||||
private static void AddNonEmpty<T>(
|
||||
IDictionary<string, object?> body,
|
||||
string key,
|
||||
|
||||
Generated
-1149
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
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
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
|
||||
@@ -250,12 +250,6 @@ namespace UniVerse.Infrastructure.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("location_id");
|
||||
|
||||
b.Property<int>("MandatoryAttendeesCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("mandatory_attendees_count");
|
||||
|
||||
b.Property<int>("MaxEnrollments")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
|
||||
@@ -122,8 +122,7 @@ public class LectureService : ILectureService
|
||||
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
|
||||
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
|
||||
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
|
||||
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
||||
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
|
||||
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
|
||||
throw new ConflictException("Lecture is full.");
|
||||
if (lecture.Enrollments.Any(e => e.UserId == userId))
|
||||
throw new ConflictException("Already enrolled.");
|
||||
|
||||
@@ -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;
|
||||
@@ -54,7 +55,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
}
|
||||
|
||||
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
|
||||
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
|
||||
var startsAt = EnsureUtc(ev.StartsAt);
|
||||
var endsAt = EnsureUtc(ev.EndsAt);
|
||||
|
||||
@@ -68,7 +68,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
existing.LocationId = location?.Id;
|
||||
existing.TeacherId = teacher?.Id;
|
||||
existing.MaxEnrollments = lectureCapacity;
|
||||
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
updated++;
|
||||
}
|
||||
@@ -92,8 +91,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
ExternalId = ev.Id,
|
||||
StartsAt = startsAt,
|
||||
EndsAt = endsAt,
|
||||
MaxEnrollments = lectureCapacity,
|
||||
MandatoryAttendeesCount = mandatoryAttendeesCount
|
||||
MaxEnrollments = lectureCapacity
|
||||
});
|
||||
created++;
|
||||
}
|
||||
@@ -113,7 +111,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
updated,
|
||||
skipped,
|
||||
[
|
||||
"endpoint=POST /api/ictis?includeCounts=true",
|
||||
$"requestJson={BuildScheduleRequestJson(request)}",
|
||||
$"timeMin={request.TimeMin:O}",
|
||||
$"timeMax={request.TimeMax:O}"
|
||||
]));
|
||||
@@ -445,9 +443,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
private static int? NormalizeCapacity(int? capacity) =>
|
||||
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) =>
|
||||
$"modeus-{personId}@modeus.local".ToLowerInvariant();
|
||||
|
||||
@@ -493,6 +488,37 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
|
||||
@@ -74,17 +74,6 @@ public class UserService : IUserService
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
|
||||
{
|
||||
var usersCount = await _db.Users
|
||||
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
|
||||
var lecturesCount = await _db.Lectures.CountAsync();
|
||||
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
|
||||
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
|
||||
|
||||
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
|
||||
{
|
||||
if (!await _db.Users.AnyAsync(u => u.Id == id))
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-vue": "~10.9.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"eslint-plugin-vue-scoped-css": "^3.1.0",
|
||||
"jiti": "^2.6.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
TagDto,
|
||||
UpdateReviewPromptRequest,
|
||||
UserAchievementDto,
|
||||
AdminDashboardStatsDto,
|
||||
CurrentUserDto,
|
||||
UserDto,
|
||||
UserQuery,
|
||||
@@ -69,7 +68,6 @@ export const usersApi = {
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
|
||||
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
|
||||
async myEnrollments() {
|
||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
||||
'/users/me/enrollments',
|
||||
|
||||
@@ -76,13 +76,6 @@ export interface UserStatsDto {
|
||||
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
||||
}
|
||||
|
||||
export interface AdminDashboardStatsDto {
|
||||
usersCount: number
|
||||
lecturesCount: number
|
||||
enrollmentsCount: number
|
||||
pendingReviewsCount: number
|
||||
}
|
||||
|
||||
export interface EnrollmentSlotRuleDto {
|
||||
level: number
|
||||
slots: number
|
||||
|
||||
@@ -3,11 +3,17 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { syncApi, usersApi } from '@/api'
|
||||
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types'
|
||||
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
||||
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
||||
|
||||
const stats = ref<AdminDashboardStatsDto | null>(null)
|
||||
const users = ref<UserDto[]>([])
|
||||
const lectures = ref<LectureDto[]>([])
|
||||
const pendingReviewsCount = ref(0)
|
||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||
|
||||
const enrollmentCount = computed(() =>
|
||||
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
|
||||
)
|
||||
const syncMeta = computed(() =>
|
||||
syncStatus.value?.lastSyncAt
|
||||
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
||||
@@ -15,8 +21,16 @@ const syncMeta = computed(() =>
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()])
|
||||
if (statsResult.status === 'fulfilled') stats.value = statsResult.value
|
||||
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
||||
usersApi.list({ PageSize: 100 }),
|
||||
lecturesApi.list({ PageSize: 100 }),
|
||||
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
|
||||
syncApi.status(),
|
||||
])
|
||||
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
||||
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
||||
if (reviewsResult.status === 'fulfilled')
|
||||
pendingReviewsCount.value = reviewsResult.value.totalCount
|
||||
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
||||
})
|
||||
</script>
|
||||
@@ -26,17 +40,12 @@ onMounted(async () => {
|
||||
<h1 class="page-title">Дашборд администратора</h1>
|
||||
|
||||
<div class="stats-row">
|
||||
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" />
|
||||
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" />
|
||||
<StatsWidget
|
||||
label="Записей"
|
||||
:value="stats?.enrollmentsCount ?? 0"
|
||||
icon="calendar-event"
|
||||
color="orange"
|
||||
/>
|
||||
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
|
||||
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
||||
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
||||
<StatsWidget
|
||||
label="Отзывы на проверке"
|
||||
:value="stats?.pendingReviewsCount ?? 0"
|
||||
:value="pendingReviewsCount"
|
||||
icon="message-circle"
|
||||
color="purple"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user