Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a74f29b246 |
@@ -53,7 +53,6 @@ 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 },
|
||||||
@@ -67,7 +66,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(32, dto.EnrollmentsCount);
|
Assert.Equal(2, dto.EnrollmentsCount);
|
||||||
Assert.True(dto.IsEnrolled);
|
Assert.True(dto.IsEnrolled);
|
||||||
Assert.False(detail.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));
|
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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using UniVerse.Api.Tests.Helpers;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.RateLimiting;
|
|
||||||
|
|
||||||
public class RateLimitingTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
|
|
||||||
{
|
|
||||||
await using var factory = new ApiWebApplicationFactory()
|
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((_, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["RateLimiting:PermitLimit"] = "1",
|
|
||||||
["RateLimiting:WindowSeconds"] = "60",
|
|
||||||
["RateLimiting:QueueLimit"] = "0"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
using var client = factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
|
|
||||||
|
|
||||||
using var firstResponse = await client.GetAsync("api/v1/tags");
|
|
||||||
using var secondResponse = await client.GetAsync("api/v1/tags");
|
|
||||||
|
|
||||||
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
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,12 +0,0 @@
|
|||||||
namespace UniVerse.Api.Options;
|
|
||||||
|
|
||||||
public class RateLimitingOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "RateLimiting";
|
|
||||||
|
|
||||||
public int PermitLimit { get; set; } = 600;
|
|
||||||
|
|
||||||
public int WindowSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
public int QueueLimit { get; set; } = 100;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
using UniVerse.Api.Filters;
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
@@ -73,50 +69,6 @@ builder.Services.AddAuthentication(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddOptions<RateLimitingOptions>()
|
|
||||||
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
|
|
||||||
.Validate(options => options.PermitLimit >= 1,
|
|
||||||
"RateLimiting:PermitLimit must be greater than or equal to 1.")
|
|
||||||
.Validate(options => options.WindowSeconds >= 1,
|
|
||||||
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
|
|
||||||
.Validate(options => options.QueueLimit >= 0,
|
|
||||||
"RateLimiting:QueueLimit must be greater than or equal to 0.")
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
|
||||||
{
|
|
||||||
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
|
|
||||||
return RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
GetRateLimitPartitionKey(context),
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = rateLimitingOptions.PermitLimit,
|
|
||||||
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
|
|
||||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
||||||
QueueLimit = rateLimitingOptions.QueueLimit,
|
|
||||||
AutoReplenishment = true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
options.OnRejected = async (context, cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
|
||||||
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
|
|
||||||
|
|
||||||
context.HttpContext.Response.ContentType = "application/problem+json";
|
|
||||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
|
||||||
{
|
|
||||||
type = "https://httpstatuses.com/429",
|
|
||||||
title = "Too Many Requests",
|
|
||||||
status = StatusCodes.Status429TooManyRequests,
|
|
||||||
detail = "Rate limit exceeded. Please try again later.",
|
|
||||||
traceId = context.HttpContext.TraceIdentifier
|
|
||||||
}, cancellationToken);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- CORS ---
|
// --- CORS ---
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -184,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(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Background Services ---
|
// --- Background Services ---
|
||||||
@@ -269,7 +221,6 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseHttpMetrics();
|
app.UseHttpMetrics();
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
@@ -286,15 +237,3 @@ app.UseWhen(
|
|||||||
app.MapMetrics();
|
app.MapMetrics();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static string GetRateLimitPartitionKey(HttpContext context)
|
|
||||||
{
|
|
||||||
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
?? context.User.FindFirstValue("sub");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(userId))
|
|
||||||
return $"user:{userId}";
|
|
||||||
|
|
||||||
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
|
|
||||||
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,11 +13,6 @@
|
|||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"RateLimiting": {
|
|
||||||
"PermitLimit": 600,
|
|
||||||
"WindowSeconds": 60,
|
|
||||||
"QueueLimit": 100
|
|
||||||
},
|
|
||||||
"Llm": {
|
"Llm": {
|
||||||
"BaseUrl": "https://api.openai.com/v1/",
|
"BaseUrl": "https://api.openai.com/v1/",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
@@ -28,8 +23,7 @@
|
|||||||
},
|
},
|
||||||
"ModeusApi": {
|
"ModeusApi": {
|
||||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||||
"ApiKey": "",
|
"ApiKey": ""
|
||||||
"TimeoutSeconds": 180
|
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.0" />
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.3.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -29,14 +29,11 @@ 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,9 +13,6 @@ 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,
|
||||||
@@ -60,7 +57,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.OccupiedSeatsCount(),
|
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,7 +67,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.OccupiedSeatsCount(),
|
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,37 +6,11 @@ public static class ReviewPromptTemplate
|
|||||||
public const string ReviewTextPlaceholder = "{reviewText}";
|
public const string ReviewTextPlaceholder = "{reviewText}";
|
||||||
|
|
||||||
public const string Default = """
|
public const string Default = """
|
||||||
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю.
|
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
|
||||||
|
- quality_score: число от 0 до 1, указывающее на качество отзыва;
|
||||||
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
|
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
|
||||||
{
|
- tags: массив соответствующих тематических тегов;
|
||||||
"quality_score": 0.0,
|
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
|
||||||
"sentiment": "Нейтральный",
|
|
||||||
"tags": [],
|
|
||||||
"is_informative": false
|
|
||||||
}
|
|
||||||
|
|
||||||
Правила оценки:
|
|
||||||
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
|
|
||||||
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
|
|
||||||
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
|
|
||||||
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
|
|
||||||
|
|
||||||
Базовые теги:
|
|
||||||
- "структура лекции"
|
|
||||||
- "понятность объяснения"
|
|
||||||
- "темп"
|
|
||||||
- "сложность"
|
|
||||||
- "практические примеры"
|
|
||||||
- "материалы"
|
|
||||||
- "актуальность темы"
|
|
||||||
- "вовлеченность"
|
|
||||||
- "организация"
|
|
||||||
- "технические проблемы"
|
|
||||||
- "польза для обучения"
|
|
||||||
- "неинформативный отзыв"
|
|
||||||
|
|
||||||
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
|
|
||||||
|
|
||||||
Контекст лекции: {lectureContext}
|
Контекст лекции: {lectureContext}
|
||||||
Текст отзыва: {reviewText}
|
Текст отзыва: {reviewText}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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,7 +22,6 @@ 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,11 +42,12 @@ 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);
|
||||||
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
|
await EnsureSuccessAsync(response, "Modeus events search",
|
||||||
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
|
BuildEventsRequestSummary(requestJson));
|
||||||
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
|
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
|
||||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
|
BuildEventsRequestSummary(requestJson))
|
||||||
?? new ModeusEventsResponse();
|
?? new ModeusEventsResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@ 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,
|
||||||
|
|||||||
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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.8")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.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,12 +250,6 @@ 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,8 +122,7 @@ 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.");
|
||||||
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
|
||||||
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,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;
|
||||||
@@ -54,7 +55,6 @@ 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,7 +68,6 @@ 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++;
|
||||||
}
|
}
|
||||||
@@ -92,8 +91,7 @@ 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++;
|
||||||
}
|
}
|
||||||
@@ -113,7 +111,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
updated,
|
updated,
|
||||||
skipped,
|
skipped,
|
||||||
[
|
[
|
||||||
"endpoint=POST /api/ictis?includeCounts=true",
|
$"requestJson={BuildScheduleRequestJson(request)}",
|
||||||
$"timeMin={request.TimeMin:O}",
|
$"timeMin={request.TimeMin:O}",
|
||||||
$"timeMax={request.TimeMax:O}"
|
$"timeMax={request.TimeMax:O}"
|
||||||
]));
|
]));
|
||||||
@@ -445,9 +443,6 @@ 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();
|
||||||
|
|
||||||
@@ -493,6 +488,37 @@ 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))
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ services:
|
|||||||
|
|
||||||
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||||
|
|
||||||
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
|
|
||||||
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
|
|
||||||
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
|
|
||||||
|
|
||||||
- Llm:BaseUrl=${LLM_BASE_URL}
|
- Llm:BaseUrl=${LLM_BASE_URL}
|
||||||
- Llm:ApiKey=${LLM_API_KEY}
|
- Llm:ApiKey=${LLM_API_KEY}
|
||||||
- Llm:Model=${LLM_MODEL}
|
- Llm:Model=${LLM_MODEL}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { mapApiReview } from '@/api/mappers'
|
|||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const reviews = ref<Review[]>([])
|
const reviews = ref<Review[]>([])
|
||||||
@@ -16,9 +17,8 @@ const reviews = ref<Review[]>([])
|
|||||||
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
|
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
|
||||||
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
|
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
|
||||||
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
|
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
|
||||||
const total = computed(() => reviews.value.length)
|
const total = computed(() => reviews.value.length || 1)
|
||||||
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0)
|
const pct = (value: number) => Math.round((value / total.value) * 100)
|
||||||
const ratio = (value: number) => `${value}/${total.value}`
|
|
||||||
|
|
||||||
async function fetchTeacherAnalytics() {
|
async function fetchTeacherAnalytics() {
|
||||||
if (!auth.user?.id) return
|
if (!auth.user?.id) return
|
||||||
@@ -39,28 +39,37 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
|
|||||||
<h1 class="page-title">Аналитика преподавателя</h1>
|
<h1 class="page-title">Аналитика преподавателя</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
<GlassCard>
|
||||||
|
<div class="section-title">Динамика оценок</div>
|
||||||
|
<div class="chart">
|
||||||
|
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
|
||||||
|
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
|
||||||
|
<span class="bar-label">Нед {{ i + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avg">Средняя оценка: 4.6</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Sentiment-анализ отзывов</div>
|
<div class="section-title">Sentiment-анализ отзывов</div>
|
||||||
<div class="sentiment">
|
<div class="sentiment">
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
|
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
|
||||||
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
|
<ProgressBar :value="pct(positive)" :max="100" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div>
|
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
:value="pct(neutral)"
|
:value="pct(neutral)"
|
||||||
:max="100"
|
:max="100"
|
||||||
:text="ratio(neutral)"
|
|
||||||
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
|
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div>
|
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
:value="pct(negative)"
|
:value="pct(negative)"
|
||||||
:max="100"
|
:max="100"
|
||||||
:text="ratio(negative)"
|
|
||||||
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
|
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +117,32 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 160px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
width: 26px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: linear-gradient(180deg, #22c55e, #86efac);
|
||||||
|
}
|
||||||
|
.bar-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.avg {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.sentiment {
|
.sentiment {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const upcoming = computed(() =>
|
|||||||
const enrolledTotal = computed(() =>
|
const enrolledTotal = computed(() =>
|
||||||
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
|
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
|
||||||
)
|
)
|
||||||
|
const visibility = computed(() =>
|
||||||
|
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
function fetchTeacherLectures() {
|
function fetchTeacherLectures() {
|
||||||
if (!auth.user?.id) return
|
if (!auth.user?.id) return
|
||||||
@@ -45,7 +48,13 @@ watch(() => auth.user?.id, fetchTeacherLectures)
|
|||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
||||||
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
||||||
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" />
|
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
|
||||||
|
<StatsWidget
|
||||||
|
label="Вовлеченность вне направления"
|
||||||
|
:value="`${visibility}%`"
|
||||||
|
icon="🌍"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user