Dev #11
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
using UniVerse.Application.DTOs.Notifications;
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
@@ -60,6 +62,37 @@ public class AuthServiceTests
|
|||||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
|
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
Email = "modeus-person-1@modeus.local",
|
||||||
|
DisplayName = "Иванов Иван Иванович",
|
||||||
|
MicrosoftId = "sso-sub-1",
|
||||||
|
IsActive = true,
|
||||||
|
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||||
|
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
|
||||||
|
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
|
||||||
|
var service = CreateService(db, microsoftAuth);
|
||||||
|
|
||||||
|
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
|
||||||
|
|
||||||
|
Assert.Equal(10, result.Response.User.Id);
|
||||||
|
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
|
||||||
|
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
|
||||||
|
Assert.Single(await db.Users.ToListAsync());
|
||||||
|
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
|
||||||
|
Assert.Equal("sso-sub-1", user.MicrosoftId);
|
||||||
|
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
|
||||||
|
}
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -68,7 +101,7 @@ public class AuthServiceTests
|
|||||||
return new AppDbContext(options);
|
return new AppDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthService CreateService(AppDbContext db)
|
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
@@ -88,6 +121,18 @@ public class AuthServiceTests
|
|||||||
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
return new AuthService(db, config, gamification, notifications, NullLogger<AuthService>.Instance);
|
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
|
||||||
|
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildIdToken(string sub, string email, string name)
|
||||||
|
{
|
||||||
|
var token = new JwtSecurityToken(claims:
|
||||||
|
[
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, sub),
|
||||||
|
new Claim("preferred_username", email),
|
||||||
|
new Claim("name", name)
|
||||||
|
]);
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
||||||
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
||||||
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
||||||
|
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
|
||||||
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
|
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
var stub = Substitute.For<IAuthService>();
|
var stub = Substitute.For<IAuthService>();
|
||||||
var authResult = new AuthResult(
|
var authResult = new AuthResult(
|
||||||
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
|
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
|
||||||
new UserAuthDto("test@test.com", "Test User", [UserRole.Student])),
|
new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])),
|
||||||
"refresh_token");
|
"refresh_token");
|
||||||
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
|
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
|
||||||
.Returns(authResult);
|
.Returns(authResult);
|
||||||
@@ -124,7 +125,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
.Returns(authResult);
|
.Returns(authResult);
|
||||||
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||||
stub.GetCurrentUserAsync(Arg.Any<int>())
|
stub.GetCurrentUserAsync(Arg.Any<int>())
|
||||||
.Returns(new CurrentUserDto("test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
||||||
return stub;
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,12 +200,12 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
|
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
|
||||||
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
|
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
|
||||||
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
|
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
|
||||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>()).Returns(lectureDto);
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(lectureDto);
|
||||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedEnrollments);
|
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(pagedEnrollments);
|
||||||
return stub;
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
|
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
|
||||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
|
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
|
||||||
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
|
||||||
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||||
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
|
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
|
||||||
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
|
|||||||
@@ -187,6 +187,39 @@ public class LectureServiceTests
|
|||||||
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
|
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
|
||||||
|
lecture.TeacherId = 2;
|
||||||
|
db.Lectures.Add(lecture);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
|
||||||
|
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
|
||||||
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
|
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
|
||||||
|
lecture.TeacherId = 2;
|
||||||
|
db.Lectures.Add(lecture);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
|
||||||
|
|
||||||
|
Assert.Empty(result.Items);
|
||||||
|
}
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using UniVerse.Application.DTOs.Reviews;
|
using UniVerse.Application.DTOs.Reviews;
|
||||||
|
using UniVerse.Application.DTOs.Common;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
|
using UniVerse.Domain.Exceptions;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -65,6 +67,29 @@ public class ReviewServiceTests
|
|||||||
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
|
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
|
||||||
|
await SeedAnalyzedReviewAsync(db, teacherId: 2);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ForbiddenException>(() =>
|
||||||
|
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
|
||||||
|
await SeedAnalyzedReviewAsync(db, teacherId: 2);
|
||||||
|
|
||||||
|
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
|
||||||
|
|
||||||
|
Assert.Single(result.Items);
|
||||||
|
}
|
||||||
|
|
||||||
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
|
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
|
||||||
{
|
{
|
||||||
var gamification = Substitute.For<IGamificationService>();
|
var gamification = Substitute.For<IGamificationService>();
|
||||||
@@ -72,7 +97,7 @@ public class ReviewServiceTests
|
|||||||
return new ReviewService(db, gamification, queue);
|
return new ReviewService(db, gamification, queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedLectureAsync(AppDbContext db)
|
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
|
||||||
{
|
{
|
||||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
|
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
|
||||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||||
@@ -80,6 +105,7 @@ public class ReviewServiceTests
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CourseId = 1,
|
CourseId = 1,
|
||||||
|
TeacherId = teacherId,
|
||||||
Title = "Lecture",
|
Title = "Lecture",
|
||||||
StartsAt = DateTime.UtcNow.AddDays(-1),
|
StartsAt = DateTime.UtcNow.AddDays(-1),
|
||||||
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
|
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
|
||||||
@@ -89,9 +115,9 @@ public class ReviewServiceTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedAnalyzedReviewAsync(AppDbContext db)
|
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
|
||||||
{
|
{
|
||||||
await SeedLectureAsync(db);
|
await SeedLectureAsync(db, teacherId);
|
||||||
db.Reviews.Add(new Review
|
db.Reviews.Add(new Review
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ namespace UniVerse.Api.Tests.Sync;
|
|||||||
|
|
||||||
public class ScheduleSyncServiceTests
|
public class ScheduleSyncServiceTests
|
||||||
{
|
{
|
||||||
|
private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
|
||||||
|
private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
|
||||||
|
private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
|
||||||
|
private const string FullName = "Иванов Иван Иванович";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
|
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
|
||||||
{
|
{
|
||||||
@@ -149,6 +154,138 @@ public class ScheduleSyncServiceTests
|
|||||||
Assert.Equal(UserRole.Teacher, teacherRole.Role);
|
Assert.Equal(UserRole.Teacher, teacherRole.Role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_SavesResolvedTeacherSubId()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||||
|
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||||
|
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
|
||||||
|
Assert.Equal("sso-sub-1", teacher.MicrosoftId);
|
||||||
|
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
|
||||||
|
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true);
|
||||||
|
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||||
|
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
|
||||||
|
Assert.Null(teacher.MicrosoftId);
|
||||||
|
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
|
||||||
|
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Id = 77,
|
||||||
|
Email = "teacher@sfedu.ru",
|
||||||
|
DisplayName = "Old Name",
|
||||||
|
MicrosoftId = "sso-sub-1",
|
||||||
|
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }]
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||||
|
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||||
|
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
Assert.Single(await db.Users.ToListAsync());
|
||||||
|
var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
|
||||||
|
Assert.Equal(77, teacher.Id);
|
||||||
|
Assert.Equal("teacher@sfedu.ru", teacher.Email);
|
||||||
|
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student);
|
||||||
|
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher);
|
||||||
|
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||||
|
Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var placeholder = new UniVerse.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
Email = $"modeus-{PersonId}@modeus.local",
|
||||||
|
DisplayName = FullName,
|
||||||
|
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||||
|
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
|
||||||
|
};
|
||||||
|
db.Users.Add(placeholder);
|
||||||
|
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Id = 20,
|
||||||
|
Email = "teacher@sfedu.ru",
|
||||||
|
DisplayName = FullName,
|
||||||
|
MicrosoftId = "sso-sub-1",
|
||||||
|
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }]
|
||||||
|
});
|
||||||
|
db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true });
|
||||||
|
db.Lectures.Add(new UniVerse.Domain.Entities.Lecture
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CourseId = 1,
|
||||||
|
TeacherId = 10,
|
||||||
|
ExternalId = EventId,
|
||||||
|
Title = "Old",
|
||||||
|
StartsAt = DateTime.UtcNow,
|
||||||
|
EndsAt = DateTime.UtcNow.AddHours(1)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||||
|
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||||
|
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
Assert.Single(await db.Users.ToListAsync());
|
||||||
|
var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
|
||||||
|
Assert.Equal(20, realUser.Id);
|
||||||
|
Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId);
|
||||||
|
Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher);
|
||||||
|
Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
Email = "teacher@sfedu.ru",
|
||||||
|
DisplayName = FullName,
|
||||||
|
MicrosoftId = "sso-sub-1",
|
||||||
|
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||||
|
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var modeus = Substitute.For<IModeusApiClient>();
|
||||||
|
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
|
||||||
|
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||||
|
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -160,10 +297,7 @@ public class ScheduleSyncServiceTests
|
|||||||
|
|
||||||
private static ModeusEventsResponse BuildEventsResponse()
|
private static ModeusEventsResponse BuildEventsResponse()
|
||||||
{
|
{
|
||||||
const string eventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
|
|
||||||
const string courseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
|
|
||||||
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
|
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
|
||||||
const string personId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
|
|
||||||
|
|
||||||
return new ModeusEventsResponse
|
return new ModeusEventsResponse
|
||||||
{
|
{
|
||||||
@@ -173,25 +307,25 @@ public class ScheduleSyncServiceTests
|
|||||||
[
|
[
|
||||||
new ModeusEvent
|
new ModeusEvent
|
||||||
{
|
{
|
||||||
Id = eventId,
|
Id = EventId,
|
||||||
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
|
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
|
||||||
TypeId = "LAB",
|
TypeId = "LAB",
|
||||||
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
|
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
|
||||||
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
|
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
|
||||||
Links = new ModeusEventLinks
|
Links = new ModeusEventLinks
|
||||||
{
|
{
|
||||||
CourseUnitRealization = new ModeusHrefLink($"/{courseId}")
|
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
CourseUnitRealizations =
|
CourseUnitRealizations =
|
||||||
[
|
[
|
||||||
new ModeusCourseUnitRealization(
|
new ModeusCourseUnitRealization(
|
||||||
courseId,
|
CourseId,
|
||||||
"Управление проектами разработки программного обеспечения",
|
"Управление проектами разработки программного обеспечения",
|
||||||
"УПРПО")
|
"УПРПО")
|
||||||
],
|
],
|
||||||
EventTeams = [new ModeusEventTeam(eventId, 25)],
|
EventTeams = [new ModeusEventTeam(EventId, 25)],
|
||||||
EventAttendees =
|
EventAttendees =
|
||||||
[
|
[
|
||||||
new ModeusEventAttendee
|
new ModeusEventAttendee
|
||||||
@@ -201,30 +335,41 @@ public class ScheduleSyncServiceTests
|
|||||||
RoleName = "Преподаватель",
|
RoleName = "Преподаватель",
|
||||||
Links = new ModeusEventAttendeeLinks
|
Links = new ModeusEventAttendeeLinks
|
||||||
{
|
{
|
||||||
Event = new ModeusHrefLink($"/{eventId}"),
|
Event = new ModeusHrefLink($"/{EventId}"),
|
||||||
Person = new ModeusHrefLink($"/{personId}")
|
Person = new ModeusHrefLink($"/{PersonId}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Persons =
|
Persons =
|
||||||
[
|
[
|
||||||
new ModeusPerson(
|
new ModeusPerson(
|
||||||
personId,
|
PersonId,
|
||||||
"Иванов",
|
"Иванов",
|
||||||
"Иван",
|
"Иван",
|
||||||
"Иванович",
|
"Иванович",
|
||||||
"Иванов Иван Иванович")
|
FullName)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeModeusApiClient(ModeusEventsResponse events) : IModeusApiClient
|
private sealed class FakeModeusApiClient(
|
||||||
|
ModeusEventsResponse events,
|
||||||
|
string? subId = null,
|
||||||
|
bool throwOnSubLookup = false) : IModeusApiClient
|
||||||
{
|
{
|
||||||
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
|
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
|
||||||
|
|
||||||
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
|
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
|
||||||
|
|
||||||
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
|
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
|
||||||
|
|
||||||
|
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (throwOnSubLookup)
|
||||||
|
throw new HttpRequestException("lookup failed");
|
||||||
|
|
||||||
|
return Task.FromResult(subId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class LecturesController : ControllerBase
|
|||||||
|
|
||||||
private int CurrentUserId => int.Parse(
|
private int CurrentUserId => int.Parse(
|
||||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||||
|
private bool CurrentUserIsAdmin => User.IsInRole("Admin");
|
||||||
|
|
||||||
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
|
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
|
||||||
/// <param name="filter">
|
/// <param name="filter">
|
||||||
@@ -84,7 +85,7 @@ public class LecturesController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
||||||
Ok(await _lectures.UpdateAsync(id, req));
|
Ok(await _lectures.UpdateAsync(id, req, CurrentUserId, CurrentUserIsAdmin));
|
||||||
|
|
||||||
/// <summary>Удалить лекцию по ID.</summary>
|
/// <summary>Удалить лекцию по ID.</summary>
|
||||||
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
|
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
|
||||||
@@ -168,7 +169,7 @@ public class LecturesController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
||||||
{
|
{
|
||||||
await _lectures.MarkAttendanceAsync(id, userId, attended);
|
await _lectures.MarkAttendanceAsync(id, userId, attended, CurrentUserId, CurrentUserIsAdmin);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +188,7 @@ public class LecturesController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
|
Ok(await _lectures.GetEnrollmentsAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
|
||||||
|
|
||||||
/// <summary>Получить отзывы к лекции.</summary>
|
/// <summary>Получить отзывы к лекции.</summary>
|
||||||
/// <remarks>Только Admin или Teacher.</remarks>
|
/// <remarks>Только Admin или Teacher.</remarks>
|
||||||
@@ -204,5 +205,5 @@ public class LecturesController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _reviews.GetByLectureAsync(id, pagination));
|
Ok(await _reviews.GetByLectureAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class UsersController : ControllerBase
|
|||||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||||
|
|
||||||
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
|
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
|
||||||
|
user.Id,
|
||||||
user.Email,
|
user.Email,
|
||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.AvatarUrl,
|
user.AvatarUrl,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ builder.Services.AddScoped<IGamificationService, GamificationService>();
|
|||||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||||
|
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
|
||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||||
|
|||||||
@@ -5063,6 +5063,10 @@
|
|||||||
"CurrentUserDto": {
|
"CurrentUserDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@@ -6010,6 +6014,10 @@
|
|||||||
"UserAuthDto": {
|
"UserAuthDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace UniVerse.Application.DTOs.Auth;
|
|||||||
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
|
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
|
||||||
public record AuthResult(AuthResponse Response, string RefreshToken);
|
public record AuthResult(AuthResponse Response, string RefreshToken);
|
||||||
|
|
||||||
public record UserAuthDto(string Email, string? DisplayName, IReadOnlyList<UserRole> Roles);
|
public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList<UserRole> Roles);
|
||||||
|
|
||||||
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null);
|
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public record UserDto(
|
|||||||
);
|
);
|
||||||
|
|
||||||
public record CurrentUserDto(
|
public record CurrentUserDto(
|
||||||
|
int Id,
|
||||||
string Email,
|
string Email,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? AvatarUrl,
|
string? AvatarUrl,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ public interface ILectureService
|
|||||||
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null);
|
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null);
|
||||||
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
|
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
|
||||||
Task<LectureDto> CreateAsync(CreateLectureRequest request);
|
Task<LectureDto> CreateAsync(CreateLectureRequest request);
|
||||||
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request);
|
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request, int currentUserId, bool isAdmin = false);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task EnrollAsync(int lectureId, int userId);
|
Task EnrollAsync(int lectureId, int userId);
|
||||||
Task UnenrollAsync(int lectureId, int userId);
|
Task UnenrollAsync(int lectureId, int userId);
|
||||||
Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
|
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false);
|
||||||
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
|
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IMicrosoftAuthClient
|
||||||
|
{
|
||||||
|
Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
||||||
|
string authorizationCode,
|
||||||
|
string redirectUri,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MicrosoftTokenResult(string IdToken);
|
||||||
@@ -9,7 +9,7 @@ public interface IReviewService
|
|||||||
Task<ReviewDto> GetByIdAsync(int id);
|
Task<ReviewDto> GetByIdAsync(int id);
|
||||||
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
|
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
|
||||||
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
||||||
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false);
|
||||||
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
|
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
|
||||||
Task ReanalyzeAsync(int id);
|
Task ReanalyzeAsync(int id);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public interface IModeusApiClient
|
|||||||
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
|
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
|
||||||
Task<ModeusRoomsResponse> SearchRoomsAsync();
|
Task<ModeusRoomsResponse> SearchRoomsAsync();
|
||||||
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
|
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
|
||||||
|
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modeus API response models
|
// Modeus API response models
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ public static class MappingExtensions
|
|||||||
);
|
);
|
||||||
|
|
||||||
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
|
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
|
||||||
user.Email, user.DisplayName, user.AvatarUrl,
|
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||||
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
|
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
public static UserAuthDto ToAuthDto(this User user) => new(
|
public static UserAuthDto ToAuthDto(this User user) => new(
|
||||||
user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
|
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Tag ---
|
// --- Tag ---
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ public class TeacherProfileConfiguration : IEntityTypeConfiguration<TeacherProfi
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
builder.HasIndex(t => t.UserId).IsUnique();
|
builder.HasIndex(t => t.UserId).IsUnique();
|
||||||
|
builder.HasIndex(t => t.ModeusId).IsUnique().HasFilter("modeus_id IS NOT NULL");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Identity.Client;
|
||||||
|
using UniVerse.Application.Interfaces;
|
||||||
|
using UniVerse.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.ExternalServices;
|
||||||
|
|
||||||
|
public class MicrosoftAuthClient : IMicrosoftAuthClient
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public MicrosoftAuthClient(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
||||||
|
string authorizationCode,
|
||||||
|
string redirectUri,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tenantId = _config["AzureAd:TenantId"];
|
||||||
|
var clientId = _config["AzureAd:ClientId"];
|
||||||
|
var clientSecret = _config["AzureAd:ClientSecret"];
|
||||||
|
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantId)
|
||||||
|
|| string.IsNullOrWhiteSpace(clientId)
|
||||||
|
|| string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
||||||
|
|
||||||
|
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
|
||||||
|
|
||||||
|
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
||||||
|
.WithClientSecret(clientSecret)
|
||||||
|
.WithAuthority(new Uri(authority))
|
||||||
|
.WithRedirectUri(redirectUri)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
|
||||||
|
.ExecuteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new MicrosoftTokenResult(result.IdToken);
|
||||||
|
}
|
||||||
|
catch (MsalException ex)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -142,4 +144,20 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
$"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}");
|
$"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}");
|
||||||
return response ?? new();
|
return response ?? new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"/api/universe/subid?fullname={Uri.EscapeDataString(fullname)}");
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
await EnsureSuccessAsync(response, "Universe user sub lookup", $"fullname={fullname}");
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return string.IsNullOrWhiteSpace(body) ? null : body.Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1143
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace UniVerse.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueTeacherProfileModeusId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_teacher_profiles_modeus_id",
|
||||||
|
table: "teacher_profiles",
|
||||||
|
column: "modeus_id",
|
||||||
|
unique: true,
|
||||||
|
filter: "modeus_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_teacher_profiles_modeus_id",
|
||||||
|
table: "teacher_profiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -515,16 +515,16 @@ namespace UniVerse.Infrastructure.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("lecture_id");
|
.HasColumnName("lecture_id");
|
||||||
|
|
||||||
|
b.Property<string>("LlmRawOutput")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("llm_raw_output");
|
||||||
|
|
||||||
b.Property<int>("LlmStatus")
|
b.Property<int>("LlmStatus")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasDefaultValue(0)
|
.HasDefaultValue(0)
|
||||||
.HasColumnName("llm_status");
|
.HasColumnName("llm_status");
|
||||||
|
|
||||||
b.Property<string>("LlmRawOutput")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("llm_raw_output");
|
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("LlmTags")
|
b.PrimitiveCollection<string[]>("LlmTags")
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("llm_tags");
|
.HasColumnName("llm_tags");
|
||||||
@@ -710,6 +710,10 @@ namespace UniVerse.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModeusId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("modeus_id IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Microsoft.Identity.Client;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
@@ -23,6 +22,7 @@ public class AuthService : IAuthService
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IMicrosoftAuthClient _microsoftAuth;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
private readonly INotificationService _notifications;
|
private readonly INotificationService _notifications;
|
||||||
private readonly ILogger<AuthService> _logger;
|
private readonly ILogger<AuthService> _logger;
|
||||||
@@ -30,12 +30,14 @@ public class AuthService : IAuthService
|
|||||||
public AuthService(
|
public AuthService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
|
IMicrosoftAuthClient microsoftAuth,
|
||||||
IGamificationService gamification,
|
IGamificationService gamification,
|
||||||
INotificationService notifications,
|
INotificationService notifications,
|
||||||
ILogger<AuthService> logger)
|
ILogger<AuthService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_microsoftAuth = microsoftAuth;
|
||||||
_gamification = gamification;
|
_gamification = gamification;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -43,36 +45,10 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null)
|
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null)
|
||||||
{
|
{
|
||||||
var tenantId = _config["AzureAd:TenantId"];
|
|
||||||
var clientId = _config["AzureAd:ClientId"];
|
|
||||||
var clientSecret = _config["AzureAd:ClientSecret"];
|
|
||||||
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
|
||||||
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
|
||||||
|
|
||||||
var effectiveRedirectUri = redirectUri
|
var effectiveRedirectUri = redirectUri
|
||||||
?? _config["AzureAd:RedirectUri"]
|
?? _config["AzureAd:RedirectUri"]
|
||||||
?? "http://localhost:5173/auth/callback";
|
?? "http://localhost:5173/auth/callback";
|
||||||
|
var result = await _microsoftAuth.ExchangeAuthorizationCodeAsync(authorizationCode, effectiveRedirectUri);
|
||||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
|
|
||||||
|
|
||||||
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
|
||||||
.WithClientSecret(clientSecret)
|
|
||||||
.WithAuthority(new Uri(authority))
|
|
||||||
.WithRedirectUri(effectiveRedirectUri)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
AuthenticationResult result;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result = await app.AcquireTokenByAuthorizationCode(new[] { "User.Read" }, authorizationCode)
|
|
||||||
.ExecuteAsync();
|
|
||||||
}
|
|
||||||
catch (MsalException ex)
|
|
||||||
{
|
|
||||||
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse claims directly from the ID token provided by Microsoft
|
// Parse claims directly from the ID token provided by Microsoft
|
||||||
var handler = new JwtSecurityTokenHandler();
|
var handler = new JwtSecurityTokenHandler();
|
||||||
@@ -80,13 +56,21 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
var email = idToken.Claims.FirstOrDefault(c => c.Type == "preferred_username" || c.Type == "email" || c.Type == ClaimTypes.Upn)?.Value;
|
var email = idToken.Claims.FirstOrDefault(c => c.Type == "preferred_username" || c.Type == "email" || c.Type == ClaimTypes.Upn)?.Value;
|
||||||
var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||||
|
var microsoftSub = idToken.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub || c.Type == "sub")?.Value;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(email))
|
if (string.IsNullOrEmpty(email))
|
||||||
throw new UnauthorizedException("Email не найден в токене Microsoft.");
|
throw new UnauthorizedException("Email не найден в токене Microsoft.");
|
||||||
|
if (string.IsNullOrWhiteSpace(microsoftSub))
|
||||||
|
throw new UnauthorizedException("Sub ID не найден в токене Microsoft.");
|
||||||
|
|
||||||
// Automatically provision user
|
// Automatically provision user
|
||||||
var user = await _db.Users
|
var user = await _db.Users
|
||||||
.Include(u => u.Roles)
|
.Include(u => u.Roles)
|
||||||
|
.Include(u => u.TeacherProfile)
|
||||||
|
.FirstOrDefaultAsync(u => u.MicrosoftId == microsoftSub);
|
||||||
|
user ??= await _db.Users
|
||||||
|
.Include(u => u.Roles)
|
||||||
|
.Include(u => u.TeacherProfile)
|
||||||
.FirstOrDefaultAsync(u => u.Email == email);
|
.FirstOrDefaultAsync(u => u.Email == email);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@@ -94,6 +78,7 @@ public class AuthService : IAuthService
|
|||||||
{
|
{
|
||||||
Email = email,
|
Email = email,
|
||||||
DisplayName = name ?? email.Split('@')[0],
|
DisplayName = name ?? email.Split('@')[0],
|
||||||
|
MicrosoftId = microsoftSub,
|
||||||
IsActive = true
|
IsActive = true
|
||||||
};
|
};
|
||||||
_db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
@@ -107,6 +92,14 @@ public class AuthService : IAuthService
|
|||||||
{
|
{
|
||||||
throw new ForbiddenException("Аккаунт деактивирован.");
|
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user.Email = email;
|
||||||
|
user.DisplayName = name ?? user.DisplayName ?? email.Split('@')[0];
|
||||||
|
user.MicrosoftId = microsoftSub;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (user.Roles.Count == 0)
|
if (user.Roles.Count == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,13 +82,14 @@ public class LectureService : ILectureService
|
|||||||
return full.ToDto();
|
return full.ToDto();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest req)
|
public async Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest req, int currentUserId, bool isAdmin = false)
|
||||||
{
|
{
|
||||||
var lecture = await _db.Lectures
|
var lecture = await _db.Lectures
|
||||||
.Include(l => l.Location)
|
.Include(l => l.Location)
|
||||||
.Include(l => l.Enrollments)
|
.Include(l => l.Enrollments)
|
||||||
.ThenInclude(e => e.User)
|
.ThenInclude(e => e.User)
|
||||||
.FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id);
|
.FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id);
|
||||||
|
EnsureTeacherOwnsLecture(lecture, currentUserId, isAdmin);
|
||||||
lecture.TeacherId = req.TeacherId; lecture.LocationId = req.LocationId;
|
lecture.TeacherId = req.TeacherId; lecture.LocationId = req.LocationId;
|
||||||
lecture.Title = req.Title; lecture.Description = req.Description;
|
lecture.Title = req.Title; lecture.Description = req.Description;
|
||||||
lecture.Format = req.Format; lecture.StartsAt = req.StartsAt; lecture.EndsAt = req.EndsAt;
|
lecture.Format = req.Format; lecture.StartsAt = req.StartsAt; lecture.EndsAt = req.EndsAt;
|
||||||
@@ -150,8 +151,9 @@ public class LectureService : ILectureService
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended)
|
public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false)
|
||||||
{
|
{
|
||||||
|
await EnsureTeacherOwnsLectureAsync(lectureId, currentUserId, isAdmin);
|
||||||
var enrollment = await _db.LectureEnrollments
|
var enrollment = await _db.LectureEnrollments
|
||||||
.FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId)
|
.FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId)
|
||||||
?? throw new NotFoundException("Enrollment not found.");
|
?? throw new NotFoundException("Enrollment not found.");
|
||||||
@@ -161,8 +163,9 @@ public class LectureService : ILectureService
|
|||||||
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination)
|
public async Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false)
|
||||||
{
|
{
|
||||||
|
await EnsureTeacherOwnsLectureAsync(lectureId, currentUserId, isAdmin);
|
||||||
var query = _db.LectureEnrollments.Include(e => e.User)
|
var query = _db.LectureEnrollments.Include(e => e.User)
|
||||||
.Where(e => e.LectureId == lectureId);
|
.Where(e => e.LectureId == lectureId);
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
@@ -171,6 +174,22 @@ public class LectureService : ILectureService
|
|||||||
return PagedResult<EnrollmentDto>.Create(items.Select(e => e.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
return PagedResult<EnrollmentDto>.Create(items.Select(e => e.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTeacherOwnsLectureAsync(int lectureId, int currentUserId, bool isAdmin)
|
||||||
|
{
|
||||||
|
if (isAdmin)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var lecture = await _db.Lectures.FirstOrDefaultAsync(l => l.Id == lectureId)
|
||||||
|
?? throw new NotFoundException("Lecture", lectureId);
|
||||||
|
EnsureTeacherOwnsLecture(lecture, currentUserId, isAdmin: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTeacherOwnsLecture(Lecture lecture, int currentUserId, bool isAdmin)
|
||||||
|
{
|
||||||
|
if (!isAdmin && lecture.TeacherId != currentUserId)
|
||||||
|
throw new ForbiddenException("Teacher can access only their own lectures.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RescheduleLectureRemindersAsync(Lecture lecture)
|
private async Task RescheduleLectureRemindersAsync(Lecture lecture)
|
||||||
{
|
{
|
||||||
foreach (var enrollment in lecture.Enrollments)
|
foreach (var enrollment in lecture.Enrollments)
|
||||||
|
|||||||
@@ -75,8 +75,23 @@ public class ReviewService : IReviewService
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination)
|
public async Task<PagedResult<ReviewDto>> GetByLectureAsync(
|
||||||
|
int lectureId,
|
||||||
|
PaginationRequest pagination,
|
||||||
|
int? currentUserId = null,
|
||||||
|
bool isAdmin = false)
|
||||||
{
|
{
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
if (!currentUserId.HasValue)
|
||||||
|
throw new ForbiddenException();
|
||||||
|
|
||||||
|
var lecture = await _db.Lectures.FirstOrDefaultAsync(l => l.Id == lectureId)
|
||||||
|
?? throw new NotFoundException("Lecture", lectureId);
|
||||||
|
if (lecture.TeacherId != currentUserId.Value)
|
||||||
|
throw new ForbiddenException("Teacher can access reviews only for their own lectures.");
|
||||||
|
}
|
||||||
|
|
||||||
var query = BaseQuery().Where(r => r.LectureId == lectureId);
|
var query = BaseQuery().Where(r => r.LectureId == lectureId);
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var items = await query.OrderByDescending(r => r.CreatedAt)
|
var items = await query.OrderByDescending(r => r.CreatedAt)
|
||||||
|
|||||||
@@ -218,15 +218,42 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
.Include(profile => profile.User)
|
.Include(profile => profile.User)
|
||||||
.ThenInclude(user => user.Roles)
|
.ThenInclude(user => user.Roles)
|
||||||
.FirstOrDefaultAsync(profile => profile.ModeusId == personId);
|
.FirstOrDefaultAsync(profile => profile.ModeusId == personId);
|
||||||
|
var subId = existingProfile?.User.MicrosoftId;
|
||||||
|
if (string.IsNullOrWhiteSpace(subId))
|
||||||
|
subId = await TryGetTeacherSubIdAsync(fullName);
|
||||||
|
|
||||||
|
User? ssoUser = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(subId))
|
||||||
|
{
|
||||||
|
ssoUser = await _db.Users
|
||||||
|
.Include(item => item.Roles)
|
||||||
|
.Include(item => item.TeacherProfile)
|
||||||
|
.FirstOrDefaultAsync(item => item.MicrosoftId == subId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProfile != null && ssoUser != null && existingProfile.UserId != ssoUser.Id)
|
||||||
|
return await MergeTeacherPlaceholderAsync(existingProfile, ssoUser, fullName, subId);
|
||||||
|
|
||||||
if (existingProfile != null)
|
if (existingProfile != null)
|
||||||
{
|
{
|
||||||
existingProfile.User.DisplayName = fullName;
|
existingProfile.User.DisplayName = fullName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(subId))
|
||||||
|
existingProfile.User.MicrosoftId = subId;
|
||||||
existingProfile.User.UpdatedAt = DateTime.UtcNow;
|
existingProfile.User.UpdatedAt = DateTime.UtcNow;
|
||||||
EnsureTeacherRole(existingProfile.User);
|
EnsureTeacherRole(existingProfile.User);
|
||||||
return existingProfile.User;
|
return existingProfile.User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ssoUser != null)
|
||||||
|
{
|
||||||
|
ssoUser.DisplayName = fullName;
|
||||||
|
ssoUser.UpdatedAt = DateTime.UtcNow;
|
||||||
|
EnsureTeacherRole(ssoUser);
|
||||||
|
EnsureTeacherProfile(ssoUser, personId);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ssoUser;
|
||||||
|
}
|
||||||
|
|
||||||
var email = BuildModeusTeacherEmail(personId);
|
var email = BuildModeusTeacherEmail(personId);
|
||||||
var user = await _db.Users
|
var user = await _db.Users
|
||||||
.Include(item => item.Roles)
|
.Include(item => item.Roles)
|
||||||
@@ -239,6 +266,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
{
|
{
|
||||||
Email = email,
|
Email = email,
|
||||||
DisplayName = fullName,
|
DisplayName = fullName,
|
||||||
|
MicrosoftId = subId,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
TeacherProfile = new TeacherProfile { ModeusId = personId }
|
TeacherProfile = new TeacherProfile { ModeusId = personId }
|
||||||
};
|
};
|
||||||
@@ -249,6 +277,8 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.DisplayName = fullName;
|
user.DisplayName = fullName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(subId))
|
||||||
|
user.MicrosoftId = subId;
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
if (user.TeacherProfile == null)
|
if (user.TeacherProfile == null)
|
||||||
user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = personId };
|
user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = personId };
|
||||||
@@ -261,6 +291,76 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryGetTeacherSubIdAsync(string fullName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _modeus.GetSubIdByFullNameAsync(fullName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not resolve SSO sub id for teacher {TeacherFullName}. A placeholder teacher will be used until a future sync succeeds.", fullName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<User> MergeTeacherPlaceholderAsync(
|
||||||
|
TeacherProfile placeholderProfile,
|
||||||
|
User targetUser,
|
||||||
|
string fullName,
|
||||||
|
string? subId)
|
||||||
|
{
|
||||||
|
var placeholderUser = placeholderProfile.User;
|
||||||
|
|
||||||
|
var lectures = await _db.Lectures
|
||||||
|
.Where(lecture => lecture.TeacherId == placeholderUser.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var lecture in lectures)
|
||||||
|
lecture.TeacherId = targetUser.Id;
|
||||||
|
|
||||||
|
targetUser.DisplayName = fullName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(subId))
|
||||||
|
targetUser.MicrosoftId = subId;
|
||||||
|
targetUser.UpdatedAt = DateTime.UtcNow;
|
||||||
|
EnsureTeacherRole(targetUser);
|
||||||
|
|
||||||
|
if (targetUser.TeacherProfile == null)
|
||||||
|
{
|
||||||
|
placeholderProfile.UserId = targetUser.Id;
|
||||||
|
placeholderProfile.User = targetUser;
|
||||||
|
targetUser.TeacherProfile = placeholderProfile;
|
||||||
|
placeholderUser.TeacherProfile = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetUser.TeacherProfile.ModeusId = placeholderProfile.ModeusId;
|
||||||
|
_db.TeacherProfiles.Remove(placeholderProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await CanDeletePlaceholderUserAsync(placeholderUser.Id))
|
||||||
|
_db.Users.Remove(placeholderUser);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return targetUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanDeletePlaceholderUserAsync(int userId) =>
|
||||||
|
!await _db.StudentProfiles.AnyAsync(profile => profile.UserId == userId)
|
||||||
|
&& !await _db.RefreshTokens.AnyAsync(token => token.UserId == userId)
|
||||||
|
&& !await _db.LectureEnrollments.AnyAsync(enrollment => enrollment.UserId == userId)
|
||||||
|
&& !await _db.Reviews.AnyAsync(review => review.UserId == userId)
|
||||||
|
&& !await _db.UserAchievements.AnyAsync(achievement => achievement.UserId == userId)
|
||||||
|
&& !await _db.CoinTransactions.AnyAsync(transaction => transaction.UserId == userId)
|
||||||
|
&& !await _db.UserNotifications.AnyAsync(notification => notification.UserId == userId);
|
||||||
|
|
||||||
|
private static void EnsureTeacherProfile(User user, string modeusId)
|
||||||
|
{
|
||||||
|
if (user.TeacherProfile == null)
|
||||||
|
user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = modeusId };
|
||||||
|
else
|
||||||
|
user.TeacherProfile.ModeusId = modeusId;
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureTeacherRole(User user)
|
private static void EnsureTeacherRole(User user)
|
||||||
{
|
{
|
||||||
if (!user.Roles.Any(role => role.Role == UserRole.Teacher))
|
if (!user.Roles.Any(role => role.Role == UserRole.Teacher))
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ function mapApiRoles(roles: string[] | undefined): UserRole[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultActiveRole(roles: UserRole[]): UserRole {
|
function getDefaultActiveRole(roles: UserRole[]): UserRole {
|
||||||
if (roles.includes('student')) return 'student'
|
|
||||||
if (roles.includes('teacher')) return 'teacher'
|
|
||||||
if (roles.includes('admin')) return 'admin'
|
if (roles.includes('admin')) return 'admin'
|
||||||
|
if (roles.includes('teacher')) return 'teacher'
|
||||||
|
if (roles.includes('student')) return 'student'
|
||||||
return 'student'
|
return 'student'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User {
|
export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User {
|
||||||
const roles = mapApiRoles(user.roles)
|
const roles = mapApiRoles(user.roles)
|
||||||
return {
|
return {
|
||||||
|
id: user.id,
|
||||||
name: user.displayName || user.email || 'Пользователь UniVerse',
|
name: user.displayName || user.email || 'Пользователь UniVerse',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
roles,
|
roles,
|
||||||
@@ -68,6 +69,7 @@ export function mapApiLecture(lecture: LectureDto): Lecture {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(lecture.id),
|
id: String(lecture.id),
|
||||||
|
teacherId: lecture.teacherId,
|
||||||
title: lecture.title || lecture.courseName || 'Лекция без названия',
|
title: lecture.title || lecture.courseName || 'Лекция без названия',
|
||||||
description: lecture.description || 'Описание появится позже.',
|
description: lecture.description || 'Описание появится позже.',
|
||||||
teacher: lecture.teacherName || 'Преподаватель уточняется',
|
teacher: lecture.teacherName || 'Преподаватель уточняется',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface LoginMicrosoftRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAuthDto {
|
export interface UserAuthDto {
|
||||||
|
id: number
|
||||||
email: string
|
email: string
|
||||||
displayName?: string | null
|
displayName?: string | null
|
||||||
roles: ApiUserRole[]
|
roles: ApiUserRole[]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { lecturesApi, usersApi } from '@/api'
|
import { lecturesApi, usersApi } from '@/api'
|
||||||
import { mapApiLecture, mapApiReview } from '@/api/mappers'
|
import { mapApiLecture, mapApiReview } from '@/api/mappers'
|
||||||
import type { Lecture, Review } from '@/types'
|
import type { Lecture, Review } from '@/types'
|
||||||
|
import type { LectureQuery } from '@/api/types'
|
||||||
import { useUserStore } from './user'
|
import { useUserStore } from './user'
|
||||||
|
|
||||||
export const useLecturesStore = defineStore('lectures', () => {
|
export const useLecturesStore = defineStore('lectures', () => {
|
||||||
@@ -18,11 +19,11 @@ export const useLecturesStore = defineStore('lectures', () => {
|
|||||||
lectures.value.filter(l => registered.value.includes(l.id) || l.registered),
|
lectures.value.filter(l => registered.value.includes(l.id) || l.registered),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function fetchLectures() {
|
async function fetchLectures(query: LectureQuery = {}) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const payload = await lecturesApi.list({ PageSize: 100 })
|
const payload = await lecturesApi.list({ PageSize: 100, ...query })
|
||||||
lectures.value = payload.map(mapApiLecture)
|
lectures.value = payload.map(mapApiLecture)
|
||||||
registered.value = lectures.value.filter(l => l.registered).map(l => l.id)
|
registered.value = lectures.value.filter(l => l.registered).map(l => l.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type UserRole = 'student' | 'teacher' | 'admin'
|
export type UserRole = 'student' | 'teacher' | 'admin'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
id: number
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
roles: UserRole[]
|
roles: UserRole[]
|
||||||
@@ -30,6 +31,7 @@ export interface EnrollmentSlotRule {
|
|||||||
|
|
||||||
export interface Lecture {
|
export interface Lecture {
|
||||||
id: string
|
id: string
|
||||||
|
teacherId?: number | null
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
teacher: string
|
teacher: string
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
@@ -7,9 +7,11 @@ import { lecturesApi } from '@/api'
|
|||||||
import type { Review } from '@/types'
|
import type { Review } from '@/types'
|
||||||
import { mapApiReview } from '@/api/mappers'
|
import { mapApiReview } from '@/api/mappers'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
const reviews = ref<Review[]>([])
|
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)
|
||||||
@@ -18,12 +20,16 @@ const negative = computed(() => reviews.value.filter(r => r.sentiment === 'negat
|
|||||||
const total = computed(() => reviews.value.length || 1)
|
const total = computed(() => reviews.value.length || 1)
|
||||||
const pct = (value: number) => Math.round((value / total.value) * 100)
|
const pct = (value: number) => Math.round((value / total.value) * 100)
|
||||||
|
|
||||||
onMounted(async () => {
|
async function fetchTeacherAnalytics() {
|
||||||
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
|
if (!auth.user?.id) return
|
||||||
|
await lecturesStore.fetchLectures({ TeacherId: auth.user.id })
|
||||||
const targetLectures = lecturesStore.all.slice(0, 5)
|
const targetLectures = lecturesStore.all.slice(0, 5)
|
||||||
const payload = await Promise.allSettled(targetLectures.map(l => lecturesApi.reviews(l.id)))
|
const payload = await Promise.allSettled(targetLectures.map(l => lecturesApi.reviews(l.id)))
|
||||||
reviews.value = payload.flatMap(result => (result.status === 'fulfilled' ? result.value.map(mapApiReview) : []))
|
reviews.value = payload.flatMap(result => (result.status === 'fulfilled' ? result.value.map(mapApiReview) : []))
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTeacherAnalytics)
|
||||||
|
watch(() => auth.user?.id, fetchTeacherAnalytics)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -13,16 +13,19 @@ const auth = useAuthStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const teacherLectures = computed(() => {
|
const teacherLectures = computed(() => {
|
||||||
const owned = lecturesStore.all.filter(l => auth.user && l.teacher.includes(auth.user.name))
|
return 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.enrolledSeats, 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(() => {
|
function fetchTeacherLectures() {
|
||||||
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
|
if (!auth.user?.id) return
|
||||||
})
|
void lecturesStore.fetchLectures({ TeacherId: auth.user.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTeacherLectures)
|
||||||
|
watch(() => auth.user?.id, fetchTeacherLectures)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -18,8 +18,7 @@ const columns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
const owned = lecturesStore.all.filter(l => auth.user && l.teacher.includes(auth.user.name))
|
return lecturesStore.all.map(l => ({
|
||||||
return (owned.length ? owned : lecturesStore.all).map(l => ({
|
|
||||||
id: l.id,
|
id: l.id,
|
||||||
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}`,
|
||||||
@@ -28,9 +27,13 @@ const rows = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
function fetchTeacherLectures() {
|
||||||
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
|
if (!auth.user?.id) return
|
||||||
})
|
void lecturesStore.fetchLectures({ TeacherId: auth.user.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTeacherLectures)
|
||||||
|
watch(() => auth.user?.id, fetchTeacherLectures)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Reference in New Issue
Block a user