diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f09063 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +.git/ +.vs/ +.github/ +**/*.local.csv +BiStudent.Server/App_Data/ +BiStudent/ +BiStudent.Tests/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98aed7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main, 'release/**'] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-test: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - run: dotnet restore BiStudent.slnx + - run: dotnet build BiStudent.slnx -c Release --no-restore -warnaserror + - run: dotnet test --solution BiStudent.slnx -c Release --no-build + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Registration site tests + run: node --test tests/site/register.test.cjs + - name: Desktop self-test + shell: pwsh + run: | + $exe = Get-ChildItem BiStudent/bin/Release -Recurse -Filter BiStudent.exe | Select-Object -First 1 + $p = Start-Process $exe.FullName -ArgumentList '--self-test' -Wait -PassThru + $result = Join-Path $exe.DirectoryName 'diagnostics/result.txt' + if ($p.ExitCode -ne 0 -or (Get-Content $result) -ne 'OK') { Get-Content (Join-Path $exe.DirectoryName 'self-test-error.txt') -ErrorAction SilentlyContinue; exit 1 } + + docker: + runs-on: ubuntu-24.04 + needs: build-test + steps: + - uses: actions/checkout@v5 + - run: docker build -f BiStudent.Server/Dockerfile -t bistudent-server . + - name: Smoke test + run: | + docker run -d --name bistudent -p 5080:8080 bistudent-server + for i in $(seq 1 30); do curl -fsS http://localhost:5080/health && exit 0; sleep 2; done + docker logs bistudent + exit 1 + + publish: + runs-on: windows-2025 + needs: build-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Client + - run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/BiStudent-Server + - uses: actions/upload-artifact@v5 + with: + name: BiStudent-win-x64 + path: out/ diff --git a/.gitignore b/.gitignore index 401b701..a740d8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,13 @@ **/obj/ dist/ *.csproj.user +*.user +BiStudent.Server/seed/*.local.csv +BiStudent.Server/seed/*.local.xlsx +BiStudent.Server/seed/*.old.csv +BiStudent.Server/App_Data*/ + +# Модель SFace скачивается при сборке (см. BiStudent.Core.csproj) +BiStudent.Core/Models/face_recognition_sface_2021dec.onnx +BiStudent.Core/Models/*.download +TestResults/ diff --git a/BiStudent.Client.Tests/ApiClientTests.cs b/BiStudent.Client.Tests/ApiClientTests.cs new file mode 100644 index 0000000..de13288 --- /dev/null +++ b/BiStudent.Client.Tests/ApiClientTests.cs @@ -0,0 +1,317 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +public sealed class ApiClientTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + private static ApiClient Client(FakeServer server) => new(new ClientSettings { ServerUrl = "http://test/" }, server); + private static string Body(object value) => JsonSerializer.Serialize(value, JsonOptions); + + [Theory] + [InlineData("")] + [InlineData("ftp://x")] + [InlineData("не url")] + public void InvalidServerUrl(string address) => + Assert.Throws(() => new ApiClient(new ClientSettings { ServerUrl = address }, new FakeServer())); + + [Fact] + public async Task LoginSetsBearerAndRefreshToken() + { + var server = new FakeServer(); + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("access", 60, "refresh"))); + server.On(HttpMethod.Get, "api/groups", _ => FakeServer.Json(Array.Empty())); + using var api = Client(server); + var changes = new List(); + api.RefreshTokenChanged += changes.Add; + await api.LoginAsync("teacher", "secret", true); + await api.GroupsAsync(); + Assert.Equal(Body(new LoginRequest("teacher", "secret", true)), server.Requests[0].Body); + Assert.Null(server.Requests[0].Authorization); + Assert.Equal("Bearer access", server.Requests[1].Authorization); + Assert.Equal("refresh", api.RefreshToken); + Assert.Equal(new string?[] { "refresh" }, changes); + } + + [Fact] + public async Task UnauthorizedPutRefreshesAndReplaysBody() + { + var server = new FakeServer(); + var attempts = 0; + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r"))); + server.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Json(new TokenResponse("new", 60, "new-r"))); + server.On(HttpMethod.Put, "api/lessons/2/2026-10-16/attendance", request => + { + attempts++; + return new HttpResponseMessage(request.Headers.Authorization?.Parameter == "old" ? HttpStatusCode.Unauthorized : HttpStatusCode.NoContent); + }); + using var api = Client(server); + var unauthorized = 0; + api.Unauthorized += () => unauthorized++; + await api.LoginAsync("teacher", "secret"); + await api.MarkAsync(2, new DateOnly(2026, 10, 16), 3, AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", true); + var requests = server.Requests; + Assert.Equal(1, requests.Count(x => x.Path == "api/auth/refresh")); + Assert.Equal(2, attempts); + Assert.Equal("Bearer old", requests[1].Authorization); + Assert.Equal("Bearer new", requests[3].Authorization); + Assert.Equal(Body(new MarkRequest(3, AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", true)), requests[1].Body); + Assert.Equal(requests[1].Body, requests[3].Body); + Assert.Equal(Body(new RefreshRequest("old-r")), requests[2].Body); + Assert.Equal(0, unauthorized); + } + + [Fact] + public async Task ParallelUnauthorizedRequestsShareRefresh() + { + var server = new FakeServer(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bothFailed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failed = 0; + var refreshes = 0; + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r"))); + server.On(HttpMethod.Post, "api/auth/refresh", async _ => + { + Interlocked.Increment(ref refreshes); + started.TrySetResult(); + await release.Task; + return FakeServer.Json(new TokenResponse("new", 60, "new-r")); + }); + Func response = request => + { + if (request.Headers.Authorization?.Parameter != "old") return FakeServer.Json(Array.Empty()); + if (Interlocked.Increment(ref failed) == 2) bothFailed.TrySetResult(); + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }; + server.On(HttpMethod.Get, "api/groups", response); + server.On(HttpMethod.Get, "api/groups/5/students", response); + using var api = Client(server); + await api.LoginAsync("teacher", "secret"); + var first = api.GroupsAsync(); + var second = api.StudentsAsync(5); + await Task.WhenAll(started.Task, bothFailed.Task); + release.TrySetResult(); + await Task.WhenAll(first, second); + Assert.Equal(1, refreshes); + } + + [Fact] + public async Task FailedRefreshRaisesUnauthorized() + { + var server = new FakeServer(); + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r"))); + server.On(HttpMethod.Get, "api/groups", _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + server.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Error(HttpStatusCode.Unauthorized, "Истёк")); + using var api = Client(server); + var raised = 0; + api.Unauthorized += () => raised++; + await api.LoginAsync("teacher", "secret"); + var error = await Assert.ThrowsAsync(() => api.GroupsAsync()); + Assert.Equal(HttpStatusCode.Unauthorized, error.StatusCode); + Assert.Equal(1, raised); + } + + [Fact] + public async Task EndSessionDiscardsPendingRefresh() + { + var server = new FakeServer(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r"))); + server.On(HttpMethod.Get, "api/groups", _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + server.On(HttpMethod.Post, "api/auth/refresh", async _ => + { + started.TrySetResult(); + await release.Task; + return FakeServer.Json(new TokenResponse("new", 60, "new-r")); + }); + using var api = Client(server); + var changes = new List(); + api.RefreshTokenChanged += changes.Add; + await api.LoginAsync("teacher", "secret"); + var pending = api.GroupsAsync(); + await started.Task; + api.EndSession(); + release.TrySetResult(); + await Assert.ThrowsAsync(() => pending); + Assert.DoesNotContain("new-r", changes); + Assert.Null(api.RefreshToken); + } + + [Fact] + public async Task ResumeHandlesSuccessRejectionAndNetworkFailure() + { + var server = new FakeServer(); + server.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Json(new TokenResponse("access", 60, "next"))); + server.On(HttpMethod.Get, "api/me", _ => FakeServer.Json(new TeacherDto(7, "Преподаватель", "teacher"))); + using var api = Client(server); + Assert.True(await api.TryResumeAsync("saved")); + Assert.Equal(7, api.Teacher?.Id); + Assert.Equal(Body(new RefreshRequest("saved")), server.Requests[0].Body); + var rejected = new FakeServer(); + rejected.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Error(HttpStatusCode.Unauthorized, "Истёк")); + using var rejectedApi = Client(rejected); + Assert.False(await rejectedApi.TryResumeAsync("saved")); + var offline = new FakeServer(); + offline.On(HttpMethod.Post, "api/auth/refresh", _ => Task.FromException(new HttpRequestException("offline"))); + using var offlineApi = Client(offline); + await Assert.ThrowsAsync(() => offlineApi.TryResumeAsync("saved")); + } + + [Fact] + public async Task ChangePasswordKeepsTokensWhenMeFails() + { + var server = new FakeServer(); + server.On(HttpMethod.Post, "api/auth/change-password", _ => FakeServer.Json(new TokenResponse("new", 60, "new-r"))); + server.On(HttpMethod.Get, "api/me", _ => FakeServer.Json(new TeacherDto(8, "Новый", "teacher"))); + using var api = Client(server); + await api.ChangePasswordAsync("old", "new password"); + Assert.Equal("new-r", api.RefreshToken); + Assert.Equal(8, api.Teacher?.Id); + Assert.Equal(Body(new ChangePasswordRequest("old", "new password")), server.Requests[0].Body); + var failing = new FakeServer(); + var meCalls = 0; + failing.On(HttpMethod.Post, "api/auth/change-password", _ => FakeServer.Json(new TokenResponse("new", 60, "new-r"))); + failing.On(HttpMethod.Get, "api/me", _ => { meCalls++; return FakeServer.Error(HttpStatusCode.InternalServerError, "Ошибка"); }); + using var failingApi = Client(failing); + await failingApi.ChangePasswordAsync("old", "new password"); + Assert.Equal(2, meCalls); + Assert.Equal("new-r", failingApi.RefreshToken); + var bad = new FakeServer(); + bad.On(HttpMethod.Post, "api/auth/change-password", _ => FakeServer.Error(HttpStatusCode.BadRequest, "Неверный пароль")); + using var badApi = Client(bad); + var error = await Assert.ThrowsAsync(() => badApi.ChangePasswordAsync("old", "new password")); + Assert.Equal("Неверный пароль", error.Message); + } + + [Fact] + public async Task ServerErrorMessagesAreReadable() + { + var server = new FakeServer(); + server.On(HttpMethod.Get, "api/groups", _ => FakeServer.Error(HttpStatusCode.BadRequest, "Ошибка проверки")); + using var api = Client(server); + var error = await Assert.ThrowsAsync(() => api.GroupsAsync()); + Assert.Equal("Ошибка проверки", error.Message); + var empty = new FakeServer(); + empty.On(HttpMethod.Get, "api/groups", _ => new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("") }); + using var emptyApi = Client(empty); + error = await Assert.ThrowsAsync(() => emptyApi.GroupsAsync()); + Assert.False(string.IsNullOrWhiteSpace(error.Message)); + } + + [Fact] + public async Task EnrollmentCodeAndAssessmentQueries() + { + var server = new FakeServer(); + var code = new EnrollmentCodeDto(2, "Группа", "ABC", DateTime.UtcNow, DateTime.UtcNow.AddHours(1), new List()); + server.On(HttpMethod.Get, "api/groups/1/enrollment-code", _ => new HttpResponseMessage(HttpStatusCode.NoContent)); + server.On(HttpMethod.Get, "api/groups/2/enrollment-code", _ => FakeServer.Json(code)); + server.On(HttpMethod.Get, "api/assessments", _ => FakeServer.Json(Array.Empty())); + using var api = Client(server); + Assert.Null(await api.EnrollmentCodeAsync(1)); + Assert.Equal("ABC", (await api.EnrollmentCodeAsync(2))?.Code); + await api.AssessmentsAsync(null, null); + await api.AssessmentsAsync(5, "Базы данных"); + Assert.Equal("api/assessments", server.Requests[2].Path); + Assert.Equal("api/assessments?groupId=5&subject=%D0%91%D0%B0%D0%B7%D1%8B%20%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85", server.Requests[3].Path); + } + + [Fact] + public async Task BinaryResponsesPreserveBytesAndJournalFileName() + { + var server = new FakeServer(); + server.On(HttpMethod.Get, "api/face-submissions/3/images/1", _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 1, 2 }) }); + server.On(HttpMethod.Get, "api/admin/backups/backup%20one.zip", _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 3, 4 }) }); + server.On(HttpMethod.Get, "api/journal/5/csv", _ => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 5, 6 }) }; + response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileNameStar = "journal.csv" }; + return response; + }); + using var api = Client(server); + Assert.Equal(new byte[] { 1, 2 }, await api.FaceSubmissionImageAsync(3, 1)); + var backup = await api.DownloadBackupAsync("backup one.zip"); + Assert.Equal(new byte[] { 3, 4 }, backup.Content); + Assert.Equal("backup one.zip", backup.FileName); + var journal = await api.JournalFileAsync(5, new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 16), null, "csv"); + Assert.Equal(new byte[] { 5, 6 }, journal.Content); + Assert.Equal("journal.csv", journal.FileName); + } + + [Fact] + public async Task PublicRoutesUseExpectedMethodsPathsAndBodies() + { + foreach (var route in Routes()) + { + var server = new FakeServer(); + server.On(route.Method, route.Path.Split('?')[0], _ => FakeServer.Json(route.Response)); + using var api = Client(server); + await route.Call(api); + var request = Assert.Single(server.Requests); + Assert.Equal(route.Method, request.Method); + Assert.Equal(route.Path, request.Path); + Assert.Equal(route.Body, request.Body); + } + } + + private static IEnumerable<(HttpMethod Method, string Path, string? Body, object Response, Func Call)> Routes() + { + var date = new DateOnly(2026, 10, 16); + var slot = new SaveSlotRequest(5, null, "Математика", "101", DayOfWeek.Friday, new TimeOnly(9, 0), new TimeOnly(10, 30), WeekParity.Odd, null); + var teacher = new SaveTeacherRequest("teacher", "Преподаватель", true, true); + var assessment = new SaveAssessmentRequest(5, "Математика", "Тест", AssessmentKind.Test, date); + yield return (HttpMethod.Get, "api/settings/automation", null, new { }, api => api.AutomationAsync()); + yield return (HttpMethod.Get, "api/recognition/status", null, new { }, api => api.RecognitionStatusAsync()); + yield return (HttpMethod.Get, "api/admin/backups", null, Array.Empty(), api => api.BackupsAsync()); + yield return (HttpMethod.Post, "api/admin/backups", null, new { }, api => api.CreateBackupAsync()); + yield return (HttpMethod.Post, "api/admin/restore", "ZIP", new { }, api => api.RestoreAsync("ZIP"u8.ToArray())); + yield return (HttpMethod.Get, "api/admin/teachers", null, Array.Empty(), api => api.TeachersAsync()); + yield return (HttpMethod.Post, "api/admin/teachers", Body(teacher), new { }, api => api.CreateTeacherAsync(teacher)); + yield return (HttpMethod.Put, "api/admin/teachers/7", Body(teacher), new { }, api => api.UpdateTeacherAsync(7, teacher)); + yield return (HttpMethod.Post, "api/admin/teachers/7/reset-password", null, new { }, api => api.ResetTeacherPasswordAsync(7)); + yield return (HttpMethod.Get, "api/dashboard", null, new { }, api => api.DashboardAsync()); + yield return (HttpMethod.Get, "api/report", null, new { }, api => api.ReportAsync()); + yield return (HttpMethod.Get, "api/schedule/day?date=2026-10-16", null, Array.Empty(), api => api.DayAsync(date)); + yield return (HttpMethod.Get, "api/schedule/week?monday=2026-10-12", null, Array.Empty(), api => api.WeekAsync(new DateOnly(2026, 10, 12))); + yield return (HttpMethod.Get, "api/schedule/parity?date=2026-10-16", null, WeekParity.Odd, api => api.ParityAsync(date)); + yield return (HttpMethod.Post, "api/schedule/slots", Body(slot), new { }, api => api.SaveSlotAsync(null, slot)); + yield return (HttpMethod.Put, "api/schedule/slots/3", Body(slot), new { }, api => api.SaveSlotAsync(3, slot)); + yield return (HttpMethod.Delete, "api/schedule/slots/3", null, new { }, api => api.DeleteSlotAsync(3)); + yield return (HttpMethod.Post, "api/schedule/slots/3/cancel", Body(new CancelLessonRequest(date, "Причина")), new { }, api => api.CancelLessonAsync(3, date, "Причина")); + yield return (HttpMethod.Post, "api/schedule/slots/3/restore", Body(new RestoreLessonRequest(date)), new { }, api => api.RestoreLessonAsync(3, date)); + yield return (HttpMethod.Post, "api/schedule/import", "a,b\n", new { }, api => api.ImportScheduleAsync("a,b\n")); + yield return (HttpMethod.Get, "api/groups", null, Array.Empty(), api => api.GroupsAsync()); + yield return (HttpMethod.Post, "api/groups", Body(new CreateGroupRequest("Группа")), new { }, api => api.CreateGroupAsync(new CreateGroupRequest("Группа"))); + yield return (HttpMethod.Delete, "api/groups/5", null, new { }, api => api.DeleteGroupAsync(5)); + yield return (HttpMethod.Get, "api/groups/5/students", null, Array.Empty(), api => api.StudentsAsync(5)); + yield return (HttpMethod.Post, "api/students", Body(new SaveStudentRequest("Студент", 5)), new { }, api => api.SaveStudentAsync(null, new SaveStudentRequest("Студент", 5))); + yield return (HttpMethod.Put, "api/students/8", Body(new SaveStudentRequest("Студент", 5)), new { }, api => api.SaveStudentAsync(8, new SaveStudentRequest("Студент", 5))); + yield return (HttpMethod.Delete, "api/students/8", null, new { }, api => api.DeleteStudentAsync(8)); + yield return (HttpMethod.Post, "api/students/import", "name,group\n", new { }, api => api.ImportStudentsAsync("name,group\n")); + yield return (HttpMethod.Post, "api/students/8/enroll", Body(new EnrollRequest(new List { new byte[] { 1, 2 } }, true)), new { }, api => api.EnrollAsync(8, new List { new byte[] { 1, 2 } }, true)); + yield return (HttpMethod.Post, "api/groups/5/enrollment-code", Body(new CreateEnrollmentCodeRequest(12)), new { }, api => api.CreateEnrollmentCodeAsync(5, 12)); + yield return (HttpMethod.Delete, "api/groups/5/enrollment-code", null, new { }, api => api.RevokeEnrollmentCodeAsync(5)); + yield return (HttpMethod.Get, "api/face-submissions", null, Array.Empty(), api => api.FaceSubmissionsAsync()); + yield return (HttpMethod.Post, "api/face-submissions/9/approve", null, new { }, api => api.ApproveSubmissionAsync(9)); + yield return (HttpMethod.Post, "api/face-submissions/9/reject", Body(new RejectSubmissionRequest("Причина")), new { }, api => api.RejectSubmissionAsync(9, "Причина")); + yield return (HttpMethod.Get, "api/lessons/3/2026-10-16/attendance", null, Array.Empty(), api => api.AttendanceAsync(3, date)); + yield return (HttpMethod.Put, "api/lessons/3/2026-10-16/attendance", Body(new MarkRequest(8, AttendanceStatus.Excused, ExcuseReason.Official, "Приказ", true)), new { }, api => api.MarkAsync(3, date, 8, AttendanceStatus.Excused, ExcuseReason.Official, "Приказ", true)); + yield return (HttpMethod.Put, "api/lessons/3/2026-10-16/grade", Body(new GradeRequest(8, 90)), new { }, api => api.LessonGradeAsync(3, date, 8, 90)); + yield return (HttpMethod.Post, "api/lessons/3/2026-10-16/finish", null, new { count = 1 }, api => api.FinishAsync(3, date)); + yield return (HttpMethod.Post, "api/lessons/3/2026-10-16/all-present", null, new { count = 1 }, api => api.AllPresentAsync(3, date)); + yield return (HttpMethod.Post, "api/recognize", Body(new RecognizeRequest(3, date, new byte[] { 1, 2 })), new { }, api => api.RecognizeAsync(3, date, new byte[] { 1, 2 }, CancellationToken.None)); + yield return (HttpMethod.Get, "api/journal/5/subjects", null, Array.Empty(), api => api.SubjectsAsync(5)); + yield return (HttpMethod.Post, "api/assessments", Body(assessment), new { }, api => api.SaveAssessmentAsync(null, assessment)); + yield return (HttpMethod.Put, "api/assessments/4", Body(assessment), new { }, api => api.SaveAssessmentAsync(4, assessment)); + yield return (HttpMethod.Delete, "api/assessments/4", null, new { }, api => api.DeleteAssessmentAsync(4)); + yield return (HttpMethod.Put, "api/assessments/4/grades", Body(new GradeRequest(8, 75)), new { }, api => api.AssessmentGradeAsync(4, 8, 75)); + yield return (HttpMethod.Put, "api/journal/5/final", Body(new FinalGradeRequest("Математика", 8, 80)), new { }, api => api.FinalGradeAsync(5, "Математика", 8, 80)); + yield return (HttpMethod.Post, "api/journal/5/final/fill", Body(new FillFinalRequest("Математика")), new { count = 1 }, api => api.FillFinalAsync(5, "Математика")); + yield return (HttpMethod.Get, "api/journal/5?from=2026-10-01&to=2026-10-16&subject=%D0%91%D0%B0%D0%B7%D1%8B%20%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85", null, new { }, api => api.JournalAsync(5, new DateOnly(2026, 10, 1), date, "Базы данных")); + } +} diff --git a/BiStudent.Client.Tests/AuditClientTests.cs b/BiStudent.Client.Tests/AuditClientTests.cs new file mode 100644 index 0000000..6c958d7 --- /dev/null +++ b/BiStudent.Client.Tests/AuditClientTests.cs @@ -0,0 +1,34 @@ +using System.Net; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +public sealed class AuditClientTests +{ + [Fact] + public async Task ChangePasswordRefreshesExpiredAccessTokenAndRetries() + { + var server = new FakeServer(); + var attempts = 0; + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("old", 60, "old-r"))); + server.On(HttpMethod.Post, "api/auth/refresh", _ => FakeServer.Json(new TokenResponse("new", 60, "new-r"))); + server.On(HttpMethod.Post, "api/auth/change-password", _ => + ++attempts == 1 + ? FakeServer.Error(HttpStatusCode.Unauthorized, "Истёк") + : FakeServer.Json(new TokenResponse("changed", 60, "changed-r"))); + server.On(HttpMethod.Get, "api/me", _ => FakeServer.Json(new TeacherDto(7, "Преподаватель", "teacher"))); + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server); + + await api.LoginAsync("teacher", "secret"); + await api.ChangePasswordAsync("secret", "new password"); + + var requests = server.Requests; + var changes = requests.Where(request => request.Path == "api/auth/change-password").ToList(); + Assert.Equal(2, changes.Count); + Assert.Equal("Bearer old", changes[0].Authorization); + Assert.Equal("Bearer new", changes[1].Authorization); + Assert.Equal(changes[0].Body, changes[1].Body); + Assert.Single(requests, request => request.Path == "api/auth/refresh"); + Assert.Equal("changed-r", api.RefreshToken); + } +} diff --git a/BiStudent.Client.Tests/BiStudent.Client.Tests.csproj b/BiStudent.Client.Tests/BiStudent.Client.Tests.csproj new file mode 100644 index 0000000..fc1282a --- /dev/null +++ b/BiStudent.Client.Tests/BiStudent.Client.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0-windows + true + enable + enable + false + $(NoWarn);xUnit1051 + Exe + + + + + + + + + + + + diff --git a/BiStudent.Client.Tests/ClientSettingsTests.cs b/BiStudent.Client.Tests/ClientSettingsTests.cs new file mode 100644 index 0000000..21e21a4 --- /dev/null +++ b/BiStudent.Client.Tests/ClientSettingsTests.cs @@ -0,0 +1,88 @@ +namespace BiStudent.Client.Tests; + +[CollectionDefinition("settings", DisableParallelization = true)] +public sealed class SettingsCollection { } + +[Collection("settings")] +public sealed class ClientSettingsTests : IDisposable +{ + private readonly string previousPath = ClientSettings.FilePath; + private readonly string directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N")); + + public ClientSettingsTests() + { + ClientSettings.FilePath = Path.Combine(directory, "client.json"); + } + + public void Dispose() + { + ClientSettings.FilePath = previousPath; + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + [Fact] + public void MissingFileLoadsDefaults() + { + var settings = ClientSettings.Load(); + Assert.Equal("http://localhost:5080", settings.ServerUrl); + Assert.Equal("", settings.LastLogin); + Assert.False(settings.RememberMe); + } + + [Fact] + public void SaveAndLoadPreservePreferences() + { + new ClientSettings + { + ServerUrl = "https://school.example", + LastLogin = "teacher", + RememberMe = true, + CameraId = "camera-id", + CameraName = "Камера", + CameraIndex = 2 + }.Save(); + var loaded = ClientSettings.Load(); + Assert.Equal("https://school.example", loaded.ServerUrl); + Assert.Equal("teacher", loaded.LastLogin); + Assert.True(loaded.RememberMe); + Assert.Equal("camera-id", loaded.CameraId); + Assert.Equal("Камера", loaded.CameraName); + Assert.Equal(2, loaded.CameraIndex); + } + + [Fact] + public void DamagedJsonLoadsDefaults() + { + Directory.CreateDirectory(directory); + File.WriteAllText(ClientSettings.FilePath, "{bad json"); + Assert.Equal("http://localhost:5080", ClientSettings.Load().ServerUrl); + } + + [Fact] + public void RefreshTokenIsProtectedAndBoundToServer() + { + var settings = new ClientSettings { ServerUrl = "https://school.example" }; + settings.StoreRefreshToken("abc"); + settings.Save(); + Assert.DoesNotContain("abc", File.ReadAllText(ClientSettings.FilePath)); + var loaded = ClientSettings.Load(); + Assert.Equal("abc", loaded.LoadRefreshToken()); + loaded.ServerUrl = "HTTPS://SCHOOL.EXAMPLE/"; + Assert.Equal("abc", loaded.LoadRefreshToken()); + loaded.ServerUrl = "https://other.example"; + Assert.Null(loaded.LoadRefreshToken()); + } + + [Fact] + public void ClearingOrCorruptingTokenReturnsNull() + { + var settings = new ClientSettings(); + settings.StoreRefreshToken("abc"); + settings.StoreRefreshToken(null); + Assert.Null(settings.LoadRefreshToken()); + Assert.Null(settings.ProtectedRefreshToken); + settings.ProtectedRefreshToken = "not base64"; + settings.RefreshTokenServer = settings.ServerUrl; + Assert.Null(settings.LoadRefreshToken()); + } +} diff --git a/BiStudent.Client.Tests/DialogsSmokeTests.cs b/BiStudent.Client.Tests/DialogsSmokeTests.cs new file mode 100644 index 0000000..aecc77b --- /dev/null +++ b/BiStudent.Client.Tests/DialogsSmokeTests.cs @@ -0,0 +1,121 @@ +using System.Drawing.Imaging; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +// Диалоги открываются поверх скрытого окна, загружают данные с фейкового сервера и закрываются без ошибок. +[Collection("settings")] +public sealed class DialogsSmokeTests +{ + [Fact] + public void DialogsLoadAndClose() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var server = PagesSmokeTests.DemoServer(); + server.On(HttpMethod.Get, "api/groups/1/enrollment-code", _ => FakeServer.Json(new EnrollmentCodeDto(1, "23-ПО-103-4р-1", "K7PM-Q2XA", + DateTime.Now, DateTime.Now.AddDays(1), ["http://192.168.0.10:5080/register/"]))); + server.On(HttpMethod.Get, "api/admin/backups", _ => FakeServer.Json(new List + { new("bistudent-2026-10-16.zip", DateTime.Now, 1_234_567, true) })); + for (var index = 0; index < 3; index++) + server.On(HttpMethod.Get, $"api/face-submissions/7/images/{index}", _ => JpegResponse()); + + var slot = new SlotDto(10, 1, "23-ПО-103-4р-1", "Базы данных", "305", today.DayOfWeek, + new TimeOnly(8, 30), new TimeOnly(9, 50), WeekParity.Every, new DateOnly(2026, 9, 1), null, null); + var lesson = new LessonDto(slot, today, today.ToDateTime(slot.Start), today.ToDateTime(slot.End), false); + var groups = new List { new(1, "23-ПО-103-4р-1", 3, 1) }; + var opened = new List(); + + RunSta(() => + { + var previousPath = ClientSettings.FilePath; + var directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N")); + ClientSettings.FilePath = Path.Combine(directory, "client.json"); + ExceptionDispatchInfo? failure = null; + try + { + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server); + using var host = new Form { StartPosition = FormStartPosition.Manual, Location = new Point(-4000, -4000), ShowInTaskbar = false }; + host.Shown += async (_, _) => + { + try + { + await api.LoginAsync("teacher", "secret"); + await api.LoadMeAsync(); + var dialogs = new (string Name, Func
Create)[] + { + ("lesson", () => new LessonDialog(api, lesson)), + ("enrollment-code", () => new EnrollmentCodeDialog(api, 1, "23-ПО-103-4р-1")), + ("face-submissions", () => new FaceSubmissionsDialog(api)), + ("backups", () => new BackupDialog(api)), + ("slot-new", () => new SlotDialog(api, null, today, ["Базы данных"])), + ("slot-edit", () => new SlotDialog(api, slot, today, ["Базы данных"])), + ("student-new", () => new StudentDialog(api, groups, null, 1)), + ("student-edit", () => new StudentDialog(api, groups, new StudentDto(101, "Абдиев Артур", 1, true), 1)) + }; + foreach (var (name, create) in dialogs) + { + using var dialog = create(); + dialog.StartPosition = FormStartPosition.Manual; + dialog.Location = new Point(-4000, -4000); + dialog.ShowInTaskbar = false; + dialog.Show(host); + await Task.Delay(500); + using var bitmap = new Bitmap(Math.Max(1, dialog.ClientSize.Width), Math.Max(1, dialog.ClientSize.Height)); + dialog.DrawToBitmap(bitmap, new Rectangle(Point.Empty, dialog.ClientSize)); + dialog.Close(); + opened.Add(name); + } + var column = new JournalColumnDto(JournalColumnKind.Lesson, 10, today, new TimeOnly(8, 30), "Базы данных"); + using (new GradeEditor(api, column, new JournalCellDto(AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", 80), 101, "Абдиев Артур")) { } + using (new GradeEditor(api, column, new JournalCellDto(null, null, null, null), 101, "Абдиев Артур", attendanceOnly: true)) { } + using (new GradeEditor(api, 1, "Базы данных", 101, "Абдиев Артур", 88)) { } + opened.Add("grade-editor"); + } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + finally { host.Close(); } + }; + Application.Run(host); + } + finally + { + ClientSettings.FilePath = previousPath; + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + failure?.Throw(); + }); + + Assert.Equal(["lesson", "enrollment-code", "face-submissions", "backups", "slot-new", "slot-edit", "student-new", "student-edit", "grade-editor"], opened); + var missing = server.Requests.Where(r => !server.Handles(r)).Select(r => $"{r.Method} {r.Path}").Distinct().ToList(); + Assert.True(missing.Count == 0, "Запросы без ответа фейкового сервера: " + string.Join("; ", missing)); + Assert.Contains(server.Requests, r => r.Path.StartsWith("api/face-submissions/7/images/0", StringComparison.Ordinal)); + } + + private static HttpResponseMessage JpegResponse() + { + using var bitmap = new Bitmap(112, 112); + using (var graphics = Graphics.FromImage(bitmap)) graphics.Clear(Color.SlateGray); + using var stream = new MemoryStream(); + bitmap.Save(stream, ImageFormat.Jpeg); + var content = new ByteArrayContent(stream.ToArray()); + content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + } + + private static void RunSta(Action action) + { + ExceptionDispatchInfo? failure = null; + var thread = new Thread(() => + { + try { action(); } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + failure?.Throw(); + } +} diff --git a/BiStudent.Client.Tests/FakeServer.cs b/BiStudent.Client.Tests/FakeServer.cs new file mode 100644 index 0000000..5b2fcde --- /dev/null +++ b/BiStudent.Client.Tests/FakeServer.cs @@ -0,0 +1,45 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +namespace BiStudent.Client.Tests; +internal sealed class FakeServer : HttpMessageHandler +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + private readonly object _gate = new(); + private readonly Dictionary<(HttpMethod, string), Func>> _routes = new(); + private readonly List _requests = new(); + internal sealed record RequestEntry(HttpMethod Method, string Path, string? Authorization, string? Body); + public IReadOnlyList Requests { get { lock (_gate) return _requests.ToArray(); } } + public void On(HttpMethod method, string path, Func> handler) + { + lock (_gate) _routes[(method, path)] = handler; + } + public void On(HttpMethod method, string path, Func handler) => + On(method, path, request => Task.FromResult(handler(request))); + public static HttpResponseMessage Json(object? value, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(JsonSerializer.Serialize(value, JsonOptions), System.Text.Encoding.UTF8, "application/json") + }; + } + public static HttpResponseMessage Error(HttpStatusCode statusCode, string message) => Json(new { message }, statusCode); + public bool Handles(RequestEntry entry) + { + var path = entry.Path.Split('?')[0]; + lock (_gate) return _routes.ContainsKey((entry.Method, path)); + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.TrimStart('/'); + var entry = new RequestEntry(request.Method, request.RequestUri.PathAndQuery.TrimStart('/'), + request.Headers.Authorization?.ToString(), request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken)); + Func>? handler; + lock (_gate) + { + _requests.Add(entry); + _routes.TryGetValue((request.Method, path), out handler); + } + return handler is null ? new HttpResponseMessage(HttpStatusCode.NotFound) : await handler(request); + } +} diff --git a/BiStudent.Client.Tests/FormsSmokeTests.cs b/BiStudent.Client.Tests/FormsSmokeTests.cs new file mode 100644 index 0000000..0a212e5 --- /dev/null +++ b/BiStudent.Client.Tests/FormsSmokeTests.cs @@ -0,0 +1,50 @@ +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +[Collection("settings")] +public sealed class FormsSmokeTests +{ + [Fact] + public void FormsConstructAndDisposeOnStaThread() + { + RunSta(() => + { + var previousPath = ClientSettings.FilePath; + var directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N")); + try + { + ClientSettings.FilePath = Path.Combine(directory, "client.json"); + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, new FakeServer()); + using var login = new LoginForm(); + using var password = new ChangePasswordDialog(api, false); + using var forcedPassword = new ChangePasswordDialog(api, true); + using var excuse = new ExcuseDialog("Студент", ExcuseReason.Family, true, " Комментарий "); + using var assessment = new AssessmentDialog(api, 1, "Группа", "Предмет", Array.Empty()); + using var teacher = new TeacherDialog(api, null); + Assert.Equal((ExcuseReason.Family, true, "Комментарий"), excuse.Result); + } + finally + { + ClientSettings.FilePath = previousPath; + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + }); + } + + private static void RunSta(Action action) + { + ExceptionDispatchInfo? failure = null; + var thread = new Thread(() => + { + try { action(); } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + failure?.Throw(); + } +} diff --git a/BiStudent.Client.Tests/ImportClientTests.cs b/BiStudent.Client.Tests/ImportClientTests.cs new file mode 100644 index 0000000..6884823 --- /dev/null +++ b/BiStudent.Client.Tests/ImportClientTests.cs @@ -0,0 +1,54 @@ +using System.Runtime.ExceptionServices; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +[Collection("settings")] +public sealed class ImportClientTests +{ + private static readonly ScheduleImportPreviewDto Preview = new(new DateOnly(2026, 8, 31), + [new("4 курс", "23-ПО-103-4р-1", 144, true), new("4 курс", "23-ПО-103-4р-2", 153, false)], + ["Ниязова М.Е.", "Вакансия"], ["4 курс, 23-ПО-103-4р-2, пн 08:00: не удалось разобрать «…»."]); + + [Fact] + public async Task ImportMethodsSendFileAndSelectedGroups() + { + var server = new FakeServer(); + server.On(HttpMethod.Post, "api/students/import", _ => FakeServer.Json(new ImportResultDto(86, 0, []))); + server.On(HttpMethod.Post, "api/schedule/import-xlsx/preview", _ => FakeServer.Json(Preview)); + server.On(HttpMethod.Post, "api/schedule/import-xlsx", _ => FakeServer.Json(new ScheduleImportResultDto(297, 0, 0, + [new("Ниязова М.Е.", "niyazova.me", "Abc23456xy", true, true)], []))); + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server); + var file = new byte[] { (byte)'P', (byte)'K', 3, 4 }; + + Assert.Equal(86, (await api.ImportStudentsAsync(file)).Added); + Assert.Equal(2, (await api.PreviewScheduleXlsxAsync(file)).Groups.Count); + var result = await api.ImportScheduleXlsxAsync(file, ["23-ПО-103-4р-1", "23-ПО-103-4р-2"]); + Assert.Equal(297, result.Added); + + var import = Assert.Single(server.Requests, r => r.Path.StartsWith("api/schedule/import-xlsx?", StringComparison.Ordinal)); + Assert.Equal("api/schedule/import-xlsx?groups=" + Uri.EscapeDataString("23-ПО-103-4р-1") + "&groups=" + Uri.EscapeDataString("23-ПО-103-4р-2"), import.Path); + Assert.All(server.Requests, r => Assert.Equal(HttpMethod.Post, r.Method)); + } + + [Fact] + public void ScheduleImportDialogShowsPreview() + { + ExceptionDispatchInfo? failure = null; + var thread = new Thread(() => + { + try + { + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, new FakeServer()); + using var dialog = new ScheduleImportDialog(api, [1, 2, 3], Preview); + dialog.CreateControl(); + Assert.Null(dialog.Result); + } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + failure?.Throw(); + } +} diff --git a/BiStudent.Client.Tests/PagesSmokeTests.cs b/BiStudent.Client.Tests/PagesSmokeTests.cs new file mode 100644 index 0000000..afe3f58 --- /dev/null +++ b/BiStudent.Client.Tests/PagesSmokeTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +// Все разделы главного окна открываются на фейковом сервере с данными и загружаются без ошибок. +[Collection("settings")] +public sealed class PagesSmokeTests +{ + private static readonly string[] PageKeys = ["today", "camera", "schedule", "journal", "groups", "report", "admin"]; + + [Fact] + public void EveryPageLoadsWithData() + { + var server = DemoServer(); + var visited = new List(); + RunSta(() => + { + var previousPath = ClientSettings.FilePath; + var directory = Path.Combine(Path.GetTempPath(), "BiStudent.Tests", Guid.NewGuid().ToString("N")); + ClientSettings.FilePath = Path.Combine(directory, "client.json"); + ExceptionDispatchInfo? failure = null; + try + { + using var api = new ApiClient(new ClientSettings { ServerUrl = "http://test/" }, server); + using var form = new MainForm(api) + { + AutoOpenFirstPage = false, StartPosition = FormStartPosition.Manual, + Location = new Point(-4000, -4000), Size = new Size(1440, 900), ShowInTaskbar = false + }; + form.Shown += async (_, _) => + { + try + { + await api.LoginAsync("teacher", "secret"); + await api.LoadMeAsync(); + foreach (var key in PageKeys) + { + await form.ShowPageAsync(key); + Assert.Null(form.LastPageError); + Assert.NotNull(form.CurrentPage); + using var bitmap = new Bitmap(form.ClientSize.Width, form.ClientSize.Height); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.ClientSize)); + visited.Add(key); + } + } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + finally { form.Close(); } + }; + Application.Run(form); + } + finally + { + ClientSettings.FilePath = previousPath; + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + failure?.Throw(); + }); + Assert.Equal(PageKeys, visited); + var missing = server.Requests.Where(r => !server.Handles(r)).Select(r => $"{r.Method} {r.Path}").Distinct().ToList(); + Assert.True(missing.Count == 0, "Запросы без ответа фейкового сервера: " + string.Join("; ", missing)); + } + + internal static FakeServer DemoServer() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var now = DateTime.Now; + var server = new FakeServer(); + var teacher = new TeacherDto(1, "Ахметова Алия Маратовна", "teacher", IsAdmin: true, PendingFaceSubmissions: 1); + var slot = new SlotDto(10, 1, "23-ПО-103-4р-1", "Базы данных", "305", today.DayOfWeek, + new TimeOnly(8, 30), new TimeOnly(9, 50), WeekParity.Every, new DateOnly(2026, 9, 1), null, null); + var lesson = new LessonDto(slot, today, today.ToDateTime(slot.Start), today.ToDateTime(slot.End), false); + var later = lesson with { Slot = slot with { Id = 11, Start = new TimeOnly(23, 0), End = new TimeOnly(23, 59) }, + StartsAt = today.ToDateTime(new TimeOnly(23, 0)), EndsAt = today.ToDateTime(new TimeOnly(23, 59)) }; + var dashboard = new DashboardDto(2, null, later, [lesson, later], 91.5, 88.2, + [new GroupAttendanceDto(1, "23-ПО-103-4р-1", 90.1)], + [new AtRiskStudentDto(101, "Абдиев Артур", "23-ПО-103-4р-1", 62.5, "Посещаемость ниже 70%")], + 40, 160, 4, 16, "Отметки камерой × 6 сек. + выгрузки журнала × 20 мин.", + [new TrendPointDto(today.AddDays(-1), 90), new TrendPointDto(today, 92)], 78.4, 1); + var students = new List + { + new(101, "Абдиев Артур", 1, true), + new(102, "Айсулатанов Руслан", 1, false, FacePending: true), + new(103, "Ашимжанов Ильхам", 1, false, FaceUnreadable: true) + }; + var attendance = new List + { + new(101, "Абдиев Артур", AttendanceStatus.Present, MarkMethod.Camera, now, Score: 85), + new(102, "Айсулатанов Руслан", AttendanceStatus.Excused, MarkMethod.Manual, now, ExcuseReason.Illness, "Справка", true), + new(103, "Ашимжанов Ильхам", null, null, null) + }; + var columns = new List + { + new(JournalColumnKind.Lesson, 10, today.AddDays(-7), new TimeOnly(8, 30), "Базы данных"), + new(JournalColumnKind.Assessment, 5, today.AddDays(-3), null, "Базы данных", "Тест №1", AssessmentKind.Test) + }; + var journal = new JournalDto(teacher.FullName, "23-ПО-103-4р-1", "Базы данных", today.AddDays(-30), today, 2, columns, + [ + new(101, "Абдиев Артур", [new(AttendanceStatus.Present, null, null, 85), new(null, null, null, 90)], 1, 0, 0, 0, 0, 100, 87.5, 88), + new(102, "Айсулатанов Руслан", [new(AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", null), new(null, null, null, null)], 0, 0, 1, 2, 2, 0, null, null), + new(103, "Ашимжанов Ильхам", [new(AttendanceStatus.Absent, null, null, null), new(null, null, null, 61)], 0, 1, 0, 2, 0, 0, 61, null) + ]); + var report = new ReportDto(dashboard, 6, 10, 1, 22, 6, 20); + + server.On(HttpMethod.Post, "api/auth/login", _ => FakeServer.Json(new TokenResponse("access", 3600, "refresh"))); + server.On(HttpMethod.Get, "api/me", _ => FakeServer.Json(teacher)); + server.On(HttpMethod.Get, "api/dashboard", _ => FakeServer.Json(dashboard)); + server.On(HttpMethod.Get, "api/report", _ => FakeServer.Json(report)); + server.On(HttpMethod.Get, "api/settings/automation", _ => FakeServer.Json(new AutomationSettingsDto(6, 20, 3))); + server.On(HttpMethod.Get, "api/recognition/status", _ => FakeServer.Json(new EnrollResultDto(true, "Распознавание готово."))); + server.On(HttpMethod.Get, "api/schedule/day", _ => FakeServer.Json(new List { lesson, later })); + server.On(HttpMethod.Get, "api/schedule/week", _ => FakeServer.Json(new List { lesson, later })); + server.On(HttpMethod.Get, "api/schedule/parity", _ => FakeServer.Json(WeekParity.Odd)); + server.On(HttpMethod.Get, "api/groups", _ => FakeServer.Json(new List { new(1, "23-ПО-103-4р-1", 3, 1) })); + server.On(HttpMethod.Get, "api/groups/1/students", _ => FakeServer.Json(students)); + server.On(HttpMethod.Get, "api/groups/1/enrollment-code", _ => new HttpResponseMessage(HttpStatusCode.NoContent)); + server.On(HttpMethod.Get, "api/journal/1/subjects", _ => FakeServer.Json(new List { "Базы данных" })); + server.On(HttpMethod.Get, "api/journal/1", _ => FakeServer.Json(journal)); + server.On(HttpMethod.Get, "api/assessments", _ => FakeServer.Json(new List + { new(5, 1, "23-ПО-103-4р-1", "Базы данных", "Тест №1", AssessmentKind.Test, today.AddDays(-3)) })); + server.On(HttpMethod.Get, $"api/lessons/10/{today:yyyy-MM-dd}/attendance", _ => FakeServer.Json(attendance)); + server.On(HttpMethod.Get, $"api/lessons/11/{today:yyyy-MM-dd}/attendance", _ => FakeServer.Json(attendance)); + server.On(HttpMethod.Get, "api/face-submissions", _ => FakeServer.Json(new List + { new(7, 102, "Айсулатанов Руслан", 1, "23-ПО-103-4р-1", now, FaceSubmissionStatus.Pending, 3, false, null, 0, null) })); + server.On(HttpMethod.Get, "api/admin/teachers", _ => FakeServer.Json(new List + { + new(1, "teacher", teacher.FullName, true, true, false, now.AddDays(-30), now), + new(2, "niyazova", "Ниязова М.Е.", false, true, true, now.AddDays(-1), null) + })); + return server; + } + + private static void RunSta(Action action) + { + ExceptionDispatchInfo? failure = null; + var thread = new Thread(() => + { + try { action(); } + catch (Exception ex) { failure = ExceptionDispatchInfo.Capture(ex); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + failure?.Throw(); + } +} diff --git a/BiStudent.Client.Tests/UiFormatTests.cs b/BiStudent.Client.Tests/UiFormatTests.cs new file mode 100644 index 0000000..51cb566 --- /dev/null +++ b/BiStudent.Client.Tests/UiFormatTests.cs @@ -0,0 +1,117 @@ +using System.Drawing; +using BiStudent.Contracts; + +namespace BiStudent.Client.Tests; + +public sealed class UiFormatTests +{ + [Theory] + [InlineData(-1, "0 сек")] + [InlineData(0.5, "30 сек")] + [InlineData(1, "1 мин")] + [InlineData(60, "1 ч")] + [InlineData(65, "1 ч 5 мин")] + public void Duration(double value, string expected) => Assert.Equal(expected, UiFormat.Duration(value)); + + [Theory] + [InlineData(30, "0,5")] + [InlineData(60, "1")] + [InlineData(90, "1,5")] + public void Hours(double value, string expected) => Assert.Equal(expected, UiFormat.Hours(value)); + + [Theory] + [InlineData(0, "0%")] + [InlineData(84.6, "85%")] + public void Percent(double value, string expected) => Assert.Equal(expected, UiFormat.Percent(value)); + + [Theory] + [InlineData(69, 0xF2, 0x78, 0x78)] + [InlineData(70, 0xF5, 0xC4, 0x51)] + [InlineData(85, 0x4A, 0xD8, 0x9A)] + public void PercentColor(double value, int red, int green, int blue) => + Assert.Equal(Color.FromArgb(red, green, blue), UiFormat.PercentColor(value)); + + [Fact] + public void DatesTimesAndRange() + { + var date = new DateOnly(2026, 10, 16); + Assert.Equal("16 октября", UiFormat.Date(date)); + Assert.Equal("Пятница, 16 октября", UiFormat.LongDate(date)); + Assert.Equal("09:05", UiFormat.Time(new TimeOnly(9, 5))); + var slot = new SlotDto(1, 2, "Группа", "Предмет", null, DayOfWeek.Friday, + new TimeOnly(9, 5), new TimeOnly(10, 30), WeekParity.Every, date, null, null); + Assert.Equal("09:05–10:30", UiFormat.Range(slot)); + Assert.Equal(new DateOnly(2026, 10, 12), UiFormat.Monday(date)); + } + + [Theory] + [InlineData(DayOfWeek.Monday, "пн", "понедельник")] + [InlineData(DayOfWeek.Tuesday, "вт", "вторник")] + [InlineData(DayOfWeek.Wednesday, "ср", "среда")] + [InlineData(DayOfWeek.Thursday, "чт", "четверг")] + [InlineData(DayOfWeek.Friday, "пт", "пятница")] + [InlineData(DayOfWeek.Saturday, "сб", "суббота")] + [InlineData(DayOfWeek.Sunday, "вс", "воскресенье")] + public void DayNames(DayOfWeek day, string shortName, string longName) + { + Assert.Equal(shortName, UiFormat.DayShort(day)); + Assert.Equal(longName, UiFormat.DayLong(day)); + } + + [Theory] + [InlineData(WeekParity.Every, "каждая")] + [InlineData(WeekParity.Odd, "нечётная")] + [InlineData(WeekParity.Even, "чётная")] + public void Parity(WeekParity value, string expected) => Assert.Equal(expected, UiFormat.Parity(value)); + + [Theory] + [InlineData(1, "студент")] + [InlineData(2, "студента")] + [InlineData(5, "студентов")] + [InlineData(11, "студентов")] + [InlineData(21, "студент")] + public void Plural(int count, string expected) => + Assert.Equal(expected, UiFormat.Plural(count, "студент", "студента", "студентов")); + + [Theory] + [InlineData(null, "—", "·", 0x9A, 0xA9, 0xBD)] + [InlineData(AttendanceStatus.Present, "П", "П", 0x4A, 0xD8, 0x9A)] + [InlineData(AttendanceStatus.Absent, "Н", "Н", 0xF2, 0x78, 0x78)] + [InlineData(AttendanceStatus.Excused, "Н (уваж.)", "Н", 0xF5, 0xC4, 0x51)] + public void Status(AttendanceStatus? status, string text, string symbol, int red, int green, int blue) + { + Assert.Equal(text, UiFormat.StatusText(status)); + Assert.Equal(symbol, UiFormat.StatusSymbol(status)); + var color = Color.FromArgb(red, green, blue); + Assert.Equal(color, UiFormat.StatusColor(status)); + Assert.Equal(Color.FromArgb(40, color), UiFormat.StatusBack(status)); + } + + [Theory] + [InlineData(ExcuseReason.Illness, "Болезнь")] + [InlineData(ExcuseReason.Family, "Семейные обстоятельства")] + [InlineData(ExcuseReason.Official, "Учебная/служебная необходимость")] + [InlineData(ExcuseReason.Other, "Другая причина")] + public void ExcuseReasonText(ExcuseReason reason, string expected) => + Assert.Equal(expected, UiFormat.ExcuseReasonText(reason)); + + [Theory] + [InlineData(AssessmentKind.Test, "Тест", "Тест")] + [InlineData(AssessmentKind.ControlWork, "Контрольная работа", "К/р")] + [InlineData(AssessmentKind.Exam, "Экзамен", "Экз.")] + [InlineData(AssessmentKind.Credit, "Зачёт", "Зачёт")] + [InlineData(AssessmentKind.Other, "Другое", "—")] + public void AssessmentKindNames(AssessmentKind kind, string text, string shortText) + { + Assert.Equal(text, UiFormat.AssessmentKindText(kind)); + Assert.Equal(shortText, UiFormat.AssessmentKindShort(kind)); + } + + [Theory] + [InlineData(null, "—")] + [InlineData(MarkMethod.Camera, "камера")] + [InlineData(MarkMethod.Manual, "вручную")] + [InlineData(MarkMethod.Demo, "отмечено")] + public void MethodText(MarkMethod? method, string expected) => + Assert.Equal(expected, UiFormat.MethodText(method)); +} diff --git a/BiStudent.Core/BiStudent.Core.csproj b/BiStudent.Core/BiStudent.Core.csproj new file mode 100644 index 0000000..ecd1b40 --- /dev/null +++ b/BiStudent.Core/BiStudent.Core.csproj @@ -0,0 +1,42 @@ + + + net10.0 + enable + enable + BiStudent + + + + + + + + $(MSBuildThisFileDirectory)Models\ + $(ModelsDirectory)face_recognition_sface_2021dec.onnx + 0BA9FBFA01B5270C96627C4EF784DA859931E02F04419C829E83484087C34E79 + https://media.githubusercontent.com/media/opencv/opencv_zoo/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx + https://huggingface.co/opencv/face_recognition_sface/resolve/main/face_recognition_sface_2021dec.onnx + + + + + + + + + + + + + + + + + + + + + diff --git a/BiStudent.Core/Contracts/Contracts.cs b/BiStudent.Core/Contracts/Contracts.cs new file mode 100644 index 0000000..04b8073 --- /dev/null +++ b/BiStudent.Core/Contracts/Contracts.cs @@ -0,0 +1,106 @@ +namespace BiStudent.Contracts; + +public enum WeekParity { Every, Odd, Even } +// Числовые значения закреплены: в старых базах 1 означало «Опоздал», миграция переводит такие отметки в Present. +public enum AttendanceStatus { Present = 0, Absent = 2, Excused = 3 } +public enum ExcuseReason { Illness = 0, Family = 1, Official = 2, Other = 3 } +public enum MarkMethod { Camera, Manual, Demo } +public enum AssessmentKind { Test = 0, ControlWork = 1, Exam = 2, Credit = 3, Other = 4 } +public enum JournalColumnKind { Lesson, Assessment } +public enum FaceSubmissionStatus { Pending = 0, Approved = 1, Rejected = 2, Superseded = 3 } +public enum PublicFaceState { None, Pending, Registered } + +public sealed record LoginRequest(string Login, string Password, bool Remember = false); +public sealed record RefreshRequest(string RefreshToken); +public sealed record TokenResponse(string AccessToken, long ExpiresIn, string? RefreshToken = null); +public sealed record TeacherDto(int Id, string FullName, string Login, bool IsAdmin = false, bool MustChangePassword = false, int PendingFaceSubmissions = 0); +public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); + +public sealed record TeacherAccountDto(int Id, string Login, string FullName, bool IsAdmin, bool IsActive, bool MustChangePassword, DateTime CreatedAt, DateTime? LastLoginAt); +public sealed record SaveTeacherRequest(string Login, string FullName, bool IsAdmin, bool IsActive); +public sealed record TemporaryPasswordDto(string Login, string Password); + +public sealed record GroupDto(int Id, string Name, int Students, int WithFace); +public sealed record StudentDto(int Id, string FullName, int GroupId, bool HasFace, bool FaceUnreadable = false, bool FacePending = false); +public sealed record CreateGroupRequest(string Name); +public sealed record SaveStudentRequest(string FullName, int GroupId); + +public sealed record SlotDto( + int Id, int GroupId, string GroupName, string Subject, string? Room, DayOfWeek DayOfWeek, + TimeOnly Start, TimeOnly End, WeekParity Parity, DateOnly ValidFrom, DateOnly? ValidTo, DateOnly? OneOffDate); + +public sealed record SaveSlotRequest( + int GroupId, string? NewGroupName, string Subject, string? Room, DayOfWeek DayOfWeek, + TimeOnly Start, TimeOnly End, WeekParity Parity, DateOnly? OneOffDate); + +public sealed record LessonDto(SlotDto Slot, DateOnly Date, DateTime StartsAt, DateTime EndsAt, bool Cancelled); +public sealed record CancelLessonRequest(DateOnly Date, string? Reason); +public sealed record RestoreLessonRequest(DateOnly Date); + +public sealed record AttendanceRowDto(int StudentId, string FullName, AttendanceStatus? Status, MarkMethod? Method, DateTime? MarkedAt, + ExcuseReason? Reason = null, string? Comment = null, bool HasDocument = false, int? Score = null); +public sealed record MarkRequest(int StudentId, AttendanceStatus? Status, ExcuseReason? Reason = null, string? Comment = null, bool HasDocument = false); +public sealed record GradeRequest(int StudentId, int? Score); +public sealed record CountResult(int Count); + +public sealed record AssessmentDto(int Id, int GroupId, string GroupName, string Subject, string Title, AssessmentKind Kind, DateOnly Date); +public sealed record SaveAssessmentRequest(int GroupId, string Subject, string Title, AssessmentKind Kind, DateOnly Date); +public sealed record FinalGradeRequest(string Subject, int StudentId, int? Score); +public sealed record FillFinalRequest(string Subject); + +public sealed record JournalColumnDto(JournalColumnKind Kind, int Id, DateOnly Date, TimeOnly? Start, string Subject, + string? Title = null, AssessmentKind? AssessmentKind = null); +public sealed record JournalCellDto(AttendanceStatus? Status, ExcuseReason? Reason, string? Comment, int? Score, bool InGroup = true); +public sealed record JournalStudentDto(int StudentId, string FullName, List Cells, + int Present, int Absent, int Excused, int MissedHours, int ExcusedHours, double Percent, double? Average, int? Final); +public sealed record JournalDto(string TeacherName, string GroupName, string? Subject, DateOnly From, DateOnly To, + int HoursPerLesson, List Columns, List Students); + +public sealed record GroupAttendanceDto(int GroupId, string GroupName, double Percent); +public sealed record AtRiskStudentDto(int StudentId, string FullName, string GroupName, double Percent, string Reason); +public sealed record TrendPointDto(DateOnly Date, double Percent); +public sealed record DashboardDto( + int LessonsToday, LessonDto? Current, LessonDto? Next, List Today, + double Attendance7Days, double Attendance30Days, List Groups, List AtRisk, + int CameraMarks7Days, int CameraMarks30Days, double SavedMinutes7Days, double SavedMinutes30Days, + string SavingsFormula, List Trend, double? AverageScore30Days = null, int PendingFaceSubmissions = 0); + +public sealed record ReportDto( + DashboardDto Dashboard, int ReportsExported30Days, int LessonsPerWeek, int GroupCount, int AverageGroupSize, + int SecondsPerManualMark, int MinutesPerManualReport); + +public sealed record AutomationSettingsDto(int SecondsPerManualMark, int MinutesPerManualReport, int FramesInRow); +public sealed record RecognitionSettingsDto(double MatchThreshold, double MinimumMargin, int FramesInRow); + +public sealed record RecognizeRequest(int SlotId, DateOnly Date, byte[] Image); +public sealed record RecognizedFaceDto(int X, int Y, int W, int H, int? StudentId, string? Name, double Similarity, + bool Marked, bool AlreadyMarked, bool OtherGroup, string? CandidateName = null, double Margin = 0); +public sealed record RecognitionResultDto(List Faces, int FrameWidth, int FrameHeight, string? Error = null); + +public sealed record EnrollRequest(List Images, bool AllowSimilar = false); +public sealed record EnrollResultDto(bool Ok, string Message, string? SimilarTo = null, double SimilarScore = 0, List? Problems = null); + +// Регистрация лица по коду группы: преподаватель выдаёт код, студент сам снимается на сайте, преподаватель подтверждает. +public sealed record EnrollmentCodeDto(int GroupId, string GroupName, string Code, DateTime CreatedAt, DateTime ExpiresAt, List Links); +public sealed record CreateEnrollmentCodeRequest(int Hours = 24); +public sealed record FaceSubmissionDto(int Id, int StudentId, string StudentName, int GroupId, string GroupName, DateTime CreatedAt, + FaceSubmissionStatus Status, int ImageCount, bool ReplacesExisting, string? SimilarTo, double SimilarScore, string? RejectReason); +public sealed record RejectSubmissionRequest(string? Reason); + +public sealed record PublicCodeRequest(string Code); +public sealed record PublicStudentDto(int Id, string FullName, PublicFaceState State); +public sealed record PublicGroupDto(string GroupName, DateTime ExpiresAt, List Students); +public sealed record PublicSubmitRequest(string Code, int StudentId, List Images, bool Consent); +public sealed record PublicSubmitResultDto(bool Ok, string Message, List? Problems = null); + +public sealed record ImportResultDto(int Added, int Skipped, List Errors); + +// Импорт расписания колледжа из Excel: сначала предпросмотр (какие группы и преподаватели найдены), затем импорт выбранных групп. +public sealed record ScheduleImportGroupDto(string Sheet, string Group, int Lessons, bool Exists); +public sealed record ScheduleImportPreviewDto(DateOnly SemesterStart, List Groups, List Teachers, List Warnings); +public sealed record ImportedTeacherDto(string FullName, string Login, string? TemporaryPassword, bool Created, bool Active); +public sealed record ScheduleImportResultDto(int Added, int Skipped, int GroupsCreated, List Teachers, List Warnings); +public sealed record ErrorDto(string Message); + +public sealed record BackupFileDto(string Name, DateTime CreatedAt, long Size, bool Automatic); +public sealed record RestoreResultDto(bool Ok, string Message, int Students, int Marks, bool RestartRecommended); diff --git a/BiStudent/SFaceEngine.cs b/BiStudent.Core/FaceEngine.cs similarity index 61% rename from BiStudent/SFaceEngine.cs rename to BiStudent.Core/FaceEngine.cs index 7151832..9c6a99b 100644 --- a/BiStudent/SFaceEngine.cs +++ b/BiStudent.Core/FaceEngine.cs @@ -3,11 +3,13 @@ namespace BiStudent; -internal sealed record FaceMatch(OpenCvSharp.Rect Bounds, int? StudentId, double Similarity); +public sealed record FaceMatch(OpenCvSharp.Rect Bounds, int? StudentId, double Similarity, int? CandidateId = null, double Margin = 0); -internal sealed class FaceEngine : IDisposable +public sealed record SampleQuality(bool Ok, string? Problem, int FaceSize, double Sharpness, double Brightness); +public sealed record CapturedSample(byte[] Image, float[] Embedding); + +public sealed class FaceEngine : IDisposable { - private static readonly System.Collections.Concurrent.ConcurrentDictionary sampleVectors = new(); private static readonly (double X, double Y)[] AlignedLandmarks = [ (38.2946, 51.6963), (73.5318, 51.5014), (56.0252, 71.7366), @@ -22,11 +24,14 @@ private static readonly (double X, double Y)[] AlignedLandmarks = // OpenCV Zoo uses 0.363 for cosine matching. A slightly higher threshold // and a margin between different students reduce accidental attendance marks. - public const double MatchThreshold = 0.43; - public const double MinimumMargin = 0.06; - // Below this size identity features are too poorly sampled. Still draw the - // detection so a user can distinguish a distant face from no detection. - public const int MinimumRecognitionSize = 36; + public const double DefaultMatchThreshold = 0.43; + public const double DefaultMinimumMargin = 0.06; + public const int MinimumSampleFace = 100; + public const double MinimumSharpness = 40; + public const double MinimumBrightness = 50; + public const double MaximumBrightness = 210; + public double MatchThreshold { get; set; } = DefaultMatchThreshold; + public double MinimumMargin { get; set; } = DefaultMinimumMargin; public FaceEngine() { @@ -38,18 +43,14 @@ public FaceEngine() recognitionNet = CvDnn.ReadNetFromOnnx(recognitionModel) ?? throw new InvalidOperationException("Не удалось загрузить модель SFace."); } - public void Train(IEnumerable<(int StudentId, byte[] Image)> samples, CancellationToken cancellation = default) + public void Train(IEnumerable<(int StudentId, byte[] Image)> samples) { enrolled.Clear(); foreach (var sample in samples) { - cancellation.ThrowIfCancellationRequested(); - var key = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(sample.Image)); - if (sampleVectors.TryGetValue(key, out var cached)) { enrolled.Add((sample.StudentId, cached)); continue; } using var aligned = Cv2.ImDecode(sample.Image, ImreadModes.Color); if (aligned.Empty() || aligned.Width != 112 || aligned.Height != 112) continue; - var vector = Feature(aligned); sampleVectors[key] = vector; - enrolled.Add((sample.StudentId, vector)); + enrolled.Add((sample.StudentId, Feature(aligned))); } } @@ -61,10 +62,12 @@ public IReadOnlyList Scan(Mat frame) for (var row = 0; row < rows; row++) { var bounds = Bounds(faces, row, frame.Width, frame.Height); - if (bounds.Width < 16 || bounds.Height < 16) continue; + if (bounds.Width < 65 || bounds.Height < 65) continue; int? id = null; + int? candidateId = null; double best = 0; - if (enrolled.Count > 0 && bounds.Width >= MinimumRecognitionSize && bounds.Height >= MinimumRecognitionSize) + double margin = 0; + if (enrolled.Count > 0) { using var aligned = Align(frame, faces, row); var vector = Feature(aligned); @@ -79,11 +82,13 @@ public IReadOnlyList Scan(Mat frame) if (ranked.Length > 0) { best = ranked[0].Value; - if (best >= MatchThreshold && (ranked.Length == 1 || best - ranked[1].Value >= MinimumMargin)) - id = ranked[0].Key; + candidateId = ranked[0].Key; + margin = ranked.Length == 1 ? best : best - ranked[1].Value; + if (best >= MatchThreshold && margin >= MinimumMargin) + id = candidateId; } } - matches.Add(new FaceMatch(bounds, id, best)); + matches.Add(new FaceMatch(bounds, id, best, candidateId, margin)); } return matches; } @@ -111,15 +116,75 @@ public byte[] CaptureSample(Mat frame, int faceIndex = 0, bool requireSingle = f return bytes; } + public SampleQuality AssessFrame(Mat frame) + { + using var faces = FindFaces(frame); + if (faces.Rows == 0) + return new SampleQuality(false, "В кадре нет лица", 0, 0, 0); + if (faces.Rows != 1) + return new SampleQuality(false, "В кадре несколько лиц — оставьте одного человека", 0, 0, 0); + + var bounds = Bounds(faces, 0, frame.Width, frame.Height); + var faceSize = Math.Min(bounds.Width, bounds.Height); + if (faceSize < MinimumSampleFace) + return new SampleQuality(false, "Лицо слишком далеко — подойдите ближе к камере", faceSize, 0, 0); + + using var aligned = Align(frame, faces, 0); + using var gray = new Mat(); + Cv2.CvtColor(aligned, gray, ColorConversionCodes.BGR2GRAY); + using var laplacian = new Mat(); + Cv2.Laplacian(gray, laplacian, MatType.CV_64F); + Cv2.MeanStdDev(laplacian, out _, out var deviation); + var sharpness = deviation.Val0 * deviation.Val0; + var brightness = Cv2.Mean(gray).Val0; + string? problem = null; + if (sharpness < MinimumSharpness) + problem = "Кадр размыт — не двигайтесь"; + else if (brightness < MinimumBrightness) + problem = "Слишком темно — добавьте света"; + else if (brightness > MaximumBrightness) + problem = "Лицо засвечено — уберите яркий свет за спиной"; + return new SampleQuality(problem is null, problem, faceSize, sharpness, brightness); + } + + public CapturedSample CaptureChecked(Mat frame) + { + var quality = AssessFrame(frame); + if (!quality.Ok) throw new InvalidOperationException(quality.Problem); + var image = CaptureSample(frame, requireSingle: true); + return new CapturedSample(image, Embedding(image)); + } + + public float[] Embedding(byte[] alignedImage) + { + using var aligned = Cv2.ImDecode(alignedImage, ImreadModes.Color); + if (aligned.Empty() || aligned.Width != 112 || aligned.Height != 112) + throw new InvalidOperationException("Образец лица должен иметь размер 112×112."); + return Feature(aligned); + } + + public static double Similarity(float[] a, float[] b) => Dot(a, b); + + public (int StudentId, double Similarity)? MostSimilar(float[] vector, int excludeStudentId) + { + (int StudentId, double Similarity)? best = null; + foreach (var sample in enrolled) + { + if (sample.StudentId == excludeStudentId) continue; + var similarity = Similarity(vector, sample.Vector); + if (best is null || similarity > best.Value.Similarity) + best = (sample.StudentId, similarity); + } + return best; + } + private Mat FindFaces(Mat frame) { var size = new OpenCvSharp.Size(frame.Width, frame.Height); if (detector is null || detectorSize != size) { detector?.Dispose(); - // Detection confidence is distinct from the identity threshold. - // Small faces often have lower detection confidence in YuNet. - detector = FaceDetectorYN.Create(detectionModel, "", size, 0.70f); + detector = FaceDetectorYN.Create(detectionModel, "", size, 0.85f); detectorSize = size; } var faces = new Mat(); @@ -164,7 +229,7 @@ private static Mat Align(Mat frame, Mat faces, int row) transform.Set(0, 0, a); transform.Set(0, 1, -b); transform.Set(0, 2, dstX - a * srcX + b * srcY); transform.Set(1, 0, b); transform.Set(1, 1, a); transform.Set(1, 2, dstY - b * srcX - a * srcY); var aligned = new Mat(); - Cv2.WarpAffine(frame, aligned, transform, new OpenCvSharp.Size(112, 112), InterpolationFlags.Cubic); + Cv2.WarpAffine(frame, aligned, transform, new OpenCvSharp.Size(112, 112)); return aligned; } diff --git a/BiStudent.Core/ImageHeader.cs b/BiStudent.Core/ImageHeader.cs new file mode 100644 index 0000000..cfc050a --- /dev/null +++ b/BiStudent.Core/ImageHeader.cs @@ -0,0 +1,50 @@ +namespace BiStudent; + +/// Читает размеры JPEG/PNG из заголовка без декодирования, чтобы отклонять огромные кадры до выделения памяти. +public static class ImageHeader +{ + public const int MaximumSide = 4096; + public const long MaximumPixels = 12_000_000; + + public static bool TryReadSize(ReadOnlySpan data, out int width, out int height) + { + width = height = 0; + if (data.Length >= 24 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) + { + width = ReadInt32BigEndian(data[16..]); + height = ReadInt32BigEndian(data[20..]); + return width > 0 && height > 0; + } + if (data.Length < 4 || data[0] != 0xFF || data[1] != 0xD8) return false; + var position = 2; + while (position + 9 < data.Length) + { + if (data[position] != 0xFF) { position++; continue; } + var marker = data[position + 1]; + if (marker == 0xFF) { position++; continue; } + if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) { position += 2; continue; } + var length = (data[position + 2] << 8) | data[position + 3]; + if (length < 2) return false; + var isFrame = marker is >= 0xC0 and <= 0xCF && marker is not (0xC4 or 0xC8 or 0xCC); + if (isFrame) + { + height = (data[position + 5] << 8) | data[position + 6]; + width = (data[position + 7] << 8) | data[position + 8]; + return width > 0 && height > 0; + } + position += 2 + length; + } + return false; + } + + /// null — кадр допустим; иначе текст причины отказа. + public static string? Validate(ReadOnlySpan data) + { + if (!TryReadSize(data, out var width, out var height)) return "Кадр должен быть изображением JPEG или PNG."; + if (width > MaximumSide || height > MaximumSide || (long)width * height > MaximumPixels) + return $"Кадр слишком большой ({width}×{height}); максимум {MaximumSide} пикселей по стороне."; + return null; + } + + private static int ReadInt32BigEndian(ReadOnlySpan data) => (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]; +} diff --git a/BiStudent/Models/LICENSE-SFACE.txt b/BiStudent.Core/Models/LICENSE-SFACE.txt similarity index 100% rename from BiStudent/Models/LICENSE-SFACE.txt rename to BiStudent.Core/Models/LICENSE-SFACE.txt diff --git a/BiStudent/Models/LICENSE-YUNET.txt b/BiStudent.Core/Models/LICENSE-YUNET.txt similarity index 100% rename from BiStudent/Models/LICENSE-YUNET.txt rename to BiStudent.Core/Models/LICENSE-YUNET.txt diff --git a/BiStudent/Models/face_detection_yunet_2023mar.onnx b/BiStudent.Core/Models/face_detection_yunet_2023mar.onnx similarity index 100% rename from BiStudent/Models/face_detection_yunet_2023mar.onnx rename to BiStudent.Core/Models/face_detection_yunet_2023mar.onnx diff --git a/BiStudent.Server/BiStudent.Server.csproj b/BiStudent.Server/BiStudent.Server.csproj new file mode 100644 index 0000000..44e6d6e --- /dev/null +++ b/BiStudent.Server/BiStudent.Server.csproj @@ -0,0 +1,30 @@ + + + net10.0 + enable + enable + BiStudent.Server + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + diff --git a/BiStudent.Server/Data/AppDbContext.cs b/BiStudent.Server/Data/AppDbContext.cs new file mode 100644 index 0000000..73a028f --- /dev/null +++ b/BiStudent.Server/Data/AppDbContext.cs @@ -0,0 +1,108 @@ +using BiStudent.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Teachers => Set(); + public DbSet Groups => Set(); + public DbSet Students => Set(); + public DbSet GroupMemberships => Set(); + public DbSet FaceSamples => Set(); + public DbSet ScheduleSlots => Set(); + public DbSet SlotCancellations => Set(); + public DbSet AttendanceMarks => Set(); + public DbSet AppSettings => Set(); + public DbSet LessonGrades => Set(); + public DbSet Assessments => Set(); + public DbSet AssessmentGrades => Set(); + public DbSet FinalGrades => Set(); + public DbSet EnrollmentCodes => Set(); + public DbSet FaceSubmissions => Set(); + public DbSet FaceSubmissionImages => Set(); + + public IQueryable MembershipsOn(DateOnly date) => + GroupMemberships.Where(x => x.From <= date && (x.To == null || x.To >= date)); + + public IQueryable MembershipsDuring(DateOnly from, DateOnly to) => + GroupMemberships.Where(x => x.From <= to && (x.To == null || x.To >= from)); + + private void AddInitialMemberships() + { + foreach (var entry in ChangeTracker.Entries().Where(x => x.State == EntityState.Added)) + { + var student = entry.Entity; + if (student.Memberships.Count > 0) continue; + var membership = new GroupMembership { From = DateOnly.MinValue }; + if (student.GroupId == 0) membership.Group = student.Group; + else membership.GroupId = student.GroupId; + student.Memberships.Add(membership); + } + } + + public override int SaveChanges() => SaveChanges(true); + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + AddInitialMemberships(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) => + SaveChangesAsync(true, cancellationToken); + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + AddInitialMemberships(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().HasIndex(x => x.Login).IsUnique(); + model.Entity().HasIndex(x => x.Name).IsUnique(); + model.Entity().HasKey(x => x.Key); + model.Entity().HasIndex(x => new { x.SlotId, x.Date }).IsUnique(); + model.Entity().HasIndex(x => new { x.SlotId, x.Date, x.StudentId }).IsUnique(); + model.Entity().HasOne(x => x.Group).WithMany(x => x.Students).HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasIndex(x => new { x.StudentId, x.From }).IsUnique(); + model.Entity().HasIndex(x => x.GroupId); + model.Entity().HasOne(x => x.Student).WithMany(x => x.Memberships).HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Group).WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Student).WithMany(x => x.Samples).HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Teacher).WithMany().HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasOne(x => x.Group).WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasOne(x => x.Slot).WithMany(x => x.Cancellations).HasForeignKey(x => x.SlotId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Slot).WithMany().HasForeignKey(x => x.SlotId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().Property(x => x.Comment).HasMaxLength(500); + + model.Entity().HasIndex(x => new { x.SlotId, x.Date, x.StudentId }).IsUnique(); + model.Entity().HasOne(x => x.Slot).WithMany().HasForeignKey(x => x.SlotId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().ToTable(t => t.HasCheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100")); + + model.Entity().HasIndex(x => new { x.TeacherId, x.GroupId, x.Subject }); + model.Entity().HasOne(x => x.Teacher).WithMany().HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasOne(x => x.Group).WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasIndex(x => new { x.AssessmentId, x.StudentId }).IsUnique(); + model.Entity().HasOne(x => x.Assessment).WithMany(x => x.Grades).HasForeignKey(x => x.AssessmentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().ToTable(t => t.HasCheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100")); + + model.Entity().HasIndex(x => new { x.TeacherId, x.GroupId, x.Subject, x.StudentId }).IsUnique(); + model.Entity().HasOne(x => x.Teacher).WithMany().HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasOne(x => x.Group).WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().ToTable(t => t.HasCheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100")); + + model.Entity().HasIndex(x => x.Code).IsUnique(); + model.Entity().HasOne(x => x.Group).WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.CreatedBy).WithMany().HasForeignKey(x => x.CreatedByTeacherId).OnDelete(DeleteBehavior.Restrict); + model.Entity().HasIndex(x => new { x.Status, x.CreatedAt }); + model.Entity().HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Cascade); + model.Entity().HasOne(x => x.EnrollmentCode).WithMany().HasForeignKey(x => x.EnrollmentCodeId).OnDelete(DeleteBehavior.SetNull); + model.Entity().HasOne(x => x.Submission).WithMany(x => x.Images).HasForeignKey(x => x.SubmissionId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/BiStudent.Server/Data/DatabaseInitializer.cs b/BiStudent.Server/Data/DatabaseInitializer.cs new file mode 100644 index 0000000..2bdd155 --- /dev/null +++ b/BiStudent.Server/Data/DatabaseInitializer.cs @@ -0,0 +1,75 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Data; + +public static class DatabaseInitializer +{ + public const string BaselineMigration = "20261016150630_InitialCreate"; + // Схема MVP без истории бывает у баз, созданных через EnsureCreated (тесты, ранние сборки). + public const string MvpMigration = "20261016152747_Mvp"; + + public static async Task InitializeAsync(AppDbContext db, CancellationToken ct = default) + { + var connection = db.Database.GetDbConnection(); + var close = connection.State != ConnectionState.Open; + if (close) await db.Database.OpenConnectionAsync(ct); + try + { + if (await TableExistsAsync(db, "Teachers", ct) && !await TableExistsAsync(db, "__EFMigrationsHistory", ct)) + { + await using var transaction = await db.Database.BeginTransactionAsync(ct); + await db.Database.ExecuteSqlRawAsync( + "CREATE TABLE \"__EFMigrationsHistory\" (\"MigrationId\" TEXT NOT NULL CONSTRAINT \"PK___EFMigrationsHistory\" PRIMARY KEY, \"ProductVersion\" TEXT NOT NULL)", ct); + foreach (var migration in await AppliedWithoutHistoryAsync(db, ct)) + await db.Database.ExecuteSqlAsync( + $"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({migration}, '10.0.12')", ct); + await transaction.CommitAsync(ct); + } + } + finally { if (close) await db.Database.CloseConnectionAsync(); } + await db.Database.MigrateAsync(ct); + } + + public static void Initialize(AppDbContext db) + { + var connection = db.Database.GetDbConnection(); + var close = connection.State != ConnectionState.Open; + if (close) db.Database.OpenConnection(); + try + { + if (TableExists(db, "Teachers") && !TableExists(db, "__EFMigrationsHistory")) + { + using var transaction = db.Database.BeginTransaction(); + db.Database.ExecuteSqlRaw( + "CREATE TABLE \"__EFMigrationsHistory\" (\"MigrationId\" TEXT NOT NULL CONSTRAINT \"PK___EFMigrationsHistory\" PRIMARY KEY, \"ProductVersion\" TEXT NOT NULL)"); + foreach (var migration in AppliedWithoutHistoryAsync(db, CancellationToken.None).GetAwaiter().GetResult()) + db.Database.ExecuteSql( + $"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({migration}, '10.0.12')"); + transaction.Commit(); + } + } + finally { if (close) db.Database.CloseConnection(); } + db.Database.Migrate(); + } + + /// База без истории: таблицы MVP означают схему MVP, иначе это схема первой версии. + private static async Task> AppliedWithoutHistoryAsync(AppDbContext db, CancellationToken ct) => + await TableExistsAsync(db, "FaceSubmissions", ct) ? [BaselineMigration, MvpMigration] : [BaselineMigration]; + + private static async Task TableExistsAsync(AppDbContext db, string name, CancellationToken ct) + { + using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name)"; + command.Parameters.Add(new Microsoft.Data.Sqlite.SqliteParameter("$name", name)); + return Convert.ToInt32(await command.ExecuteScalarAsync(ct)) != 0; + } + + private static bool TableExists(AppDbContext db, string name) + { + using var command = db.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name)"; + command.Parameters.Add(new Microsoft.Data.Sqlite.SqliteParameter("$name", name)); + return Convert.ToInt32(command.ExecuteScalar()) != 0; + } +} diff --git a/BiStudent.Server/Data/Entities.cs b/BiStudent.Server/Data/Entities.cs new file mode 100644 index 0000000..e7c554b --- /dev/null +++ b/BiStudent.Server/Data/Entities.cs @@ -0,0 +1,195 @@ +using BiStudent.Contracts; + +namespace BiStudent.Server.Data; + +public sealed class Teacher +{ + public int Id { get; set; } + public string Login { get; set; } = ""; + public string FullName { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public bool IsAdmin { get; set; } + public bool IsActive { get; set; } = true; + public bool MustChangePassword { get; set; } + public string SecurityStamp { get; set; } = Guid.NewGuid().ToString("N"); + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime? LastLoginAt { get; set; } +} + +public sealed class Group +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public List Students { get; set; } = []; +} + +public sealed class Student +{ + public int Id { get; set; } + public string FullName { get; set; } = ""; + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public DateTime CreatedAt { get; set; } = DateTime.Now; + public List Samples { get; set; } = []; + public List Memberships { get; set; } = []; +} + +public sealed class GroupMembership +{ + public int Id { get; set; } + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public DateOnly From { get; set; } + public DateOnly? To { get; set; } +} + +public sealed class FaceSample +{ + public int Id { get; set; } + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public byte[] ProtectedImage { get; set; } = []; + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +public sealed class ScheduleSlot +{ + public int Id { get; set; } + public int TeacherId { get; set; } + public Teacher Teacher { get; set; } = null!; + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public string Subject { get; set; } = ""; + public string? Room { get; set; } + public DayOfWeek DayOfWeek { get; set; } + public TimeOnly Start { get; set; } + public TimeOnly End { get; set; } + public WeekParity Parity { get; set; } + public DateOnly ValidFrom { get; set; } + public DateOnly? ValidTo { get; set; } + public DateOnly? OneOffDate { get; set; } + public List Cancellations { get; set; } = []; +} + +public sealed class SlotCancellation +{ + public int Id { get; set; } + public int SlotId { get; set; } + public ScheduleSlot Slot { get; set; } = null!; + public DateOnly Date { get; set; } + public string Reason { get; set; } = ""; +} + +public sealed class AttendanceMark +{ + public int Id { get; set; } + public int SlotId { get; set; } + public ScheduleSlot Slot { get; set; } = null!; + public DateOnly Date { get; set; } + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public AttendanceStatus Status { get; set; } + public MarkMethod Method { get; set; } + public DateTime MarkedAt { get; set; } + public ExcuseReason? ExcuseReason { get; set; } + public string? Comment { get; set; } + public bool HasDocument { get; set; } +} + +public sealed class LessonGrade +{ + public int Id { get; set; } + public int SlotId { get; set; } + public ScheduleSlot Slot { get; set; } = null!; + public DateOnly Date { get; set; } + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public int Score { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.Now; +} + +public sealed class Assessment +{ + public int Id { get; set; } + public int TeacherId { get; set; } + public Teacher Teacher { get; set; } = null!; + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public string Subject { get; set; } = ""; + public string Title { get; set; } = ""; + public AssessmentKind Kind { get; set; } + public DateOnly Date { get; set; } + public List Grades { get; set; } = []; +} + +public sealed class AssessmentGrade +{ + public int Id { get; set; } + public int AssessmentId { get; set; } + public Assessment Assessment { get; set; } = null!; + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public int Score { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.Now; +} + +public sealed class FinalGrade +{ + public int Id { get; set; } + public int TeacherId { get; set; } + public Teacher Teacher { get; set; } = null!; + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public string Subject { get; set; } = ""; + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public int Score { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.Now; +} + +public sealed class EnrollmentCode +{ + public int Id { get; set; } + public int GroupId { get; set; } + public Group Group { get; set; } = null!; + public string Code { get; set; } = ""; + public int CreatedByTeacherId { get; set; } + public Teacher CreatedBy { get; set; } = null!; + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime ExpiresAt { get; set; } + public DateTime? RevokedAt { get; set; } +} + +public sealed class FaceSubmission +{ + public int Id { get; set; } + public int StudentId { get; set; } + public Student Student { get; set; } = null!; + public int? EnrollmentCodeId { get; set; } + public EnrollmentCode? EnrollmentCode { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + public FaceSubmissionStatus Status { get; set; } + public string? SimilarTo { get; set; } + public double SimilarScore { get; set; } + public DateTime? ReviewedAt { get; set; } + public int? ReviewedByTeacherId { get; set; } + public string? RejectReason { get; set; } + public List Images { get; set; } = []; +} + +public sealed class FaceSubmissionImage +{ + public int Id { get; set; } + public int SubmissionId { get; set; } + public FaceSubmission Submission { get; set; } = null!; + // Выровненный кроп лица, зашифрованный тем же протектором, что и FaceSample. + public byte[] ProtectedImage { get; set; } = []; +} + +public sealed class AppSetting +{ + public string Key { get; set; } = ""; + public string Value { get; set; } = ""; +} diff --git a/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.Designer.cs b/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.Designer.cs new file mode 100644 index 0000000..ff74f77 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.Designer.cs @@ -0,0 +1,322 @@ +// +using System; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261016150630_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("BiStudent.Server.Data.AppSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("AttendanceMarks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.ToTable("FaceSamples"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("OneOffDate") + .HasColumnType("TEXT"); + + b.Property("Parity") + .HasColumnType("INTEGER"); + + b.Property("Room") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ScheduleSlots"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SlotId", "Date") + .IsUnique(); + + b.ToTable("SlotCancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Samples") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany("Cancellations") + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany("Students") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Navigation("Cancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Navigation("Samples"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.cs b/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.cs new file mode 100644 index 0000000..7e40a8f --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261016150630_InitialCreate.cs @@ -0,0 +1,260 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Key = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Teachers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Login = table.Column(type: "TEXT", nullable: false), + FullName = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Teachers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Students", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FullName = table.Column(type: "TEXT", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Students", x => x.Id); + table.ForeignKey( + name: "FK_Students_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ScheduleSlots", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TeacherId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + Subject = table.Column(type: "TEXT", nullable: false), + Room = table.Column(type: "TEXT", nullable: true), + DayOfWeek = table.Column(type: "INTEGER", nullable: false), + Start = table.Column(type: "TEXT", nullable: false), + End = table.Column(type: "TEXT", nullable: false), + Parity = table.Column(type: "INTEGER", nullable: false), + ValidFrom = table.Column(type: "TEXT", nullable: false), + ValidTo = table.Column(type: "TEXT", nullable: true), + OneOffDate = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleSlots", x => x.Id); + table.ForeignKey( + name: "FK_ScheduleSlots_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ScheduleSlots_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "FaceSamples", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + StudentId = table.Column(type: "INTEGER", nullable: false), + ProtectedImage = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FaceSamples", x => x.Id); + table.ForeignKey( + name: "FK_FaceSamples_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AttendanceMarks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SlotId = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + StudentId = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + Method = table.Column(type: "INTEGER", nullable: false), + MarkedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AttendanceMarks", x => x.Id); + table.ForeignKey( + name: "FK_AttendanceMarks_ScheduleSlots_SlotId", + column: x => x.SlotId, + principalTable: "ScheduleSlots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AttendanceMarks_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SlotCancellations", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SlotId = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + Reason = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SlotCancellations", x => x.Id); + table.ForeignKey( + name: "FK_SlotCancellations_ScheduleSlots_SlotId", + column: x => x.SlotId, + principalTable: "ScheduleSlots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceMarks_SlotId_Date_StudentId", + table: "AttendanceMarks", + columns: new[] { "SlotId", "Date", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceMarks_StudentId", + table: "AttendanceMarks", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_FaceSamples_StudentId", + table: "FaceSamples", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + table: "Groups", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleSlots_GroupId", + table: "ScheduleSlots", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleSlots_TeacherId", + table: "ScheduleSlots", + column: "TeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_SlotCancellations_SlotId_Date", + table: "SlotCancellations", + columns: new[] { "SlotId", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Students_GroupId", + table: "Students", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_Login", + table: "Teachers", + column: "Login", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + + migrationBuilder.DropTable( + name: "AttendanceMarks"); + + migrationBuilder.DropTable( + name: "FaceSamples"); + + migrationBuilder.DropTable( + name: "SlotCancellations"); + + migrationBuilder.DropTable( + name: "Students"); + + migrationBuilder.DropTable( + name: "ScheduleSlots"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "Teachers"); + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261016152747_Mvp.Designer.cs b/BiStudent.Server/Data/Migrations/20261016152747_Mvp.Designer.cs new file mode 100644 index 0000000..1f121e0 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261016152747_Mvp.Designer.cs @@ -0,0 +1,734 @@ +// +using System; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261016152747_Mvp")] + partial class Mvp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("BiStudent.Server.Data.AppSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId", "GroupId", "Subject"); + + b.ToTable("Assessments"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssessmentId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("AssessmentId", "StudentId") + .IsUnique(); + + b.ToTable("AssessmentGrades", t => + { + t.HasCheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("ExcuseReason") + .HasColumnType("INTEGER"); + + b.Property("HasDocument") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("AttendanceMarks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedByTeacherId"); + + b.HasIndex("GroupId"); + + b.ToTable("EnrollmentCodes"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.ToTable("FaceSamples"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentCodeId") + .HasColumnType("INTEGER"); + + b.Property("RejectReason") + .HasColumnType("TEXT"); + + b.Property("ReviewedAt") + .HasColumnType("TEXT"); + + b.Property("ReviewedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("SimilarScore") + .HasColumnType("REAL"); + + b.Property("SimilarTo") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("FaceSubmissions"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SubmissionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubmissionId"); + + b.ToTable("FaceSubmissionImages"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeacherId", "GroupId", "Subject", "StudentId") + .IsUnique(); + + b.ToTable("FinalGrades", t => + { + t.HasCheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("LessonGrades", t => + { + t.HasCheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("OneOffDate") + .HasColumnType("TEXT"); + + b.Property("Parity") + .HasColumnType("INTEGER"); + + b.Property("Room") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ScheduleSlots"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SlotId", "Date") + .IsUnique(); + + b.ToTable("SlotCancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.HasOne("BiStudent.Server.Data.Assessment", "Assessment") + .WithMany("Grades") + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assessment"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.HasOne("BiStudent.Server.Data.Teacher", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByTeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Samples") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.HasOne("BiStudent.Server.Data.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.HasOne("BiStudent.Server.Data.FaceSubmission", "Submission") + .WithMany("Images") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany("Cancellations") + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany("Students") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Navigation("Cancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Navigation("Samples"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261016152747_Mvp.cs b/BiStudent.Server/Data/Migrations/20261016152747_Mvp.cs new file mode 100644 index 0000000..5a28641 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261016152747_Mvp.cs @@ -0,0 +1,436 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + /// + public partial class Mvp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Teachers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "IsActive", + table: "Teachers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsAdmin", + table: "Teachers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastLoginAt", + table: "Teachers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MustChangePassword", + table: "Teachers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SecurityStamp", + table: "Teachers", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Comment", + table: "AttendanceMarks", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExcuseReason", + table: "AttendanceMarks", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "HasDocument", + table: "AttendanceMarks", + type: "INTEGER", + nullable: false, + defaultValue: false); + + // Данные прежних версий: учётные записи остаются активными, первая получает права администратора, + // опоздания (старый статус 1) считаются присутствием. + migrationBuilder.Sql("UPDATE \"Teachers\" SET \"IsActive\" = 1, \"CreatedAt\" = datetime('now', 'localtime'), \"SecurityStamp\" = lower(hex(randomblob(16)));"); + migrationBuilder.Sql("UPDATE \"Teachers\" SET \"IsAdmin\" = 1 WHERE \"Id\" = (SELECT MIN(\"Id\") FROM \"Teachers\");"); + migrationBuilder.Sql("UPDATE \"AttendanceMarks\" SET \"Status\" = 0 WHERE \"Status\" = 1;"); + + migrationBuilder.CreateTable( + name: "Assessments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TeacherId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + Subject = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Assessments", x => x.Id); + table.ForeignKey( + name: "FK_Assessments_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Assessments_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "EnrollmentCodes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + GroupId = table.Column(type: "INTEGER", nullable: false), + Code = table.Column(type: "TEXT", nullable: false), + CreatedByTeacherId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EnrollmentCodes", x => x.Id); + table.ForeignKey( + name: "FK_EnrollmentCodes_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_EnrollmentCodes_Teachers_CreatedByTeacherId", + column: x => x.CreatedByTeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "FinalGrades", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TeacherId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + Subject = table.Column(type: "TEXT", nullable: false), + StudentId = table.Column(type: "INTEGER", nullable: false), + Score = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FinalGrades", x => x.Id); + table.CheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100"); + table.ForeignKey( + name: "FK_FinalGrades_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_FinalGrades_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FinalGrades_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "LessonGrades", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SlotId = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + StudentId = table.Column(type: "INTEGER", nullable: false), + Score = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LessonGrades", x => x.Id); + table.CheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100"); + table.ForeignKey( + name: "FK_LessonGrades_ScheduleSlots_SlotId", + column: x => x.SlotId, + principalTable: "ScheduleSlots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LessonGrades_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AssessmentGrades", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AssessmentId = table.Column(type: "INTEGER", nullable: false), + StudentId = table.Column(type: "INTEGER", nullable: false), + Score = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AssessmentGrades", x => x.Id); + table.CheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100"); + table.ForeignKey( + name: "FK_AssessmentGrades_Assessments_AssessmentId", + column: x => x.AssessmentId, + principalTable: "Assessments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AssessmentGrades_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FaceSubmissions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + StudentId = table.Column(type: "INTEGER", nullable: false), + EnrollmentCodeId = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + SimilarTo = table.Column(type: "TEXT", nullable: true), + SimilarScore = table.Column(type: "REAL", nullable: false), + ReviewedAt = table.Column(type: "TEXT", nullable: true), + ReviewedByTeacherId = table.Column(type: "INTEGER", nullable: true), + RejectReason = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FaceSubmissions", x => x.Id); + table.ForeignKey( + name: "FK_FaceSubmissions_EnrollmentCodes_EnrollmentCodeId", + column: x => x.EnrollmentCodeId, + principalTable: "EnrollmentCodes", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_FaceSubmissions_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FaceSubmissionImages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SubmissionId = table.Column(type: "INTEGER", nullable: false), + ProtectedImage = table.Column(type: "BLOB", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FaceSubmissionImages", x => x.Id); + table.ForeignKey( + name: "FK_FaceSubmissionImages_FaceSubmissions_SubmissionId", + column: x => x.SubmissionId, + principalTable: "FaceSubmissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AssessmentGrades_AssessmentId_StudentId", + table: "AssessmentGrades", + columns: new[] { "AssessmentId", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AssessmentGrades_StudentId", + table: "AssessmentGrades", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_Assessments_GroupId", + table: "Assessments", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Assessments_TeacherId_GroupId_Subject", + table: "Assessments", + columns: new[] { "TeacherId", "GroupId", "Subject" }); + + migrationBuilder.CreateIndex( + name: "IX_EnrollmentCodes_Code", + table: "EnrollmentCodes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EnrollmentCodes_CreatedByTeacherId", + table: "EnrollmentCodes", + column: "CreatedByTeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_EnrollmentCodes_GroupId", + table: "EnrollmentCodes", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_FaceSubmissionImages_SubmissionId", + table: "FaceSubmissionImages", + column: "SubmissionId"); + + migrationBuilder.CreateIndex( + name: "IX_FaceSubmissions_EnrollmentCodeId", + table: "FaceSubmissions", + column: "EnrollmentCodeId"); + + migrationBuilder.CreateIndex( + name: "IX_FaceSubmissions_Status_CreatedAt", + table: "FaceSubmissions", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_FaceSubmissions_StudentId", + table: "FaceSubmissions", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_FinalGrades_GroupId", + table: "FinalGrades", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_FinalGrades_StudentId", + table: "FinalGrades", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_FinalGrades_TeacherId_GroupId_Subject_StudentId", + table: "FinalGrades", + columns: new[] { "TeacherId", "GroupId", "Subject", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LessonGrades_SlotId_Date_StudentId", + table: "LessonGrades", + columns: new[] { "SlotId", "Date", "StudentId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LessonGrades_StudentId", + table: "LessonGrades", + column: "StudentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AssessmentGrades"); + + migrationBuilder.DropTable( + name: "FaceSubmissionImages"); + + migrationBuilder.DropTable( + name: "FinalGrades"); + + migrationBuilder.DropTable( + name: "LessonGrades"); + + migrationBuilder.DropTable( + name: "Assessments"); + + migrationBuilder.DropTable( + name: "FaceSubmissions"); + + migrationBuilder.DropTable( + name: "EnrollmentCodes"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "IsActive", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "IsAdmin", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "LastLoginAt", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "MustChangePassword", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "SecurityStamp", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "Comment", + table: "AttendanceMarks"); + + migrationBuilder.DropColumn( + name: "ExcuseReason", + table: "AttendanceMarks"); + + migrationBuilder.DropColumn( + name: "HasDocument", + table: "AttendanceMarks"); + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.Designer.cs b/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.Designer.cs new file mode 100644 index 0000000..5593278 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.Designer.cs @@ -0,0 +1,783 @@ +// +using System; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261017140941_GroupMemberships")] + partial class GroupMemberships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("BiStudent.Server.Data.AppSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId", "GroupId", "Subject"); + + b.ToTable("Assessments"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssessmentId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("AssessmentId", "StudentId") + .IsUnique(); + + b.ToTable("AssessmentGrades", t => + { + t.HasCheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("ExcuseReason") + .HasColumnType("INTEGER"); + + b.Property("HasDocument") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("AttendanceMarks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedByTeacherId"); + + b.HasIndex("GroupId"); + + b.ToTable("EnrollmentCodes"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.ToTable("FaceSamples"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentCodeId") + .HasColumnType("INTEGER"); + + b.Property("RejectReason") + .HasColumnType("TEXT"); + + b.Property("ReviewedAt") + .HasColumnType("TEXT"); + + b.Property("ReviewedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("SimilarScore") + .HasColumnType("REAL"); + + b.Property("SimilarTo") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("FaceSubmissions"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SubmissionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubmissionId"); + + b.ToTable("FaceSubmissionImages"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeacherId", "GroupId", "Subject", "StudentId") + .IsUnique(); + + b.ToTable("FinalGrades", t => + { + t.HasCheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("To") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId", "From") + .IsUnique(); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("LessonGrades", t => + { + t.HasCheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("OneOffDate") + .HasColumnType("TEXT"); + + b.Property("Parity") + .HasColumnType("INTEGER"); + + b.Property("Room") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ScheduleSlots"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SlotId", "Date") + .IsUnique(); + + b.ToTable("SlotCancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.HasOne("BiStudent.Server.Data.Assessment", "Assessment") + .WithMany("Grades") + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assessment"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.HasOne("BiStudent.Server.Data.Teacher", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByTeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Samples") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.HasOne("BiStudent.Server.Data.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.HasOne("BiStudent.Server.Data.FaceSubmission", "Submission") + .WithMany("Images") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Memberships") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany("Cancellations") + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany("Students") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Navigation("Cancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Navigation("Memberships"); + + b.Navigation("Samples"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.cs b/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.cs new file mode 100644 index 0000000..51c02ab --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261017140941_GroupMemberships.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + /// + public partial class GroupMemberships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + StudentId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + From = table.Column(type: "TEXT", nullable: false), + To = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => x.Id); + table.ForeignKey( + name: "FK_GroupMemberships_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupMemberships_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_GroupId", + table: "GroupMemberships", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_StudentId_From", + table: "GroupMemberships", + columns: new[] { "StudentId", "From" }, + unique: true); + + migrationBuilder.Sql( + "INSERT INTO \"GroupMemberships\" (\"StudentId\", \"GroupId\", \"From\", \"To\") " + + "SELECT \"Id\", \"GroupId\", '0001-01-01', NULL FROM \"Students\";"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupMemberships"); + } + } +} diff --git a/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs b/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..f246409 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,780 @@ +// +using System; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("BiStudent.Server.Data.AppSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId", "GroupId", "Subject"); + + b.ToTable("Assessments"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssessmentId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("AssessmentId", "StudentId") + .IsUnique(); + + b.ToTable("AssessmentGrades", t => + { + t.HasCheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("ExcuseReason") + .HasColumnType("INTEGER"); + + b.Property("HasDocument") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("AttendanceMarks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedByTeacherId"); + + b.HasIndex("GroupId"); + + b.ToTable("EnrollmentCodes"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.ToTable("FaceSamples"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentCodeId") + .HasColumnType("INTEGER"); + + b.Property("RejectReason") + .HasColumnType("TEXT"); + + b.Property("ReviewedAt") + .HasColumnType("TEXT"); + + b.Property("ReviewedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("SimilarScore") + .HasColumnType("REAL"); + + b.Property("SimilarTo") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("FaceSubmissions"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SubmissionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubmissionId"); + + b.ToTable("FaceSubmissionImages"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeacherId", "GroupId", "Subject", "StudentId") + .IsUnique(); + + b.ToTable("FinalGrades", t => + { + t.HasCheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("To") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId", "From") + .IsUnique(); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("LessonGrades", t => + { + t.HasCheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("OneOffDate") + .HasColumnType("TEXT"); + + b.Property("Parity") + .HasColumnType("INTEGER"); + + b.Property("Room") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ScheduleSlots"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SlotId", "Date") + .IsUnique(); + + b.ToTable("SlotCancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.HasOne("BiStudent.Server.Data.Assessment", "Assessment") + .WithMany("Grades") + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assessment"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.HasOne("BiStudent.Server.Data.Teacher", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByTeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Samples") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.HasOne("BiStudent.Server.Data.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.HasOne("BiStudent.Server.Data.FaceSubmission", "Submission") + .WithMany("Images") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Memberships") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany("Cancellations") + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany("Students") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Navigation("Cancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Navigation("Memberships"); + + b.Navigation("Samples"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BiStudent.Server/Dockerfile b/BiStudent.Server/Dockerfile new file mode 100644 index 0000000..81a477d --- /dev/null +++ b/BiStudent.Server/Dockerfile @@ -0,0 +1,26 @@ +# Сервер API BiStudent. Контекст сборки — корень репозитория: +# docker build -f BiStudent.Server/Dockerfile -t bistudent-server . +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY BiStudent.Core/BiStudent.Core.csproj BiStudent.Core/ +COPY BiStudent.Server/BiStudent.Server.csproj BiStudent.Server/ +RUN dotnet restore BiStudent.Server/BiStudent.Server.csproj -r linux-x64 +COPY BiStudent.Core/ BiStudent.Core/ +COPY BiStudent.Server/ BiStudent.Server/ +RUN dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r linux-x64 --self-contained false --no-restore -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +# OpenCvSharp4.official.runtime.linux-x64: OpenMP и базовые графические библиотеки; curl — для healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgomp1 libgdiplus libgtk2.0-0 libtesseract5 curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app . +RUN mkdir -p /data && chown -R app:app /data +USER app +ENV ASPNETCORE_URLS=http://+:8080 \ + ConnectionStrings__Default="Data Source=/data/bistudent.db" +VOLUME /data +EXPOSE 8080 +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 CMD curl -fsS http://localhost:8080/health || exit 1 +ENTRYPOINT ["dotnet", "BiStudent.Server.dll"] diff --git a/BiStudent.Server/Endpoints/ApiEndpoints.cs b/BiStudent.Server/Endpoints/ApiEndpoints.cs new file mode 100644 index 0000000..971a270 --- /dev/null +++ b/BiStudent.Server/Endpoints/ApiEndpoints.cs @@ -0,0 +1,334 @@ +using System.Globalization; +using System.Security.Claims; +using System.Text; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Endpoints; + +public static class ApiEndpoints +{ + public static void MapApi(this WebApplication app) + { + app.MapAuth(); + + var api = app.MapGroup("/api").RequireAuthorization().AddEndpointFilter(HandleErrorsAsync); + api.MapGet("/settings/automation", async (IConfiguration configuration, RecognitionService recognition) => + Automation(configuration, (await recognition.GetSettingsAsync()).FramesInRow)); + api.MapGet("/settings/recognition", (RecognitionService recognition) => recognition.GetSettingsAsync()); + api.MapPut("/settings/recognition", (RecognitionSettingsDto settings, RecognitionService recognition) => recognition.SaveSettingsAsync(settings)) + .RequireAuthorization("Admin"); + + api.MapGrades(); + api.MapGet("/dashboard", async (ClaimsPrincipal user, StatsService stats, ScheduleService schedule) => + await DashboardAsync(TeacherId(user), stats, schedule, DateTime.Now)); + api.MapGet("/report", ReportAsync); + + var schedule = api.MapGroup("/schedule"); + schedule.MapGet("/day", async (DateOnly date, ClaimsPrincipal user, ScheduleService service) => + (await service.ForDayAsync(TeacherId(user), date)).Select(Map).ToList()); + schedule.MapGet("/week", async (DateOnly monday, ClaimsPrincipal user, ScheduleService service) => + (await service.ForWeekAsync(TeacherId(user), monday)).Select(Map).ToList()); + schedule.MapGet("/parity", (DateOnly date, ScheduleService service) => service.WeekParityOf(date)); + schedule.MapPost("/slots", SaveSlotAsync); + schedule.MapPut("/slots/{id:int}", SaveSlotAsync); + schedule.MapDelete("/slots/{id:int}", async (int id, ClaimsPrincipal user, AppDbContext db, ScheduleService service) => + { + await EnsureSlotAsync(db, user, id); + await service.DeleteSlotAsync(id); + return Results.NoContent(); + }); + schedule.MapPost("/slots/{id:int}/cancel", async (int id, CancelLessonRequest request, ClaimsPrincipal user, AppDbContext db, ScheduleService service) => + { + await EnsureSlotAsync(db, user, id); + await service.CancelAsync(id, request.Date, string.IsNullOrWhiteSpace(request.Reason) ? "Отменена преподавателем" : request.Reason.Trim()); + return Results.NoContent(); + }); + schedule.MapPost("/slots/{id:int}/restore", async (int id, RestoreLessonRequest request, ClaimsPrincipal user, AppDbContext db, ScheduleService service) => + { + await EnsureSlotAsync(db, user, id); + await service.RestoreAsync(id, request.Date); + return Results.NoContent(); + }); + // Расписание колледжа из Excel: предпросмотр доступен всем, импорт создаёт учётные записи — только администратору. + schedule.MapPost("/import-xlsx/preview", async (HttpRequest request, ScheduleImportService import) => + await import.PreviewAsync(await BufferAsync(request))).WithMetadata(new RequestSizeLimitAttribute(20 * 1024 * 1024)); + schedule.MapPost("/import-xlsx", async (HttpRequest request, [FromQuery] string[]? groups, ScheduleImportService import) => + await import.ImportAsync(await BufferAsync(request), groups ?? [])) + .RequireAuthorization("Admin").WithMetadata(new RequestSizeLimitAttribute(20 * 1024 * 1024)); + schedule.MapPost("/import", async (HttpRequest request, ClaimsPrincipal user, ScheduleService service) => + { + using var body = new MemoryStream(); + await request.Body.CopyToAsync(body); + body.Position = 0; + try { return Results.Ok(new ImportResultDto(await service.ImportCsvAsync(TeacherId(user), body), 0, [])); } + catch (InvalidOperationException ex) + { + var errors = ex.Message.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).ToList(); + return Results.Ok(new ImportResultDto(0, errors.Count, errors)); + } + }); + + var groups = api.MapGroup("/groups"); + groups.MapGet("", async (StudentService students) => + (await students.GroupsAsync()).Select(g => new GroupDto(g.Id, g.Name, g.Students, g.WithFace)).ToList()); + groups.MapPost("", async (CreateGroupRequest request, StudentService students) => + { + var group = await students.AddGroupAsync(request.Name); + return new GroupDto(group.Id, group.Name, 0, 0); + }); + groups.MapDelete("/{id:int}", async (int id, ClaimsPrincipal user, AppDbContext db, StudentService students) => + { + await EnsureGroupAsync(db, user, id); + await students.DeleteGroupAsync(id); + return Results.NoContent(); + }); + groups.MapGet("/{id:int}/students", async (int id, StudentService students, FaceSampleHealth health, AppDbContext db) => + { + var faces = await students.WithFaceAsync(id); + var unreadable = await health.UnreadableStudentsAsync(); + var pending = await db.FaceSubmissions.AsNoTracking() + .Where(x => x.Student.GroupId == id && x.Status == FaceSubmissionStatus.Pending) + .Select(x => x.StudentId).Distinct().ToListAsync(); + return (await students.ForGroupAsync(id)) + .Select(s => new StudentDto(s.Id, s.FullName, s.GroupId, faces.Contains(s.Id) && !unreadable.Contains(s.Id), + unreadable.Contains(s.Id), pending.Contains(s.Id))).ToList(); + }); + + var students = api.MapGroup("/students"); + students.MapPost("", async (SaveStudentRequest request, ClaimsPrincipal user, AppDbContext db, StudentService service) => + { + await EnsureGroupAsync(db, user, request.GroupId); + var student = await service.AddStudentAsync(request.GroupId, request.FullName); + return new StudentDto(student.Id, student.FullName, student.GroupId, false); + }); + students.MapPut("/{id:int}", async (int id, SaveStudentRequest request, ClaimsPrincipal user, AppDbContext db, StudentService service) => + { + await using var transaction = await db.Database.BeginTransactionAsync(); + var student = await db.Students.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException("Студент не найден."); + await EnsureGroupAsync(db, user, student.GroupId); + if (student.GroupId != request.GroupId) + { + if (!await db.Groups.AnyAsync(x => x.Id == request.GroupId)) throw new KeyNotFoundException("Группа не найдена."); + await EnsureGroupAsync(db, user, request.GroupId); + } + if (student.FullName != request.FullName.Trim()) await service.RenameStudentAsync(id, request.FullName); + if (student.GroupId != request.GroupId) await service.MoveStudentAsync(id, request.GroupId); + await transaction.CommitAsync(); + return Results.NoContent(); + }); + students.MapDelete("/{id:int}", async (int id, ClaimsPrincipal user, AppDbContext db, StudentService service) => + { + var groupId = await db.Students.AsNoTracking().Where(x => x.Id == id).Select(x => (int?)x.GroupId).SingleOrDefaultAsync() + ?? throw new KeyNotFoundException("Студент не найден."); + await EnsureGroupAsync(db, user, groupId); + await service.DeleteStudentAsync(id); + return Results.NoContent(); + }); + students.MapPost("/import", async (HttpRequest request, StudentService service) => await service.ImportAsync(request.Body)) + .WithMetadata(new RequestSizeLimitAttribute(10 * 1024 * 1024)); + students.MapPost("/{id:int}/enroll", async (int id, EnrollRequest request, ClaimsPrincipal user, AppDbContext db, RecognitionService recognition) => + { + var groupId = await db.Students.AsNoTracking().Where(x => x.Id == id).Select(x => (int?)x.GroupId).SingleOrDefaultAsync() + ?? throw new KeyNotFoundException("Студент не найден."); + await EnsureGroupAsync(db, user, groupId); + return await recognition.EnrollAsync(id, request.Images, request.AllowSimilar); + }).WithMetadata(new RequestSizeLimitAttribute(8 * 1024 * 1024)); + + var lessons = api.MapGroup("/lessons/{slotId:int}/{date}"); + lessons.MapGet("/attendance", async (int slotId, DateOnly date, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => + { + await EnsureSlotAsync(db, user, slotId); + return (await attendance.ForLessonAsync(slotId, date)) + .Select(r => new AttendanceRowDto(r.StudentId, r.FullName, r.Status, r.Method, r.MarkedAt, + r.Reason, r.Comment, r.HasDocument, r.Score)).ToList(); + }); + lessons.MapPut("/attendance", async (int slotId, DateOnly date, MarkRequest request, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => + { + await EnsureSlotAsync(db, user, slotId); + if (request.Status is AttendanceStatus status) await attendance.MarkAsync(slotId, date, request.StudentId, + status, MarkMethod.Manual, request.Reason, request.Comment, request.HasDocument); + else await attendance.ClearAsync(slotId, date, request.StudentId); + return Results.NoContent(); + }); + lessons.MapPost("/finish", async (int slotId, DateOnly date, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => + { + await EnsureSlotAsync(db, user, slotId); + return new CountResult(await attendance.MarkRestAbsentAsync(slotId, date)); + }); + lessons.MapPost("/all-present", async (int slotId, DateOnly date, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => + { + await EnsureSlotAsync(db, user, slotId); + var count = 0; + foreach (var row in (await attendance.ForLessonAsync(slotId, date)).Where(r => r.Status is null)) + if (await attendance.MarkAsync(slotId, date, row.StudentId, AttendanceStatus.Present, MarkMethod.Manual, onlyIfUnmarked: true)) count++; + return new CountResult(count); + }); + + api.MapPost("/recognize", async (RecognizeRequest request, ClaimsPrincipal user, AppDbContext db, RecognitionService recognition) => + { + await EnsureSlotAsync(db, user, request.SlotId); + return await recognition.RecognizeAsync(TeacherId(user), request.SlotId, request.Date, request.Image); + }).WithMetadata(new RequestSizeLimitAttribute(4 * 1024 * 1024)); + api.MapGet("/recognition/status", async (RecognitionService recognition, FaceSampleHealth health) => + { + if (!recognition.IsAvailable) return new EnrollResultDto(false, recognition.Error ?? "Распознавание недоступно."); + var unreadable = (await health.UnreadableStudentsAsync()).Count; + return new EnrollResultDto(true, unreadable == 0 ? "Распознавание готово." + : $"Образцы лиц {unreadable} студентов не расшифровываются (база перенесена без ключей шифрования). Зарегистрируйте их лица заново — история посещаемости сохранена."); + }); + + var admin = api.MapGroup("/admin").RequireAuthorization("Admin"); + admin.MapGet("/backups", (BackupService backups) => backups.List()); + admin.MapPost("/backups", async (BackupService backups, CancellationToken ct) => await backups.CreateAsync(automatic: false, ct)); + admin.MapGet("/backups/{name}", (string name, BackupService backups) => + backups.Resolve(name) is { } path + ? Results.File(path, "application/zip", Path.GetFileName(path)) + : Results.Json(new ErrorDto("Копия не найдена."), statusCode: StatusCodes.Status404NotFound)); + admin.MapPost("/restore", async (HttpRequest request, ClaimsPrincipal user, BackupService backups, SessionGeneration sessions, CancellationToken ct) => + await backups.RestoreAsync(request.Body, ct, () => sessions.IsCurrent(user))).WithMetadata(new RequestSizeLimitAttribute(200L * 1024 * 1024)); + + var journal = api.MapGroup("/journal"); + journal.MapGet("/{groupId:int}", async (int groupId, DateOnly from, DateOnly to, string? subject, ClaimsPrincipal user, AttendanceService attendance) => + Map(await attendance.JournalAsync(TeacherId(user), groupId, from, to, NullIfEmpty(subject)))); + journal.MapGet("/{groupId:int}/xlsx", async (int groupId, DateOnly from, DateOnly to, string? subject, ClaimsPrincipal user, AttendanceService attendance, ExportService export) => + { + var matrix = await attendance.JournalAsync(TeacherId(user), groupId, from, to, NullIfEmpty(subject)); + return Results.File(export.JournalXlsx(matrix, "Журнал учёта посещаемости и успеваемости"), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", FileName(matrix, ".xlsx")); + }); + journal.MapGet("/{groupId:int}/csv", async (int groupId, DateOnly from, DateOnly to, string? subject, ClaimsPrincipal user, AttendanceService attendance, ExportService export) => + { + var matrix = await attendance.JournalAsync(TeacherId(user), groupId, from, to, NullIfEmpty(subject)); + return Results.File(Encoding.UTF8.GetBytes(export.JournalCsv(matrix)), "text/csv; charset=utf-8", FileName(matrix, ".csv")); + }); + } + + internal static async ValueTask HandleErrorsAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + try { return await next(context); } + catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteErrorCode: 19 }) { return Results.Json(new ErrorDto("Данные одновременно изменил другой запрос — обновите и повторите."), statusCode: StatusCodes.Status409Conflict); } + catch (InvalidOperationException ex) { return Results.Json(new ErrorDto(ex.Message), statusCode: StatusCodes.Status400BadRequest); } + catch (KeyNotFoundException ex) { return Results.Json(new ErrorDto(ex.Message), statusCode: StatusCodes.Status404NotFound); } + catch (UnauthorizedAccessException ex) { return Results.Json(new ErrorDto(ex.Message), statusCode: StatusCodes.Status403Forbidden); } + catch (FormatException ex) { return Results.Json(new ErrorDto(ex.Message), statusCode: StatusCodes.Status400BadRequest); } + } + + internal static int TeacherId(ClaimsPrincipal user) => + int.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), CultureInfo.InvariantCulture, out var id) + ? id : throw new UnauthorizedAccessException("Требуется вход преподавателя."); + + internal static async Task EnsureSlotAsync(AppDbContext db, ClaimsPrincipal user, int slotId) + { + var teacherId = TeacherId(user); + if (!await db.ScheduleSlots.AnyAsync(x => x.Id == slotId && x.TeacherId == teacherId)) + throw new KeyNotFoundException("Пара не найдена в вашем расписании."); + } + + /// Менять состав группы может администратор, преподаватель с парами в ней или любой — если у группы ещё нет расписания. + internal static async Task EnsureGroupAsync(AppDbContext db, ClaimsPrincipal user, int groupId) + { + if (user.HasClaim("bistudent:admin", "true")) return; + var teacherId = TeacherId(user); + if (await db.ScheduleSlots.AnyAsync(x => x.GroupId == groupId && x.TeacherId == teacherId) || + !await db.ScheduleSlots.AnyAsync(x => x.GroupId == groupId)) return; + throw new UnauthorizedAccessException("Группа не входит в ваше расписание."); + } + + private static async Task SaveSlotAsync(int? id, SaveSlotRequest request, ClaimsPrincipal user, + AppDbContext db, ScheduleService service, StudentService students, IConfiguration configuration) + { + var teacherId = TeacherId(user); + if (string.IsNullOrWhiteSpace(request.Subject)) throw new InvalidOperationException("Укажите предмет."); + var groupId = request.GroupId; + if (!string.IsNullOrWhiteSpace(request.NewGroupName)) groupId = (await students.AddGroupAsync(request.NewGroupName)).Id; + if (!await db.Groups.AnyAsync(x => x.Id == groupId)) throw new InvalidOperationException("Выберите группу."); + var existing = id is int slotId ? await db.ScheduleSlots.AsNoTracking().SingleOrDefaultAsync(x => x.Id == slotId && x.TeacherId == teacherId) + ?? throw new KeyNotFoundException("Пара не найдена в вашем расписании.") : null; + var semester = DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + var slot = new ScheduleSlot + { + Id = existing?.Id ?? 0, TeacherId = teacherId, GroupId = groupId, Subject = request.Subject.Trim(), + Room = string.IsNullOrWhiteSpace(request.Room) ? null : request.Room.Trim(), + DayOfWeek = request.OneOffDate?.DayOfWeek ?? request.DayOfWeek, Start = request.Start, End = request.End, + Parity = request.OneOffDate is null ? request.Parity : WeekParity.Every, + ValidFrom = request.OneOffDate ?? (existing?.OneOffDate is not null ? semester : existing?.ValidFrom ?? semester), + ValidTo = request.OneOffDate ?? (existing?.OneOffDate is not null ? null : existing?.ValidTo), OneOffDate = request.OneOffDate + }; + if (existing is null) slot = await service.AddSlotAsync(slot); + else await service.UpdateSlotAsync(slot); + var group = await db.Groups.AsNoTracking().SingleAsync(x => x.Id == slot.GroupId); + return Results.Ok(Map(slot, group.Name)); + } + + private static async Task DashboardAsync(int teacherId, StatsService stats, ScheduleService schedule, DateTime now) + { + var dashboard = await stats.DashboardAsync(teacherId, now); + var today = await schedule.ForDayAsync(teacherId, DateOnly.FromDateTime(now)); + var current = await schedule.CurrentAsync(teacherId, now); + var next = current is null ? today.FirstOrDefault(x => !x.Cancelled && x.StartsAt > now) : null; + return new DashboardDto( + dashboard.LessonsToday, current is null ? null : Map(current), next is null ? null : Map(next), today.Select(Map).ToList(), + dashboard.Attendance7Days, dashboard.Attendance30Days, + dashboard.Groups.Select(g => new GroupAttendanceDto(g.GroupId, g.GroupName, g.Percent)).ToList(), + dashboard.AtRisk.Select(r => new AtRiskStudentDto(r.StudentId, r.FullName, r.GroupName, r.Percent, r.Reason)).ToList(), + dashboard.CameraMarks7Days, dashboard.CameraMarks30Days, dashboard.SavedMinutes7Days, dashboard.SavedMinutes30Days, + dashboard.SavingsFormula, dashboard.Trend.Select(t => new TrendPointDto(t.Date, t.Percent)).ToList(), + dashboard.AverageScore30Days, dashboard.PendingFaceSubmissions); + } + + private static async Task ReportAsync(ClaimsPrincipal user, StatsService stats, ScheduleService schedule, + StudentService students, IConfiguration configuration) + { + var teacherId = TeacherId(user); + var now = DateTime.Now; + var today = DateOnly.FromDateTime(now); + var dashboard = await DashboardAsync(teacherId, stats, schedule, now); + var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7)); + var week = await schedule.ForWeekAsync(teacherId, monday); + var groupIds = week.Select(x => x.Slot.GroupId).ToHashSet(); + var groups = (await students.GroupsAsync()).Where(g => groupIds.Contains(g.Id)).ToList(); + var automation = Automation(configuration); + return new ReportDto(dashboard, await stats.ReportsExportedAsync(today.AddDays(-29), today), + week.Count(x => !x.Cancelled), groups.Count, groups.Count == 0 ? 25 : (int)Math.Round(groups.Average(g => g.Students)), + automation.SecondsPerManualMark, automation.MinutesPerManualReport); + } + + private static AutomationSettingsDto Automation(IConfiguration configuration, int? framesInRow = null) => new( + configuration.GetValue("Automation:SecondsPerManualMark", 6), configuration.GetValue("Automation:MinutesPerManualReport", 20), + framesInRow ?? configuration.GetValue("Recognition:FramesInRow", 3)); + + private static SlotDto Map(ScheduleSlot slot, string groupName) => new( + slot.Id, slot.GroupId, groupName, slot.Subject, slot.Room, slot.DayOfWeek, slot.Start, slot.End, + slot.Parity, slot.ValidFrom, slot.ValidTo, slot.OneOffDate); + + private static LessonDto Map(LessonOccurrence lesson) => + new(Map(lesson.Slot, lesson.GroupName), lesson.Date, lesson.StartsAt, lesson.EndsAt, lesson.Cancelled); + + private static JournalDto Map(JournalMatrix matrix) => new(matrix.TeacherName, matrix.GroupName, matrix.Subject, + matrix.From, matrix.To, matrix.HoursPerLesson, + matrix.Columns.Select(c => new JournalColumnDto(c.Kind, c.Id, c.Date, c.Start, c.Subject, c.Title, c.AssessmentKind)).ToList(), + matrix.Students.Select(s => new JournalStudentDto(s.StudentId, s.FullName, + s.Cells.Select(c => new JournalCellDto(c.Status, c.Reason, c.Comment, c.Score, c.InGroup)).ToList(), + s.Present, s.Absent, s.Excused, s.MissedHours, s.ExcusedHours, s.Percent, s.Average, s.Final)).ToList()); + + private static async Task BufferAsync(HttpRequest request) + { + var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer); + buffer.Position = 0; + return buffer; + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + private static string FileName(JournalMatrix matrix, string extension) + { + var group = string.Concat(matrix.GroupName.Where(x => !Path.GetInvalidFileNameChars().Contains(x))); + return $"Журнал_{group}_{matrix.From.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}_{matrix.To.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}{extension}"; + } +} diff --git a/BiStudent.Server/Endpoints/AuthEndpoints.cs b/BiStudent.Server/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..4336279 --- /dev/null +++ b/BiStudent.Server/Endpoints/AuthEndpoints.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Security.Claims; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuth(this WebApplication app) + { + var auth = app.MapGroup("/api/auth").AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + auth.MapPost("/login", LoginAsync).AllowAnonymous().RequireRateLimiting("login"); + auth.MapPost("/refresh", RefreshAsync).AllowAnonymous().RequireRateLimiting("login"); + auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization(); + + app.MapGet("/api/me", MeAsync).RequireAuthorization().AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + + var teachers = app.MapGroup("/api/admin/teachers").RequireAuthorization("Admin").AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + teachers.MapGet("", (AccountService accounts) => accounts.ListAsync()); + teachers.MapPost("", (SaveTeacherRequest request, AccountService accounts) => accounts.CreateAsync(request)); + teachers.MapPut("/{id:int}", (int id, SaveTeacherRequest request, ClaimsPrincipal user, AccountService accounts) => + accounts.UpdateAsync(id, request, ApiEndpoints.TeacherId(user))); + teachers.MapPost("/{id:int}/reset-password", (int id, ClaimsPrincipal user, AccountService accounts) => + accounts.ResetPasswordAsync(id, ApiEndpoints.TeacherId(user))); + } + + private static async Task LoginAsync(LoginRequest request, AppDbContext db, + IPasswordHasher hasher, SessionGeneration sessions) + { + var login = request.Login.Trim().ToLowerInvariant(); + var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Login.ToLower() == login); + var verification = teacher is null ? PasswordVerificationResult.Failed + : hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, request.Password); + if (teacher is null || verification == PasswordVerificationResult.Failed) + return Results.Json(new ErrorDto("Неверный логин или пароль."), statusCode: StatusCodes.Status401Unauthorized); + if (!teacher.IsActive) + return Results.Json(new ErrorDto("Учётная запись отключена. Обратитесь к администратору."), statusCode: StatusCodes.Status403Forbidden); + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + teacher.PasswordHash = hasher.HashPassword(teacher, request.Password); + teacher.LastLoginAt = DateTime.Now; + await db.SaveChangesAsync(); + return Results.SignIn(Principal(teacher, sessions), authenticationScheme: BearerTokenDefaults.AuthenticationScheme); + } + + private static async Task RefreshAsync(RefreshRequest request, AppDbContext db, + IOptionsMonitor options, TimeProvider clock, SessionGeneration sessions) + { + var ticket = options.Get(BearerTokenDefaults.AuthenticationScheme).RefreshTokenProtector.Unprotect(request.RefreshToken); + if (ticket is null || ticket.Properties.ExpiresUtc is not { } expires || expires <= clock.GetUtcNow() || + !sessions.IsCurrent(ticket.Principal)) + return Results.Json(new ErrorDto("Сессия завершена — войдите снова."), statusCode: StatusCodes.Status401Unauthorized); + var id = ticket.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + var stamp = ticket.Principal.FindFirstValue("bistudent:stamp"); + var teacher = int.TryParse(id, out var teacherId) + ? await db.Teachers.SingleOrDefaultAsync(x => x.Id == teacherId) : null; + if (teacher is null || !teacher.IsActive || teacher.SecurityStamp != stamp) + return Results.Json(new ErrorDto("Сессия завершена — войдите снова."), statusCode: StatusCodes.Status401Unauthorized); + return Results.SignIn(Principal(teacher, sessions), authenticationScheme: BearerTokenDefaults.AuthenticationScheme); + } + + private static async Task ChangePasswordAsync(ChangePasswordRequest request, ClaimsPrincipal user, + AppDbContext db, IPasswordHasher hasher, SessionGeneration sessions) + { + var teacher = await db.Teachers.SingleAsync(x => x.Id == ApiEndpoints.TeacherId(user)); + if (hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, request.CurrentPassword) == PasswordVerificationResult.Failed) + throw new InvalidOperationException("Текущий пароль указан неверно."); + if (request.NewPassword.Length < 8) throw new InvalidOperationException("Новый пароль должен содержать не меньше 8 символов."); + if (request.NewPassword == request.CurrentPassword) throw new InvalidOperationException("Новый пароль должен отличаться от текущего."); + if (string.Equals(request.NewPassword, teacher.Login, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Пароль не должен совпадать с логином."); + teacher.PasswordHash = hasher.HashPassword(teacher, request.NewPassword); + teacher.MustChangePassword = false; + teacher.SecurityStamp = Guid.NewGuid().ToString("N"); + await db.SaveChangesAsync(); + return Results.SignIn(Principal(teacher, sessions), authenticationScheme: BearerTokenDefaults.AuthenticationScheme); + } + + private static async Task MeAsync(ClaimsPrincipal user, AppDbContext db) + { + var teacher = await db.Teachers.AsNoTracking().SingleAsync(x => x.Id == ApiEndpoints.TeacherId(user)); + var pending = await db.FaceSubmissions.CountAsync(x => x.Status == FaceSubmissionStatus.Pending); + return new TeacherDto(teacher.Id, teacher.FullName, teacher.Login, teacher.IsAdmin, teacher.MustChangePassword, pending); + } + + private static ClaimsPrincipal Principal(Teacher teacher, SessionGeneration sessions) + { + var identity = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, teacher.Id.ToString(CultureInfo.InvariantCulture)), + new Claim(ClaimTypes.Name, teacher.FullName), + new Claim("bistudent:login", teacher.Login), + new Claim("bistudent:admin", teacher.IsAdmin ? "true" : "false"), + new Claim("bistudent:stamp", teacher.SecurityStamp), + sessions.Claim() + ], BearerTokenDefaults.AuthenticationScheme); + return new ClaimsPrincipal(identity); + } +} diff --git a/BiStudent.Server/Endpoints/EnrollmentEndpoints.cs b/BiStudent.Server/Endpoints/EnrollmentEndpoints.cs new file mode 100644 index 0000000..d45fdf0 --- /dev/null +++ b/BiStudent.Server/Endpoints/EnrollmentEndpoints.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Endpoints; + +public static class EnrollmentEndpoints +{ + public static void MapEnrollment(this WebApplication app) + { + var publicApi = app.MapGroup("/api/public/enrollment").AllowAnonymous() + .AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + publicApi.MapPost("/check", (PublicCodeRequest request, EnrollmentService service) => + service.CheckAsync(request.Code)).RequireRateLimiting("public-check"); + publicApi.MapPost("/submit", (PublicSubmitRequest request, EnrollmentService service) => + service.SubmitAsync(request)).RequireRateLimiting("public-submit") + .WithMetadata(new RequestSizeLimitAttribute(12 * 1024 * 1024)); + + var groups = app.MapGroup("/api/groups").RequireAuthorization() + .AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + groups.MapGet("/{id:int}/enrollment-code", async (int id, HttpContext http, EnrollmentService service) => + { + var code = await service.GetActiveAsync(id); + return code is null ? Results.NoContent() : Results.Ok(service.ToDto(code, http)); + }); + groups.MapPost("/{id:int}/enrollment-code", async (int id, CreateEnrollmentCodeRequest request, + ClaimsPrincipal user, HttpContext http, AppDbContext db, EnrollmentService service) => + { + await ApiEndpoints.EnsureGroupAsync(db, user, id); + return service.ToDto(await service.CreateAsync(ApiEndpoints.TeacherId(user), id, request.Hours), http); + }); + groups.MapDelete("/{id:int}/enrollment-code", async (int id, ClaimsPrincipal user, AppDbContext db, EnrollmentService service) => + { + await ApiEndpoints.EnsureGroupAsync(db, user, id); + await service.RevokeAsync(id); + return Results.NoContent(); + }); + + var submissions = app.MapGroup("/api/face-submissions").RequireAuthorization() + .AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + submissions.MapGet("", async (FaceSubmissionStatus? status, ClaimsPrincipal user, AppDbContext db, EnrollmentService service) => + { + var list = await service.ListAsync(status); + if (user.HasClaim("bistudent:admin", "true")) return list; + var teacherId = ApiEndpoints.TeacherId(user); + var groupIds = (await db.Groups.AsNoTracking() + .Where(group => !db.ScheduleSlots.Any(slot => slot.GroupId == group.Id) || + db.ScheduleSlots.Any(slot => slot.GroupId == group.Id && slot.TeacherId == teacherId)) + .Select(group => group.Id).ToListAsync()).ToHashSet(); + return list.Where(x => groupIds.Contains(x.GroupId)).ToList(); + }); + submissions.MapGet("/{id:int}/images/{index:int}", async (int id, int index, ClaimsPrincipal user, AppDbContext db, EnrollmentService service) => + { + await EnsureSubmissionAsync(db, user, id); + return Results.File(await service.ImageAsync(id, index), "image/jpeg"); + }); + submissions.MapPost("/{id:int}/approve", async (int id, ClaimsPrincipal user, AppDbContext db, EnrollmentService service) => + { + await EnsureSubmissionAsync(db, user, id); + await service.ApproveAsync(ApiEndpoints.TeacherId(user), id); + return Results.NoContent(); + }); + submissions.MapPost("/{id:int}/reject", async (int id, RejectSubmissionRequest request, + ClaimsPrincipal user, AppDbContext db, EnrollmentService service) => + { + await EnsureSubmissionAsync(db, user, id); + await service.RejectAsync(ApiEndpoints.TeacherId(user), id, request.Reason); + return Results.NoContent(); + }); + } + + private static async Task EnsureSubmissionAsync(AppDbContext db, ClaimsPrincipal user, int id) + { + var groupId = await db.FaceSubmissions.AsNoTracking().Where(x => x.Id == id) + .Select(x => (int?)x.Student.GroupId).SingleOrDefaultAsync() + ?? throw new KeyNotFoundException("Заявка не найдена."); + await ApiEndpoints.EnsureGroupAsync(db, user, groupId); + } +} diff --git a/BiStudent.Server/Endpoints/GradeEndpoints.cs b/BiStudent.Server/Endpoints/GradeEndpoints.cs new file mode 100644 index 0000000..77c5b68 --- /dev/null +++ b/BiStudent.Server/Endpoints/GradeEndpoints.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; + +namespace BiStudent.Server.Endpoints; + +public static class GradeEndpoints +{ + internal static void MapGrades(this RouteGroupBuilder api) + { + api.MapPut("/lessons/{slotId:int}/{date}/grade", async ( + int slotId, DateOnly date, GradeRequest request, ClaimsPrincipal user, AppDbContext db, AttendanceService attendance) => + { + await ApiEndpoints.EnsureSlotAsync(db, user, slotId); + await attendance.SetLessonGradeAsync(slotId, date, request.StudentId, request.Score); + return Results.NoContent(); + }); + + api.MapGet("/journal/{groupId:int}/subjects", (int groupId, ClaimsPrincipal user, GradeService grades) => + grades.SubjectsAsync(ApiEndpoints.TeacherId(user), groupId)); + + api.MapGet("/assessments", (int? groupId, string? subject, ClaimsPrincipal user, GradeService grades) => + grades.ListAsync(ApiEndpoints.TeacherId(user), groupId, subject)); + api.MapPost("/assessments", (SaveAssessmentRequest request, ClaimsPrincipal user, GradeService grades) => + grades.SaveAsync(ApiEndpoints.TeacherId(user), null, request)); + api.MapPut("/assessments/{id:int}", (int id, SaveAssessmentRequest request, ClaimsPrincipal user, GradeService grades) => + grades.SaveAsync(ApiEndpoints.TeacherId(user), id, request)); + api.MapDelete("/assessments/{id:int}", async (int id, ClaimsPrincipal user, GradeService grades) => + { + await grades.DeleteAsync(ApiEndpoints.TeacherId(user), id); + return Results.NoContent(); + }); + api.MapPut("/assessments/{id:int}/grades", async ( + int id, GradeRequest request, ClaimsPrincipal user, GradeService grades) => + { + await grades.SetGradeAsync(ApiEndpoints.TeacherId(user), id, request.StudentId, request.Score); + return Results.NoContent(); + }); + + api.MapPut("/journal/{groupId:int}/final", async ( + int groupId, FinalGradeRequest request, ClaimsPrincipal user, GradeService grades) => + { + await grades.SetFinalAsync(ApiEndpoints.TeacherId(user), groupId, request.Subject, request.StudentId, request.Score); + return Results.NoContent(); + }); + api.MapPost("/journal/{groupId:int}/final/fill", async ( + int groupId, FillFinalRequest request, ClaimsPrincipal user, GradeService grades) => + new CountResult(await grades.FillFinalAsync(ApiEndpoints.TeacherId(user), groupId, request.Subject))); + } +} diff --git a/BiStudent.Server/Program.cs b/BiStudent.Server/Program.cs new file mode 100644 index 0000000..9b80f77 --- /dev/null +++ b/BiStudent.Server/Program.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Security.Claims; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using BiStudent.Server.Data; +using BiStudent.Server.Endpoints; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddConsole(); +var configuredConnection = builder.Configuration.GetConnectionString("Default"); +var defaultData = builder.Configuration["Storage:DataDirectory"] ?? + (OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BiStudent") + : Path.Combine(builder.Environment.ContentRootPath, "App_Data")); +if (!Path.IsPathRooted(defaultData)) + defaultData = Path.Combine(builder.Environment.ContentRootPath, defaultData); +var sqlite = new SqliteConnectionStringBuilder(configuredConnection ?? ""); +if (configuredConnection is null) sqlite.DataSource = Path.Combine(defaultData, "bistudent.db"); +if (!Path.IsPathRooted(sqlite.DataSource)) + sqlite.DataSource = Path.Combine(builder.Environment.ContentRootPath, sqlite.DataSource); +var data = Path.GetDirectoryName(Path.GetFullPath(sqlite.DataSource))!; +Directory.CreateDirectory(data); +Directory.CreateDirectory(Path.Combine(data, "keys")); + +builder.Services.AddDbContext(options => options.UseSqlite(sqlite.ToString())); +builder.Services.AddHealthChecks().AddDbContextCheck(); +builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(data, "keys"))) + .SetApplicationName("BiStudent"); +builder.Services.AddAuthentication(BearerTokenDefaults.AuthenticationScheme) + .AddBearerToken(options => + { + options.BearerTokenExpiration = TimeSpan.FromHours(1); + options.RefreshTokenExpiration = TimeSpan.FromDays(30); + }); +builder.Services.AddAuthorization(options => + options.AddPolicy("Admin", policy => policy.RequireClaim("bistudent:admin", "true"))); +builder.Services.AddRateLimiter(options => +{ + options.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), AutoReplenishment = true })); + options.AddPolicy("public-check", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), AutoReplenishment = true })); + options.AddPolicy("public-submit", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), AutoReplenishment = true })); + options.OnRejected = async (context, ct) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync( + new BiStudent.Contracts.ErrorDto("Слишком много попыток входа. Подождите минуту."), cancellationToken: ct); + }; +}); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); +builder.Services.AddProblemDetails(); +builder.Services.Configure(options => options.ThrowOnBadRequest = false); +builder.Services.AddScoped, PasswordHasher>(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new StoragePaths(Path.GetFullPath(sqlite.DataSource), sqlite.ToString())); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); + +var app = builder.Build(); +if (configuredConnection is null) +{ + var legacy = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); + var oldDatabase = Path.Combine(legacy, "bistudent.db"); + var newDatabase = Path.GetFullPath(sqlite.DataSource); + if (!File.Exists(newDatabase) && File.Exists(oldDatabase) && + !Path.GetFullPath(oldDatabase).Equals(newDatabase, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + var temporary = newDatabase + ".migrating"; + try + { + File.Delete(temporary); + using (var source = new SqliteConnection(new SqliteConnectionStringBuilder { + DataSource = oldDatabase, Mode = SqliteOpenMode.ReadOnly, Pooling = false + }.ToString())) + using (var destination = new SqliteConnection(new SqliteConnectionStringBuilder { + DataSource = temporary, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false + }.ToString())) + { + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + } + var oldKeys = Path.Combine(legacy, "keys"); + if (Directory.Exists(oldKeys)) + foreach (var key in Directory.EnumerateFiles(oldKeys)) + File.Copy(key, Path.Combine(data, "keys", Path.GetFileName(key)), overwrite: true); + File.Move(temporary, newDatabase); + app.Logger.LogInformation("Данные перенесены из {Directory}.", legacy); + } + catch (Exception ex) + { + app.Logger.LogCritical(ex, "Не удалось перенести данные из {Directory}. Запуск остановлен, чтобы не создать пустую базу вместо старой; устраните причину и запустите сервер снова.", legacy); + foreach (var file in new[] { temporary, newDatabase }) + { + try { File.Delete(file); } + catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) + { + app.Logger.LogWarning(cleanup, "Не удалось удалить {File}.", file); + } + } + throw; + } + } +} +app.Logger.LogInformation("Каталог данных: {Directory}.", data); +var culture = CultureInfo.GetCultureInfo("ru-RU"); +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; + +app.Use(async (context, next) => +{ + if (context.Request.Path.StartsWithSegments("/register")) + { + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["Content-Security-Policy"] = + "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'"; + context.Response.Headers["Permissions-Policy"] = "camera=(self)"; + } + await next(); +}); +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.UseExceptionHandler(); +app.UseAuthentication(); +app.UseRateLimiter(); +app.Use(async (context, next) => +{ + var path = context.Request.Path; + // Операции с копиями сериализуются внутри BackupService; остальные запросы к API восстановление дожидается. + var tracked = path.StartsWithSegments("/api") && !path.StartsWithSegments("/api/admin/backups") && + !path.StartsWithSegments("/api/admin/restore"); + var backups = context.RequestServices.GetRequiredService(); + if (tracked && !backups.TryEnterRequest()) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new BiStudent.Contracts.ErrorDto("Идёт восстановление резервной копии — повторите через минуту.")); + return; + } + try + { + if (context.User.Identity?.IsAuthenticated == true && !path.StartsWithSegments("/api/auth/login") && + !path.StartsWithSegments("/api/auth/refresh")) + { + var db = context.RequestServices.GetRequiredService(); + var id = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var stamp = context.User.FindFirst("bistudent:stamp")?.Value; + if (!context.RequestServices.GetRequiredService().IsCurrent(context.User) || + !int.TryParse(id, out var teacherId) || + !await db.Teachers.AsNoTracking().AnyAsync(x => x.Id == teacherId && x.IsActive && x.SecurityStamp == stamp)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new BiStudent.Contracts.ErrorDto("Сессия завершена — войдите снова.")); + return; + } + } + await next(); + } + finally { if (tracked) backups.ExitRequest(); } +}); +app.UseAuthorization(); +app.MapHealthChecks("/health"); +app.MapGet("/", () => Results.Redirect("/register/")); +// Маршрутизация не различает завершающий слэш, поэтому страницу для «/register/» отдаём здесь же. +app.MapGet("/register", (HttpContext http) => http.Request.Path.Value!.EndsWith('/') + ? Results.File(Path.Combine(app.Environment.WebRootPath, "register", "index.html"), "text/html; charset=utf-8") + : Results.Redirect("/register/")); +app.MapApi(); +app.MapEnrollment(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await DatabaseInitializer.InitializeAsync(db); + await scope.ServiceProvider.GetRequiredService().SeedAsync(); +} + +app.Run(); + +public partial class Program; diff --git a/BiStudent.Server/Properties/launchSettings.json b/BiStudent.Server/Properties/launchSettings.json new file mode 100644 index 0000000..1e1b286 --- /dev/null +++ b/BiStudent.Server/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "BiStudent.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/BiStudent.Server/Services/AccountService.cs b/BiStudent.Server/Services/AccountService.cs new file mode 100644 index 0000000..eebf00d --- /dev/null +++ b/BiStudent.Server/Services/AccountService.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class AccountService(AppDbContext db, IPasswordHasher hasher) +{ + private const string PasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + private static readonly Regex LoginPattern = new(@"^[A-Za-z0-9._-]{3,32}$", RegexOptions.CultureInvariant); + private static readonly SemaphoreSlim accountGate = new(1, 1); + + public async Task> ListAsync() => + (await db.Teachers.AsNoTracking().OrderBy(x => x.FullName).ToListAsync()).Select(Map).ToList(); + + public async Task CreateAsync(SaveTeacherRequest request) + { + await accountGate.WaitAsync(); + try + { + var (login, name) = await ValidateAsync(request); + var teacher = new Teacher { Login = login, FullName = name, IsAdmin = request.IsAdmin, + IsActive = request.IsActive, MustChangePassword = true }; + var password = RandomNumberGenerator.GetString(PasswordAlphabet, 10); + teacher.PasswordHash = hasher.HashPassword(teacher, password); + db.Teachers.Add(teacher); + await db.SaveChangesAsync(); + return new TemporaryPasswordDto(login, password); + } + finally { accountGate.Release(); } + } + + public async Task UpdateAsync(int id, SaveTeacherRequest request, int actorId) + { + await accountGate.WaitAsync(); + try + { + var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException("Преподаватель не найден."); + var (login, name) = await ValidateAsync(request, id); + if (id == actorId && (!request.IsAdmin || !request.IsActive)) + throw new InvalidOperationException("Нельзя снять с себя права администратора или отключить свою учётную запись."); + if (teacher.IsAdmin && teacher.IsActive && (!request.IsAdmin || !request.IsActive) && + !await db.Teachers.AnyAsync(x => x.Id != id && x.IsAdmin && x.IsActive)) + throw new InvalidOperationException("Должен остаться хотя бы один активный администратор."); + if (teacher.Login != login || teacher.IsAdmin != request.IsAdmin || teacher.IsActive != request.IsActive) + teacher.SecurityStamp = Guid.NewGuid().ToString("N"); + teacher.Login = login; + teacher.FullName = name; + teacher.IsAdmin = request.IsAdmin; + teacher.IsActive = request.IsActive; + await db.SaveChangesAsync(); + return Map(teacher); + } + finally { accountGate.Release(); } + } + + public async Task ResetPasswordAsync(int id, int actorId) + { + if (id == actorId) throw new InvalidOperationException("Для своего пароля используйте смену пароля."); + await accountGate.WaitAsync(); + try + { + var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException("Преподаватель не найден."); + var password = RandomNumberGenerator.GetString(PasswordAlphabet, 10); + teacher.PasswordHash = hasher.HashPassword(teacher, password); + teacher.MustChangePassword = true; + teacher.SecurityStamp = Guid.NewGuid().ToString("N"); + await db.SaveChangesAsync(); + return new TemporaryPasswordDto(teacher.Login, password); + } + finally { accountGate.Release(); } + } + + private async Task<(string Login, string FullName)> ValidateAsync(SaveTeacherRequest request, int? id = null) + { + var login = request.Login.Trim(); + var name = request.FullName.Trim(); + if (!LoginPattern.IsMatch(login)) throw new InvalidOperationException("Логин: 3–32 символа, латинские буквы, цифры, точка, дефис или подчёркивание."); + if (name.Length is 0 or > 120) throw new InvalidOperationException("Укажите ФИО длиной не более 120 символов."); + var lower = login.ToLowerInvariant(); + if (await db.Teachers.AnyAsync(x => x.Id != id && x.Login.ToLower() == lower)) + throw new InvalidOperationException("Такой логин уже занят."); + return (login, name); + } + + private static TeacherAccountDto Map(Teacher teacher) => new( + teacher.Id, teacher.Login, teacher.FullName, teacher.IsAdmin, teacher.IsActive, + teacher.MustChangePassword, teacher.CreatedAt, teacher.LastLoginAt); +} diff --git a/BiStudent.Server/Services/AttendanceService.cs b/BiStudent.Server/Services/AttendanceService.cs new file mode 100644 index 0000000..9096f3d --- /dev/null +++ b/BiStudent.Server/Services/AttendanceService.cs @@ -0,0 +1,195 @@ +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed record AttendanceRow(int StudentId, string FullName, AttendanceStatus? Status, MarkMethod? Method, + DateTime? MarkedAt, ExcuseReason? Reason, string? Comment, bool HasDocument, int? Score); +public sealed record JournalColumn(JournalColumnKind Kind, int Id, DateOnly Date, TimeOnly? Start, string Subject, + string? Title = null, AssessmentKind? AssessmentKind = null); +public sealed record JournalCell(AttendanceStatus? Status, ExcuseReason? Reason, string? Comment, bool HasDocument, int? Score, bool InGroup = true); +public sealed record JournalStudent(int StudentId, string FullName, List Cells, + int Present, int Absent, int Excused, int MissedHours, int ExcusedHours, double Percent, double? Average, int? Final); +public sealed record JournalMatrix(string TeacherName, string GroupName, string? Subject, DateOnly From, DateOnly To, + int HoursPerLesson, List Columns, List Students); + +public sealed class AttendanceNotifier +{ + public event Action? Changed; + public void Notify(int slotId, DateOnly date) => Changed?.Invoke(slotId, date); +} + +public sealed class AttendanceService(AppDbContext db, IConfiguration configuration, ScheduleService schedule, AttendanceNotifier notifier) +{ + public async Task> ForLessonAsync(int slotId, DateOnly date) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + var members = db.MembershipsOn(date).Where(x => x.GroupId == slot.GroupId).Select(x => x.StudentId); + var students = await db.Students.AsNoTracking().Where(x => members.Contains(x.Id)).OrderBy(x => x.FullName).ToListAsync(); + var marks = await db.AttendanceMarks.AsNoTracking().Where(x => x.SlotId == slotId && x.Date == date).ToDictionaryAsync(x => x.StudentId); + var grades = await db.LessonGrades.AsNoTracking().Where(x => x.SlotId == slotId && x.Date == date).ToDictionaryAsync(x => x.StudentId); + return students.Select(x => { + marks.TryGetValue(x.Id, out var mark); + grades.TryGetValue(x.Id, out var grade); + return new AttendanceRow(x.Id, x.FullName, mark?.Status, mark?.Method, mark?.MarkedAt, + mark?.ExcuseReason, mark?.Comment, mark?.HasDocument ?? false, grade?.Score); + }).ToList(); + } + + public async Task MarkAsync(int slotId, DateOnly date, int studentId, AttendanceStatus status, MarkMethod method, + ExcuseReason? reason = null, string? comment = null, bool hasDocument = false, bool onlyIfUnmarked = false) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + if (!await db.MembershipsOn(date).AnyAsync(x => x.StudentId == studentId && x.GroupId == slot.GroupId)) + throw new InvalidOperationException("Студент не принадлежит группе этой пары."); + if (!Enum.IsDefined(status)) throw new InvalidOperationException("Неверный статус посещаемости."); + var now = DateTime.Now; + if (method == MarkMethod.Camera) status = AttendanceStatus.Present; + if (status == AttendanceStatus.Excused) + { + reason ??= ExcuseReason.Other; + if (!Enum.IsDefined(reason.Value)) throw new InvalidOperationException("Неверная причина отсутствия."); + comment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim(); + if (comment?.Length > 500) throw new InvalidOperationException("Комментарий длиннее 500 символов."); + } + else { reason = null; comment = null; hasDocument = false; } + var mark = await db.AttendanceMarks.SingleOrDefaultAsync(x => x.SlotId == slotId && x.Date == date && x.StudentId == studentId); + if (onlyIfUnmarked && mark is not null) return false; + if (mark is not null && (method == MarkMethod.Camera && mark.Method == MarkMethod.Manual || + mark.Status == status && mark.Method == method && mark.ExcuseReason == reason && + mark.Comment == comment && mark.HasDocument == hasDocument)) return false; + if (mark is null) + { + mark = new AttendanceMark { SlotId = slotId, Date = date, StudentId = studentId }; + db.AttendanceMarks.Add(mark); + } + mark.Status = status; + mark.Method = method; + mark.MarkedAt = now; + mark.ExcuseReason = reason; + mark.Comment = comment; + mark.HasDocument = hasDocument; + await db.SaveChangesAsync(); + notifier.Notify(slotId, date); + return true; + } + + + public async Task SetLessonGradeAsync(int slotId, DateOnly date, int studentId, int? score) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + if (!await db.MembershipsOn(date).AnyAsync(x => x.StudentId == studentId && x.GroupId == slot.GroupId)) + throw new InvalidOperationException("Студент не принадлежит группе этой пары."); + if (score is < 1 or > 100) throw new InvalidOperationException("Балл должен быть от 1 до 100."); + var grade = await db.LessonGrades.SingleOrDefaultAsync(x => x.SlotId == slotId && x.Date == date && x.StudentId == studentId); + if (score is null) + { + if (grade is null) return; + db.LessonGrades.Remove(grade); + } + else if (grade is null) + db.LessonGrades.Add(new LessonGrade { SlotId = slotId, Date = date, StudentId = studentId, Score = score.Value }); + else + { + if (grade.Score == score) return; + grade.Score = score.Value; + grade.UpdatedAt = DateTime.Now; + } + await db.SaveChangesAsync(); + notifier.Notify(slotId, date); + } + public async Task ClearAsync(int slotId, DateOnly date, int studentId) + { + var mark = await db.AttendanceMarks.SingleOrDefaultAsync(x => x.SlotId == slotId && x.Date == date && x.StudentId == studentId); + if (mark is null) return; + db.AttendanceMarks.Remove(mark); + await db.SaveChangesAsync(); + notifier.Notify(slotId, date); + } + + public async Task MarkRestAbsentAsync(int slotId, DateOnly date) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + var marked = await db.AttendanceMarks.Where(x => x.SlotId == slotId && x.Date == date).Select(x => x.StudentId).ToListAsync(); + var missing = await db.MembershipsOn(date).Where(x => x.GroupId == slot.GroupId && !marked.Contains(x.StudentId)) + .Select(x => x.StudentId).Distinct().ToListAsync(); + var now = DateTime.Now; + db.AttendanceMarks.AddRange(missing.Select(id => new AttendanceMark { + SlotId = slotId, Date = date, StudentId = id, Status = AttendanceStatus.Absent, Method = MarkMethod.Manual, MarkedAt = now + })); + if (missing.Count > 0) { await db.SaveChangesAsync(); notifier.Notify(slotId, date); } + return missing.Count; + } + + public async Task JournalAsync(int teacherId, int groupId, DateOnly from, DateOnly to, string? subject) + { + if (to < from) throw new InvalidOperationException("Конец периода раньше начала."); + var teacher = await db.Teachers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == teacherId) ?? throw new KeyNotFoundException("Преподаватель не найден."); + var group = await db.Groups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == groupId) ?? throw new KeyNotFoundException("Группа не найдена."); + subject = string.IsNullOrWhiteSpace(subject) ? null : subject.Trim(); + var lessonTo = to < DateOnly.FromDateTime(DateTime.Now) ? to : DateOnly.FromDateTime(DateTime.Now); + var occurrences = new List(); + var firstSlotDate = await db.ScheduleSlots.AsNoTracking() + .Where(x => x.TeacherId == teacherId && x.GroupId == groupId) + .OrderBy(x => x.ValidFrom).Select(x => (DateOnly?)x.ValidFrom).FirstOrDefaultAsync(); + if (firstSlotDate is { } firstDate) + for (var day = from > firstDate ? from : firstDate; day <= lessonTo; day = day.AddDays(1)) + occurrences.AddRange((await schedule.ForDayAsync(teacherId, day)).Where(x => + !x.Cancelled && x.Slot.GroupId == groupId && + (subject is null || x.Slot.Subject == subject) && x.EndsAt <= DateTime.Now)); + var assessmentsQuery = db.Assessments.AsNoTracking() + .Where(x => x.TeacherId == teacherId && x.GroupId == groupId && x.Date >= from && x.Date <= to); + if (subject is not null) assessmentsQuery = assessmentsQuery.Where(x => x.Subject == subject); + var assessments = await assessmentsQuery.ToListAsync(); + var columns = occurrences.Select(x => new JournalColumn( + JournalColumnKind.Lesson, x.Slot.Id, x.Date, x.Slot.Start, x.Slot.Subject)) + .Concat(assessments.Select(x => new JournalColumn( + JournalColumnKind.Assessment, x.Id, x.Date, null, x.Subject, x.Title, x.Kind))) + .OrderBy(x => x.Date).ThenBy(x => x.Kind).ThenBy(x => x.Start).ThenBy(x => x.Id).ToList(); + var periods = await db.MembershipsDuring(from, to).AsNoTracking() + .Where(x => x.GroupId == groupId).ToListAsync(); + var studentIds = periods.Select(x => x.StudentId).Distinct().ToArray(); + var students = await db.Students.AsNoTracking().Where(x => studentIds.Contains(x.Id)) + .OrderBy(x => x.FullName).ToListAsync(); + var membershipMap = periods.GroupBy(x => x.StudentId).ToDictionary(x => x.Key, x => x.ToList()); + var slotIds = occurrences.Select(x => x.Slot.Id).Distinct().ToArray(); + var assessmentIds = assessments.Select(x => x.Id).ToArray(); + var marks = await db.AttendanceMarks.AsNoTracking() + .Where(x => slotIds.Contains(x.SlotId) && x.Date >= from && x.Date <= lessonTo).ToListAsync(); + var lessonGrades = await db.LessonGrades.AsNoTracking() + .Where(x => slotIds.Contains(x.SlotId) && x.Date >= from && x.Date <= lessonTo).ToListAsync(); + var assessmentGrades = await db.AssessmentGrades.AsNoTracking() + .Where(x => assessmentIds.Contains(x.AssessmentId)).ToListAsync(); + var finals = subject is null ? new List() : await db.FinalGrades.AsNoTracking() + .Where(x => x.TeacherId == teacherId && x.GroupId == groupId && x.Subject == subject).ToListAsync(); + var markMap = marks.ToDictionary(x => (x.SlotId, x.Date, x.StudentId)); + var lessonGradeMap = lessonGrades.ToDictionary(x => (x.SlotId, x.Date, x.StudentId)); + var assessmentGradeMap = assessmentGrades.ToDictionary(x => (x.AssessmentId, x.StudentId)); + var finalMap = finals.ToDictionary(x => x.StudentId, x => x.Score); + var hours = configuration.GetValue("Attendance:HoursPerLesson", 2); + var rows = students.Select(student => { + var cells = columns.Select(column => { + var inGroup = membershipMap[student.Id].Any(x => x.From <= column.Date && (x.To is null || x.To >= column.Date)); + if (!inGroup) return new JournalCell(null, null, null, false, null, false); + if (column.Kind == JournalColumnKind.Assessment) + return new JournalCell(null, null, null, false, + assessmentGradeMap.TryGetValue((column.Id, student.Id), out var assessmentGrade) ? assessmentGrade.Score : null); + markMap.TryGetValue((column.Id, column.Date, student.Id), out var mark); + return new JournalCell(mark?.Status, mark?.ExcuseReason, mark?.Comment, mark?.HasDocument ?? false, + lessonGradeMap.TryGetValue((column.Id, column.Date, student.Id), out var lessonGrade) ? lessonGrade.Score : null); + }).ToList(); + var lessonCount = cells.Where((cell, index) => cell.InGroup && columns[index].Kind == JournalColumnKind.Lesson).Count(); + var present = cells.Count(x => x.InGroup && x.Status == AttendanceStatus.Present); + var absent = cells.Count(x => x.InGroup && x.Status == AttendanceStatus.Absent); + var excused = cells.Count(x => x.InGroup && x.Status == AttendanceStatus.Excused); + var scores = cells.Where(x => x.InGroup && x.Score.HasValue).Select(x => x.Score!.Value).ToList(); + return new JournalStudent(student.Id, student.FullName, cells, present, absent, excused, + (absent + excused) * hours, excused * hours, + lessonCount == 0 ? 0 : 100d * present / lessonCount, + scores.Count == 0 ? null : Math.Round(scores.Average(), 1), + finalMap.TryGetValue(student.Id, out var final) ? final : null); + }).ToList(); + return new JournalMatrix(teacher.FullName, group.Name, subject, from, to, hours, columns, rows); + } +} diff --git a/BiStudent.Server/Services/BackupService.cs b/BiStudent.Server/Services/BackupService.cs new file mode 100644 index 0000000..4bc0648 --- /dev/null +++ b/BiStudent.Server/Services/BackupService.cs @@ -0,0 +1,402 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.RegularExpressions; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class BackupService(StoragePaths paths, IServiceScopeFactory scopes, RecognitionService recognition, SessionGeneration sessions, ILogger logger) +{ + private static readonly Regex BackupName = new(@"\Abistudent-[0-9]{8}-[0-9]{6}(-auto)?(-[0-9]+)?\.zip\z", RegexOptions.CultureInvariant); + private readonly SemaphoreSlim gate = new(1, 1); + private static readonly Regex KeyName = new(@"\Akeys/key-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.xml\z", RegexOptions.CultureInvariant); + private const long MaxZipSize = 200L * 1024 * 1024; + private const long MaxDatabaseSize = 1024L * 1024 * 1024; + private const long MaxManifestSize = 64 * 1024; + private const long MaxKeySize = 64 * 1024; + private const int MaxEntries = 64; + private int maintenance; + private int inflight; + + public async Task CreateAsync(bool automatic, CancellationToken ct = default) + { + await gate.WaitAsync(ct); + try { return await CreateLockedAsync(automatic, ct); } + finally { gate.Release(); } + } + + private async Task CreateLockedAsync(bool automatic, CancellationToken ct) + { + Directory.CreateDirectory(paths.BackupsDirectory); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var stem = $"bistudent-{stamp}{(automatic ? "-auto" : "")}"; + var name = $"{stem}.zip"; + for (var suffix = 2; File.Exists(Path.Combine(paths.BackupsDirectory, name)); suffix++) name = $"{stem}-{suffix}.zip"; + var target = Path.Combine(paths.BackupsDirectory, name); + var snapshot = Path.Combine(paths.BackupsDirectory, $"{Guid.NewGuid():N}.db.tmp"); + var temporary = target + ".tmp"; + try + { + using (var source = new SqliteConnection(paths.ConnectionString)) + { + await source.OpenAsync(ct); + using var command = source.CreateCommand(); + command.CommandText = $"VACUUM INTO '{snapshot.Replace("'", "''")}'"; + await command.ExecuteNonQueryAsync(ct); + } + var (students, marks) = await CountsAsync(snapshot, ct); + var keyFiles = Directory.Exists(paths.KeysDirectory) + ? Directory.EnumerateFiles(paths.KeysDirectory, "*.xml").Where(key => KeyName.IsMatch($"keys/{Path.GetFileName(key)}")).ToList() + : []; + if (new FileInfo(snapshot).Length > MaxDatabaseSize || keyFiles.Count + 2 > MaxEntries || + keyFiles.Any(key => new FileInfo(key).Length > MaxKeySize)) + throw new InvalidOperationException("Данные не помещаются в формат резервной копии (база больше 1 ГБ или слишком много ключей)."); + using (var stream = File.Create(temporary)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(snapshot, "bistudent.db"); + foreach (var key in keyFiles) + archive.CreateEntryFromFile(key, $"keys/{Path.GetFileName(key)}"); + using var manifest = archive.CreateEntry("manifest.json").Open(); + await JsonSerializer.SerializeAsync(manifest, new { app = "BiStudent", format = 1, + createdAt = DateTime.UtcNow.ToString("O"), students, marks }, cancellationToken: ct); + } + if (new FileInfo(temporary).Length > MaxZipSize) + throw new InvalidOperationException("Копия получилась больше 200 МБ — её нельзя будет восстановить через приложение."); + File.Move(temporary, target); + var info = new FileInfo(target); + logger.LogInformation("Создана резервная копия {Name}.", name); + return new BackupFileDto(name, info.LastWriteTime, info.Length, automatic); + } + finally + { + TryDelete(snapshot); + TryDelete(temporary); + } + } + + private void TryDelete(string path) + { + try { File.Delete(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogWarning(ex, "Не удалось удалить временный файл {Path}.", path); } + } + + public IReadOnlyList List() + { + if (!Directory.Exists(paths.BackupsDirectory)) return []; + var result = new List(); + foreach (var path in Directory.EnumerateFiles(paths.BackupsDirectory, "bistudent-*.zip")) + { + try + { + var info = new FileInfo(path); + if (BackupName.IsMatch(info.Name)) + result.Add(new BackupFileDto(info.Name, info.LastWriteTime, info.Length, info.Name.Contains("-auto", StringComparison.Ordinal))); + } + catch (FileNotFoundException) { } + } + return result.OrderByDescending(x => x.CreatedAt).ToList(); + } + + public string? Resolve(string name) + { + if (!BackupName.IsMatch(name)) return null; + var path = Path.Combine(paths.BackupsDirectory, name); + return File.Exists(path) ? path : null; + } + + public void Prune(int keepAutomatic = 7) + { + gate.Wait(); + try { PruneLocked(keepAutomatic); } + finally { gate.Release(); } + } + + private void PruneLocked(int keepAutomatic) + { + foreach (var file in List().Where(x => x.Automatic).Skip(Math.Max(0, keepAutomatic))) + File.Delete(Path.Combine(paths.BackupsDirectory, file.Name)); + } + + public async Task AutoBackupIfDueAsync(CancellationToken ct) + { + await gate.WaitAsync(ct); + try + { + if (Directory.Exists(paths.BackupsDirectory)) + foreach (var stale in Directory.EnumerateFiles(paths.BackupsDirectory, "*.tmp")) + if (File.GetLastWriteTimeUtc(stale) < DateTime.UtcNow.AddHours(-1)) TryDelete(stale); + var latest = List().FirstOrDefault(x => x.Automatic); + if (latest is not null && DateTime.Now - latest.CreatedAt < TimeSpan.FromHours(24)) return; + if (!File.Exists(paths.DatabasePath)) return; + using (var scope = scopes.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Students.AnyAsync(ct)) return; + } + await CreateLockedAsync(true, ct); + PruneLocked(7); + } + finally { gate.Release(); } + } + + public async Task RestoreAsync(Stream zip, CancellationToken ct = default, Func? stillAuthorized = null) + { + await gate.WaitAsync(ct); + try + { + if (stillAuthorized?.Invoke() == false) throw new UnauthorizedAccessException("Сессия завершена — войдите снова."); + return await RestoreLockedAsync(zip, ct); + } + finally { gate.Release(); } + } + + /// Обычные API-запросы регистрируются здесь, чтобы восстановление дождалось их и не потеряло изменения. + public bool TryEnterRequest() + { + Interlocked.Increment(ref inflight); + if (Volatile.Read(ref maintenance) == 0) return true; + Interlocked.Decrement(ref inflight); + return false; + } + + public void ExitRequest() => Interlocked.Decrement(ref inflight); + + private async Task EnterMaintenanceAsync(CancellationToken ct) + { + Volatile.Write(ref maintenance, 1); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (Volatile.Read(ref inflight) > 0) + { + if (DateTime.UtcNow > deadline) + { + Volatile.Write(ref maintenance, 0); + throw new InvalidOperationException("Сервер занят другими запросами — повторите восстановление через минуту."); + } + await Task.Delay(50, ct); + } + } + + private async Task RestoreLockedAsync(Stream zip, CancellationToken ct) + { + Directory.CreateDirectory(paths.BackupsDirectory); + var work = Path.Combine(paths.BackupsDirectory, $"{Guid.NewGuid():N}.restore.tmp"); + Directory.CreateDirectory(work); + var upload = Path.Combine(work, "upload.zip"); + var extracted = Path.Combine(work, "bistudent.db"); + var keysDirectory = Path.Combine(work, "keys"); + var replaced = false; + try + { + await using (var output = File.Create(upload)) + await CopyLimitedAsync(zip, output, MaxZipSize, "Файл копии больше 200 МБ.", ct); + var keys = new List(); + int students, marks; + try + { + await using var archiveStream = File.OpenRead(upload); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true); + if (archive.Entries.Count > MaxEntries) throw InvalidBackup(); + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (entry.FullName is not ("manifest.json" or "bistudent.db") && !KeyName.IsMatch(entry.FullName)) + throw InvalidBackup(); + if (!entries.TryAdd(entry.FullName, entry)) throw InvalidBackup(); + } + if (!entries.TryGetValue("manifest.json", out var manifest) || + !entries.TryGetValue("bistudent.db", out var database)) throw InvalidBackup(); + using (var manifestBytes = new MemoryStream()) + { + await using (var input = manifest.Open()) + await CopyLimitedAsync(input, manifestBytes, MaxManifestSize, null, ct); + try + { + using var json = JsonDocument.Parse(manifestBytes.ToArray()); + if (json.RootElement.ValueKind != JsonValueKind.Object || + !json.RootElement.TryGetProperty("app", out var app) || app.ValueKind != JsonValueKind.String || + app.GetString() != "BiStudent" || + !json.RootElement.TryGetProperty("format", out var format) || format.ValueKind != JsonValueKind.Number || + !format.TryGetInt32(out var version) || version != 1) throw InvalidBackup(); + } + catch (JsonException) { throw InvalidBackup(); } + } + await using (var output = File.Create(extracted)) + await using (var input = database.Open()) + await CopyLimitedAsync(input, output, MaxDatabaseSize, null, ct); + Directory.CreateDirectory(keysDirectory); + foreach (var entry in entries.Values.Where(x => KeyName.IsMatch(x.FullName))) + { + var staged = Path.Combine(keysDirectory, entry.Name); + await using (var output = File.Create(staged)) + await using (var input = entry.Open()) + await CopyLimitedAsync(input, output, MaxKeySize, null, ct); + keys.Add(staged); + } + (students, marks) = await ValidateAsync(extracted, ct); + } + catch (InvalidDataException) { throw InvalidBackup(); } + + await EnterMaintenanceAsync(ct); + var insurance = await CreateLockedAsync(false, ct); + Directory.CreateDirectory(paths.KeysDirectory); + var restart = false; + foreach (var staged in keys) + { + var target = Path.Combine(paths.KeysDirectory, Path.GetFileName(staged)); + if (File.Exists(target) && File.ReadAllBytes(target).AsSpan().SequenceEqual(File.ReadAllBytes(staged))) continue; + var temporary = target + ".tmp"; + File.Copy(staged, temporary, overwrite: true); + File.Move(temporary, target, overwrite: true); + restart = true; + } + sessions.Rotate(); + replaced = true; + using (var source = new SqliteConnection(ReadOnlyConnection(extracted))) + using (var destination = new SqliteConnection(paths.ConnectionString)) + { + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + } + SqliteConnection.ClearAllPools(); + var message = $"Восстановлено: {students} студентов, {marks} отметок. Текущие данные сохранены в копии {insurance.Name}. Все пользователи должны войти заново." + + (restart ? " Перезапустите сервер, чтобы образцы лиц из копии расшифровались." : ""); + logger.LogInformation("Восстановлена резервная копия; страховочная копия {Name}.", insurance.Name); + return new RestoreResultDto(true, message, students, marks, restart); + } + finally + { + if (replaced) recognition.Invalidate(); + Volatile.Write(ref maintenance, 0); + try { Directory.Delete(work, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogWarning(ex, "Не удалось удалить временную папку {Path}.", work); } + } + } + + private static async Task CopyLimitedAsync(Stream input, Stream output, long limit, string? message, CancellationToken ct) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await input.ReadAsync(buffer, ct)) != 0) + { + total += read; + if (total > limit) throw message is null ? InvalidBackup() : new InvalidOperationException(message); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + } + + private static InvalidOperationException InvalidBackup() => new("Это не резервная копия BiStudent."); + private static string ReadOnlyConnection(string path) => new SqliteConnectionStringBuilder + { DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = false }.ToString(); + + private static async Task<(int Students, int Marks)> CountsAsync(string path, CancellationToken ct) + { + using var connection = new SqliteConnection(ReadOnlyConnection(path)); + await connection.OpenAsync(ct); + return (await CountAsync(connection, "Students", ct), await CountAsync(connection, "AttendanceMarks", ct)); + } + + private async Task<(int Students, int Marks)> ValidateAsync(string path, CancellationToken ct) + { + using var connection = new SqliteConnection(ReadOnlyConnection(path)); + await connection.OpenAsync(ct); + using var integrity = connection.CreateCommand(); + integrity.CommandText = "PRAGMA integrity_check"; + if ((string?)await integrity.ExecuteScalarAsync(ct) != "ok") throw InvalidBackup(); + connection.Close(); + using (var writable = new SqliteConnection(new SqliteConnectionStringBuilder + { DataSource = path, Pooling = false }.ToString())) + { + var options = new DbContextOptionsBuilder().UseSqlite(writable).Options; + using var db = new AppDbContext(options); + try { await DatabaseInitializer.InitializeAsync(db, ct); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException("Копия от несовместимой версии BiStudent: не удалось обновить структуру базы.", ex); + } + } + await connection.OpenAsync(ct); + var expected = await SchemaAsync(ReferenceConnection(), ct); + var actual = await SchemaAsync(connection, ct); + foreach (var (table, definition) in expected) + if (!actual.TryGetValue(table, out var found) || found != definition) + throw new InvalidOperationException($"Копия от несовместимой версии BiStudent: отличается таблица {table}."); + using var foreignKeys = connection.CreateCommand(); + foreignKeys.CommandText = "PRAGMA foreign_key_check"; + await using (var reader = await foreignKeys.ExecuteReaderAsync(ct)) + if (await reader.ReadAsync(ct)) throw new InvalidOperationException("В копии нарушены связи между таблицами."); + return (await CountAsync(connection, "Students", ct), await CountAsync(connection, "AttendanceMarks", ct)); + } + + private SqliteConnection ReferenceConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + using var db = new AppDbContext(options); + DatabaseInitializer.Initialize(db); + return connection; + } + + /// Описание схемы для сравнения: столбцы (имя, тип, NOT NULL, PK) и внешние ключи каждой таблицы. + private static async Task> SchemaAsync(SqliteConnection connection, CancellationToken ct) + { + var tables = new List(); + using (var list = connection.CreateCommand()) + { + list.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; + await using var reader = await list.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) tables.Add(reader.GetString(0)); + } + var result = new Dictionary(StringComparer.Ordinal); + foreach (var table in tables) + { + var parts = new List(); + using (var columns = connection.CreateCommand()) + { + columns.CommandText = "SELECT name, upper(type), \"notnull\", pk FROM pragma_table_info($table) ORDER BY name"; + columns.Parameters.AddWithValue("$table", table); + await using var reader = await columns.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + parts.Add($"c:{reader.GetString(0)}:{reader.GetString(1)}:{reader.GetInt64(2)}:{reader.GetInt64(3)}"); + } + using (var keys = connection.CreateCommand()) + { + keys.CommandText = "SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list($table) ORDER BY \"from\""; + keys.Parameters.AddWithValue("$table", table); + await using var reader = await keys.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + parts.Add($"f:{reader.GetString(0)}:{reader.GetString(1)}:{(reader.IsDBNull(2) ? "" : reader.GetString(2))}:{reader.GetString(3)}"); + } + result[table] = string.Join("|", parts); + } + return result; + } + + private static async Task CountAsync(SqliteConnection connection, string table, CancellationToken ct) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table}\""; + return Convert.ToInt32(await command.ExecuteScalarAsync(ct)); + } + + public sealed class AutoBackupWorker(BackupService backups, ILogger logger) : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + while (!stoppingToken.IsCancellationRequested) + { + try { await backups.AutoBackupIfDueAsync(stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogError(ex, "Не удалось создать автоматическую резервную копию."); } + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + } +} diff --git a/BiStudent.Server/Services/CollegeScheduleParser.cs b/BiStudent.Server/Services/CollegeScheduleParser.cs new file mode 100644 index 0000000..7b6910b --- /dev/null +++ b/BiStudent.Server/Services/CollegeScheduleParser.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using ClosedXML.Excel; + +namespace BiStudent.Server.Services; + +/// Одно занятие из файла: либо еженедельное в диапазоне недель (FromWeek–ToWeek), либо разовое (FromWeek == ToWeek, Once). +public sealed record ParsedLesson(string Sheet, string Group, DayOfWeek Day, TimeOnly Start, TimeOnly End, + string Subject, string? Room, string Teacher, int FromWeek, int ToWeek, bool Once); + +public sealed record ParsedSchedule(DateOnly SemesterStart, List Lessons, List<(string Sheet, string Group)> Groups, List Warnings); + +/// Разбор расписания колледжа в формате «блок из пяти колонок на группу»: +/// день · время звонка · № пары · аудитория · группа. В ячейке группы — строки «[ТИП] Предмет · аудитория · нед. 2-4, 8 (разово)», +/// строкой ниже — преподаватели в том же порядке («Фамилия И.О. · аудитория»). +public static partial class CollegeScheduleParser +{ + public const string Vacancy = "Вакансия"; + + public static ParsedSchedule Parse(Stream xlsx, DateOnly fallbackSemesterStart) + { + using var workbook = new XLWorkbook(xlsx); + var lessons = new List(); + var groups = new List<(string, string)>(); + var warnings = new List(); + DateOnly? semester = null; + foreach (var sheet in workbook.Worksheets) + { + var used = sheet.RangeUsed(); + if (used is null) continue; + var lastRow = used.LastRow().RowNumber(); + var lastColumn = used.LastColumn().ColumnNumber(); + semester ??= FindSemesterStart(sheet, Math.Min(lastRow, 10), lastColumn); + var header = FindHeaderRow(sheet, Math.Min(lastRow, 30), lastColumn); + if (header is not int headerRow) continue; + for (var column = 5; column <= lastColumn; column++) + { + var name = Text(sheet.Cell(headerRow, column)); + if (!GroupName().IsMatch(name)) continue; + groups.Add((sheet.Name, name)); + ParseGroup(sheet, name, headerRow, column, lastRow, lessons, warnings); + } + } + return new ParsedSchedule(semester ?? fallbackSemesterStart, UnifyTeachers(lessons), groups, warnings); + } + + private static void ParseGroup(IXLWorksheet sheet, string group, int headerRow, int column, int lastRow, + List lessons, List warnings) + { + int dayColumn = column - 4, timeColumn = column - 3; + for (var row = headerRow + 1; row <= lastRow; row++) + { + var timeCell = sheet.Cell(row, timeColumn); + var merged = timeCell.MergedRange(); + // Пара занимает две строки (предмет и преподаватель); берём первую строку объединённой ячейки времени. + if (merged is not null && merged.FirstRow().RowNumber() != row) continue; + var time = Text(merged?.FirstCell() ?? timeCell); + var times = TimePattern().Matches(time); + if (times.Count < 2) continue; + var dayCell = sheet.Cell(row, dayColumn); + var day = ParseDay(Text(dayCell.MergedRange()?.FirstCell() ?? dayCell)); + if (day is not DayOfWeek weekday) continue; + var start = TimeOnly.ParseExact(times[0].Value, ["H:mm", "HH:mm"], CultureInfo.InvariantCulture); + var end = TimeOnly.ParseExact(times[1].Value, ["H:mm", "HH:mm"], CultureInfo.InvariantCulture); + var subjectCell = sheet.Cell(row, column); + var subjectText = Text(subjectCell); + if (subjectText.Length == 0) continue; + var teacherRow = merged?.LastRow().RowNumber() is int last && last > row ? last : row + 1; + var teachers = SplitEntries(Text(sheet.Cell(teacherRow, column))).Select(TeacherName).ToList(); + var entries = SplitEntries(subjectText); + for (var i = 0; i < entries.Count; i++) + { + var teacher = teachers.Count == 0 ? Vacancy : teachers[Math.Min(i, teachers.Count - 1)]; + if (!TryParseEntry(entries[i], out var subject, out var room, out var weeks)) + { + warnings.Add($"{sheet.Name}, {group}, {DayName(weekday)} {start:HH\\:mm}: не удалось разобрать «{entries[i]}»."); + continue; + } + foreach (var (from, to, once) in weeks) + lessons.Add(new ParsedLesson(sheet.Name, group, weekday, start, end, subject, room, teacher, from, to, once)); + } + } + } + + /// Строка ячейки может содержать несколько записей: по строкам, через два и более пробела или перед новым «[ТИП]». + public static List SplitEntries(string text) + { + var result = new List(); + foreach (var line in text.Split('\n')) + { + var chunks = EntrySeparator().Split(line).Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); + for (var i = 0; i < chunks.Count; i++) + { + // Двойной пробел внутри текста («экономики и предпринимательской») — не новая запись: продолжение начинается со строчной буквы. + if (i > 0 && (char.IsLower(chunks[i][0]) || chunks[i][0] == ')')) result[^1] += " " + chunks[i]; + else result.Add(chunks[i]); + } + } + return result; + } + + public static bool TryParseEntry(string entry, out string subject, out string? room, out List<(int From, int To, bool Once)> weeks) + { + subject = ""; room = null; weeks = []; + var tag = TagPattern().Match(entry); + var kind = tag.Success ? tag.Groups[1].Value.Trim().ToUpperInvariant() : ""; + var rest = tag.Success ? entry[tag.Length..].Trim() : entry.Trim(); + var weeksMatch = WeeksPattern().Match(rest); + if (!weeksMatch.Success) weeksMatch = TrailingWeeksPattern().Match(rest); + if (!weeksMatch.Success) return false; + weeks = ParseWeeks(weeksMatch.Groups["weeks"].Value); + if (weeks.Count == 0) return false; + var head = rest[..weeksMatch.Index].Trim().TrimEnd('·', '-', ',', ' '); + var parts = head.Split('·').Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); + if (parts.Count == 1 && DashRoom().Match(parts[0]) is { Success: true } dashed) + parts = [dashed.Groups["subject"].Value.Trim(), dashed.Groups["room"].Value.Trim()]; + parts.RemoveAll(x => x.StartsWith("с ", StringComparison.OrdinalIgnoreCase) && GroupName().IsMatch(x[2..].Trim())); + if (kind == "СРОП" && parts.Count > 0 && parts[0].Equals("СРОП", StringComparison.OrdinalIgnoreCase)) parts.RemoveAt(0); + if (parts.Count == 0) return false; + subject = Regex.Replace(parts[0], @"\s+", " "); + room = parts.Count > 1 ? Room(parts[1]) : null; + return subject.Length > 0; + } + + public static List<(int From, int To, bool Once)> ParseWeeks(string text) + { + var result = new List<(int, int, bool)>(); + foreach (var raw in text.Split(',')) + { + var token = raw.Trim().Replace('–', '-').Replace('—', '-'); + if (token.Length == 0) continue; + var once = token.Contains("разово", StringComparison.OrdinalIgnoreCase); + var numbers = new List(); + foreach (Match match in Regex.Matches(token, @"\d+")) + { + if (!int.TryParse(match.Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + { + numbers.Clear(); + break; + } + numbers.Add(n); + } + if (numbers.Count == 0 || numbers.Any(x => x is < 1 or > 60)) continue; + var from = numbers[0]; + var to = numbers.Count > 1 && token.Contains('-') ? numbers[1] : from; + if (to < from) (from, to) = (to, from); + result.Add((from, to, once || from == to)); + } + return result; + } + + public static string TeacherName(string line) + { + var name = line.Split('·')[0].Trim(); + if (name.Length == 0 || name.StartsWith("вакансия", StringComparison.OrdinalIgnoreCase)) return Vacancy; + name = TeacherRoomSuffix().Replace(name, ""); + return Regex.Replace(name, @"\s+", " ").Trim(); + } + + /// «Ниязова» и «Ниязова М.Е.» — один человек, если полная запись с этой фамилией единственная. + private static List UnifyTeachers(List lessons) + { + var full = lessons.Select(x => x.Teacher).Where(x => x != Vacancy && x.Contains(' ')).Distinct().ToList(); + return lessons.Select(lesson => + { + if (lesson.Teacher == Vacancy || lesson.Teacher.Contains(' ')) return lesson; + var matches = full.Where(x => x.Split(' ')[0].Equals(lesson.Teacher, StringComparison.OrdinalIgnoreCase)).ToList(); + return matches.Count == 1 ? lesson with { Teacher = matches[0] } : lesson; + }).ToList(); + } + + private static string? Room(string value) + { + var room = value.Trim().TrimEnd('-').Trim(); + return room.Length == 0 || room.StartsWith("уточн", StringComparison.OrdinalIgnoreCase) ? null : room; + } + + private static int? FindHeaderRow(IXLWorksheet sheet, int lastRow, int lastColumn) + { + for (var row = 1; row <= lastRow; row++) + for (var column = 5; column <= lastColumn; column++) + if (GroupName().IsMatch(Text(sheet.Cell(row, column))) && + Text(sheet.Cell(row, column - 4)).Contains("Дни", StringComparison.OrdinalIgnoreCase)) + return row; + return null; + } + + private static DateOnly? FindSemesterStart(IXLWorksheet sheet, int lastRow, int lastColumn) + { + for (var row = 1; row <= lastRow; row++) + for (var column = 1; column <= Math.Min(lastColumn, 5); column++) + if (SemesterPattern().Match(Text(sheet.Cell(row, column))) is { Success: true } match && + DateOnly.TryParseExact(match.Groups[1].Value, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + return null; + } + + private static DayOfWeek? ParseDay(string text) + { + var value = text.ToLowerInvariant(); + if (value.Contains("понедельник")) return DayOfWeek.Monday; + if (value.Contains("вторник")) return DayOfWeek.Tuesday; + if (value.Contains("среда")) return DayOfWeek.Wednesday; + if (value.Contains("четверг")) return DayOfWeek.Thursday; + if (value.Contains("пятница")) return DayOfWeek.Friday; + if (value.Contains("суббота")) return DayOfWeek.Saturday; + if (value.Contains("воскресенье")) return DayOfWeek.Sunday; + return null; + } + + public static string DayName(DayOfWeek day) => CultureInfo.GetCultureInfo("ru-RU").DateTimeFormat.GetAbbreviatedDayName(day); + + private static string Text(IXLCell cell) => cell.GetFormattedString().Replace("\r", "").Trim(); + + [GeneratedRegex(@"^\d{2}-[А-ЯЁA-Z]{1,4}-\d{2,4}-\S+$")] + private static partial Regex GroupName(); + + [GeneratedRegex(@"\b\d{1,2}:\d{2}\b")] + private static partial Regex TimePattern(); + + [GeneratedRegex(@"^\[([^\]]+)\]")] + private static partial Regex TagPattern(); + + [GeneratedRegex(@"нед\.?\s*,?\s*(?\d[\d\s,\-–—()а-яё]*)$", RegexOptions.IgnoreCase)] + private static partial Regex WeeksPattern(); + + [GeneratedRegex(@"\s-\s*(?\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*)$")] + private static partial Regex TrailingWeeksPattern(); + + [GeneratedRegex(@"^(?.+?)\s+-\s*(?[^\s-][^-]*)$")] + private static partial Regex DashRoom(); + + [GeneratedRegex(@"\s{2,}|(?<=\S)\s+(?=\[[А-ЯЁA-Z]+\])")] + private static partial Regex EntrySeparator(); + + [GeneratedRegex(@"\s*-\s*(?:уточн\S*|[A-Za-zА-ЯЁа-яё]?\d+[A-Za-zА-ЯЁа-яё]?)$", RegexOptions.IgnoreCase)] + private static partial Regex TeacherRoomSuffix(); + + [GeneratedRegex(@"недели\s*\d+\s*-\s*\d+\s*,\s*(\d{2}\.\d{2}\.\d{4})", RegexOptions.IgnoreCase)] + private static partial Regex SemesterPattern(); +} diff --git a/BiStudent.Server/Services/EnrollmentService.cs b/BiStudent.Server/Services/EnrollmentService.cs new file mode 100644 index 0000000..523dfa1 --- /dev/null +++ b/BiStudent.Server/Services/EnrollmentService.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class EnrollmentService(AppDbContext db, RecognitionService recognition, IConfiguration configuration) +{ + private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private const int MaximumImageSize = 3 * 1024 * 1024; + private static string Normalize(string? input) => new((input ?? "").ToUpperInvariant().Where(x => !char.IsWhiteSpace(x) && x != '-').ToArray()); + private static string Format(string code) => $"{code[..4]}-{code[4..]}"; + + public Task GetActiveAsync(int groupId) + { + var now = DateTime.Now; + return db.EnrollmentCodes.AsNoTracking().Include(x => x.Group) + .Where(x => x.GroupId == groupId && x.RevokedAt == null && x.ExpiresAt > now) + .OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(); + } + + public async Task CreateAsync(int teacherId, int groupId, int hours) + { + if (hours is < 1 or > 168) throw new InvalidOperationException("Срок действия кода — от 1 часа до 7 дней."); + var group = await db.Groups.SingleOrDefaultAsync(x => x.Id == groupId) + ?? throw new KeyNotFoundException("Группа не найдена."); + var now = DateTime.Now; + var active = await db.EnrollmentCodes.Where(x => x.GroupId == groupId && x.RevokedAt == null && x.ExpiresAt > now).ToListAsync(); + foreach (var old in active) old.RevokedAt = now; + string code; + do { code = RandomNumberGenerator.GetString(Alphabet, 8); } + while (await db.EnrollmentCodes.AnyAsync(x => x.Code == code)); + var result = new EnrollmentCode { + GroupId = groupId, Group = group, Code = code, CreatedByTeacherId = teacherId, + CreatedAt = now, ExpiresAt = now.AddHours(hours) + }; + db.EnrollmentCodes.Add(result); + await db.SaveChangesAsync(); + return result; + } + + public async Task RevokeAsync(int groupId) + { + var now = DateTime.Now; + var active = await db.EnrollmentCodes.Where(x => x.GroupId == groupId && x.RevokedAt == null && x.ExpiresAt > now).ToListAsync(); + foreach (var code in active) code.RevokedAt = now; + await db.SaveChangesAsync(); + } + + public List Links(HttpContext http) + { + var configured = configuration["Enrollment:PublicUrl"]; + if (!string.IsNullOrWhiteSpace(configured)) return [configured.Trim().TrimEnd('/') + "/register/"]; + var links = new List(); + void Add(string link) { if (!links.Contains(link, StringComparer.OrdinalIgnoreCase)) links.Add(link); } + var host = http.Request.Host; + var localLinks = new List(); + var otherLinks = new List(); + if (host.Host is not ("localhost" or "127.0.0.1")) + Add($"{http.Request.Scheme}://{host}/register/"); + var port = http.Connection.LocalPort; + if (port == 0) port = host.Port ?? 80; + foreach (var network in NetworkInterface.GetAllNetworkInterfaces()) + { + if (network.OperationalStatus != OperationalStatus.Up || + network.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) continue; + foreach (var address in network.GetIPProperties().UnicastAddresses) + { + if (address.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork || + address.Address.ToString().StartsWith("169.254.", StringComparison.Ordinal)) continue; + var bytes = address.Address.GetAddressBytes(); + var local = bytes[0] == 10 || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || + (bytes[0] == 192 && bytes[1] == 168); + var link = $"http://{address.Address}:{port}/register/"; + if (local) localLinks.Add(link); + else otherLinks.Add(link); + } + } + foreach (var link in localLinks.Concat(otherLinks)) Add(link); + if (links.Count == 0) Add($"{http.Request.Scheme}://{host}/register/"); + return links; + } + + public EnrollmentCodeDto ToDto(EnrollmentCode code, HttpContext http) => + new(code.GroupId, code.Group.Name, Format(code.Code), code.CreatedAt, code.ExpiresAt, Links(http)); + + private async Task ValidCodeAsync(string? input) + { + var code = Normalize(input); + var now = DateTime.Now; + return await db.EnrollmentCodes.AsNoTracking() + .SingleOrDefaultAsync(x => x.Code == code && x.RevokedAt == null && x.ExpiresAt > now) + ?? throw new KeyNotFoundException("Код недействителен или срок его действия истёк. Попросите у преподавателя новый код."); + } + + public async Task CheckAsync(string code) + { + var enrollment = await ValidCodeAsync(code); + var group = await db.Groups.AsNoTracking().SingleAsync(x => x.Id == enrollment.GroupId); + var students = await db.Students.AsNoTracking().Where(x => x.GroupId == group.Id).OrderBy(x => x.FullName).ToListAsync(); + var registered = (await db.FaceSamples.AsNoTracking().Where(x => x.Student.GroupId == group.Id) + .Select(x => x.StudentId).Distinct().ToListAsync()).ToHashSet(); + var pending = (await db.FaceSubmissions.AsNoTracking().Where(x => x.Student.GroupId == group.Id && x.Status == FaceSubmissionStatus.Pending) + .Select(x => x.StudentId).Distinct().ToListAsync()).ToHashSet(); + return new PublicGroupDto(group.Name, enrollment.ExpiresAt, students.Select(x => new PublicStudentDto(x.Id, x.FullName, + registered.Contains(x.Id) ? PublicFaceState.Registered : pending.Contains(x.Id) ? PublicFaceState.Pending : PublicFaceState.None)).ToList()); + } + + public async Task SubmitAsync(PublicSubmitRequest request) + { + var code = await ValidCodeAsync(request.Code); + if (!await db.Students.AnyAsync(x => x.Id == request.StudentId && x.GroupId == code.GroupId)) + throw new InvalidOperationException("Студент не найден в группе."); + if (!request.Consent) throw new InvalidOperationException("Нужно согласие на обработку биометрических данных."); + if (request.Images is null || request.Images.Count != 3) throw new InvalidOperationException("Нужно ровно три снимка."); + var jpegs = new List(3); + foreach (var image in request.Images) + { + byte[] bytes; + try + { + if (string.IsNullOrEmpty(image)) throw new InvalidOperationException("Не удалось прочитать снимок."); + var source = image.StartsWith("data:image/jpeg;base64,", StringComparison.OrdinalIgnoreCase) ? image[23..] : image; + bytes = Convert.FromBase64String(source); + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + throw new InvalidOperationException("Не удалось прочитать снимок."); + } + if (bytes.Length > MaximumImageSize) throw new InvalidOperationException("Снимок слишком большой."); + jpegs.Add(bytes); + } + var check = await recognition.CheckSubmissionAsync(request.StudentId, jpegs); + if (!check.Ok) return new PublicSubmitResultDto(false, check.Message, check.Problems); + await recognition.RunExclusiveAsync(async () => + { + var pending = await db.FaceSubmissions.Include(x => x.Images) + .Where(x => x.StudentId == request.StudentId && x.Status == FaceSubmissionStatus.Pending).ToListAsync(); + foreach (var old in pending) + { + old.Status = FaceSubmissionStatus.Superseded; + db.FaceSubmissionImages.RemoveRange(old.Images); + } + db.FaceSubmissions.Add(new FaceSubmission { + StudentId = request.StudentId, EnrollmentCodeId = code.Id, Status = FaceSubmissionStatus.Pending, + SimilarTo = check.SimilarTo, SimilarScore = check.SimilarScore, + Images = check.Crops.Select(crop => new FaceSubmissionImage { ProtectedImage = recognition.ProtectImage(crop) }).ToList() + }); + await db.SaveChangesAsync(); + }); + return new PublicSubmitResultDto(true, "Снимки отправлены. Преподаватель проверит их и подтвердит регистрацию."); + } + + public Task> ListAsync(FaceSubmissionStatus? status) + { + var selected = status ?? FaceSubmissionStatus.Pending; + return db.FaceSubmissions.AsNoTracking().Where(x => x.Status == selected).OrderByDescending(x => x.CreatedAt) + .Select(x => new FaceSubmissionDto(x.Id, x.StudentId, x.Student.FullName, x.Student.GroupId, x.Student.Group.Name, + x.CreatedAt, x.Status, x.Images.Count, db.FaceSamples.Any(sample => sample.StudentId == x.StudentId), + x.SimilarTo, x.SimilarScore, x.RejectReason)).ToListAsync(); + } + + public async Task ImageAsync(int id, int index) + { + if (index is < 0 or > 2) throw new KeyNotFoundException("Снимок не найден."); + var image = await db.FaceSubmissionImages.AsNoTracking().Where(x => x.SubmissionId == id) + .OrderBy(x => x.Id).Skip(index).FirstOrDefaultAsync() + ?? throw new KeyNotFoundException("Снимок не найден."); + return recognition.UnprotectImage(image.ProtectedImage); + } + + public async Task ApproveAsync(int teacherId, int id) + { + await recognition.RunExclusiveAsync(async () => + { + await using var transaction = await db.Database.BeginTransactionAsync(); + var submission = await db.FaceSubmissions.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException("Заявка не найдена."); + if (submission.Status != FaceSubmissionStatus.Pending) throw new InvalidOperationException("Заявка уже рассмотрена."); + var reviewedAt = DateTime.Now; + var updated = await db.FaceSubmissions.Where(x => x.Id == id && x.Status == FaceSubmissionStatus.Pending) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, FaceSubmissionStatus.Approved) + .SetProperty(x => x.ReviewedAt, reviewedAt) + .SetProperty(x => x.ReviewedByTeacherId, teacherId)); + if (updated != 1) throw new InvalidOperationException("Заявка уже рассмотрена."); + var images = await db.FaceSubmissionImages.Where(x => x.SubmissionId == id).OrderBy(x => x.Id).ToListAsync(); + var crops = images.Select(x => recognition.UnprotectImage(x.ProtectedImage)).ToList(); + var old = await db.FaceSamples.Where(x => x.StudentId == submission.StudentId).ToListAsync(); + db.FaceSamples.RemoveRange(old); + db.FaceSamples.AddRange(crops.Select(crop => new FaceSample { + StudentId = submission.StudentId, ProtectedImage = recognition.ProtectImage(crop), CreatedAt = reviewedAt + })); + db.FaceSubmissionImages.RemoveRange(images); + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + recognition.Invalidate(); + }); + } + + public async Task RejectAsync(int teacherId, int id, string? reason) + { + await recognition.RunExclusiveAsync(async () => + { + await using var transaction = await db.Database.BeginTransactionAsync(); + var submission = await db.FaceSubmissions.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException("Заявка не найдена."); + if (submission.Status != FaceSubmissionStatus.Pending) throw new InvalidOperationException("Заявка уже рассмотрена."); + var reviewedAt = DateTime.Now; + var rejectReason = string.IsNullOrWhiteSpace(reason) ? "Отклонено преподавателем" : reason.Trim()[..Math.Min(reason.Trim().Length, 200)]; + var updated = await db.FaceSubmissions.Where(x => x.Id == id && x.Status == FaceSubmissionStatus.Pending) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, FaceSubmissionStatus.Rejected) + .SetProperty(x => x.RejectReason, rejectReason) + .SetProperty(x => x.ReviewedAt, reviewedAt) + .SetProperty(x => x.ReviewedByTeacherId, teacherId)); + if (updated != 1) throw new InvalidOperationException("Заявка уже рассмотрена."); + var images = await db.FaceSubmissionImages.Where(x => x.SubmissionId == id).ToListAsync(); + db.FaceSubmissionImages.RemoveRange(images); + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + }); + } +} diff --git a/BiStudent.Server/Services/ExportService.cs b/BiStudent.Server/Services/ExportService.cs new file mode 100644 index 0000000..7ea3cf0 --- /dev/null +++ b/BiStudent.Server/Services/ExportService.cs @@ -0,0 +1,174 @@ +using BiStudent.Contracts; +using System.Globalization; +using System.Text; +using BiStudent.Server.Data; +using ClosedXML.Excel; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class ExportService(AppDbContext db) +{ + public static string ExcuseReasonText(ExcuseReason reason) => reason switch + { + ExcuseReason.Illness => "Болезнь", + ExcuseReason.Family => "Семейные обстоятельства", + ExcuseReason.Official => "Учебная/служебная необходимость", + _ => "Другая причина" + }; + + private static string Symbol(JournalCell cell, bool csv = false) + { + if (!cell.InGroup) return "—"; + var status = cell.Status switch + { + AttendanceStatus.Present => "П", + AttendanceStatus.Absent => "Н", + AttendanceStatus.Excused => csv ? "Н(у)" : "Н", + _ => "" + }; + return cell.Score is int score ? $"{status} {score}".Trim() : status; + } + + private void CountReport() + { + foreach (var key in new[] { "stats.reports_exported", $"stats.reports_exported.{DateOnly.FromDateTime(DateTime.Now):yyyy-MM-dd}" }) + { + db.Database.ExecuteSql( + $"INSERT INTO \"AppSettings\" (\"Key\",\"Value\") VALUES ({key}, '1') ON CONFLICT(\"Key\") DO UPDATE SET \"Value\" = CAST(CAST(\"Value\" AS INTEGER) + 1 AS TEXT)"); + } + } + + public byte[] JournalXlsx(JournalMatrix matrix, string title) + { + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("Журнал"); + var lastColumn = matrix.Columns.Count + 7; + sheet.Cell(1, 1).Value = title; + sheet.Range(1, 1, 1, lastColumn).Merge(); + sheet.Cell(1, 1).Style.Font.Bold = true; + sheet.Cell(1, 1).Style.Font.FontSize = 14; + sheet.Cell(2, 1).Value = $"Группа: {matrix.GroupName}"; + sheet.Cell(3, 1).Value = $"Дисциплина: {matrix.Subject ?? "все дисциплины"}"; + sheet.Cell(4, 1).Value = $"Преподаватель: {matrix.TeacherName}"; + sheet.Cell(5, 1).Value = $"Период: {matrix.From:dd.MM.yyyy} — {matrix.To:dd.MM.yyyy}"; + sheet.Cell(7, 1).Value = "№"; + sheet.Cell(7, 2).Value = "ФИО студента"; + for (var i = 0; i < matrix.Columns.Count; i++) + { + var column = matrix.Columns[i]; + sheet.Cell(7, i + 3).Value = column.Kind == JournalColumnKind.Lesson + ? $"{column.Date:dd.MM}\n{column.Start:HH:mm}" + : $"{column.Date:dd.MM}\n{AssessmentKindText(column.AssessmentKind)}\n{column.Title}"; + } + var summary = matrix.Columns.Count + 3; + sheet.Cell(7, summary).Value = "Пропущено, ч"; + sheet.Cell(7, summary + 1).Value = "в т.ч. по уваж., ч"; + sheet.Cell(7, summary + 2).Value = "Посещаемость, %"; + sheet.Cell(7, summary + 3).Value = "Средний балл"; + sheet.Cell(7, summary + 4).Value = "Итог"; + for (var row = 0; row < matrix.Students.Count; row++) + { + var student = matrix.Students[row]; + var rowNumber = row + 8; + sheet.Cell(rowNumber, 1).Value = row + 1; + sheet.Cell(rowNumber, 2).Value = student.FullName; + for (var i = 0; i < student.Cells.Count; i++) + { + var column = matrix.Columns[i]; + var value = student.Cells[i]; + var cell = sheet.Cell(rowNumber, i + 3); + if (!value.InGroup) + { + cell.Value = "—"; + continue; + } + if (column.Kind == JournalColumnKind.Assessment) + { + if (value.Score is int score) cell.Value = score; + continue; + } + cell.Value = Symbol(value); + if (value.Status == AttendanceStatus.Present) cell.Style.Fill.BackgroundColor = XLColor.LightGreen; + if (value.Status == AttendanceStatus.Absent) cell.Style.Fill.BackgroundColor = XLColor.LightPink; + if (value.Status == AttendanceStatus.Excused) + { + cell.Style.Fill.BackgroundColor = XLColor.LightYellow; + var note = $"Причина: {ExcuseReasonText(value.Reason ?? ExcuseReason.Other)}."; + if (value.HasDocument) note += " Документ предоставлен."; + if (value.Comment is not null) note += $" {value.Comment}"; + cell.GetComment().AddText(note); + } + } + sheet.Cell(rowNumber, summary).Value = student.MissedHours; + sheet.Cell(rowNumber, summary + 1).Value = student.ExcusedHours; + sheet.Cell(rowNumber, summary + 2).Value = student.Percent; + sheet.Cell(rowNumber, summary + 2).Style.NumberFormat.Format = "0.0"; + if (student.Average is double average) sheet.Cell(rowNumber, summary + 3).Value = average; + sheet.Cell(rowNumber, summary + 3).Style.NumberFormat.Format = "0.0"; + if (student.Final is int final) sheet.Cell(rowNumber, summary + 4).Value = final; + } + var lastRow = Math.Max(7, matrix.Students.Count + 7); + var table = sheet.Range(7, 1, lastRow, lastColumn); + table.Style.Border.OutsideBorder = XLBorderStyleValues.Thin; + table.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + table.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + var header = sheet.Range(7, 1, 7, lastColumn); + header.Style.Font.Bold = true; + header.Style.Fill.BackgroundColor = XLColor.LightGray; + header.Style.Alignment.WrapText = true; + if (matrix.Students.Count > 0) + sheet.Range(8, 2, lastRow, 2).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Left; + sheet.Cell(lastRow + 2, 1).Value = + "Обозначения: П — присутствовал(а), Н — отсутствовал(а); жёлтым отмечено отсутствие по уважительной причине (причина — в примечании к ячейке). Баллы — по 100-балльной шкале."; + sheet.Cell(lastRow + 3, 1).Value = $"Дата формирования: {DateTime.Now:dd.MM.yyyy}"; + sheet.Cell(lastRow + 4, 1).Value = $"Преподаватель: ____________ / {matrix.TeacherName} /"; + sheet.SheetView.Freeze(7, 2); + sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape; + sheet.PageSetup.FitToPages(1, 0); + sheet.PageSetup.SetRowsToRepeatAtTop(7, 7); + sheet.Column(1).Width = 5; + sheet.Column(2).Width = 32; + sheet.Columns(3, lastColumn).AdjustToContents(); + using var output = new MemoryStream(); + workbook.SaveAs(output); + CountReport(); + return output.ToArray(); + } + + public string JournalCsv(JournalMatrix matrix) + { + static string Escape(string value) => $"\"{value.Replace("\"", "\"\"")}\""; + static string EscapeText(string value) => Escape(value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r' + ? "'" + value : value); + var lines = new List { + string.Join(';', new[] { "№", "ФИО студента" } + .Concat(matrix.Columns.Select(x => x.Kind == JournalColumnKind.Lesson + ? $"{x.Date:dd.MM.yyyy} {x.Start:HH:mm} {x.Subject}" + : $"{x.Date:dd.MM.yyyy} {AssessmentKindText(x.AssessmentKind)} {x.Title}")) + .Concat(["Пропущено, ч", "в т.ч. по уваж., ч", "Посещаемость, %", + "Средний балл", "Итог"]).Select(EscapeText)) + }; + lines.AddRange(matrix.Students.Select((student, index) => string.Join(';', + new[] { Escape((index + 1).ToString(CultureInfo.InvariantCulture)), EscapeText(student.FullName) } + .Concat(student.Cells.Select(x => Escape(Symbol(x, true)))) + .Concat([ + Escape(student.MissedHours.ToString(CultureInfo.InvariantCulture)), + Escape(student.ExcusedHours.ToString(CultureInfo.InvariantCulture)), + Escape(student.Percent.ToString("0.0", CultureInfo.InvariantCulture)), + Escape(student.Average?.ToString("0.0", CultureInfo.InvariantCulture) ?? ""), + Escape(student.Final?.ToString(CultureInfo.InvariantCulture) ?? "") + ])))); + CountReport(); + return "\uFEFF" + string.Join("\r\n", lines) + "\r\n"; + } + + private static string AssessmentKindText(AssessmentKind? kind) => kind switch + { + AssessmentKind.Test => "Тест", + AssessmentKind.ControlWork => "К/р", + AssessmentKind.Exam => "Экзамен", + AssessmentKind.Credit => "Зачёт", + _ => "—" + }; +} diff --git a/BiStudent.Server/Services/FaceSampleHealth.cs b/BiStudent.Server/Services/FaceSampleHealth.cs new file mode 100644 index 0000000..a8083b3 --- /dev/null +++ b/BiStudent.Server/Services/FaceSampleHealth.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class FaceSampleHealth(AppDbContext db, IDataProtectionProvider protection) +{ + public async Task> UnreadableStudentsAsync() + { + var protector = protection.CreateProtector("BiStudent.FaceSamples"); + var samples = await db.FaceSamples.AsNoTracking() + .Select(x => new { x.StudentId, x.ProtectedImage }).ToListAsync(); + var unreadable = new HashSet(); + foreach (var group in samples.GroupBy(x => x.StudentId)) + { + var readable = false; + foreach (var sample in group) + { + try { protector.Unprotect(sample.ProtectedImage); readable = true; break; } + catch (CryptographicException) { } + } + if (!readable) unreadable.Add(group.Key); + } + return unreadable; + } +} diff --git a/BiStudent.Server/Services/GradeService.cs b/BiStudent.Server/Services/GradeService.cs new file mode 100644 index 0000000..7b243c6 --- /dev/null +++ b/BiStudent.Server/Services/GradeService.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class GradeService(AppDbContext db, IConfiguration configuration) +{ + private static readonly SemaphoreSlim AssessmentGate = new(1, 1); + + public Task> SubjectsAsync(int teacherId, int groupId) => + db.ScheduleSlots.AsNoTracking().Where(x => x.TeacherId == teacherId && x.GroupId == groupId) + .Select(x => x.Subject).Distinct().OrderBy(x => x).ToListAsync(); + + public async Task> ListAsync(int teacherId, int? groupId, string? subject) + { + var query = db.Assessments.AsNoTracking().Where(x => x.TeacherId == teacherId); + if (groupId is not null) query = query.Where(x => x.GroupId == groupId); + if (!string.IsNullOrWhiteSpace(subject)) + { + var name = subject.Trim(); + query = query.Where(x => x.Subject == name); + } + return await query.OrderBy(x => x.Date).ThenBy(x => x.Id) + .Select(x => new AssessmentDto(x.Id, x.GroupId, x.Group.Name, x.Subject, x.Title, x.Kind, x.Date)).ToListAsync(); + } + + public async Task SaveAsync(int teacherId, int? id, SaveAssessmentRequest request) + { + await AssessmentGate.WaitAsync(); + try { return await SaveCoreAsync(teacherId, id, request); } + finally { AssessmentGate.Release(); } + } + + private async Task SaveCoreAsync(int teacherId, int? id, SaveAssessmentRequest request) + { + var assessment = id is int assessmentId + ? await OwnAssessmentAsync(teacherId, assessmentId) + : new Assessment { TeacherId = teacherId }; + var group = await db.Groups.AsNoTracking().SingleOrDefaultAsync(x => x.Id == request.GroupId) + ?? throw new InvalidOperationException("Группа не найдена."); + var subject = await ValidateSubjectAsync(teacherId, group.Id, request.Subject); + var title = request.Title?.Trim(); + if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("Укажите название контрольного мероприятия."); + if (title.Length > 120) throw new InvalidOperationException("Название длиннее 120 символов."); + if (!Enum.IsDefined(request.Kind)) throw new InvalidOperationException("Неверный вид контрольного мероприятия."); + if (id is not null && (assessment.GroupId != group.Id || assessment.Subject != subject) && + await db.AssessmentGrades.AnyAsync(x => x.AssessmentId == assessment.Id)) + throw new InvalidOperationException(assessment.GroupId != group.Id + ? "Нельзя перенести мероприятие с выставленными баллами в другую группу." + : "Нельзя перенести мероприятие с выставленными баллами на другой предмет."); + assessment.GroupId = group.Id; + assessment.Subject = subject; + assessment.Title = title; + assessment.Kind = request.Kind; + assessment.Date = request.Date; + if (id is null) db.Assessments.Add(assessment); + await db.SaveChangesAsync(); + return new AssessmentDto(assessment.Id, group.Id, group.Name, subject, title, assessment.Kind, assessment.Date); + } + + public async Task DeleteAsync(int teacherId, int id) + { + db.Assessments.Remove(await OwnAssessmentAsync(teacherId, id)); + await db.SaveChangesAsync(); + } + + public async Task SetGradeAsync(int teacherId, int assessmentId, int studentId, int? score) + { + await AssessmentGate.WaitAsync(); + try { await SetGradeCoreAsync(teacherId, assessmentId, studentId, score); } + finally { AssessmentGate.Release(); } + } + + private async Task SetGradeCoreAsync(int teacherId, int assessmentId, int studentId, int? score) + { + var assessment = await OwnAssessmentAsync(teacherId, assessmentId); + await ValidateStudentAsync(assessment.GroupId, studentId, assessment.Date); + ValidateScore(score); + var grade = await db.AssessmentGrades.SingleOrDefaultAsync(x => x.AssessmentId == assessmentId && x.StudentId == studentId); + if (score is null) + { + if (grade is null) return; + db.AssessmentGrades.Remove(grade); + } + else if (grade is null) + db.AssessmentGrades.Add(new AssessmentGrade { AssessmentId = assessmentId, StudentId = studentId, Score = score.Value }); + else + { + if (grade.Score == score) return; + grade.Score = score.Value; + grade.UpdatedAt = DateTime.Now; + } + await db.SaveChangesAsync(); + } + + public async Task SetFinalAsync(int teacherId, int groupId, string subject, int studentId, int? score) + { + subject = await ValidateSubjectAsync(teacherId, groupId, subject); + await ValidateStudentAsync(groupId, studentId); + ValidateScore(score); + var grade = await db.FinalGrades.SingleOrDefaultAsync(x => + x.TeacherId == teacherId && x.GroupId == groupId && x.Subject == subject && x.StudentId == studentId); + if (score is null) + { + if (grade is null) return; + db.FinalGrades.Remove(grade); + } + else if (grade is null) + db.FinalGrades.Add(new FinalGrade { + TeacherId = teacherId, GroupId = groupId, Subject = subject, StudentId = studentId, Score = score.Value + }); + else + { + if (grade.Score == score) return; + grade.Score = score.Value; + grade.UpdatedAt = DateTime.Now; + } + await db.SaveChangesAsync(); + } + + public async Task FillFinalAsync(int teacherId, int groupId, string subject) + { + subject = await ValidateSubjectAsync(teacherId, groupId, subject); + var semester = DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + var periods = await db.GroupMemberships.AsNoTracking().Where(x => x.GroupId == groupId).ToListAsync(); + var students = periods.Select(x => x.StudentId).Distinct().ToList(); + bool InGroup(int studentId, DateOnly date) => + periods.Any(x => x.StudentId == studentId && x.From <= date && (x.To == null || x.To >= date)); + var existing = await db.FinalGrades.AsNoTracking() + .Where(x => x.TeacherId == teacherId && x.GroupId == groupId && x.Subject == subject) + .Select(x => x.StudentId).ToListAsync(); + var lessonGrades = await db.LessonGrades.AsNoTracking() + .Where(x => x.Slot.TeacherId == teacherId && x.Slot.GroupId == groupId && + x.Slot.Subject == subject && x.Date >= semester) + .Select(x => new { x.StudentId, x.Score, x.Date }).ToListAsync(); + var assessmentGrades = await db.AssessmentGrades.AsNoTracking() + .Where(x => x.Assessment.TeacherId == teacherId && x.Assessment.GroupId == groupId && + x.Assessment.Subject == subject && x.Assessment.Date >= semester) + .Select(x => new { x.StudentId, x.Score, x.Assessment.Date }).ToListAsync(); + var scores = lessonGrades.Concat(assessmentGrades).Where(x => InGroup(x.StudentId, x.Date)).GroupBy(x => x.StudentId) + .ToDictionary(x => x.Key, x => x.Select(y => y.Score).ToList()); + var count = 0; + foreach (var studentId in students) + { + if (existing.Contains(studentId) || !scores.TryGetValue(studentId, out var values)) continue; + db.FinalGrades.Add(new FinalGrade { + TeacherId = teacherId, GroupId = groupId, Subject = subject, StudentId = studentId, + Score = Math.Clamp((int)Math.Round(values.Average(), MidpointRounding.AwayFromZero), 1, 100) + }); + count++; + } + if (count > 0) await db.SaveChangesAsync(); + return count; + } + + private async Task OwnAssessmentAsync(int teacherId, int id) => + await db.Assessments.SingleOrDefaultAsync(x => x.Id == id && x.TeacherId == teacherId) + ?? throw new KeyNotFoundException("Контрольное мероприятие не найдено."); + + private async Task ValidateSubjectAsync(int teacherId, int groupId, string subject) + { + var name = subject?.Trim(); + if (string.IsNullOrWhiteSpace(name) || !await db.ScheduleSlots.AnyAsync(x => + x.TeacherId == teacherId && x.GroupId == groupId && x.Subject == name)) + throw new InvalidOperationException("Этот предмет не ведётся у группы в вашем расписании."); + return name; + } + + private async Task ValidateStudentAsync(int groupId, int studentId, DateOnly? date = null) + { + var members = date is { } day ? db.MembershipsOn(day) : db.GroupMemberships.AsQueryable(); + if (!await members.AnyAsync(x => x.StudentId == studentId && x.GroupId == groupId)) + throw new InvalidOperationException("Студент не принадлежит группе."); + } + + private static void ValidateScore(int? score) + { + if (score is < 1 or > 100) throw new InvalidOperationException("Балл должен быть от 1 до 100."); + } +} diff --git a/BiStudent.Server/Services/RecognitionService.cs b/BiStudent.Server/Services/RecognitionService.cs new file mode 100644 index 0000000..77c66ca --- /dev/null +++ b/BiStudent.Server/Services/RecognitionService.cs @@ -0,0 +1,335 @@ +using System.Globalization; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using OpenCvSharp; + +namespace BiStudent.Server.Services; + +public sealed record FaceCheckResult(bool Ok, string Message, List Problems, List Crops, string? SimilarTo, double SimilarScore); + +public sealed class RecognitionService(IServiceScopeFactory scopes, IDataProtectionProvider protection, IConfiguration configuration) : IDisposable +{ + private const int MaximumEnrollImages = 6; + private readonly object gate = new(); + private readonly SemaphoreSlim settingsGate = new(1, 1); + private readonly SemaphoreSlim enrollGate = new(1, 1); + private readonly Dictionary<(int TeacherId, int SlotId, DateOnly Date), Dictionary> streaks = []; + private FaceEngine? engine; + private RecognitionSettingsDto? settingsCache; + private bool initialized; + private string? error; + private int revision; + private int trainedRevision = -1; + private IDataProtector Protector => protection.CreateProtector("BiStudent.FaceSamples"); + + public bool IsAvailable { get { EnsureEngine(); lock (gate) return engine is not null; } } + public string? Error { get { EnsureEngine(); lock (gate) return error; } } + + private void EnsureEngine() + { + lock (gate) + { + if (initialized) return; + initialized = true; + try { engine = new FaceEngine(); } + catch (Exception ex) { error = ex.Message; } + } + } + + public void Invalidate() + { + lock (gate) { revision++; streaks.Clear(); settingsCache = null; } + } + + private static bool ValidThreshold(double value) => double.IsFinite(value) && value is >= 0.30 and <= 0.70; + private static bool ValidMargin(double value) => double.IsFinite(value) && value is >= 0.00 and <= 0.20; + private static bool ValidFrames(int value) => value is >= 1 and <= 10; + + public async Task GetSettingsAsync() + { + lock (gate) { if (settingsCache is not null) return settingsCache; } + await settingsGate.WaitAsync(); + try { return await LoadSettingsAsync(); } + finally { settingsGate.Release(); } + } + + private async Task LoadSettingsAsync() + { + int startRevision; + lock (gate) { if (settingsCache is not null) return settingsCache; startRevision = revision; } + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var values = await db.AppSettings.AsNoTracking().ToDictionaryAsync(x => x.Key, x => x.Value); + var threshold = values.TryGetValue("recognition.threshold", out var thresholdText) + && double.TryParse(thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedThreshold) + && ValidThreshold(parsedThreshold) ? parsedThreshold : FaceEngine.DefaultMatchThreshold; + var margin = values.TryGetValue("recognition.margin", out var marginText) + && double.TryParse(marginText, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedMargin) + && ValidMargin(parsedMargin) ? parsedMargin : FaceEngine.DefaultMinimumMargin; + var frames = values.TryGetValue("recognition.frames", out var framesText) + && int.TryParse(framesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFrames) + && ValidFrames(parsedFrames) ? parsedFrames : configuration.GetValue("Recognition:FramesInRow", 3); + if (!ValidFrames(frames)) frames = 3; + var loaded = new RecognitionSettingsDto(threshold, margin, frames); + // Если за время чтения был Invalidate (восстановление базы), прочитанное могло устареть — не кешируем. + lock (gate) return revision == startRevision ? settingsCache ??= loaded : loaded; + } + + public async Task SaveSettingsAsync(RecognitionSettingsDto settings) + { + if (!ValidThreshold(settings.MatchThreshold)) + throw new InvalidOperationException("Порог сходства должен быть в диапазоне 0.30–0.70."); + if (!ValidMargin(settings.MinimumMargin)) + throw new InvalidOperationException("Отрыв от второго кандидата должен быть в диапазоне 0.00–0.20."); + if (!ValidFrames(settings.FramesInRow)) + throw new InvalidOperationException("Количество кадров подряд должно быть в диапазоне 1–10."); + await settingsGate.WaitAsync(); + try + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var values = new Dictionary + { + ["recognition.threshold"] = settings.MatchThreshold.ToString("R", CultureInfo.InvariantCulture), + ["recognition.margin"] = settings.MinimumMargin.ToString("R", CultureInfo.InvariantCulture), + ["recognition.frames"] = settings.FramesInRow.ToString(CultureInfo.InvariantCulture) + }; + foreach (var pair in values) + { + var stored = await db.AppSettings.FindAsync(pair.Key); + if (stored is null) db.AppSettings.Add(new AppSetting { Key = pair.Key, Value = pair.Value }); + else stored.Value = pair.Value; + } + await db.SaveChangesAsync(); + lock (gate) { settingsCache = settings; streaks.Clear(); } + return settings; + } + finally { settingsGate.Release(); } + } + + private async Task TrainIfNeededAsync() + { + for (var attempt = 0; attempt < 5; attempt++) + if (await TrainOnceAsync()) return; + throw new InvalidOperationException("Образцы лиц меняются слишком часто — повторите попытку."); + } + + private async Task TrainOnceAsync() + { + int version; + lock (gate) { if (trainedRevision == revision) return true; version = revision; } + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.FaceSamples.AsNoTracking().Select(x => new { x.StudentId, x.ProtectedImage }).ToListAsync(); + var samples = new List<(int StudentId, byte[] Image)>(); + foreach (var sample in stored) + { + try { samples.Add((sample.StudentId, Protector.Unprotect(sample.ProtectedImage))); } + catch { /* Повреждённый образец не блокирует остальные. */ } + } + lock (gate) + { + if (engine is null) return true; + if (version != revision) return false; + engine.Train(samples); + trainedRevision = version; + return true; + } + } + + public async Task RecognizeAsync(int teacherId, int slotId, DateOnly date, byte[] jpeg) + { + EnsureEngine(); + if (!IsAvailable) return new RecognitionResultDto([], 0, 0, Error); + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var slot = await db.ScheduleSlots.AsNoTracking().SingleOrDefaultAsync(x => x.Id == slotId && x.TeacherId == teacherId) + ?? throw new UnauthorizedAccessException("Пара недоступна преподавателю."); + var students = await db.Students.AsNoTracking().Select(x => new { x.Id, x.FullName, x.GroupId }).ToDictionaryAsync(x => x.Id); + var marked = await db.AttendanceMarks.AsNoTracking().Where(x => x.SlotId == slotId && x.Date == date) + .Select(x => x.StudentId).ToListAsync(); + if (ImageHeader.Validate(jpeg) is { } invalid) + { + lock (gate) streaks.Remove((teacherId, slotId, date)); + return new RecognitionResultDto([], 0, 0, invalid); + } + var settings = await GetSettingsAsync(); + using var frame = Cv2.ImDecode(jpeg, ImreadModes.Color); + if (frame.Empty()) + { + lock (gate) streaks.Remove((teacherId, slotId, date)); + return new RecognitionResultDto([], 0, 0, "Не удалось прочитать кадр JPEG."); + } + IReadOnlyList? matches = null; + HashSet ready = []; + try + { + for (var attempt = 0; attempt < 3 && matches is null; attempt++) + { + await TrainIfNeededAsync(); + lock (gate) + { + if (trainedRevision != revision) continue; + var current = settingsCache ?? settings; + engine!.MatchThreshold = current.MatchThreshold; + engine.MinimumMargin = current.MinimumMargin; + matches = engine.Scan(frame); + var eligible = matches.Where(x => x.StudentId is int id && students.TryGetValue(id, out var s) && s.GroupId == slot.GroupId) + .Select(x => x.StudentId!.Value).ToHashSet(); + ready.Clear(); + var key = (teacherId, slotId, date); + if (!streaks.TryGetValue(key, out var counts)) + { + // Чистим по серверной дате, а не по дате из запроса; предел размера — на случай произвольных дат. + var cutoff = DateOnly.FromDateTime(DateTime.Now).AddDays(-1); + foreach (var old in streaks.Keys.Where(x => x.Date < cutoff).ToArray()) streaks.Remove(old); + if (streaks.Count >= 1000) streaks.Clear(); + streaks[key] = counts = []; + } + foreach (var id in counts.Keys.Except(eligible).ToArray()) counts.Remove(id); + foreach (var id in eligible) + { + counts[id] = counts.GetValueOrDefault(id) + 1; + if (counts[id] >= current.FramesInRow && !marked.Contains(id)) ready.Add(id); + } + } + } + } + catch (Exception ex) { return new RecognitionResultDto([], frame.Width, frame.Height, ex.Message); } + if (matches is null) return new RecognitionResultDto([], frame.Width, frame.Height, "Образцы лиц обновляются — повторите через секунду."); + var changed = new HashSet(); + var attendance = scope.ServiceProvider.GetRequiredService(); + foreach (var id in ready) + if (await attendance.MarkAsync(slotId, date, id, AttendanceStatus.Present, MarkMethod.Camera)) changed.Add(id); + var faces = matches.Select(match => { + var id = match.StudentId; + var known = id is int value && students.TryGetValue(value, out var student) ? student : null; + var candidate = match.CandidateId is int candidateId && students.TryGetValue(candidateId, out var possible) + ? possible.FullName : null; + var other = known is not null && known.GroupId != slot.GroupId; + return new RecognizedFaceDto(match.Bounds.X, match.Bounds.Y, match.Bounds.Width, match.Bounds.Height, + id, known?.FullName, match.Similarity, id is int studentId && changed.Contains(studentId), + id is int existing && marked.Contains(existing), other, candidate, match.Margin); + }).ToList(); + return new RecognitionResultDto(faces, frame.Width, frame.Height); + } + + public async Task EnrollAsync(int studentId, IReadOnlyList jpegs, bool allowSimilar) + { + if (jpegs.Count < 3) return new EnrollResultDto(false, "Нужно не меньше трёх кадров."); + if (jpegs.Count > MaximumEnrollImages) return new EnrollResultDto(false, $"Нужно не больше {MaximumEnrollImages} кадров."); + await enrollGate.WaitAsync(); + try { return await EnrollCoreAsync(studentId, jpegs, allowSimilar); } + finally { enrollGate.Release(); } + } + + public async Task CheckSubmissionAsync(int studentId, IReadOnlyList jpegs) + { + if (jpegs.Count < 3) return new FaceCheckResult(false, "Нужно не меньше трёх кадров.", [], [], null, 0); + if (jpegs.Count > MaximumEnrollImages) return new FaceCheckResult(false, $"Нужно не больше {MaximumEnrollImages} кадров.", [], [], null, 0); + await enrollGate.WaitAsync(); + try { return await CheckCoreAsync(studentId, jpegs); } + finally { enrollGate.Release(); } + } + + public byte[] ProtectImage(byte[] image) => Protector.Protect(image); + public byte[] UnprotectImage(byte[] data) => Protector.Unprotect(data); + + public async Task RunExclusiveAsync(Func action) + { + await enrollGate.WaitAsync(); + try { await action(); } + finally { enrollGate.Release(); } + } + + private async Task EnrollCoreAsync(int studentId, IReadOnlyList jpegs, bool allowSimilar) + { + var check = await CheckCoreAsync(studentId, jpegs); + if (!check.Ok) return new EnrollResultDto(false, check.Message, Problems: check.Problems); + if (check.SimilarTo is not null && !allowSimilar) + return new EnrollResultDto(false, check.Message, check.SimilarTo, check.SimilarScore); + await SaveSamplesAsync(studentId, check.Crops); + return new EnrollResultDto(true, check.SimilarTo is null ? "Образцы лица сохранены." + : $"Образцы лица сохранены. Лицо похоже на {check.SimilarTo} ({check.SimilarScore:P0}); распознавание этих студентов будет менее надёжным.", + check.SimilarTo, check.SimilarScore); + } + + private async Task CheckCoreAsync(int studentId, IReadOnlyList jpegs) + { + EnsureEngine(); + if (!IsAvailable) return new FaceCheckResult(false, Error ?? "Распознавание недоступно.", [], [], null, 0); + var samples = new List(); + var problems = new List(); + for (var i = 0; i < jpegs.Count; i++) + { + try + { + if (ImageHeader.Validate(jpegs[i]) is { } invalid) throw new InvalidOperationException(invalid); + using var frame = Cv2.ImDecode(jpegs[i], ImreadModes.Color); + if (frame.Empty()) throw new InvalidOperationException("Не удалось прочитать JPEG."); + lock (gate) samples.Add(engine!.CaptureChecked(frame)); + } + catch (Exception ex) { problems.Add($"Снимок {i + 1}: {ex.Message}"); } + } + if (problems.Count > 0) + return new FaceCheckResult(false, "Некоторые снимки не подходят — переснимите их", problems, [], null, 0); + for (var i = 0; i < samples.Count; i++) + for (var j = i + 1; j < samples.Count; j++) + { + double similarity; + lock (gate) similarity = FaceEngine.Similarity(samples[i].Embedding, samples[j].Embedding); + if (similarity > 0.95) problems.Add($"Снимки {i + 1} и {j + 1} почти одинаковые — немного поверните голову"); + } + if (problems.Count > 0) + return new FaceCheckResult(false, "Некоторые снимки не подходят — переснимите их", problems, [], null, 0); + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Students.AnyAsync(x => x.Id == studentId)) + return new FaceCheckResult(false, "Студент не найден.", [], [], null, 0); + try { await TrainIfNeededAsync(); } + catch (Exception ex) { return new FaceCheckResult(false, ex.Message, [], [], null, 0); } + var settings = await GetSettingsAsync(); + (int StudentId, double Similarity)? similar = null; + foreach (var sample in samples) + { + (int StudentId, double Similarity)? candidate; + lock (gate) candidate = engine!.MostSimilar(sample.Embedding, studentId); + if (candidate is { } match && (similar is null || match.Similarity > similar.Value.Similarity)) + similar = match; + } + string? similarTo = null; + double similarScore = 0; + if (similar is { } closest && closest.Similarity >= settings.MatchThreshold) + { + similarTo = await db.Students.AsNoTracking().Where(x => x.Id == closest.StudentId) + .Select(x => x.FullName).SingleOrDefaultAsync(); + if (similarTo is not null) + { + similarScore = closest.Similarity; + } + } + var message = similarTo is null ? "Снимки подходят." + : $"Лицо похоже на {similarTo} ({similarScore:P0}). Если это другой человек — подтвердите сохранение: распознавание этих студентов будет менее надёжным."; + return new FaceCheckResult(true, message, [], samples.Select(x => x.Image).ToList(), similarTo, similarScore); + } + + private async Task SaveSamplesAsync(int studentId, IReadOnlyList crops) + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var old = await db.FaceSamples.Where(x => x.StudentId == studentId).ToListAsync(); + db.FaceSamples.RemoveRange(old); + db.FaceSamples.AddRange(crops.Select(crop => new FaceSample { + StudentId = studentId, ProtectedImage = ProtectImage(crop), CreatedAt = DateTime.Now + })); + await db.SaveChangesAsync(); + Invalidate(); + } + + public void Dispose() + { + lock (gate) engine?.Dispose(); + } +} diff --git a/BiStudent.Server/Services/ScheduleImportService.cs b/BiStudent.Server/Services/ScheduleImportService.cs new file mode 100644 index 0000000..1cfc9e8 --- /dev/null +++ b/BiStudent.Server/Services/ScheduleImportService.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using System.Text; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +/// Импорт расписания колледжа (см. CollegeScheduleParser) в базу: группы, учётные записи преподавателей и занятия. +/// Повторный импорт того же файла ничего не дублирует: совпадающие занятия пропускаются. +public sealed class ScheduleImportService(AppDbContext db, AccountService accounts, IConfiguration configuration) +{ + public async Task PreviewAsync(Stream xlsx) + { + var parsed = Parse(xlsx); + var existing = (await db.Groups.AsNoTracking().Select(x => x.Name).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var groups = parsed.Groups.Select(g => new ScheduleImportGroupDto(g.Sheet, g.Group, + parsed.Lessons.Count(x => x.Group == g.Group), existing.Contains(g.Group))).ToList(); + var teachers = parsed.Lessons.Select(x => x.Teacher).Distinct().Order(StringComparer.CurrentCulture).ToList(); + return new ScheduleImportPreviewDto(parsed.SemesterStart, groups, teachers, parsed.Warnings); + } + + public async Task ImportAsync(Stream xlsx, IReadOnlyCollection selectedGroups) + { + if (selectedGroups.Count == 0) throw new InvalidOperationException("Выберите хотя бы одну группу."); + var parsed = Parse(xlsx); + var selected = selectedGroups.ToHashSet(StringComparer.OrdinalIgnoreCase); + var unknown = selected.Where(g => parsed.Groups.All(x => !x.Group.Equals(g, StringComparison.OrdinalIgnoreCase))).ToList(); + if (unknown.Count > 0) throw new InvalidOperationException($"В файле нет групп: {string.Join(", ", unknown)}."); + var lessons = parsed.Lessons.Where(x => selected.Contains(x.Group)).ToList(); + var warnings = parsed.Warnings.Where(w => selected.Any(g => w.Contains(g, StringComparison.OrdinalIgnoreCase))).ToList(); + var semester = DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + if (Monday(semester) != parsed.SemesterStart) + warnings.Add($"Начало семестра в файле — {parsed.SemesterStart:dd.MM.yyyy}, в настройках сервера — {semester:dd.MM.yyyy}. Недели считаются по файлу."); + + await using var transaction = await db.Database.BeginTransactionAsync(); + var groupsCreated = 0; + var groupIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in lessons.Select(x => x.Group).Distinct()) + { + var group = await db.Groups.SingleOrDefaultAsync(x => x.Name == name); + if (group is null) { group = new Group { Name = name }; db.Groups.Add(group); await db.SaveChangesAsync(); groupsCreated++; } + groupIds[name] = group.Id; + } + + var teachers = new List(); + var teacherIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in lessons.Select(x => x.Teacher).Distinct()) + { + var vacancy = name == CollegeScheduleParser.Vacancy; + var existing = await db.Teachers.AsNoTracking().FirstOrDefaultAsync(x => x.FullName == name); + if (existing is not null) + { + teacherIds[name] = existing.Id; + teachers.Add(new ImportedTeacherDto(existing.FullName, existing.Login, null, false, existing.IsActive)); + continue; + } + var login = await FreeLoginAsync(vacancy ? "vacancy" : Login(name)); + var created = await accounts.CreateAsync(new SaveTeacherRequest(login, name, false, !vacancy)); + teacherIds[name] = (await db.Teachers.AsNoTracking().SingleAsync(x => x.Login == login)).Id; + teachers.Add(new ImportedTeacherDto(name, login, vacancy ? null : created.Password, true, !vacancy)); + } + + var existingSlots = await db.ScheduleSlots.AsNoTracking() + .Where(x => groupIds.Values.Contains(x.GroupId)).ToListAsync(); + var keys = existingSlots.Select(Key).ToHashSet(); + var added = 0; + var skipped = 0; + foreach (var lesson in lessons) + { + var monday = parsed.SemesterStart.AddDays(7 * (lesson.FromWeek - 1)); + var offset = ((int)lesson.Day + 6) % 7; + var slot = new ScheduleSlot + { + TeacherId = teacherIds[lesson.Teacher], GroupId = groupIds[lesson.Group], + Subject = lesson.Subject.Length > 200 ? lesson.Subject[..200] : lesson.Subject, Room = lesson.Room, + DayOfWeek = lesson.Day, Start = lesson.Start, End = lesson.End, Parity = WeekParity.Every + }; + if (lesson.Once) + { + var date = monday.AddDays(offset); + slot.OneOffDate = date; slot.ValidFrom = date; slot.ValidTo = date; + } + else + { + slot.ValidFrom = monday; + slot.ValidTo = parsed.SemesterStart.AddDays(7 * lesson.ToWeek - 1); + } + if (!keys.Add(Key(slot))) { skipped++; continue; } + db.ScheduleSlots.Add(slot); + added++; + } + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + return new ScheduleImportResultDto(added, skipped, groupsCreated, teachers, warnings); + } + + private ParsedSchedule Parse(Stream xlsx) + { + var fallback = DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + try { return CollegeScheduleParser.Parse(xlsx, Monday(fallback)); } + catch (Exception ex) when (ex is not InvalidOperationException) + { + throw new InvalidOperationException("Не удалось прочитать файл расписания. Нужен файл Excel (.xlsx) в формате расписания колледжа.", ex); + } + } + + private static (int, int, string, DayOfWeek, TimeOnly, TimeOnly, DateOnly, DateOnly?, DateOnly?, string?) Key(ScheduleSlot x) => + (x.TeacherId, x.GroupId, x.Subject, x.DayOfWeek, x.Start, x.End, x.ValidFrom, x.ValidTo, x.OneOffDate, x.Room); + + private static DateOnly Monday(DateOnly date) => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + + internal async Task FreeLoginAsync(string login) + { + var candidate = login; + for (var i = 2; await db.Teachers.AnyAsync(x => x.Login.ToLower() == candidate.ToLower()); i++) + { + var suffix = i.ToString(CultureInfo.InvariantCulture); + candidate = login[..Math.Min(login.Length, 32 - suffix.Length)] + suffix; + } + return candidate; + } + + /// «Ниязова М.Е.» → niyazova.me; «Хамит А.Ү.» → khamit.au; «Халель» → khalel. + public static string Login(string fullName) + { + var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var surname = Translit(parts[0]); + var initials = string.Concat(parts.Skip(1).SelectMany(p => p.Split('.', StringSplitOptions.RemoveEmptyEntries)).Select(p => Translit(p[..1]))); + var login = initials.Length > 0 ? $"{surname}.{initials}" : surname; + login = new string(login.Where(c => char.IsAsciiLetterOrDigit(c) || c == '.').ToArray()).Trim('.'); + if (login.Length < 3) login = (login + "teacher")[..Math.Max(3, login.Length)]; + return login.Length > 32 ? login[..32] : login; + } + + private static string Translit(string text) + { + var map = new Dictionary + { + ['а'] = "a", ['б'] = "b", ['в'] = "v", ['г'] = "g", ['д'] = "d", ['е'] = "e", ['ё'] = "e", ['ж'] = "zh", ['з'] = "z", + ['и'] = "i", ['й'] = "y", ['к'] = "k", ['л'] = "l", ['м'] = "m", ['н'] = "n", ['о'] = "o", ['п'] = "p", ['р'] = "r", + ['с'] = "s", ['т'] = "t", ['у'] = "u", ['ф'] = "f", ['х'] = "kh", ['ц'] = "ts", ['ч'] = "ch", ['ш'] = "sh", ['щ'] = "sch", + ['ъ'] = "", ['ы'] = "y", ['ь'] = "", ['э'] = "e", ['ю'] = "yu", ['я'] = "ya", + ['ә'] = "a", ['ғ'] = "g", ['қ'] = "k", ['ң'] = "n", ['ө'] = "o", ['ұ'] = "u", ['ү'] = "u", ['һ'] = "h", ['і'] = "i" + }; + var builder = new StringBuilder(); + foreach (var c in text.ToLowerInvariant()) + builder.Append(map.TryGetValue(c, out var latin) ? latin : char.IsAsciiLetterOrDigit(c) ? c.ToString() : ""); + return builder.ToString(); + } +} diff --git a/BiStudent.Server/Services/ScheduleService.cs b/BiStudent.Server/Services/ScheduleService.cs new file mode 100644 index 0000000..14498f4 --- /dev/null +++ b/BiStudent.Server/Services/ScheduleService.cs @@ -0,0 +1,181 @@ +using BiStudent.Contracts; +using System.Globalization; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed record LessonOccurrence(ScheduleSlot Slot, DateOnly Date, DateTime StartsAt, DateTime EndsAt, bool Cancelled, string GroupName); + +public sealed class ScheduleService(AppDbContext db, IConfiguration configuration) +{ + private DateOnly SemesterStart => DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + + public WeekParity WeekParityOf(DateOnly date) + { + var week = (int)Math.Floor((date.DayNumber - SemesterStart.DayNumber) / 7d) + 1; + return Math.Abs(week % 2) == 1 ? WeekParity.Odd : WeekParity.Even; + } + + private bool Applies(ScheduleSlot slot, DateOnly date) => + date >= slot.ValidFrom && (slot.ValidTo is null || date <= slot.ValidTo) && + (slot.OneOffDate is DateOnly once + ? date == once + : slot.DayOfWeek == date.DayOfWeek && (slot.Parity == WeekParity.Every || slot.Parity == WeekParityOf(date))); + + public async Task> ForDayAsync(int teacherId, DateOnly date) + { + var slots = await db.ScheduleSlots.AsNoTracking().Include(x => x.Group).Where(x => x.TeacherId == teacherId && x.ValidFrom <= date && (x.ValidTo == null || x.ValidTo >= date)).ToListAsync(); + var ids = slots.Select(x => x.Id).ToArray(); + var cancelled = await db.SlotCancellations.AsNoTracking().Where(x => ids.Contains(x.SlotId) && x.Date == date).Select(x => x.SlotId).ToListAsync(); + return slots.Where(x => Applies(x, date)).Select(x => new LessonOccurrence( + x, date, date.ToDateTime(x.Start), date.ToDateTime(x.End), cancelled.Contains(x.Id), x.Group.Name)) + .OrderBy(x => x.StartsAt).ThenBy(x => x.Slot.Id).ToList(); + } + + public async Task> ForWeekAsync(int teacherId, DateOnly monday) + { + var result = new List(); + for (var day = 0; day < 7; day++) result.AddRange(await ForDayAsync(teacherId, monday.AddDays(day))); + return result; + } + + public async Task CurrentAsync(int teacherId, DateTime now) + { + var active = (await ForDayAsync(teacherId, DateOnly.FromDateTime(now))) + .Where(x => !x.Cancelled && x.StartsAt <= now && now < x.EndsAt).Take(2).ToList(); + return active.Count == 1 ? active[0] : null; + } + + private static bool TimesOverlap(ScheduleSlot a, ScheduleSlot b) => a.Start < b.End && b.Start < a.End; + + private bool DatesOverlap(ScheduleSlot a, ScheduleSlot b) + { + var first = a.ValidFrom > b.ValidFrom ? a.ValidFrom : b.ValidFrom; + var last = a.ValidTo is null ? b.ValidTo : b.ValidTo is null ? a.ValidTo : a.ValidTo < b.ValidTo ? a.ValidTo : b.ValidTo; + if (last is DateOnly end && end < first) return false; + if (a.OneOffDate is DateOnly aDate) return (!last.HasValue || aDate <= last) && aDate >= first && Applies(b, aDate); + if (b.OneOffDate is DateOnly bDate) return (!last.HasValue || bDate <= last) && bDate >= first && Applies(a, bDate); + for (var i = 0; i < 14 && (!last.HasValue || first.AddDays(i) <= last); i++) + if (Applies(a, first.AddDays(i)) && Applies(b, first.AddDays(i))) return true; + return false; + } + + private async Task ValidateAsync(ScheduleSlot slot) + { + if (slot.Start >= slot.End) throw new InvalidOperationException("Начало пары должно быть раньше окончания."); + if (slot.ValidTo is DateOnly end && end < slot.ValidFrom) throw new InvalidOperationException("Конец периода раньше начала."); + if (slot.OneOffDate is DateOnly once && (once < slot.ValidFrom || slot.ValidTo is DateOnly to && once > to)) + throw new InvalidOperationException("Разовое занятие вне периода действия."); + var others = await db.ScheduleSlots.AsNoTracking().Where(x => x.TeacherId == slot.TeacherId && x.Id != slot.Id).ToListAsync(); + if (others.Any(x => TimesOverlap(x, slot) && DatesOverlap(x, slot))) + throw new InvalidOperationException("У преподавателя уже есть пара в это время."); + } + + public async Task AddSlotAsync(ScheduleSlot slot) + { + await ValidateAsync(slot); + db.ScheduleSlots.Add(slot); + await db.SaveChangesAsync(); + return slot; + } + + public async Task UpdateSlotAsync(ScheduleSlot slot) + { + await ValidateAsync(slot); + var current = await db.ScheduleSlots.FindAsync(slot.Id) ?? throw new KeyNotFoundException("Пара не найдена."); + current.TeacherId = slot.TeacherId; + current.GroupId = slot.GroupId; + current.Subject = slot.Subject; + current.Room = slot.Room; + current.DayOfWeek = slot.DayOfWeek; + current.Start = slot.Start; + current.End = slot.End; + current.Parity = slot.Parity; + current.ValidFrom = slot.ValidFrom; + current.ValidTo = slot.ValidTo; + current.OneOffDate = slot.OneOffDate; + await db.SaveChangesAsync(); + } + + public async Task DeleteSlotAsync(int slotId) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + db.ScheduleSlots.Remove(slot); + await db.SaveChangesAsync(); + } + + public async Task CancelAsync(int slotId, DateOnly date, string reason) + { + var slot = await db.ScheduleSlots.FindAsync(slotId) ?? throw new KeyNotFoundException("Пара не найдена."); + if (!Applies(slot, date)) throw new InvalidOperationException("Пары в указанную дату нет."); + if (await db.SlotCancellations.AnyAsync(x => x.SlotId == slotId && x.Date == date)) return; + db.SlotCancellations.Add(new SlotCancellation { SlotId = slotId, Date = date, Reason = reason }); + await db.SaveChangesAsync(); + } + + public async Task RestoreAsync(int slotId, DateOnly date) + { + var cancellation = await db.SlotCancellations.SingleOrDefaultAsync(x => x.SlotId == slotId && x.Date == date); + if (cancellation is null) return; + db.SlotCancellations.Remove(cancellation); + await db.SaveChangesAsync(); + } + + public Task AddOneOffAsync(ScheduleSlot slot, DateOnly date) + { + slot.OneOffDate = date; + slot.ValidFrom = date; + slot.ValidTo = date; + return AddSlotAsync(slot); + } + + public async Task ImportCsvAsync(int teacherId, Stream csv) + { + await using var transaction = await db.Database.BeginTransactionAsync(); + using var reader = new StreamReader(csv, leaveOpen: true); + var errors = new List(); + var added = 0; + var line = 0; + while (await reader.ReadLineAsync() is { } raw) + { + line++; + if (line == 1 || string.IsNullOrWhiteSpace(raw)) continue; + try + { + var parts = raw.Split(raw.Contains(';') ? ';' : ',').Select(x => x.Trim()).ToArray(); + if (parts.Length < 7) throw new FormatException("Ожидаются семь столбцов."); + var day = parts[0].ToLowerInvariant() switch + { + "пн" or "1" => DayOfWeek.Monday, "вт" or "2" => DayOfWeek.Tuesday, + "ср" or "3" => DayOfWeek.Wednesday, "чт" or "4" => DayOfWeek.Thursday, + "пт" or "5" => DayOfWeek.Friday, "сб" or "6" => DayOfWeek.Saturday, + "вс" or "7" => DayOfWeek.Sunday, _ => throw new FormatException("Неверный день недели.") + }; + var parity = parts[6].ToLowerInvariant() switch + { + "каждая" => WeekParity.Every, "нечёт" or "нечет" => WeekParity.Odd, + "чёт" or "чет" => WeekParity.Even, _ => throw new FormatException("Неверная чётность.") + }; + var group = await db.Groups.SingleOrDefaultAsync(x => x.Name == parts[4]); + if (group is null) { group = new Group { Name = parts[4] }; db.Groups.Add(group); await db.SaveChangesAsync(); } + await AddSlotAsync(new ScheduleSlot { + TeacherId = teacherId, GroupId = group.Id, DayOfWeek = day, + Start = TimeOnly.Parse(parts[1], CultureInfo.GetCultureInfo("ru-RU")), + End = TimeOnly.Parse(parts[2], CultureInfo.GetCultureInfo("ru-RU")), + Subject = parts[3], Room = string.IsNullOrWhiteSpace(parts[5]) ? null : parts[5], + Parity = parity, ValidFrom = SemesterStart + }); + added++; + } + catch (Exception ex) { errors.Add($"Строка {line}: {ex.Message}"); } + } + if (errors.Count > 0) + { + await transaction.RollbackAsync(); + throw new InvalidOperationException(string.Join(Environment.NewLine, errors)); + } + await transaction.CommitAsync(); + return added; + } +} diff --git a/BiStudent.Server/Services/SeedService.cs b/BiStudent.Server/Services/SeedService.cs new file mode 100644 index 0000000..e24ccc5 --- /dev/null +++ b/BiStudent.Server/Services/SeedService.cs @@ -0,0 +1,210 @@ +using BiStudent.Contracts; +using System.Globalization; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class SeedService(AppDbContext db, IConfiguration configuration, IWebHostEnvironment environment, + IPasswordHasher hasher, ScheduleService schedule, ScheduleImportService importer, ILogger logger) +{ + public async Task SeedAsync() + { + if (await db.Teachers.AnyAsync()) return; + var demo = configuration.GetValue("Demo:Enabled", false); + var teacher = new Teacher { + Login = demo ? configuration["Demo:Login"] ?? "teacher" : configuration["Setup:AdminLogin"] ?? "admin", + FullName = demo ? configuration["Demo:TeacherName"] ?? "Преподаватель (демо)" + : configuration["Setup:AdminName"] ?? "Администратор", + IsAdmin = true, + MustChangePassword = !demo + }; + teacher.PasswordHash = hasher.HashPassword(teacher, demo + ? configuration["Demo:Password"] ?? "teacher123" + : configuration["Setup:AdminPassword"] ?? "admin"); + db.Teachers.Add(teacher); + await db.SaveChangesAsync(); + if (!demo) + { + logger.LogWarning("Создан администратор «{Login}». Войдите и смените пароль.", teacher.Login); + return; + } + + var path = new[] { + Path.Combine(environment.ContentRootPath, "seed", "students.local.csv"), + Path.Combine(AppContext.BaseDirectory, "seed", "students.local.csv"), + Path.Combine(environment.ContentRootPath, "seed", "students.demo.csv"), + Path.Combine(AppContext.BaseDirectory, "seed", "students.demo.csv") + }.FirstOrDefault(File.Exists); + if (path is null) + { + logger.LogWarning("Список студентов для демо (seed/students.demo.csv) не найден: создан только преподаватель."); + return; + } + foreach (var raw in await File.ReadAllLinesAsync(path)) + { + var parts = raw.Split(';').Select(x => x.Trim()).ToArray(); + if (parts.Length < 2 || parts[0].Equals("ФИО", StringComparison.OrdinalIgnoreCase)) continue; + var group = await db.Groups.SingleOrDefaultAsync(x => x.Name == parts[1]); + if (group is null) { group = new Group { Name = parts[1] }; db.Groups.Add(group); await db.SaveChangesAsync(); } + if (!await db.Students.AnyAsync(x => x.GroupId == group.Id && x.FullName == parts[0])) + db.Students.Add(new Student { FullName = parts[0], GroupId = group.Id }); + } + await db.SaveChangesAsync(); + + var start = DateOnly.Parse(configuration["Semester:Start"] ?? "2026-09-01", CultureInfo.InvariantCulture); + var teacherIds = new List { teacher.Id }; + if (await ImportLocalScheduleAsync(teacher) is { } imported) + { + teacher = imported.Main; + teacherIds = imported.Teachers; + } + else await AddDemoScheduleAsync(teacher, start); + await AddHistoryAsync(teacherIds, start); + } + + /// Реальное расписание колледжа для демо (seed/schedule.local.xlsx, в git не хранится): занятия групп из списка студентов. + /// Демо-вход получает преподаватель с наибольшей нагрузкой. + private async Task<(Teacher Main, List Teachers)?> ImportLocalScheduleAsync(Teacher placeholder) + { + if (!configuration.GetValue("Demo:UseLocalSchedule", false)) return null; + var path = new[] { + Path.Combine(environment.ContentRootPath, "seed", "schedule.local.xlsx"), + Path.Combine(AppContext.BaseDirectory, "seed", "schedule.local.xlsx") + }.FirstOrDefault(File.Exists); + if (path is null) return null; + var bytes = await File.ReadAllBytesAsync(path); + var preview = await importer.PreviewAsync(new MemoryStream(bytes)); + var groups = preview.Groups.Where(x => x.Exists).Select(x => x.Group).ToList(); + if (groups.Count == 0) + { + logger.LogWarning("В seed/schedule.local.xlsx нет групп из списка студентов — используется демонстрационное расписание."); + return null; + } + await importer.ImportAsync(new MemoryStream(bytes), groups); + var load = await db.ScheduleSlots.AsNoTracking().GroupBy(x => x.TeacherId) + .Select(x => new { TeacherId = x.Key, Count = x.Count() }).ToListAsync(); + var active = await db.Teachers.Where(x => x.IsActive && x.Id != placeholder.Id).ToListAsync(); + var main = active.OrderByDescending(t => load.FirstOrDefault(x => x.TeacherId == t.Id)?.Count ?? 0).First(); + db.Teachers.Remove(placeholder); + await db.SaveChangesAsync(); + main.Login = placeholder.Login; + main.IsAdmin = true; + main.MustChangePassword = false; + main.PasswordHash = hasher.HashPassword(main, configuration["Demo:Password"] ?? "teacher123"); + await db.SaveChangesAsync(); + logger.LogInformation("Демо: импортировано расписание групп {Groups}; вход «{Login}» — {Teacher}.", + string.Join(", ", groups), main.Login, main.FullName); + return (main, load.Select(x => x.TeacherId).ToList()); + } + + private async Task AddDemoScheduleAsync(Teacher teacher, DateOnly start) + { + var subjects = new[] { "Базы данных", "DevOps", "Веб-программирование", "Алгоритмы", "Английский язык", "Компьютерные сети" }; + var bells = new[] { (new TimeOnly(8, 30), new TimeOnly(9, 50)), (new TimeOnly(10, 0), new TimeOnly(11, 20)), + (new TimeOnly(11, 30), new TimeOnly(12, 50)), (new TimeOnly(13, 20), new TimeOnly(14, 40)), + (new TimeOnly(14, 50), new TimeOnly(16, 10)), (new TimeOnly(16, 20), new TimeOnly(17, 40)) }; + var groups = await db.Groups.OrderByDescending(x => x.Name).ToListAsync(); + for (var groupIndex = 0; groupIndex < groups.Count; groupIndex++) + { + for (var day = 0; day < 5; day++) + { + foreach (var pair in new[] { groupIndex * 2 % 6, (groupIndex * 2 + 3) % 6 }) + { + var subject = subjects[(day * 2 + pair + groupIndex) % subjects.Length]; + db.ScheduleSlots.Add(new ScheduleSlot { + TeacherId = teacher.Id, GroupId = groups[groupIndex].Id, + Subject = subject, Room = $"{305 + groupIndex * 4 + pair % 2}", + DayOfWeek = (DayOfWeek)(day + 1), Start = bells[pair].Item1, End = bells[pair].Item2, + Parity = day == 4 && pair > 2 ? (groupIndex % 2 == 0 ? WeekParity.Odd : WeekParity.Even) : WeekParity.Every, + ValidFrom = start + }); + } + } + } + await db.SaveChangesAsync(); + } + + private async Task AddHistoryAsync(List teacherIds, DateOnly start) + { + var yesterday = DateOnly.FromDateTime(DateTime.Now).AddDays(-1); + var first = yesterday.AddDays(-27); + if (first < start) first = start; + var past = new List(); + foreach (var teacherId in teacherIds) + for (var day = first; day <= yesterday; day = day.AddDays(1)) + past.AddRange(await schedule.ForDayAsync(teacherId, day)); + var cancelled = past.FirstOrDefault(); + if (cancelled is not null) + { + db.SlotCancellations.Add(new SlotCancellation { + SlotId = cancelled.Slot.Id, Date = cancelled.Date, Reason = "Перенос по распоряжению учебной части" + }); + } + var students = await db.Students.OrderBy(x => x.Id).ToListAsync(); + var riskIds = students.Take(3).Select(x => x.Id).ToHashSet(); + var random = new Random(42); + foreach (var lesson in past.Where(x => x != cancelled)) + { + foreach (var student in students.Where(x => x.GroupId == lesson.Slot.GroupId)) + { + var roll = random.NextDouble(); + var risk = riskIds.Contains(student.Id); + var status = roll < (risk ? 0.60 : 0.89) ? AttendanceStatus.Present : + roll < (risk ? 0.68 : 0.93) ? AttendanceStatus.Excused : AttendanceStatus.Absent; + var reasonRoll = random.NextDouble(); + var reason = reasonRoll < 0.70 ? ExcuseReason.Illness : + reasonRoll < 0.85 ? ExcuseReason.Family : ExcuseReason.Official; + var attended = status == AttendanceStatus.Present; + db.AttendanceMarks.Add(new AttendanceMark { + SlotId = lesson.Slot.Id, Date = lesson.Date, StudentId = student.Id, Status = status, + Method = attended && random.NextDouble() < 0.93 ? MarkMethod.Camera : MarkMethod.Manual, + ExcuseReason = status == AttendanceStatus.Excused ? reason : null, + HasDocument = status == AttendanceStatus.Excused && reason == ExcuseReason.Illness && random.NextDouble() < 0.80, + MarkedAt = attended + ? lesson.StartsAt.AddMinutes(random.Next(-4, 6)).AddSeconds(random.Next(60)) + : lesson.EndsAt.AddMinutes(-random.Next(5)) + }); + if (attended && random.NextDouble() < 0.35) + db.LessonGrades.Add(new LessonGrade { + SlotId = lesson.Slot.Id, Date = lesson.Date, StudentId = student.Id, + Score = risk ? random.Next(45, 81) : random.Next(60, 101) + }); + } + } + var assessments = await db.ScheduleSlots.AsNoTracking() + .Where(x => teacherIds.Contains(x.TeacherId)) + .Select(x => new { x.TeacherId, x.GroupId, x.Subject }).Distinct().ToListAsync(); + foreach (var pair in assessments) + { + foreach (var (date, title, kind) in new[] { + (yesterday.AddDays(-13), "Тест №1", AssessmentKind.Test), + (yesterday.AddDays(-6), "Контрольная работа №1", AssessmentKind.ControlWork) + }) + { + if (date < start) continue; + var assessment = new Assessment { + TeacherId = pair.TeacherId, GroupId = pair.GroupId, Subject = pair.Subject, + Title = title, Kind = kind, Date = date + }; + db.Assessments.Add(assessment); + var members = students.Where(x => x.GroupId == pair.GroupId).OrderBy(_ => random.Next()).ToList(); + var withoutGrade = Math.Min(members.Count, random.Next(1, 3)); + foreach (var student in members.Skip(withoutGrade)) + assessment.Grades.Add(new AssessmentGrade { + StudentId = student.Id, Score = riskIds.Contains(student.Id) ? random.Next(35, 76) : random.Next(45, 101) + }); + } + } + for (var week = 0; week < 4; week++) + { + var exported = yesterday.AddDays(-7 * week); + if (exported < start) break; + db.AppSettings.Add(new AppSetting { + Key = $"stats.reports_exported.{exported.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}", Value = "2" + }); + } + await db.SaveChangesAsync(); + } +} diff --git a/BiStudent.Server/Services/SessionGeneration.cs b/BiStudent.Server/Services/SessionGeneration.cs new file mode 100644 index 0000000..117b6e6 --- /dev/null +++ b/BiStudent.Server/Services/SessionGeneration.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using System.Security.Cryptography; + +namespace BiStudent.Server.Services; + +/// Случайный идентификатор поколения сессий: хранится вне базы и меняется при восстановлении копии, +/// чтобы токены, выданные раньше (в том числе другим сервером с теми же ключами), перестали действовать. +public sealed class SessionGeneration(StoragePaths paths) +{ + public const string ClaimType = "bistudent:session"; + private readonly object gate = new(); + private string? current; + + private string FilePath => Path.Combine(paths.DataDirectory, "session.generation"); + + public string Current + { + get + { + lock (gate) + { + if (current is not null) return current; + var stored = File.Exists(FilePath) ? File.ReadAllText(FilePath).Trim() : ""; + if (stored.Length < 32) { stored = NewId(); Write(stored); } + return current = stored; + } + } + } + + /// Сохраняет новое поколение до изменения данных; при ошибке записи бросает исключение и поколение не меняется. + public void Rotate() + { + lock (gate) + { + var next = NewId(); + Write(next); + current = next; + } + } + + public Claim Claim() => new(ClaimType, Current); + + public bool IsCurrent(ClaimsPrincipal user) => user.FindFirst(ClaimType)?.Value == Current; + + private static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + private void Write(string value) + { + Directory.CreateDirectory(paths.DataDirectory); + var temporary = FilePath + ".tmp"; + File.WriteAllText(temporary, value); + File.Move(temporary, FilePath, overwrite: true); + } +} diff --git a/BiStudent.Server/Services/StatsService.cs b/BiStudent.Server/Services/StatsService.cs new file mode 100644 index 0000000..c1b294a --- /dev/null +++ b/BiStudent.Server/Services/StatsService.cs @@ -0,0 +1,124 @@ +using BiStudent.Contracts; +using System.Globalization; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed record GroupAttendance(int GroupId, string GroupName, double Percent); +public sealed record AtRiskStudent(int StudentId, string FullName, string GroupName, double Percent, string Reason); +public sealed record AttendanceTrend(DateOnly Date, double Percent); +public sealed record DashboardStats(int LessonsToday, LessonOccurrence? NextOrCurrent, double Attendance7Days, + double Attendance30Days, List Groups, List AtRisk, + int CameraMarks7Days, int CameraMarks30Days, double SavedMinutes7Days, double SavedMinutes30Days, + string SavingsFormula, List Trend, double? AverageScore30Days, + int PendingFaceSubmissions); + +public sealed class StatsService(AppDbContext db, ScheduleService schedule, IConfiguration configuration) +{ + public async Task ReportsExportedAsync(DateOnly from, DateOnly to) + { + var settings = await db.AppSettings.AsNoTracking().Where(x => x.Key.StartsWith("stats.reports_exported.")).ToListAsync(); + return settings.Sum(x => + DateOnly.TryParseExact(x.Key["stats.reports_exported.".Length..], "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date) && date >= from && date <= to && + int.TryParse(x.Value, out var count) ? count : 0); + } + + public async Task DashboardAsync(int teacherId, DateTime now) + { + var today = DateOnly.FromDateTime(now); + var from = today.AddDays(-29); + var occurrences = new List(); + for (var day = from; day <= today; day = day.AddDays(1)) + occurrences.AddRange((await schedule.ForDayAsync(teacherId, day)) + .Where(x => !x.Cancelled && x.EndsAt <= now)); + var todayLessons = await schedule.ForDayAsync(teacherId, today); + var next = todayLessons.FirstOrDefault(x => !x.Cancelled && x.EndsAt > now); + var groupIds = occurrences.Select(x => x.Slot.GroupId).Distinct().ToArray(); + var memberships = await db.MembershipsDuring(from, today).AsNoTracking() + .Where(x => groupIds.Contains(x.GroupId)).ToListAsync(); + var studentIds = memberships.Select(x => x.StudentId).Distinct().ToArray(); + var students = await db.Students.AsNoTracking().Include(x => x.Group) + .Where(x => studentIds.Contains(x.Id)).ToListAsync(); + var membershipMap = memberships.GroupBy(x => x.StudentId).ToDictionary(x => x.Key, x => x.ToList()); + bool InGroup(int studentId, int groupId, DateOnly date) => + membershipMap.TryGetValue(studentId, out var periods) && + periods.Any(x => x.GroupId == groupId && x.From <= date && (x.To is null || x.To >= date)); + var slotIds = occurrences.Select(x => x.Slot.Id).Distinct().ToArray(); + var marks = await db.AttendanceMarks.AsNoTracking() + .Where(x => slotIds.Contains(x.SlotId) && x.Date >= from && x.Date <= today).ToListAsync(); + var markMap = marks.ToDictionary(x => (x.SlotId, x.Date, x.StudentId)); + + double Percent(IEnumerable lessons, IEnumerable people) + { + var possible = 0; + var attended = 0; + foreach (var lesson in lessons) + foreach (var person in people) + { + if (!InGroup(person.Id, lesson.Slot.GroupId, lesson.Date)) continue; + possible++; + if (markMap.TryGetValue((lesson.Slot.Id, lesson.Date, person.Id), out var mark) && + mark.Status == AttendanceStatus.Present) attended++; + } + return possible == 0 ? 0 : 100d * attended / possible; + } + + var groupNames = await db.Groups.AsNoTracking().Where(x => groupIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Name }).ToListAsync(); + var groups = groupNames.Select(group => + new GroupAttendance(group.Id, group.Name, + Percent(occurrences.Where(x => x.Slot.GroupId == group.Id), students))).OrderBy(x => x.GroupName).ToList(); + var risk = new List(); + foreach (var student in students) + { + var lessons = occurrences.Where(x => InGroup(student.Id, x.Slot.GroupId, x.Date)) + .OrderBy(x => x.StartsAt).ToList(); + var percent = Percent(lessons, new[] { student }); + var missed = 0; + var longest = 0; + foreach (var lesson in lessons) + { + var absent = !markMap.TryGetValue((lesson.Slot.Id, lesson.Date, student.Id), out var mark) || + mark.Status == AttendanceStatus.Absent; + missed = absent ? missed + 1 : 0; + longest = Math.Max(longest, missed); + } + if (lessons.Count > 0 && (percent < 70 || longest >= 3)) + risk.Add(new AtRiskStudent(student.Id, student.FullName, student.Group.Name, percent, + longest >= 3 ? "Три и более пропуска подряд без уважительной причины" : "Посещаемость ниже 70%")); + } + var last7 = today.AddDays(-6); + var camera7 = marks.Count(x => x.Method == MarkMethod.Camera && x.Date >= last7); + var camera30 = marks.Count(x => x.Method == MarkMethod.Camera); + var seconds = configuration.GetValue("Automation:SecondsPerManualMark", 6); + var reportMinutes = configuration.GetValue("Automation:MinutesPerManualReport", 20); + var reportSettings = await db.AppSettings.AsNoTracking() + .Where(x => x.Key.StartsWith("stats.reports_exported.")).ToListAsync(); + int ReportsSince(DateOnly date) => reportSettings.Sum(x => + DateOnly.TryParseExact(x.Key["stats.reports_exported.".Length..], "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var exported) && exported >= date && exported <= today && + int.TryParse(x.Value, out var count) ? count : 0); + var trend = occurrences.GroupBy(x => x.Date).OrderBy(x => x.Key) + .Select(x => new AttendanceTrend(x.Key, Percent(x, students))).ToList(); + var lessonScores = await db.LessonGrades.AsNoTracking() + .Where(x => x.Slot.TeacherId == teacherId && x.Date >= from && x.Date <= today) + .Select(x => x.Score).ToListAsync(); + var assessmentScores = await db.AssessmentGrades.AsNoTracking() + .Where(x => x.Assessment.TeacherId == teacherId && + x.Assessment.Date >= from && x.Assessment.Date <= today) + .Select(x => x.Score).ToListAsync(); + var scores = lessonScores.Concat(assessmentScores).ToList(); + var pending = await db.FaceSubmissions.CountAsync(x => x.Status == FaceSubmissionStatus.Pending && + (!db.ScheduleSlots.Any(slot => slot.GroupId == x.Student.GroupId) || + db.ScheduleSlots.Any(slot => slot.GroupId == x.Student.GroupId && slot.TeacherId == teacherId))); + return new DashboardStats(todayLessons.Count(x => !x.Cancelled), next, + Percent(occurrences.Where(x => x.Date >= last7), students), Percent(occurrences, students), + groups, risk.OrderBy(x => x.Percent).ToList(), camera7, camera30, + camera7 * seconds / 60d + ReportsSince(last7) * reportMinutes, + camera30 * seconds / 60d + ReportsSince(from) * reportMinutes, + $"Отметки камерой × {seconds} сек. + выгрузки журнала × {reportMinutes} мин.", + trend, scores.Count == 0 ? null : scores.Average(), pending); + } +} diff --git a/BiStudent.Server/Services/StoragePaths.cs b/BiStudent.Server/Services/StoragePaths.cs new file mode 100644 index 0000000..2d04fa2 --- /dev/null +++ b/BiStudent.Server/Services/StoragePaths.cs @@ -0,0 +1,8 @@ +namespace BiStudent.Server.Services; + +public sealed record StoragePaths(string DatabasePath, string ConnectionString) +{ + public string DataDirectory => Path.GetDirectoryName(Path.GetFullPath(DatabasePath))!; + public string KeysDirectory => Path.Combine(DataDirectory, "keys"); + public string BackupsDirectory => Path.Combine(DataDirectory, "backups"); +} diff --git a/BiStudent.Server/Services/StudentService.cs b/BiStudent.Server/Services/StudentService.cs new file mode 100644 index 0000000..0251cd2 --- /dev/null +++ b/BiStudent.Server/Services/StudentService.cs @@ -0,0 +1,169 @@ +using BiStudent.Contracts; +using ClosedXML.Excel; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed record GroupSummary(int Id, string Name, int Students, int WithFace); + +public sealed class StudentService(AppDbContext db, RecognitionService recognition) +{ + public Task> GroupsAsync() => db.Groups.AsNoTracking().OrderBy(x => x.Name) + .Select(x => new GroupSummary(x.Id, x.Name, x.Students.Count, x.Students.Count(s => s.Samples.Any()))).ToListAsync(); + + public Task> ForGroupAsync(int groupId) => db.Students.AsNoTracking() + .Where(x => x.GroupId == groupId).OrderBy(x => x.FullName).ToListAsync(); + + public Task HasFace(int studentId) => db.FaceSamples.AnyAsync(x => x.StudentId == studentId); + + public async Task> WithFaceAsync(int groupId) => (await db.FaceSamples.AsNoTracking() + .Where(x => x.Student.GroupId == groupId).Select(x => x.StudentId).Distinct().ToListAsync()).ToHashSet(); + + public async Task AddGroupAsync(string name) + { + name = name.Trim(); + if (name.Length == 0) throw new InvalidOperationException("Укажите название группы."); + if (await db.Groups.AnyAsync(x => x.Name == name)) throw new InvalidOperationException("Такая группа уже есть."); + var group = new Group { Name = name }; + db.Groups.Add(group); + await db.SaveChangesAsync(); + return group; + } + + public async Task DeleteGroupAsync(int id) + { + var group = await db.Groups.FindAsync(id) ?? throw new KeyNotFoundException("Группа не найдена."); + if (await db.Students.AnyAsync(x => x.GroupId == id) || await db.ScheduleSlots.AnyAsync(x => x.GroupId == id)) + throw new InvalidOperationException("Нельзя удалить группу со студентами или расписанием."); + db.Groups.Remove(group); + await db.SaveChangesAsync(); + } + + public async Task AddStudentAsync(int groupId, string fullName) + { + fullName = fullName.Trim(); + if (fullName.Length == 0) throw new InvalidOperationException("Укажите ФИО."); + if (!await db.Groups.AnyAsync(x => x.Id == groupId)) throw new KeyNotFoundException("Группа не найдена."); + if (await db.Students.AnyAsync(x => x.GroupId == groupId && x.FullName == fullName)) + throw new InvalidOperationException("Студент уже есть в группе."); + var student = new Student { GroupId = groupId, FullName = fullName }; + db.Students.Add(student); + await db.SaveChangesAsync(); + return student; + } + + public async Task RenameStudentAsync(int id, string fullName) + { + var student = await db.Students.FindAsync(id) ?? throw new KeyNotFoundException("Студент не найден."); + fullName = fullName.Trim(); + if (fullName.Length == 0) throw new InvalidOperationException("Укажите ФИО."); + if (await db.Students.AnyAsync(x => x.Id != id && x.GroupId == student.GroupId && x.FullName == fullName)) + throw new InvalidOperationException("Студент уже есть в группе."); + student.FullName = fullName; + await db.SaveChangesAsync(); + } + + public async Task MoveStudentAsync(int id, int groupId) + { + var student = await db.Students.FindAsync(id) ?? throw new KeyNotFoundException("Студент не найден."); + if (!await db.Groups.AnyAsync(x => x.Id == groupId)) throw new KeyNotFoundException("Группа не найдена."); + if (await db.Students.AnyAsync(x => x.Id != id && x.GroupId == groupId && x.FullName == student.FullName)) + throw new InvalidOperationException("Студент уже есть в группе."); + if (student.GroupId == groupId) return; + var today = DateOnly.FromDateTime(DateTime.Now); + var open = await db.GroupMemberships.FirstOrDefaultAsync(x => x.StudentId == id && x.To == null); + if (open is null) + { + if (!await db.GroupMemberships.AnyAsync(x => x.StudentId == id)) + db.GroupMemberships.Add(new GroupMembership { + StudentId = id, GroupId = student.GroupId, From = DateOnly.MinValue, To = today.AddDays(-1) + }); + db.GroupMemberships.Add(new GroupMembership { StudentId = id, GroupId = groupId, From = today }); + } + else if (open.From >= today) + open.GroupId = groupId; + else + { + open.To = today.AddDays(-1); + db.GroupMemberships.Add(new GroupMembership { StudentId = id, GroupId = groupId, From = today }); + } + student.GroupId = groupId; + await db.SaveChangesAsync(); + } + + public async Task DeleteStudentAsync(int id) + { + var student = await db.Students.FindAsync(id) ?? throw new KeyNotFoundException("Студент не найден."); + db.Students.Remove(student); + await db.SaveChangesAsync(); + recognition.Invalidate(); + } + + /// Список группы из CSV («ФИО;Группа») или из журнала Excel: каждый лист — группа (имя листа), ФИО — в колонке с заголовком «ФИО». + public async Task ImportAsync(Stream content) + { + using var buffer = new MemoryStream(); + await content.CopyToAsync(buffer); + buffer.Position = 0; + if (buffer.Length < 2 || buffer.GetBuffer()[0] != (byte)'P' || buffer.GetBuffer()[1] != (byte)'K') + { + var (added, skipped) = await ImportCsvAsync(buffer); + return new ImportResultDto(added, skipped, []); + } + XLWorkbook workbook; + try { workbook = new XLWorkbook(buffer); } + catch (Exception ex) { throw new InvalidOperationException("Не удалось прочитать файл Excel.", ex); } + using (workbook) + { + int totalAdded = 0, totalSkipped = 0; + var errors = new List(); + foreach (var sheet in workbook.Worksheets) + { + var group = System.Text.RegularExpressions.Regex.Replace(sheet.Name, @"\s+", " ").Trim(); + var used = sheet.RangeUsed(); + if (used is null || group.Length == 0) continue; + var header = used.CellsUsed().FirstOrDefault(c => c.Address.RowNumber <= 15 && + c.GetFormattedString().Trim().Equals("ФИО", StringComparison.OrdinalIgnoreCase)); + if (header is null) { errors.Add($"Лист «{sheet.Name}»: нет колонки «ФИО»."); continue; } + var names = new List(); + for (var row = header.Address.RowNumber + 1; row <= used.LastRow().RowNumber(); row++) + { + var name = System.Text.RegularExpressions.Regex.Replace(sheet.Cell(row, header.Address.ColumnNumber).GetFormattedString(), @"\s+", " ").Trim(); + // Служебные строки вроде «куратор: …» и пустые строки пропускаются. + if (name.Length < 3 || name.Contains(':')) continue; + names.Add(name); + } + if (names.Count == 0) { errors.Add($"Лист «{sheet.Name}»: список студентов пуст."); continue; } + var target = await db.Groups.SingleOrDefaultAsync(x => x.Name == group) ?? await AddGroupAsync(group); + foreach (var name in names.Distinct()) + { + if (await db.Students.AnyAsync(x => x.GroupId == target.Id && x.FullName == name)) { totalSkipped++; continue; } + db.Students.Add(new Student { GroupId = target.Id, FullName = name }); + totalAdded++; + } + await db.SaveChangesAsync(); + } + return new ImportResultDto(totalAdded, totalSkipped, errors); + } + } + + public async Task<(int Added, int Skipped)> ImportCsvAsync(Stream csv) + { + using var reader = new StreamReader(csv, leaveOpen: true); + var added = 0; + var skipped = 0; + while (await reader.ReadLineAsync() is { } raw) + { + var parts = raw.Split(';').Select(x => x.Trim()).ToArray(); + if (parts.Length < 2 || parts[0].Length == 0 || parts[1].Length == 0) continue; + if (parts[0].Equals("ФИО", StringComparison.OrdinalIgnoreCase)) continue; + var group = await db.Groups.SingleOrDefaultAsync(x => x.Name == parts[1]) ?? await AddGroupAsync(parts[1]); + if (await db.Students.AnyAsync(x => x.GroupId == group.Id && x.FullName == parts[0])) { skipped++; continue; } + db.Students.Add(new Student { GroupId = group.Id, FullName = parts[0] }); + await db.SaveChangesAsync(); + added++; + } + return (added, skipped); + } +} diff --git a/BiStudent.Server/appsettings.Development.json b/BiStudent.Server/appsettings.Development.json new file mode 100644 index 0000000..60aedc2 --- /dev/null +++ b/BiStudent.Server/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "Logging": { + "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Information" } + } +} diff --git a/BiStudent.Server/appsettings.json b/BiStudent.Server/appsettings.json new file mode 100644 index 0000000..825d1fd --- /dev/null +++ b/BiStudent.Server/appsettings.json @@ -0,0 +1,30 @@ +{ + "Semester": { + "Start": "2026-08-31" + }, + "Attendance": { + "HoursPerLesson": 2 + }, + "Recognition": { + "FramesInRow": 3 + }, + "Automation": { + "SecondsPerManualMark": 6, + "MinutesPerManualReport": 20 + }, + "Setup": { + "AdminLogin": "admin", + "AdminPassword": "admin", + "AdminName": "Администратор" + }, + "Demo": { + "Enabled": false, + "UseLocalSchedule": false, + "Login": "teacher", + "Password": "teacher123", + "TeacherName": "Ахметова Алия Маратовна" + }, + "Logging": { + "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } + } +} diff --git a/BiStudent.Server/seed/students.demo.csv b/BiStudent.Server/seed/students.demo.csv new file mode 100644 index 0000000..1006860 --- /dev/null +++ b/BiStudent.Server/seed/students.demo.csv @@ -0,0 +1,31 @@ +ФИО;Группа +Алина Берёзова;ПО-Демо-1 +Айбек Сарыбаев;ПО-Демо-1 +Мария Тихонова;ПО-Демо-1 +Данияр Нуров;ПО-Демо-1 +София Миронова;ПО-Демо-1 +Арман Есенов;ПО-Демо-1 +Елизавета Лукина;ПО-Демо-1 +Нурай Алимова;ПО-Демо-1 +Илья Савельев;ПО-Демо-1 +Жанель Касымова;ПО-Демо-1 +Максим Орлов;ПО-Демо-1 +Айдана Ермекова;ПО-Демо-1 +Кирилл Белов;ПО-Демо-1 +Аружан Тлеубекова;ПО-Демо-1 +Никита Ветров;ПО-Демо-1 +Милана Сергеева;ПО-Демо-2 +Тимур Омаров;ПО-Демо-2 +Полина Нестерова;ПО-Демо-2 +Алихан Досов;ПО-Демо-2 +Дарья Рябинина;ПО-Демо-2 +Еркебулан Сеитов;ПО-Демо-2 +Виктория Захарова;ПО-Демо-2 +Мадина Бекова;ПО-Демо-2 +Артём Корнеев;ПО-Демо-2 +Диана Исаева;ПО-Демо-2 +Санжар Муратов;ПО-Демо-2 +Вероника Громова;ПО-Демо-2 +Рауан Кенжев;ПО-Демо-2 +Ксения Агапова;ПО-Демо-2 +Азамат Рахимов;ПО-Демо-2 diff --git a/BiStudent.Server/wwwroot/register/index.html b/BiStudent.Server/wwwroot/register/index.html new file mode 100644 index 0000000..b7325c8 --- /dev/null +++ b/BiStudent.Server/wwwroot/register/index.html @@ -0,0 +1,82 @@ + + + + + + Регистрация лица — BiStudent + + + + + +
+ +
+

Введите код группы

+

Код выдаёт преподаватель, например K7PM-Q2XA

+ + + +

+ + +
+ + + +
+
Данные передаются только на сервер учебного заведения
+ + diff --git a/BiStudent.Server/wwwroot/register/register.css b/BiStudent.Server/wwwroot/register/register.css new file mode 100644 index 0000000..040d3df --- /dev/null +++ b/BiStudent.Server/wwwroot/register/register.css @@ -0,0 +1,66 @@ +:root { color-scheme: light; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #172033; background: #f3f5f9; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; } +button, input, select { font: inherit; } +button, input[type="text"], input[type="search"], select { min-height: 44px; } +button { border: 0; border-radius: 8px; padding: 11px 18px; background: #1f3a8a; color: #fff; font-weight: 650; cursor: pointer; } +button:hover:not(:disabled) { background: #172e70; } +button:disabled { opacity: .55; cursor: not-allowed; } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #8ba5f0; outline-offset: 2px; } +button.secondary { background: #e8edf8; color: #1f3a8a; } +button.secondary:hover:not(:disabled) { background: #d8e2f8; } +input[type="text"], input[type="search"], #code, select { width: 100%; border: 1px solid #aeb8c8; border-radius: 8px; padding: 9px 12px; background: #fff; color: #172033; } +input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; accent-color: #1f3a8a; flex: none; } +label { display: block; font-weight: 600; } +.shell { width: min(100% - 32px, 560px); margin-inline: auto; } +.site-header { background: #fff; border-bottom: 1px solid #dfe4ee; padding: 21px 0; } +.brand { color: #1f3a8a; font-size: 1.35rem; letter-spacing: .01em; } +.site-header p { margin: 5px 0 0; color: #526078; font-size: .92rem; } +.steps { display: flex; gap: 5px; margin: 25px 0 16px; } +.steps span { flex: 1; min-width: 0; border-bottom: 3px solid #cbd3df; padding: 0 2px 10px; color: #657186; text-align: center; font-size: .82rem; } +.steps span.active { border-color: #1f3a8a; color: #1f3a8a; font-weight: 700; } +.card { background: #fff; border: 1px solid #e0e5ee; border-radius: 14px; box-shadow: 0 4px 20px #18244a0a; padding: 28px; } +[hidden] { display: none !important; } +h1 { margin: 0 0 12px; font-size: 1.45rem; line-height: 1.25; } +p { line-height: 1.5; } +.hint { margin: 0 0 19px; color: #5b6679; } +form label, .card > label:not(.consent) { margin-bottom: 8px; } +form button { width: 100%; } +#code { font-size: 1.3rem; letter-spacing: .12em; text-transform: uppercase; } +.error { min-height: 1.3em; color: #a52b30; font-weight: 600; } +.group-name { margin-bottom: 5px; font-size: 1.12rem; font-weight: 700; } +.student-list { max-height: 320px; overflow: auto; margin: 15px 0; display: grid; gap: 8px; } +.student-card { display: flex; align-items: center; gap: 12px; min-height: 56px; border: 1px solid #cbd3df; border-radius: 9px; padding: 11px; cursor: pointer; } +.student-card:has(input:checked) { border-color: #1f3a8a; background: #f1f4ff; } +.student-card .name { flex: 1; } +.badge { display: inline-block; border-radius: 20px; padding: 3px 8px; color: #536072; background: #edf0f4; font-size: .76rem; font-weight: 600; } +.badge.pending { color: #765400; background: #fff2c6; } +.notice { border-radius: 8px; padding: 12px; background: #f4f6fb; color: #46536b; } +.consent { display: flex; align-items: flex-start; gap: 10px; margin: 20px 0 10px; font-weight: 400; line-height: 1.5; } +.consent input { margin-top: 3px; } +#student-next { width: 100%; } +.pose { font-weight: 700; color: #1f3a8a; } +.viewfinder { position: relative; overflow: hidden; border-radius: 10px; background: #172033; aspect-ratio: 4 / 3; margin: 12px 0; } +.viewfinder video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); } +.oval { position: absolute; inset: 11% 25%; border: 3px solid #ffffffd9; border-radius: 50%; box-shadow: 0 0 0 999px #0003; pointer-events: none; } +#camera-select { margin: 8px 0; } +#capture { width: 100%; } +.file-row { margin: 13px 0; } +.file-row label { margin-bottom: 6px; } +.file-row input { width: 100%; min-height: 44px; } +.thumbnails { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 20px 0 8px; } +.thumb { min-width: 0; } +.thumb img, .thumb-placeholder { display: block; width: 100%; aspect-ratio: 1; border-radius: 8px; object-fit: cover; background: #eef1f6; } +.thumb-placeholder { display: grid; place-items: center; color: #637086; text-align: center; font-size: .85rem; } +.thumb button { width: 100%; margin-top: 6px; padding: 8px 4px; font-size: .8rem; } +.problems { margin: 0 0 14px; color: #a52b30; } +.problems:empty { display: none; } +.actions { display: flex; gap: 10px; } +.actions button { flex: 1; } +footer { padding: 24px 0 32px; color: #677284; text-align: center; font-size: .83rem; } +@media (max-width: 480px) { + .card { padding: 20px; } + .steps span { font-size: .72rem; } + .student-card { flex-wrap: wrap; } + .student-card .badge { margin-left: 32px; } +} diff --git a/BiStudent.Server/wwwroot/register/register.js b/BiStudent.Server/wwwroot/register/register.js new file mode 100644 index 0000000..994b567 --- /dev/null +++ b/BiStudent.Server/wwwroot/register/register.js @@ -0,0 +1,350 @@ +const $ = id => document.getElementById(id); +const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const poses = ['Смотрите прямо в камеру', 'Слегка поверните голову влево', 'Слегка поверните голову вправо']; +let code = ''; +let group = null; +let selectedId = null; +let captures = [null, null, null]; +let stream = null; +let captureIndex = 0; +let retaking = false; +let sending = false; +let currentStep = 1; +let cameraAttempt = 0; +let registrationGeneration = 0; + +function formatCode(value) { + const clean = [...value.toUpperCase()].filter(char => alphabet.includes(char)).slice(0, 8).join(''); + return clean.length > 4 ? `${clean.slice(0, 4)}-${clean.slice(4)}` : clean; +} + +function showStep(number) { + if (sending && number !== 4) return; + if (currentStep === 3 && number !== 3) registrationGeneration++; + currentStep = number; + for (let index = 1; index <= 4; index++) { + $(`step-${index}`).hidden = index !== number; + document.querySelector(`[data-step-label="${index}"]`).classList.toggle('active', index === number); + } + if (number !== 3) stopCamera(); + updateButtons(); + window.scrollTo(0, 0); +} + +function stopCamera() { + cameraAttempt++; + if (stream) stream.getTracks().forEach(track => track.stop()); + stream = null; + $('video').srcObject = null; +} + +async function api(path, body, generation) { + let response; + try { + response = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (generation !== registrationGeneration) return null; + } catch { + throw new Error('Нет связи с сервером. Проверьте подключение и повторите попытку.'); + } + if (response.status === 429) throw new Error('Слишком много попыток — подождите минуту.'); + let data; + try { + data = await response.json(); + if (generation !== registrationGeneration) return null; + } + catch { throw new Error('Сервер вернул неожиданный ответ. Повторите попытку.'); } + if (!response.ok) throw new Error(data.message || 'Не удалось выполнить запрос.'); + return data; +} + +function updateButtons() { + $('student-next').disabled = sending || selectedId === null || !$('consent').checked; + $('back').disabled = sending; + $('another').disabled = sending; + $('camera-select').disabled = sending; + $('submit').disabled = sending || currentStep !== 3 || captures.some(image => !image); + $('capture').disabled = sending || currentStep !== 3 || !stream || + (captures[captureIndex] !== null && !retaking); + for (let index = 0; index < 3; index++) $(`file-${index}`).disabled = sending; + $('thumbnails').querySelectorAll('button').forEach(button => { button.disabled = sending; }); +} + +function updateNext() { + updateButtons(); + const selected = group?.students.find(student => student.id === selectedId); + $('replacement-warning').hidden = !selected || selected.state === 'None'; +} + +function renderStudents() { + const list = $('students'); + list.replaceChildren(); + const query = $('student-search').value.trim().toLocaleLowerCase('ru'); + for (const student of group.students.filter(item => item.fullName.toLocaleLowerCase('ru').includes(query))) { + const label = document.createElement('label'); + label.className = 'student-card'; + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'student'; + radio.value = student.id; + radio.checked = student.id === selectedId; + radio.addEventListener('change', () => { + if (selectedId !== student.id) registrationGeneration++; + selectedId = student.id; + updateNext(); + }); + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = student.fullName; + label.append(radio, name); + if (student.state !== 'None') { + const badge = document.createElement('span'); + badge.className = `badge${student.state === 'Pending' ? ' pending' : ''}`; + badge.textContent = student.state === 'Pending' ? 'На проверке' : 'Лицо зарегистрировано'; + label.append(badge); + } + list.append(label); + } +} + +async function checkCode() { + const generation = ++registrationGeneration; + $('code-error').textContent = ''; + code = formatCode($('code').value); + $('code').value = code; + if (code.replace('-', '').length !== 8) { + $('code-error').textContent = 'Введите восьмизначный код группы.'; + return; + } + $('code-button').disabled = true; + try { + const checkedGroup = await api('/api/public/enrollment/check', { code }, generation); + if (generation !== registrationGeneration) return; + group = checkedGroup; + $('group-name').textContent = group.groupName; + const expires = new Date(group.expiresAt); + $('expires').textContent = `Код действует до ${expires.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}`; + selectedId = null; + $('consent').checked = false; + $('student-search').value = ''; + renderStudents(); + updateNext(); + showStep(2); + } catch (error) { + if (generation === registrationGeneration) $('code-error').textContent = error.message; + } finally { + if (generation === registrationGeneration) $('code-button').disabled = false; + } +} + +function updateCaptures() { + const list = $('thumbnails'); + list.replaceChildren(); + for (let index = 0; index < 3; index++) { + const item = document.createElement('div'); + item.className = 'thumb'; + if (captures[index]) { + const image = document.createElement('img'); + image.src = `data:image/jpeg;base64,${captures[index]}`; + image.alt = `Снимок ${index + 1}`; + item.append(image); + const retake = document.createElement('button'); + retake.type = 'button'; + retake.className = 'secondary'; + retake.textContent = 'Переснять'; + retake.addEventListener('click', () => { + if (sending) return; + if (stream) { + captureIndex = index; + retaking = true; + updatePose(); + updateButtons(); + $('capture').focus(); + } else { + const input = $(`file-${index}`); + input.value = ''; + input.click(); + } + }); + item.append(retake); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'thumb-placeholder'; + placeholder.textContent = `Снимок ${index + 1}`; + item.append(placeholder); + } + list.append(item); + } + updateButtons(); + updatePose(); +} + +function updatePose() { + $('pose').textContent = `${captureIndex + 1}. ${poses[captureIndex]}`; +} + +function scaledJpeg(source, width, height) { + const ratio = Math.min(1, 1280 / Math.max(width, height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.getContext('2d').drawImage(source, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/jpeg', 0.9).split(',')[1]; +} + +async function startCamera(deviceId) { + stopCamera(); + const generation = registrationGeneration; + const attempt = cameraAttempt; + if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) { + $('camera-mode').hidden = true; + $('file-mode').hidden = false; + updateCaptures(); + return; + } + try { + const nextStream = await navigator.mediaDevices.getUserMedia({ + video: deviceId ? { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } + : { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false + }); + if (generation !== registrationGeneration || attempt !== cameraAttempt || currentStep !== 3 || sending) { + nextStream.getTracks().forEach(track => track.stop()); + return; + } + stream = nextStream; + $('video').srcObject = stream; + $('camera-mode').hidden = false; + $('file-mode').hidden = true; + const devices = (await navigator.mediaDevices.enumerateDevices()).filter(item => item.kind === 'videoinput'); + if (generation !== registrationGeneration || attempt !== cameraAttempt || currentStep !== 3 || sending) return; + $('camera-select').replaceChildren(); + devices.forEach((device, index) => { + const option = document.createElement('option'); + option.value = device.deviceId; + option.textContent = device.label || `Камера ${index + 1}`; + $('camera-select').append(option); + }); + $('camera-select').value = stream.getVideoTracks()[0].getSettings().deviceId || ''; + $('camera-select').hidden = $('camera-label').hidden = devices.length <= 1; + } catch { + if (generation !== registrationGeneration || attempt !== cameraAttempt || currentStep !== 3 || sending) return; + stopCamera(); + $('camera-mode').hidden = true; + $('file-mode').hidden = false; + } + updateCaptures(); +} + +$('code').addEventListener('input', event => { event.target.value = formatCode(event.target.value); }); +$('code-form').addEventListener('submit', event => { event.preventDefault(); checkCode(); }); +$('student-search').addEventListener('input', renderStudents); +$('consent').addEventListener('change', updateNext); +$('student-next').addEventListener('click', () => { + if (sending) return; + captures = [null, null, null]; + for (let index = 0; index < 3; index++) $(`file-${index}`).value = ''; + captureIndex = 0; + retaking = false; + $('submit-error').textContent = ''; + $('problems').replaceChildren(); + updateCaptures(); + showStep(3); + startCamera(); +}); +$('camera-select').addEventListener('change', event => { if (!sending) startCamera(event.target.value); }); +$('capture').addEventListener('click', () => { + if (sending || currentStep !== 3 || !stream) return; + const video = $('video'); + if (!video.videoWidth || !video.videoHeight) return; + captures[captureIndex] = scaledJpeg(video, video.videoWidth, video.videoHeight); + retaking = false; + captureIndex = captures.findIndex(image => !image); + if (captureIndex < 0) captureIndex = 2; + updateCaptures(); +}); +for (let index = 0; index < 3; index++) { + $(`file-${index}`).addEventListener('change', async event => { + if (sending || currentStep !== 3) return; + const file = event.target.files?.[0]; + if (!file) return; + const generation = registrationGeneration; + try { + const bitmap = await createImageBitmap(file); + if (generation !== registrationGeneration || sending || currentStep !== 3) { bitmap.close(); return; } + captures[index] = scaledJpeg(bitmap, bitmap.width, bitmap.height); + bitmap.close(); + updateCaptures(); + $('submit-error').textContent = ''; + } catch { + if (generation === registrationGeneration) + $('submit-error').textContent = 'Не удалось прочитать снимок. Выберите другой файл.'; + } + }); +} +$('back').addEventListener('click', () => showStep(2)); +$('submit').addEventListener('click', async () => { + if (sending || captures.some(image => !image)) return; + const studentId = selectedId; + const studentName = group.students.find(student => student.id === studentId).fullName; + const images = [...captures]; + const generation = ++registrationGeneration; + sending = true; + updateButtons(); + $('submit').textContent = 'Отправляем…'; + $('submit-error').textContent = ''; + $('problems').replaceChildren(); + try { + const result = await api('/api/public/enrollment/submit', { code, studentId, images, consent: true }, generation); + if (generation !== registrationGeneration) return; + if (!result.ok) { + $('submit-error').textContent = result.message; + for (const problem of result.problems || []) { + const item = document.createElement('li'); + item.textContent = problem; + $('problems').append(item); + } + return; + } + $('completed-student').textContent = studentName; + $('completed-message').textContent = result.message; + sending = false; + $('submit').textContent = 'Отправить'; + showStep(4); + } catch (error) { + if (generation === registrationGeneration) $('submit-error').textContent = error.message; + } finally { + if (generation === registrationGeneration) { + sending = false; + $('submit').textContent = 'Отправить'; + updateCaptures(); + } + } +}); +$('another').addEventListener('click', async () => { + if (sending) return; + const generation = ++registrationGeneration; + selectedId = null; + captures = [null, null, null]; + for (let index = 0; index < 3; index++) $(`file-${index}`).value = ''; + $('consent').checked = false; + $('student-search').value = ''; + try { + const checkedGroup = await api('/api/public/enrollment/check', { code }, generation); + if (generation !== registrationGeneration) return; + group = checkedGroup; + } + catch (error) { + if (generation === registrationGeneration) $('student-error').textContent = error.message; + } + if (generation !== registrationGeneration) return; + renderStudents(); + updateNext(); + showStep(2); +}); +showStep(1); +const queryCode = new URLSearchParams(location.search).get('code'); +if (queryCode) { $('code').value = formatCode(queryCode); checkCode(); } diff --git a/BiStudent.Tests/ApiEndpointsTests.cs b/BiStudent.Tests/ApiEndpointsTests.cs new file mode 100644 index 0000000..aaa68f5 --- /dev/null +++ b/BiStudent.Tests/ApiEndpointsTests.cs @@ -0,0 +1,224 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class ApiEndpointsTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public ApiEndpointsTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [Fact] + public async Task SettingsDashboardAndPublicRoutes() + { + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/dashboard")).StatusCode); + await AuthenticateAsync(); + var automation = await client.GetFromJsonAsync("/api/settings/automation", JsonOptions); + Assert.True(automation!.SecondsPerManualMark > 0 && automation.MinutesPerManualReport > 0 && automation.FramesInRow > 0); + var recognition = await client.GetFromJsonAsync("/api/settings/recognition", JsonOptions); + Assert.True(recognition!.MatchThreshold > 0 && recognition.FramesInRow > 0); + var updated = new RecognitionSettingsDto(0.64, 0.04, 4); + Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync("/api/settings/recognition", updated, JsonOptions)).StatusCode); + var saved = await client.GetFromJsonAsync("/api/settings/recognition", JsonOptions); + Assert.Equal(updated, saved); + var temporary = await PostAsync("/api/admin/teachers", new SaveTeacherRequest("regular", "Обычный преподаватель", false, true)); + using var other = factory.CreateClient(); + var token = await LoginAsync(other, "regular", temporary.Password); + other.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + Assert.Equal(HttpStatusCode.Forbidden, (await other.PutAsJsonAsync("/api/settings/recognition", updated, JsonOptions)).StatusCode); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Статистика")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Студент статистики", group.Id)); + var today = DateOnly.FromDateTime(DateTime.Now); + var slot = await PostAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "Статистика", null, today.DayOfWeek, new TimeOnly(2, 0), new TimeOnly(3, 0), WeekParity.Every, today)); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/lessons/{slot.Id}/{today:yyyy-MM-dd}/grade", new GradeRequest(student.Id, 88), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/lessons/{slot.Id}/{today:yyyy-MM-dd}/attendance", new MarkRequest(student.Id, AttendanceStatus.Present), JsonOptions)).StatusCode); + var dashboard = await client.GetFromJsonAsync("/api/dashboard", JsonOptions); + Assert.NotNull(dashboard!.AverageScore30Days); + var report = await client.GetFromJsonAsync("/api/report", JsonOptions); + Assert.NotNull(report!.Dashboard.AverageScore30Days); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/health")).StatusCode); + var root = await client.GetAsync("/"); + Assert.Equal(HttpStatusCode.Redirect, root.StatusCode); + Assert.Equal("/register/", root.Headers.Location?.ToString()); + var register = await client.GetAsync("/register"); + Assert.Equal(HttpStatusCode.Redirect, register.StatusCode); + var page = await client.GetAsync("/register/"); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.True(page.Headers.Contains("Content-Security-Policy")); + Assert.Equal("nosniff", page.Headers.GetValues("X-Content-Type-Options").Single()); + Assert.Equal("no-referrer", page.Headers.GetValues("Referrer-Policy").Single()); + } + + [Fact] + public async Task GroupsStudentsAndImport() + { + await AuthenticateAsync(); + var empty = await PostAsync("/api/groups", new CreateGroupRequest("Пустая группа")); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/groups", new CreateGroupRequest(empty.Name), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/groups/{empty.Id}")).StatusCode); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Исходная группа")); + var target = await PostAsync("/api/groups", new CreateGroupRequest("Новая группа")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Старое имя", group.Id)); + Assert.Equal(HttpStatusCode.BadRequest, (await client.DeleteAsync($"/api/groups/{group.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/students/{student.Id}", new SaveStudentRequest("Новое имя", target.Id), JsonOptions)).StatusCode); + var roster = await client.GetFromJsonAsync>($"/api/groups/{target.Id}/students", JsonOptions); + Assert.Contains(roster!, x => x.Id == student.Id && x.FullName == "Новое имя"); + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync("/api/students/999999", new SaveStudentRequest("Имя", target.Id), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/students/{student.Id}")).StatusCode); + var imported = await (await PostCsvAsync("/api/students/import", $"ФИО;Группа\nИмпортирован;{target.Name}\nИмпортирован;{target.Name}\n")).Content.ReadFromJsonAsync(JsonOptions); + Assert.Equal(1, imported!.Added); + Assert.Equal(1, imported.Skipped); + } + + [Fact] + public async Task ScheduleCanBeEditedCancelledRestoredAndImported() + { + await AuthenticateAsync(); + var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); + var slot = await PostAsync("/api/schedule/slots", new SaveSlotRequest(0, "Группа расписания", "Алгебра", null, date.DayOfWeek, new TimeOnly(2, 0), new TimeOnly(3, 0), WeekParity.Every, date)); + Assert.Contains((await client.GetFromJsonAsync>("/api/groups", JsonOptions))!, x => x.Id == slot.GroupId); + var day = await client.GetFromJsonAsync>($"/api/schedule/day?date={date:yyyy-MM-dd}", JsonOptions); + Assert.Contains(day!, x => x.Slot.Id == slot.Id); + var week = await client.GetFromJsonAsync>($"/api/schedule/week?monday={date:yyyy-MM-dd}", JsonOptions); + Assert.Contains(week!, x => x.Slot.Id == slot.Id); + var parity = await client.GetFromJsonAsync($"/api/schedule/parity?date={date:yyyy-MM-dd}", JsonOptions); + Assert.Contains(parity, new[] { WeekParity.Odd, WeekParity.Even }); + var updated = new SaveSlotRequest(slot.GroupId, null, "Геометрия", "12", date.DayOfWeek, slot.Start, slot.End, WeekParity.Every, date); + Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/api/schedule/slots/{slot.Id}", updated, JsonOptions)).StatusCode); + Assert.Contains((await client.GetFromJsonAsync>($"/api/schedule/day?date={date:yyyy-MM-dd}", JsonOptions))!, x => x.Slot.Id == slot.Id && x.Slot.Subject == "Геометрия"); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/schedule/slots", updated with { Subject = " " }, JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/schedule/slots", updated with { GroupId = 999999 }, JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync("/api/schedule/slots/999999", updated, JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PostAsJsonAsync($"/api/schedule/slots/{slot.Id}/cancel", new CancelLessonRequest(date, null), JsonOptions)).StatusCode); + Assert.Contains((await client.GetFromJsonAsync>($"/api/schedule/day?date={date:yyyy-MM-dd}", JsonOptions))!, x => x.Slot.Id == slot.Id && x.Cancelled); + Assert.Equal(HttpStatusCode.NoContent, (await client.PostAsJsonAsync($"/api/schedule/slots/{slot.Id}/restore", new RestoreLessonRequest(date), JsonOptions)).StatusCode); + Assert.Contains((await client.GetFromJsonAsync>($"/api/schedule/day?date={date:yyyy-MM-dd}", JsonOptions))!, x => x.Slot.Id == slot.Id && !x.Cancelled); + var valid = await (await PostCsvAsync("/api/schedule/import", "День;Начало;Конец;Предмет;Группа;Аудитория;Чётность\nпн;05:00;06:00;История;Группа импорта;;каждая\n")).Content.ReadFromJsonAsync(JsonOptions); + Assert.True(valid!.Added > 0); + var invalid = await (await PostCsvAsync("/api/schedule/import", "День;Начало;Конец;Предмет;Группа;Аудитория;Чётность\nнеизвестно;05:00;06:00;История;Группа импорта;;каждая\n")).Content.ReadFromJsonAsync(JsonOptions); + Assert.NotEmpty(invalid!.Errors); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/schedule/slots/{slot.Id}")).StatusCode); + } + + [Fact] + public async Task AttendanceAndJournalExports() + { + await AuthenticateAsync(); + var (group, first, slot, date) = await LessonAsync(); + var second = await PostAsync("/api/students", new SaveStudentRequest("Второй", group.Id)); + var third = await PostAsync("/api/students", new SaveStudentRequest("Третий", group.Id)); + var fourth = await PostAsync("/api/students", new SaveStudentRequest("Четвёртый", group.Id)); + var path = $"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/attendance"; + Assert.Equal(4, (await client.GetFromJsonAsync>(path, JsonOptions))!.Count); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new MarkRequest(first.Id, AttendanceStatus.Present), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new MarkRequest(second.Id, AttendanceStatus.Absent), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new MarkRequest(third.Id, AttendanceStatus.Excused, ExcuseReason.Illness, "Справка", true), JsonOptions)).StatusCode); + var rows = await client.GetFromJsonAsync>(path, JsonOptions); + Assert.Equal(AttendanceStatus.Absent, rows!.Single(x => x.StudentId == second.Id).Status); + Assert.True(rows!.Single(x => x.StudentId == third.Id) is { Status: AttendanceStatus.Excused, Reason: ExcuseReason.Illness, Comment: "Справка", HasDocument: true }); + var finished = await (await client.PostAsync($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/finish", null)).Content.ReadFromJsonAsync(JsonOptions); + Assert.Equal(1, finished!.Count); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new MarkRequest(first.Id, null), JsonOptions)).StatusCode); + var present = await (await client.PostAsync($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/all-present", null)).Content.ReadFromJsonAsync(JsonOptions); + Assert.Equal(1, present!.Count); + var journalPath = $"/api/journal/{group.Id}?from={date:yyyy-MM-dd}&to={date:yyyy-MM-dd}&subject=Алгебра"; + var journal = await client.GetFromJsonAsync(journalPath, JsonOptions); + Assert.Contains(journal!.Columns, x => x.Subject == "Алгебра"); + Assert.Contains(journal.Students, x => x.StudentId == fourth.Id && x.Absent == 1); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/journal/{group.Id}?from=2026-11-03&to=2026-11-02")).StatusCode); + var xlsx = await client.GetAsync($"/api/journal/{group.Id}/xlsx?from={date:yyyy-MM-dd}&to={date:yyyy-MM-dd}"); + Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsx.Content.Headers.ContentType?.MediaType); + Assert.Contains("Журнал_", xlsx.Content.Headers.ContentDisposition?.FileNameStar ?? ""); + var csv = await client.GetAsync($"/api/journal/{group.Id}/csv?from={date:yyyy-MM-dd}&to={date:yyyy-MM-dd}"); + Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType); + Assert.True((await csv.Content.ReadAsByteArrayAsync()).Take(3).SequenceEqual(Encoding.UTF8.GetPreamble())); + } + + private async Task PostAsync(string path, object value) + { + var response = await client.PostAsJsonAsync(path, value, JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(JsonOptions); + return result!; + } + + private async Task<(GroupDto Group, StudentDto Student, SlotDto Slot, DateOnly Date)> LessonAsync() + { + var group = await PostAsync("/api/groups", new CreateGroupRequest("Группа журнала")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Первый", group.Id)); + var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); + var slot = await PostAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "Алгебра", null, date.DayOfWeek, new TimeOnly(2, 0), new TimeOnly(3, 0), WeekParity.Every, date)); + return (group, student, slot, date); + } + + private async Task AuthenticateAsync() + { + var token = await LoginAsync(client, "teacher", "teacher123"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private static async Task LoginAsync(HttpClient target, string login, string password) + { + var response = await target.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(token); + return token; + } + + private Task PostCsvAsync(string path, string csv) + { + return client.PostAsync(path, new StringContent(csv, Encoding.UTF8, "text/csv")); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/ApiTests.cs b/BiStudent.Tests/ApiTests.cs new file mode 100644 index 0000000..19a752b --- /dev/null +++ b/BiStudent.Tests/ApiTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class ApiTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public ApiTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task Health_ReturnsOk() + { + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Me_WithoutTokenReturnsUnauthorized() + { + var response = await client.GetAsync("/api/me"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Login_WithWrongPasswordReturnsErrorDto() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "wrong"), JsonOptions); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(error); + Assert.Contains("Неверный", error.Message); + } + + [Fact] + public async Task Login_WithTokenCanReadMeGroupsAndDashboard() + { + await AuthenticateAsync(); + foreach (var path in new[] { "/api/me", "/api/groups", "/api/dashboard" }) + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(path)).StatusCode); + } + + [Fact] + public async Task AddSlot_WithOverlapReturnsBadRequestAndMessage() + { + await AuthenticateAsync(); + var lessons = await client.GetFromJsonAsync>("/api/schedule/day?date=2026-09-01", JsonOptions); + Assert.NotNull(lessons); + Assert.NotEmpty(lessons); + var slot = lessons[0].Slot; + var request = new SaveSlotRequest(slot.GroupId, null, "Конфликт", null, + slot.DayOfWeek, slot.Start, slot.End, slot.Parity, null); + var response = await client.PostAsJsonAsync("/api/schedule/slots", request, JsonOptions); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(error); + Assert.Contains("уже есть пара", error.Message); + } + + [Fact] + public async Task Attendance_ForMissingSlotReturnsNotFound() + { + await AuthenticateAsync(); + var response = await client.GetAsync("/api/lessons/999999/2026-09-01/attendance"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(error); + Assert.Contains("не найдена", error.Message); + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123"), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(token); + Assert.False(string.IsNullOrWhiteSpace(token.AccessToken)); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/AttendanceServiceTests.cs b/BiStudent.Tests/AttendanceServiceTests.cs new file mode 100644 index 0000000..7fa422a --- /dev/null +++ b/BiStudent.Tests/AttendanceServiceTests.cs @@ -0,0 +1,91 @@ +using BiStudent.Contracts; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class AttendanceServiceTests +{ + [Fact] + public async Task MarkAsync_UpsertsManualMarkAndCameraCannotOverwriteIt() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var date = new DateOnly(2026, 9, 1); + var student = group.Students.Single(); + Assert.True(await test.Attendance.MarkAsync(slot.Id, date, student.Id, AttendanceStatus.Present, MarkMethod.Manual)); + Assert.True(await test.Attendance.MarkAsync(slot.Id, date, student.Id, AttendanceStatus.Absent, MarkMethod.Manual)); + Assert.False(await test.Attendance.MarkAsync(slot.Id, date, student.Id, AttendanceStatus.Present, MarkMethod.Camera)); + var mark = Assert.Single(test.Db.AttendanceMarks); + Assert.Equal(AttendanceStatus.Absent, mark.Status); + Assert.Equal(MarkMethod.Manual, mark.Method); + } + + [Fact] + public async Task MarkAsync_NotifiesOnlyForChanges() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var date = new DateOnly(2026, 9, 1); + var calls = new List<(int SlotId, DateOnly Date)>(); + test.Notifier.Changed += (slotId, changedDate) => calls.Add((slotId, changedDate)); + Assert.True(await test.Attendance.MarkAsync(slot.Id, date, group.Students[0].Id, AttendanceStatus.Present, MarkMethod.Manual)); + Assert.False(await test.Attendance.MarkAsync(slot.Id, date, group.Students[0].Id, AttendanceStatus.Present, MarkMethod.Manual)); + Assert.Equal(new[] { (slot.Id, date) }, calls); + } + + [Fact] + public async Task MarkAsync_RejectsStudentFromAnotherGroup() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1"); + var other = test.AddGroup("ИВТ-2", "Пётр"); + var slot = test.AddSlot(teacher, group); + var error = await Assert.ThrowsAsync(() => + test.Attendance.MarkAsync(slot.Id, new DateOnly(2026, 9, 1), other.Students[0].Id, + AttendanceStatus.Present, MarkMethod.Manual)); + Assert.Contains("не принадлежит", error.Message); + } + + [Fact] + public async Task MarkRestAbsentAsync_OnlyMarksUnmarkedStudents() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван", "Анна", "Пётр"); + var slot = test.AddSlot(teacher, group); + var date = new DateOnly(2026, 9, 1); + await test.Attendance.MarkAsync(slot.Id, date, group.Students[0].Id, AttendanceStatus.Present, MarkMethod.Manual); + Assert.Equal(2, await test.Attendance.MarkRestAbsentAsync(slot.Id, date)); + Assert.Equal(0, await test.Attendance.MarkRestAbsentAsync(slot.Id, date)); + Assert.Equal(3, test.Db.AttendanceMarks.Count()); + Assert.Equal(AttendanceStatus.Present, test.Db.AttendanceMarks.Single(x => x.StudentId == group.Students[0].Id).Status); + Assert.Equal(2, test.Db.AttendanceMarks.Count(x => x.Status == AttendanceStatus.Absent)); + } + + [Fact] + public async Task JournalAsync_SkipsCancelledLessonsAndCalculatesPercent() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var first = new DateOnly(2026, 9, 1); + var second = first.AddDays(7); + var third = first.AddDays(14); + await test.Schedule.CancelAsync(slot.Id, second, "Cancelled"); + await test.Attendance.MarkAsync(slot.Id, first, group.Students[0].Id, AttendanceStatus.Present, MarkMethod.Manual); + await test.Attendance.MarkAsync(slot.Id, third, group.Students[0].Id, AttendanceStatus.Absent, MarkMethod.Manual); + var journal = await test.Attendance.JournalAsync(teacher.Id, group.Id, first, third, null); + Assert.Equal(2, journal.Columns.Count); + Assert.DoesNotContain(journal.Columns, x => x.Date == second); + var row = Assert.Single(journal.Students); + Assert.Equal(new AttendanceStatus?[] { AttendanceStatus.Present, AttendanceStatus.Absent }, + row.Cells.Select(x => x.Status)); + Assert.Equal(50d, row.Percent); + } +} diff --git a/BiStudent.Tests/AuditFixes2Tests.cs b/BiStudent.Tests/AuditFixes2Tests.cs new file mode 100644 index 0000000..e791aa6 --- /dev/null +++ b/BiStudent.Tests/AuditFixes2Tests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Endpoints; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class AuditFixes2Tests : IDisposable +{ + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public AuditFixes2Tests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task CsvImportRollsBackGroupsAndSlots() + { + var token = await LoginAsync(client, "teacher", "teacher123"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var csv = "day;start;end;subject;group;room;parity\n" + + "пн;08:00;09:20;Атомарность CSV;Новая группа CSV;101;каждая\n" + + "ошибка;10:00;11:20;Тест;Другая группа;102;каждая\n"; + using var response = await client.PostAsync("/api/schedule/import", new StringContent(csv, Encoding.UTF8, "text/csv")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(0, result.RootElement.GetProperty("added").GetInt32()); + Assert.True(result.RootElement.GetProperty("errors").GetArrayLength() > 0); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Groups.AnyAsync(x => x.Name == "Новая группа CSV")); + Assert.False(await db.ScheduleSlots.AnyAsync(x => x.Subject == "Атомарность CSV")); + } + + [Fact] + public async Task FreeLoginStaysWithinLimitWhenBaseIsTaken() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var login = new string('a', 32); + db.Teachers.Add(new Teacher { Login = login, FullName = "Занятый преподаватель" }); + await db.SaveChangesAsync(); + var import = scope.ServiceProvider.GetRequiredService(); + var candidate = await import.FreeLoginAsync(login); + Assert.Equal(32, candidate.Length); + Assert.EndsWith("2", candidate); + Assert.NotEqual(login, candidate); + } + + [Fact] + public async Task OnlyIfUnmarkedKeepsExcusedMark() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("Группа", "Студент"); + var slot = test.AddSlot(teacher, group); + var studentId = Assert.Single(group.Students).Id; + var date = new DateOnly(2026, 9, 1); + await test.Attendance.MarkAsync(slot.Id, date, studentId, AttendanceStatus.Excused, MarkMethod.Manual, ExcuseReason.Other); + var changed = await test.Attendance.MarkAsync(slot.Id, date, studentId, AttendanceStatus.Present, MarkMethod.Manual, onlyIfUnmarked: true); + Assert.False(changed); + var mark = await test.Db.AttendanceMarks.AsNoTracking().SingleAsync(); + Assert.Equal(AttendanceStatus.Excused, mark.Status); + Assert.Equal(ExcuseReason.Other, mark.ExcuseReason); + } + + [Fact] + public async Task SqliteConstraintBecomesConflict() + { + var result = await ApiEndpoints.HandleErrorsAsync(null!, + _ => throw new DbUpdateException("x", new SqliteException("UNIQUE", 19))); + Assert.Equal(StatusCodes.Status409Conflict, Assert.IsAssignableFrom(result).StatusCode); + } + + [Fact] + public async Task DashboardExcludesOtherTeachersSubmissions() + { + using var test = new TestDb(); + var first = test.AddTeacher(); + var other = new Teacher { Login = "other", FullName = "Другой преподаватель" }; + test.Db.Teachers.Add(other); + await test.Db.SaveChangesAsync(); + var group = test.AddGroup("Чужая группа", "Чужой студент"); + test.AddSlot(other, group); + test.Db.FaceSubmissions.Add(new FaceSubmission { + StudentId = Assert.Single(group.Students).Id, Status = FaceSubmissionStatus.Pending + }); + await test.Db.SaveChangesAsync(); + var dashboard = await test.Stats.DashboardAsync(first.Id, DateTime.Now); + Assert.Equal(0, dashboard.PendingFaceSubmissions); + } + + [Fact] + public async Task InvalidateReloadsRecognitionSettings() + { + var recognition = factory.Services.GetRequiredService(); + await recognition.SaveSettingsAsync(new RecognitionSettingsDto(0.45, 0.05, 3)); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var frames = await db.AppSettings.SingleAsync(x => x.Key == "recognition.frames"); + frames.Value = "5"; + await db.SaveChangesAsync(); + } + recognition.Invalidate(); + Assert.Equal(5, (await recognition.GetSettingsAsync()).FramesInRow); + } + + private static async Task LoginAsync(HttpClient target, string login, string password) + { + using var response = await target.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/AuditFixesTests.cs b/BiStudent.Tests/AuditFixesTests.cs new file mode 100644 index 0000000..ff96dbb --- /dev/null +++ b/BiStudent.Tests/AuditFixesTests.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using BiStudent.Server.Services; +using ClosedXML.Excel; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class AuditFixesTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public AuditFixesTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task GroupChangesRequireOwnScheduleOrAdmin() + { + await AuthenticateAsync(); + var password = await PostAsync("/api/admin/teachers", + new SaveTeacherRequest("teacher-b", "Преподаватель Б", false, true)); + using var other = factory.CreateClient(); + var token = await LoginAsync(other, password.Login, password.Password); + other.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var thirdPassword = await PostAsync("/api/admin/teachers", + new SaveTeacherRequest("teacher-c", "Преподаватель В", false, true)); + using var third = factory.CreateClient(); + var thirdToken = await LoginAsync(third, thirdPassword.Login, thirdPassword.Password); + third.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", thirdToken.AccessToken); + + var group = await PostAsync("/api/groups", new CreateGroupRequest("Группа А")); + var target = await PostAsync("/api/groups", new CreateGroupRequest("Новая группа")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Первое имя", group.Id)); + var date = DateOnly.FromDateTime(DateTime.Today); + Assert.Equal(HttpStatusCode.OK, (await other.PostAsJsonAsync("/api/schedule/slots", + new SaveSlotRequest(group.Id, null, "Алгебра", null, date.DayOfWeek, + new TimeOnly(8, 0), new TimeOnly(9, 20), WeekParity.Every, null), JsonOptions)).StatusCode); + + Assert.Equal(HttpStatusCode.Forbidden, (await third.PostAsJsonAsync("/api/students", + new SaveStudentRequest("Чужой студент", group.Id), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.PutAsJsonAsync($"/api/students/{student.Id}", + new SaveStudentRequest("Другое имя", group.Id), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.PutAsJsonAsync($"/api/students/{student.Id}", + new SaveStudentRequest("Первое имя", target.Id), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.DeleteAsync($"/api/students/{student.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.PostAsJsonAsync($"/api/groups/{group.Id}/enrollment-code", + new CreateEnrollmentCodeRequest(), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.DeleteAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.DeleteAsync($"/api/groups/{group.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await third.PostAsJsonAsync($"/api/students/{student.Id}/enroll", + new EnrollRequest([]), JsonOptions)).StatusCode); + + var added = await other.PostAsJsonAsync("/api/students", new SaveStudentRequest("Студент Б", target.Id), JsonOptions); + Assert.Equal(HttpStatusCode.OK, added.StatusCode); + var newStudent = await added.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(newStudent); + + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/students/{student.Id}", + new SaveStudentRequest("Имя администратора", group.Id), JsonOptions)).StatusCode); + await PostAsync($"/api/groups/{group.Id}/enrollment-code", new CreateEnrollmentCodeRequest()); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/students/{student.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/students/{newStudent!.Id}", + new SaveStudentRequest("Студент Б", group.Id), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/students/{newStudent!.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/groups/{target.Id}")).StatusCode); + } + + [Fact] + public async Task MissingTargetGroupDoesNotRenameStudent() + { + await AuthenticateAsync(); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Группа для перевода")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Старое имя", group.Id)); + + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/api/students/{student.Id}", + new SaveStudentRequest("Новое имя", int.MaxValue), JsonOptions)).StatusCode); + var roster = await client.GetFromJsonAsync>($"/api/groups/{group.Id}/students", JsonOptions); + Assert.Contains(roster!, x => x.Id == student.Id && x.FullName == "Старое имя"); + } + + [Theory] + [InlineData("Программирование С# · 505 · нед. 2–4")] + [InlineData("Программирование С# · 505 · нед. 2—4")] + public void ParserReadsDashWeekRanges(string entry) + { + Assert.True(CollegeScheduleParser.TryParseEntry(entry, out var subject, out var room, out var weeks)); + Assert.Equal("Программирование С#", subject); + Assert.Equal("505", room); + Assert.Equal((2, 4, false), Assert.Single(weeks)); + } + + [Fact] + public void ParserSkipsOverflowingWeekNumber() + { + Assert.False(CollegeScheduleParser.TryParseEntry( + "Программирование · 505 · нед. 999999999999999999999", out _, out _, out var weeks)); + Assert.Empty(weeks); + } + + [Fact] + public void ParserReadsSingleDigitHour() + { + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("Расписание"); + sheet.Cell(6, 1).Value = "Дни"; + sheet.Cell(6, 5).Value = "23-ПО-103-4р-1"; + sheet.Cell(7, 1).Value = "Понедельник"; + sheet.Cell(7, 2).Value = "8:00 9:20"; + sheet.Range(7, 2, 8, 2).Merge(); + sheet.Cell(7, 5).Value = "Программирование С# · 505 · нед. 2-4"; + sheet.Cell(8, 5).Value = "Иванов И.И."; + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + stream.Position = 0; + + var parsed = CollegeScheduleParser.Parse(stream, new DateOnly(2026, 9, 1)); + Assert.Contains(parsed.Lessons, x => x.Start == new TimeOnly(8, 0) && x.End == new TimeOnly(9, 20)); + } + + [Fact] + public async Task OneOffSlotBecomesRegular() + { + await AuthenticateAsync(); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Регулярная группа")); + var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); + var request = new SaveSlotRequest(group.Id, null, "Физика", null, date.DayOfWeek, + new TimeOnly(8, 0), new TimeOnly(9, 20), WeekParity.Every, date); + var slot = await PostAsync("/api/schedule/slots", request); + + Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/api/schedule/slots/{slot.Id}", + request with { OneOffDate = null }, JsonOptions)).StatusCode); + var next = date.AddDays(7); + var lessons = await client.GetFromJsonAsync>($"/api/schedule/day?date={next:yyyy-MM-dd}", JsonOptions); + Assert.Contains(lessons!, x => x.Slot.Id == slot.Id); + } + + [Fact] + public async Task JournalWithAncientStartReturnsQuickly() + { + await AuthenticateAsync(); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Большой период")); + var today = DateOnly.FromDateTime(DateTime.Today); + await PostAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "История", null, + today.DayOfWeek, new TimeOnly(8, 0), new TimeOnly(9, 20), WeekParity.Every, null)); + + var timer = Stopwatch.StartNew(); + using var response = await client.GetAsync($"/api/journal/{group.Id}?from=0001-01-01&to={today:yyyy-MM-dd}"); + timer.Stop(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(5), $"Журнал формировался {timer.Elapsed}."); + } + + private async Task PostAsync(string path, object value) + { + var response = await client.PostAsJsonAsync(path, value, JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task AuthenticateAsync() + { + var token = await LoginAsync(client, "teacher", "teacher123"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private static async Task LoginAsync(HttpClient target, string login, string password) + { + var response = await target.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/AuthTests.cs b/BiStudent.Tests/AuthTests.cs new file mode 100644 index 0000000..36b28bb --- /dev/null +++ b/BiStudent.Tests/AuthTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class AuthTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), $"BiStudent.AuthTests.{Guid.NewGuid():N}"); + + public AuthTests() => Directory.CreateDirectory(directory); + + [Fact] + public async Task EmptyDatabase_CreatesAdminWhoMustChangePassword() + { + using var factory = new AuthFactory(directory); + using var client = factory.CreateClient(); + var token = await LoginAsync(client, "admin", "admin"); + Authorize(client, token); + var me = await client.GetFromJsonAsync("/api/me"); + Assert.NotNull(me); + Assert.True(me.IsAdmin); + Assert.True(me.MustChangePassword); + } + + [Fact] + public async Task ChangedPassword_SurvivesRestart() + { + using (var factory = new AuthFactory(directory)) + using (var client = factory.CreateClient()) + { + Authorize(client, await LoginAsync(client, "admin", "admin")); + var response = await client.PostAsJsonAsync("/api/auth/change-password", + new ChangePasswordRequest("admin", "new-secret-123")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + client.DefaultRequestHeaders.Authorization = null; + Assert.Equal(HttpStatusCode.Unauthorized, + (await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("admin", "admin"))).StatusCode); + await LoginAsync(client, "admin", "new-secret-123"); + } + using var restarted = new AuthFactory(directory); + using var afterRestart = restarted.CreateClient(); + await LoginAsync(afterRestart, "admin", "new-secret-123"); + } + + [Fact] + public async Task Refresh_StopsWorkingAfterPasswordChange() + { + using var factory = new AuthFactory(directory); + using var client = factory.CreateClient(); + var token = await LoginAsync(client, "admin", "admin"); + Assert.NotNull(token.RefreshToken); + var refreshed = await client.PostAsJsonAsync("/api/auth/refresh", new RefreshRequest(token.RefreshToken)); + Assert.Equal(HttpStatusCode.OK, refreshed.StatusCode); + Assert.False(string.IsNullOrWhiteSpace((await refreshed.Content.ReadFromJsonAsync())?.AccessToken)); + Authorize(client, token); + var changed = await client.PostAsJsonAsync("/api/auth/change-password", + new ChangePasswordRequest("admin", "new-secret-123")); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + client.DefaultRequestHeaders.Authorization = null; + Assert.Equal(HttpStatusCode.Unauthorized, + (await client.PostAsJsonAsync("/api/auth/refresh", new RefreshRequest(token.RefreshToken))).StatusCode); + } + + [Fact] + public async Task Admin_ManagesTeacherAccounts() + { + using var factory = new AuthFactory(directory); + using var client = factory.CreateClient(); + Authorize(client, await LoginAsync(client, "admin", "admin")); + var created = await client.PostAsJsonAsync("/api/admin/teachers", + new SaveTeacherRequest("anna", "Анна Иванова", false, true)); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + var temporary = await created.Content.ReadFromJsonAsync(); + Assert.NotNull(temporary); + var accounts = await client.GetFromJsonAsync>("/api/admin/teachers"); + var account = Assert.Single(accounts!, x => x.Login == "anna"); + using var teacherClient = factory.CreateClient(); + Authorize(teacherClient, await LoginAsync(teacherClient, "anna", temporary.Password)); + var me = await teacherClient.GetFromJsonAsync("/api/me"); + Assert.True(me!.MustChangePassword); + Assert.Equal(HttpStatusCode.Forbidden, (await teacherClient.GetAsync("/api/admin/teachers")).StatusCode); + var disabled = await client.PutAsJsonAsync($"/api/admin/teachers/{account.Id}", + new SaveTeacherRequest("anna", "Анна Иванова", false, false)); + Assert.Equal(HttpStatusCode.OK, disabled.StatusCode); + using var loginClient = factory.CreateClient(); + Assert.Equal(HttpStatusCode.Forbidden, + (await loginClient.PostAsJsonAsync("/api/auth/login", new LoginRequest("anna", temporary.Password))).StatusCode); + } + + [Fact] + public void LegacyDatabase_ReceivesBaselineAndAllMigrations() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + using var db = new AppDbContext(options); + db.GetService().Migrate(DatabaseInitializer.BaselineMigration); + db.Database.ExecuteSqlRaw("DROP TABLE \"__EFMigrationsHistory\""); + DatabaseInitializer.Initialize(db); + Assert.Equal(db.Database.GetMigrations(), db.Database.GetAppliedMigrations()); + } + + private static async Task LoginAsync(HttpClient client, string login, string password) + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + return token; + } + + private static void Authorize(HttpClient client, TokenResponse token) => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + public void Dispose() => Directory.Delete(directory, recursive: true); + + private sealed class AuthFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "false"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/BackupTests.cs b/BiStudent.Tests/BackupTests.cs new file mode 100644 index 0000000..0b1f74f --- /dev/null +++ b/BiStudent.Tests/BackupTests.cs @@ -0,0 +1,161 @@ +using System.IO.Compression; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class BackupTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly ServiceProvider services; + private readonly RecognitionService recognition; + private readonly BackupService backups; + private readonly StoragePaths paths; + private readonly SessionGeneration sessions; + + public BackupTests() + { + Directory.CreateDirectory(root); + var database = Path.Combine(root, "bistudent.db"); + paths = new StoragePaths(database, new SqliteConnectionStringBuilder { DataSource = database }.ToString()); + Directory.CreateDirectory(paths.KeysDirectory); + File.WriteAllText(Path.Combine(paths.KeysDirectory, "key-11111111-2222-3333-4444-555555555555.xml"), ""); + services = new ServiceCollection().AddDbContext(options => options.UseSqlite(paths.ConnectionString)).BuildServiceProvider(); + using (var scope = services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + DatabaseInitializer.Initialize(db); + var teacher = new Teacher { Login = "teacher", FullName = "Ирина Иванова" }; + var group = new Group { Name = "10-А" }; + var student = new Student { FullName = "Анна", Group = group }; + var slot = new ScheduleSlot { Teacher = teacher, Group = group, Subject = "Алгебра", + DayOfWeek = DayOfWeek.Tuesday, Start = new TimeOnly(9, 0), End = new TimeOnly(10, 0), + ValidFrom = new DateOnly(2026, 9, 1) }; + db.AttendanceMarks.Add(new AttendanceMark { Slot = slot, Student = student, + Date = new DateOnly(2026, 9, 1), Status = AttendanceStatus.Present, Method = MarkMethod.Manual }); + db.SaveChanges(); + } + var config = new ConfigurationBuilder().Build(); + recognition = new RecognitionService(services.GetRequiredService(), + DataProtectionProvider.Create("tests"), config); + sessions = new SessionGeneration(paths); + backups = new BackupService(paths, services.GetRequiredService(), recognition, sessions, + NullLogger.Instance); + } + + [Fact] + public async Task CreateIncludesDatabaseManifestAndKeys() + { + var backup = await backups.CreateAsync(false); + Assert.Equal(backup.Name, Assert.Single(backups.List()).Name); + Assert.Equal(Path.Combine(paths.BackupsDirectory, backup.Name), backups.Resolve(backup.Name)); + Assert.Null(backups.Resolve("../x.zip")); + Assert.Null(backups.Resolve("other.zip")); + using var archive = ZipFile.OpenRead(backups.Resolve(backup.Name)!); + Assert.Contains(archive.Entries, x => x.FullName == "bistudent.db"); + Assert.Contains(archive.Entries, x => x.FullName == "manifest.json"); + Assert.Contains(archive.Entries, x => x.FullName == "keys/key-11111111-2222-3333-4444-555555555555.xml"); + } + + [Fact] + public async Task RestoreReturnsStudentAndKeepsSafetyCopy() + { + var generation = sessions.Current; + var backup = await backups.CreateAsync(false); + using (var scope = services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Students.Remove(await db.Students.SingleAsync()); + await db.SaveChangesAsync(); + } + await using var zip = File.OpenRead(backups.Resolve(backup.Name)!); + var result = await backups.RestoreAsync(zip); + Assert.True(result.Ok); + Assert.Equal(1, result.Students); + Assert.Equal(1, result.Marks); + Assert.False(result.RestartRecommended); + Assert.Equal(2, backups.List().Count); + Assert.NotEqual(generation, sessions.Current); + using var restoredScope = services.CreateScope(); + var restored = restoredScope.ServiceProvider.GetRequiredService(); + Assert.Equal("Анна", (await restored.Students.SingleAsync()).FullName); + } + + [Fact] + public async Task RestoreRejectsInvalidEntries() + { + foreach (var names in new[] { new[] { "manifest.json" }, new[] { "manifest.json", "bistudent.db", "../evil.txt" }, + new[] { "manifest.json", "bistudent.db", "keys/C:injected.xml" } }) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var name in names) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(name == "manifest.json" ? "{\"app\":\"BiStudent\",\"format\":1}" : "x"); + } + stream.Position = 0; + await Assert.ThrowsAsync(() => backups.RestoreAsync(stream)); + } + } + + [Fact] + public async Task PruneKeepsNewestAutomaticAndManual() + { + var automatic = new List(); + for (var i = 0; i < 4; i++) + { + var backup = await backups.CreateAsync(true); + File.SetLastWriteTimeUtc(backups.Resolve(backup.Name)!, DateTime.UtcNow.AddHours(i - 4)); + automatic.Add(backup); + } + var manual = await backups.CreateAsync(false); + backups.Prune(2); + var remaining = backups.List(); + Assert.Equal(3, remaining.Count); + Assert.Contains(remaining, x => x.Name == manual.Name); + Assert.Contains(remaining, x => x.Name == automatic[2].Name); + Assert.Contains(remaining, x => x.Name == automatic[3].Name); + } + + [Fact] + public async Task RestoreRejectsDatabaseWithIncompatibleSchema() + { + var generation = sessions.Current; + var foreign = Path.Combine(root, "foreign.db"); + using (var connection = new SqliteConnection($"Data Source={foreign};Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE Teachers(x); CREATE TABLE Students(x); CREATE TABLE AttendanceMarks(x); CREATE TABLE FaceSamples(x); CREATE TABLE ScheduleSlots(x);"; + command.ExecuteNonQuery(); + } + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntryFromFile(foreign, "bistudent.db"); + using var writer = new StreamWriter(archive.CreateEntry("manifest.json").Open()); + writer.Write("{\"app\":\"BiStudent\",\"format\":1}"); + } + stream.Position = 0; + var error = await Assert.ThrowsAsync(() => backups.RestoreAsync(stream)); + Assert.Contains("несовместимой", error.Message); + Assert.Equal(generation, sessions.Current); + } + + public void Dispose() + { + recognition.Dispose(); + services.Dispose(); + SqliteConnection.ClearAllPools(); + Directory.Delete(root, recursive: true); + } +} diff --git a/BiStudent.Tests/BiStudent.Tests.csproj b/BiStudent.Tests/BiStudent.Tests.csproj new file mode 100644 index 0000000..bb778b6 --- /dev/null +++ b/BiStudent.Tests/BiStudent.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + $(NoWarn);xUnit1051 + Exe + + + + + + + + + + + + + + + + + diff --git a/BiStudent.Tests/EnrollmentApiTests.cs b/BiStudent.Tests/EnrollmentApiTests.cs new file mode 100644 index 0000000..6169eae --- /dev/null +++ b/BiStudent.Tests/EnrollmentApiTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class EnrollmentApiTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public EnrollmentApiTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new EnrollmentFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task EnrollmentCodeLifecycleAndMissingSubmissions() + { + await AuthenticateAsync(); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Группа регистрации")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Студент регистрации", group.Id)); + Assert.Equal(HttpStatusCode.NoContent, (await client.GetAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync($"/api/groups/{group.Id}/enrollment-code", new CreateEnrollmentCodeRequest(0), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync($"/api/groups/{group.Id}/enrollment-code", new CreateEnrollmentCodeRequest(200), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsJsonAsync("/api/groups/999999/enrollment-code", new CreateEnrollmentCodeRequest(), JsonOptions)).StatusCode); + var code = await PostAsync($"/api/groups/{group.Id}/enrollment-code", new CreateEnrollmentCodeRequest()); + Assert.NotEmpty(code.Links); + Assert.All(code.Links, link => Assert.EndsWith("/register/", link)); + var check = await (await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest(code.Code), JsonOptions)).Content.ReadFromJsonAsync(JsonOptions); + Assert.Contains(check!.Students, x => x.Id == student.Id); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsJsonAsync("/api/public/enrollment/submit", new PublicSubmitRequest("INVALID", student.Id, [], true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/public/enrollment/submit", new PublicSubmitRequest(code.Code, student.Id, [null!, "x", "y"], true), JsonOptions)).StatusCode); + Assert.Empty((await client.GetFromJsonAsync>("/api/face-submissions", JsonOptions))!); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync("/api/face-submissions/999/approve", null)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsJsonAsync("/api/face-submissions/999/reject", new RejectSubmissionRequest(null), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/face-submissions/999/images/0")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.GetAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsJsonAsync("/api/public/enrollment/submit", new PublicSubmitRequest(code.Code, student.Id, [], true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest("INVALID"), JsonOptions)).StatusCode); + } + + [Fact] + public async Task ThirtyFirstPublicCheckIsRateLimited() + { + for (var attempt = 0; attempt < 30; attempt++) + { + var response = await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest("INVALID"), JsonOptions); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + var limited = await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest("INVALID"), JsonOptions); + Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); + var error = await limited.Content.ReadFromJsonAsync(JsonOptions); + Assert.False(string.IsNullOrWhiteSpace(error!.Message)); + } + + private async Task PostAsync(string path, object value) + { + var response = await client.PostAsJsonAsync(path, value, JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(JsonOptions); + return result!; + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123"), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token!.AccessToken); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class EnrollmentFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/EnrollmentTests.cs b/BiStudent.Tests/EnrollmentTests.cs new file mode 100644 index 0000000..d30f170 --- /dev/null +++ b/BiStudent.Tests/EnrollmentTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class EnrollmentTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.EnrollmentTests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public EnrollmentTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new EnrollmentFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task Code_CanBeCheckedAndReplacementRevokesOldCode() + { + await AuthenticateAsync(); + var group = (await client.GetFromJsonAsync>("/api/groups", JsonOptions))!.First(g => g.Students > 0); + var first = await CreateCodeAsync(group.Id); + client.DefaultRequestHeaders.Authorization = null; + var lower = first.Code.ToLowerInvariant().Replace("-", ""); + var check = await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest(lower), JsonOptions); + Assert.Equal(HttpStatusCode.OK, check.StatusCode); + var found = await check.Content.ReadFromJsonAsync(JsonOptions); + Assert.Equal(group.Name, found!.GroupName); + Assert.NotEmpty(found.Students); + await AuthenticateAsync(); + var second = await CreateCodeAsync(group.Id); + Assert.NotEqual(first.Code, second.Code); + client.DefaultRequestHeaders.Authorization = null; + Assert.Equal(HttpStatusCode.NotFound, + (await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest(first.Code), JsonOptions)).StatusCode); + } + + [Fact] + public async Task InvalidAndRevokedCodesReturnNotFound() + { + var invalid = await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest("BAD-CODE"), JsonOptions); + Assert.Equal(HttpStatusCode.NotFound, invalid.StatusCode); + await AuthenticateAsync(); + var group = (await client.GetFromJsonAsync>("/api/groups", JsonOptions))!.First(); + var code = await CreateCodeAsync(group.Id); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/groups/{group.Id}/enrollment-code")).StatusCode); + client.DefaultRequestHeaders.Authorization = null; + Assert.Equal(HttpStatusCode.NotFound, + (await client.PostAsJsonAsync("/api/public/enrollment/check", new PublicCodeRequest(code.Code), JsonOptions)).StatusCode); + } + + [Fact] + public async Task SubmitValidatesConsentCountAndBase64() + { + await AuthenticateAsync(); + var group = (await client.GetFromJsonAsync>("/api/groups", JsonOptions))!.First(); + var student = (await client.GetFromJsonAsync>($"/api/groups/{group.Id}/students", JsonOptions))!.First(); + var code = await CreateCodeAsync(group.Id); + client.DefaultRequestHeaders.Authorization = null; + async Task Submit(List images, bool consent) => + (await client.PostAsJsonAsync("/api/public/enrollment/submit", + new PublicSubmitRequest(code.Code, student.Id, images, consent), JsonOptions)).StatusCode; + Assert.Equal(HttpStatusCode.BadRequest, await Submit(["a", "b", "c"], false)); + Assert.Equal(HttpStatusCode.BadRequest, await Submit(["a", "b"], true)); + Assert.Equal(HttpStatusCode.BadRequest, await Submit(["bad!", "bad!", "bad!"], true)); + Assert.Equal(HttpStatusCode.BadRequest, await Submit([null!, "a", "b"], true)); + Assert.Equal(HttpStatusCode.BadRequest, await Submit(null!, true)); + } + + [Fact] + public async Task ReviewMovesEncryptedCropsToSamplesAndRejectsAnotherSubmission() + { + await AuthenticateAsync(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var recognition = scope.ServiceProvider.GetRequiredService(); + var service = scope.ServiceProvider.GetRequiredService(); + var teacher = await db.Teachers.FirstAsync(); + var student = await db.Students.FirstAsync(); + var first = new FaceSubmission { + StudentId = student.Id, Status = FaceSubmissionStatus.Pending, + Images = Enumerable.Range(0, 3).Select(index => new FaceSubmissionImage { + ProtectedImage = recognition.ProtectImage([(byte)index]) + }).ToList() + }; + db.FaceSubmissions.Add(first); + await db.SaveChangesAsync(); + await service.ApproveAsync(teacher.Id, first.Id); + db.ChangeTracker.Clear(); + Assert.Equal(3, await db.FaceSamples.CountAsync(x => x.StudentId == student.Id)); + Assert.Equal(0, await db.FaceSubmissionImages.CountAsync(x => x.SubmissionId == first.Id)); + Assert.Equal(FaceSubmissionStatus.Approved, (await db.FaceSubmissions.SingleAsync(x => x.Id == first.Id)).Status); + await Assert.ThrowsAsync(() => service.ApproveAsync(teacher.Id, first.Id)); + var second = new FaceSubmission { + StudentId = student.Id, Status = FaceSubmissionStatus.Pending, + Images = Enumerable.Range(0, 3).Select(index => new FaceSubmissionImage { + ProtectedImage = recognition.ProtectImage([(byte)index]) + }).ToList() + }; + db.FaceSubmissions.Add(second); + await db.SaveChangesAsync(); + await service.RejectAsync(teacher.Id, second.Id, " Неверные снимки "); + db.ChangeTracker.Clear(); + var rejected = await db.FaceSubmissions.SingleAsync(x => x.Id == second.Id); + Assert.Equal(FaceSubmissionStatus.Rejected, rejected.Status); + Assert.Equal("Неверные снимки", rejected.RejectReason); + Assert.Equal(0, await db.FaceSubmissionImages.CountAsync(x => x.SubmissionId == second.Id)); + } + + [Fact] + public async Task ParallelApproveAndRejectOnlyOneSucceeds() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var recognition = scope.ServiceProvider.GetRequiredService(); + var teacher = await db.Teachers.FirstAsync(); + var student = await db.Students.FirstAsync(); + var submission = new FaceSubmission { + StudentId = student.Id, Status = FaceSubmissionStatus.Pending, + Images = Enumerable.Range(0, 3).Select(index => new FaceSubmissionImage { + ProtectedImage = recognition.ProtectImage([(byte)index]) + }).ToList() + }; + db.FaceSubmissions.Add(submission); + await db.SaveChangesAsync(); + + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = 0; + async Task ReviewAsync(bool approve) + { + using var reviewScope = factory.Services.CreateScope(); + var service = reviewScope.ServiceProvider.GetRequiredService(); + if (Interlocked.Increment(ref ready) == 2) start.SetResult(true); + await start.Task; + try + { + if (approve) await service.ApproveAsync(teacher.Id, submission.Id); + else await service.RejectAsync(teacher.Id, submission.Id, null); + return true; + } + catch (InvalidOperationException) { return false; } + } + + var approval = Task.Run(() => ReviewAsync(true)); + var rejection = Task.Run(() => ReviewAsync(false)); + var results = await Task.WhenAll(approval, rejection); + Assert.Single(results, x => x); + db.ChangeTracker.Clear(); + var reviewed = await db.FaceSubmissions.SingleAsync(x => x.Id == submission.Id); + Assert.Contains(reviewed.Status, new[] { FaceSubmissionStatus.Approved, FaceSubmissionStatus.Rejected }); + Assert.Equal(0, await db.FaceSubmissionImages.CountAsync(x => x.SubmissionId == submission.Id)); + Assert.Equal(reviewed.Status == FaceSubmissionStatus.Approved ? 3 : 0, + await db.FaceSamples.CountAsync(x => x.StudentId == student.Id)); + } + + [Fact] + public async Task RegisterPageReturnsHtml() + { + var response = await client.GetAsync("/register/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Регистрация лица", await response.Content.ReadAsStringAsync()); + } + + private async Task CreateCodeAsync(int groupId) + { + var response = await client.PostAsJsonAsync($"/api/groups/{groupId}/enrollment-code", new CreateEnrollmentCodeRequest(), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123"), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token!.AccessToken); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class EnrollmentFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/FaceEngineTests.cs b/BiStudent.Tests/FaceEngineTests.cs new file mode 100644 index 0000000..1ceb5a8 --- /dev/null +++ b/BiStudent.Tests/FaceEngineTests.cs @@ -0,0 +1,174 @@ +using BiStudent; +using OpenCvSharp; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class FaceEngineTests +{ + [Theory] + [InlineData("armstrong")] + [InlineData("aldrin")] + [InlineData("ride")] + public void Detect_FindsOneFaceInEachPortrait(string name) + { + using var engine = new FaceEngine(); + using var frame = FaceFixtures.Load(name); + Assert.Single(engine.Detect(frame)); + } + + [Fact] + public void Detect_FindsTwoFacesAndNone() + { + using var engine = new FaceEngine(); + using var armstrong = FaceFixtures.Load("armstrong"); + using var aldrin = FaceFixtures.Load("aldrin"); + using var collage = FaceFixtures.Collage(armstrong, aldrin); + using var black = new Mat(armstrong.Height, armstrong.Width, armstrong.Type(), Scalar.All(0)); + Assert.Equal(2, engine.Detect(collage).Count); + Assert.Empty(engine.Detect(black)); + } + + [Fact] + public void AssessFrame_ReportsQualityProblems() + { + using var engine = new FaceEngine(); + using var frame = FaceFixtures.Load("armstrong"); + Assert.True(engine.AssessFrame(frame).Ok); + using var far = FaceFixtures.Far(frame); + var farQuality = engine.AssessFrame(far); + Assert.False(farQuality.Ok); + Assert.Contains("далеко", farQuality.Problem); + using var dark = new Mat(); + frame.ConvertTo(dark, -1, 0.15, 0); + var darkQuality = engine.AssessFrame(dark); + Assert.False(darkQuality.Ok); + Assert.False(string.IsNullOrWhiteSpace(darkQuality.Problem)); + using var blurred = new Mat(); + Cv2.GaussianBlur(frame, blurred, new Size(31, 31), 0); + Assert.False(engine.AssessFrame(blurred).Ok); + } + + [Fact] + public void CaptureChecked_ProducesNormalizedSampleAndRejectsBadFrames() + { + using var engine = new FaceEngine(); + using var frame = FaceFixtures.Load("armstrong"); + var sample = engine.CaptureChecked(frame); + Assert.NotEmpty(sample.Image); + Assert.NotEmpty(sample.Embedding); + var norm = Math.Sqrt(sample.Embedding.Sum(value => (double)value * value)); + Assert.InRange(norm, 0.99, 1.01); + using var far = FaceFixtures.Far(frame); + var problem = engine.AssessFrame(far).Problem; + var exception = Assert.Throws(() => engine.CaptureChecked(far)); + Assert.Contains(problem!, exception.Message); + using var aldrin = FaceFixtures.Load("aldrin"); + using var collage = FaceFixtures.Collage(frame, aldrin); + Assert.Throws(() => engine.CaptureChecked(collage)); + } + + [Fact] + public void CaptureSample_EmbeddingMatchesCaptureChecked() + { + using var engine = new FaceEngine(); + using var frame = FaceFixtures.Load("armstrong"); + var sample = engine.CaptureChecked(frame); + var image = engine.CaptureSample(frame); + Assert.True(FaceEngine.Similarity(sample.Embedding, engine.Embedding(image)) > 0.99); + } + + [Fact] + public void Similarity_SeparatesPeopleAndIsSymmetric() + { + using var engine = new FaceEngine(); + using var armstrong = FaceFixtures.Load("armstrong"); + using var flip = FaceFixtures.Flip(armstrong); + using var aldrin = FaceFixtures.Load("aldrin"); + var first = engine.CaptureChecked(armstrong).Embedding; + var same = engine.CaptureChecked(flip).Embedding; + var different = engine.CaptureChecked(aldrin).Embedding; + Assert.True(FaceEngine.Similarity(first, same) > 0.8); + Assert.True(FaceEngine.Similarity(first, different) < 0.3); + Assert.Equal(FaceEngine.Similarity(first, same), FaceEngine.Similarity(same, first), 6); + } + + [Fact] + public void TrainAndScan_MatchKnownFacesAndKeepBoundsInsideFrame() + { + using var engine = new FaceEngine(); + engine.Train(Crops(engine, (1, "armstrong"), (2, "aldrin"))); + using var armstrong = FaceFixtures.Load("armstrong"); + using var bright = FaceFixtures.Brighter(armstrong); + var known = Assert.Single(engine.Scan(bright)); + Assert.Equal(1, known.StudentId); + Assert.True(known.Similarity > engine.MatchThreshold); + using var ride = FaceFixtures.Load("ride"); + Assert.Null(Assert.Single(engine.Scan(ride)).StudentId); + using var aldrin = FaceFixtures.Load("aldrin"); + using var collage = FaceFixtures.Collage(armstrong, aldrin); + var matches = engine.Scan(collage); + Assert.Equal(2, matches.Count); + Assert.Equal(new[] { 1, 2 }, matches.Select(match => match.StudentId!.Value).Order().ToArray()); + foreach (var match in matches) + { + Assert.True(match.Bounds.X >= 0 && match.Bounds.Y >= 0); + Assert.True(match.Bounds.Right <= collage.Width && match.Bounds.Bottom <= collage.Height); + } + } + + [Fact] + public void MostSimilar_ExcludesRequestedStudent() + { + using var engine = new FaceEngine(); + engine.Train(Crops(engine, (1, "armstrong"), (2, "aldrin"))); + using var armstrong = FaceFixtures.Load("armstrong"); + var vector = engine.CaptureChecked(armstrong).Embedding; + var other = engine.MostSimilar(vector, 1); + var own = engine.MostSimilar(vector, 2); + Assert.NotNull(other); + Assert.Equal(2, other.Value.StudentId); + Assert.True(other.Value.Similarity < 0.3); + Assert.NotNull(own); + Assert.Equal(1, own.Value.StudentId); + Assert.True(own.Value.Similarity > 0.8); + } + + [Fact] + public void Scan_RespectsThresholdAndMinimumMargin() + { + using var engine = new FaceEngine(); + var armstrong = Crops(engine, (1, "armstrong")); + engine.Train(armstrong); + using var portrait = FaceFixtures.Load("armstrong"); + using var bright = FaceFixtures.Brighter(portrait); + engine.MatchThreshold = 0.99; + Assert.Null(Assert.Single(engine.Scan(bright)).StudentId); + engine.MatchThreshold = FaceEngine.DefaultMatchThreshold; + engine.Train(armstrong.Concat(armstrong.Select(sample => (3, sample.Image)))); + var ambiguous = Assert.Single(engine.Scan(bright)); + Assert.Null(ambiguous.StudentId); + Assert.NotNull(ambiguous.CandidateId); + Assert.True(ambiguous.Margin < engine.MinimumMargin); + } + + [Fact] + public void Dispose_TwiceDoesNotThrow() + { + var engine = new FaceEngine(); + engine.Dispose(); + engine.Dispose(); + } + + private static List<(int StudentId, byte[] Image)> Crops(FaceEngine engine, params (int StudentId, string Name)[] people) + { + var samples = new List<(int StudentId, byte[] Image)>(); + foreach (var (studentId, name) in people) + foreach (var jpeg in FaceFixtures.EnrollmentSet(name)) + { + using var frame = Cv2.ImDecode(jpeg, ImreadModes.Color); + samples.Add((studentId, engine.CaptureChecked(frame).Image)); + } + return samples; + } +} diff --git a/BiStudent.Tests/FaceFixtures.cs b/BiStudent.Tests/FaceFixtures.cs new file mode 100644 index 0000000..8c63b60 --- /dev/null +++ b/BiStudent.Tests/FaceFixtures.cs @@ -0,0 +1,74 @@ +using OpenCvSharp; + +namespace BiStudent.Tests; + +internal static class FaceFixtures +{ + public static Mat Load(string name) + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Faces", name + ".jpg"); + var image = Cv2.ImRead(path, ImreadModes.Color); + if (image.Empty()) throw new InvalidOperationException($"Не удалось прочитать {path}."); + return image; + } + + public static Mat Flip(Mat image) + { + var result = new Mat(); + Cv2.Flip(image, result, FlipMode.Y); + return result; + } + + public static Mat Rotate(Mat image, double degrees) + { + using var transform = Cv2.GetRotationMatrix2D(new Point2f(image.Width / 2f, image.Height / 2f), degrees, 1); + var result = new Mat(); + Cv2.WarpAffine(image, result, transform, new Size(image.Width, image.Height), + InterpolationFlags.Linear, BorderTypes.Replicate); + return result; + } + + public static Mat Brighter(Mat image) + { + var result = new Mat(); + image.ConvertTo(result, -1, 1.0, 25); + return result; + } + + public static Mat Far(Mat image) + { + using var resized = new Mat(); + Cv2.Resize(image, resized, new Size(image.Width * 3 / 4, image.Height * 3 / 4)); + var result = new Mat(); + Cv2.CopyMakeBorder(resized, result, 75, 75, 60, 60, BorderTypes.Replicate); + return result; + } + + public static byte[] Jpeg(Mat image) + { + Cv2.ImEncode(".jpg", image, out var bytes); + return bytes; + } + + public static List EnrollmentSet(string name) + { + using var image = Load(name); + using var flip = Flip(image); + using var plus = Rotate(image, 15); + using var minus = Rotate(image, -15); + return [Jpeg(flip), Jpeg(plus), Jpeg(minus)]; + } + + public static Mat Collage(params Mat[] images) + { + var result = new Mat(images[0].Height, images.Sum(image => image.Width), images[0].Type()); + var x = 0; + foreach (var image in images) + { + using var target = new Mat(result, new Rect(x, 0, image.Width, image.Height)); + image.CopyTo(target); + x += image.Width; + } + return result; + } +} diff --git a/BiStudent.Tests/Fixtures/Faces/README.md b/BiStudent.Tests/Fixtures/Faces/README.md new file mode 100644 index 0000000..d0c0d30 --- /dev/null +++ b/BiStudent.Tests/Fixtures/Faces/README.md @@ -0,0 +1,10 @@ +# Тестовые портреты + +Официальные портреты NASA — работы правительства США, общественное достояние (Public domain). +Используются только в тестах распознавания лиц. Портреты обрезаны вокруг лица и уменьшены до 480×600. + +| Файл | Источник | +|---|---| +| `armstrong.jpg` | https://commons.wikimedia.org/wiki/File:Neil_Armstrong_pose.jpg | +| `aldrin.jpg` | https://commons.wikimedia.org/wiki/File:Buzz_Aldrin.jpg | +| `ride.jpg` | https://commons.wikimedia.org/wiki/File:Sally_Ride_(1984).jpg | diff --git a/BiStudent.Tests/Fixtures/Faces/aldrin.jpg b/BiStudent.Tests/Fixtures/Faces/aldrin.jpg new file mode 100644 index 0000000..7c4a6c3 Binary files /dev/null and b/BiStudent.Tests/Fixtures/Faces/aldrin.jpg differ diff --git a/BiStudent.Tests/Fixtures/Faces/armstrong.jpg b/BiStudent.Tests/Fixtures/Faces/armstrong.jpg new file mode 100644 index 0000000..8e031d6 Binary files /dev/null and b/BiStudent.Tests/Fixtures/Faces/armstrong.jpg differ diff --git a/BiStudent.Tests/Fixtures/Faces/ride.jpg b/BiStudent.Tests/Fixtures/Faces/ride.jpg new file mode 100644 index 0000000..7eda088 Binary files /dev/null and b/BiStudent.Tests/Fixtures/Faces/ride.jpg differ diff --git a/BiStudent.Tests/GradeApiTests.cs b/BiStudent.Tests/GradeApiTests.cs new file mode 100644 index 0000000..a303658 --- /dev/null +++ b/BiStudent.Tests/GradeApiTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class GradeApiTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public GradeApiTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new GradeFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task LessonGradeAcceptsScoresAndClearButRejectsZero() + { + await AuthenticateAsync(); + var (group, student, slot, date) = await LessonAsync(); + var path = $"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/grade"; + var subjects = await client.GetFromJsonAsync>($"/api/journal/{group.Id}/subjects", JsonOptions); + Assert.Contains("Алгебра", subjects!); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new GradeRequest(student.Id, 100), JsonOptions)).StatusCode); + var attendance = await client.GetFromJsonAsync>($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/attendance", JsonOptions); + Assert.Equal(100, attendance!.Single(x => x.StudentId == student.Id).Score); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new GradeRequest(student.Id, 1), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync(path, new GradeRequest(student.Id, null), JsonOptions)).StatusCode); + Assert.Null((await client.GetFromJsonAsync>($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/attendance", JsonOptions))!.Single(x => x.StudentId == student.Id).Score); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync(path, new GradeRequest(student.Id, 0), JsonOptions)).StatusCode); + } + + [Fact] + public async Task AssessmentsAndFinalGradesAppearInJournal() + { + await AuthenticateAsync(); + var (group, first, slot, date) = await LessonAsync(); + var second = await PostAsync("/api/students", new SaveStudentRequest("Второй", group.Id)); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/grade", new GradeRequest(first.Id, 80), JsonOptions)).StatusCode); + var request = new SaveAssessmentRequest(group.Id, "Алгебра", "Контрольная", AssessmentKind.ControlWork, date); + var assessment = await PostAsync("/api/assessments", request); + var listed = await client.GetFromJsonAsync>($"/api/assessments?groupId={group.Id}&subject=Алгебра", JsonOptions); + Assert.Contains(listed!, x => x.Id == assessment.Id); + var renamed = request with { Title = "Итоговая контрольная" }; + var changed = await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}", renamed, JsonOptions); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + Assert.Equal(renamed.Title, (await changed.Content.ReadFromJsonAsync(JsonOptions))!.Title); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}/grades", new GradeRequest(first.Id, 100), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}/grades", new GradeRequest(second.Id, 70), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}/grades", new GradeRequest(first.Id, 0), JsonOptions)).StatusCode); + var filled = await (await client.PostAsJsonAsync($"/api/journal/{group.Id}/final/fill", new FillFinalRequest("Алгебра"), JsonOptions)).Content.ReadFromJsonAsync(JsonOptions); + Assert.Equal(2, filled!.Count); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/journal/{group.Id}/final", new FinalGradeRequest("Алгебра", first.Id, 91), JsonOptions)).StatusCode); + var journal = await client.GetFromJsonAsync($"/api/journal/{group.Id}?from={date:yyyy-MM-dd}&to={date:yyyy-MM-dd}&subject=Алгебра", JsonOptions); + var column = journal!.Columns.FindIndex(x => x.Kind == JournalColumnKind.Assessment && x.Id == assessment.Id); + Assert.True(column >= 0); + var firstRow = journal.Students.Single(x => x.StudentId == first.Id); + var secondRow = journal.Students.Single(x => x.StudentId == second.Id); + Assert.Equal(100, firstRow.Cells[column].Score); + Assert.Equal(91, firstRow.Final); + Assert.Equal(70, secondRow.Final); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}/grades", new GradeRequest(second.Id, null), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/api/assessments/{assessment.Id}")).StatusCode); + Assert.Empty((await client.GetFromJsonAsync>($"/api/assessments?groupId={group.Id}", JsonOptions))!); + } + + [Fact] + public async Task AnotherTeachersAssessmentIsNotAccessible() + { + await AuthenticateAsync(); + var group = await PostAsync("/api/groups", new CreateGroupRequest("Чужая группа")); + var temporary = await PostAsync("/api/admin/teachers", new SaveTeacherRequest("second", "Второй преподаватель", false, true)); + using var other = factory.CreateClient(); + var token = await LoginAsync(other, "second", temporary.Password); + other.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); + var slot = await other.PostAsJsonAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "Физика", null, date.DayOfWeek, new TimeOnly(4, 0), new TimeOnly(5, 0), WeekParity.Every, date), JsonOptions); + Assert.Equal(HttpStatusCode.OK, slot.StatusCode); + var created = await other.PostAsJsonAsync("/api/assessments", new SaveAssessmentRequest(group.Id, "Физика", "Тест", AssessmentKind.Test, date), JsonOptions); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + var assessment = await created.Content.ReadFromJsonAsync(JsonOptions); + Assert.DoesNotContain((await client.GetFromJsonAsync>("/api/assessments", JsonOptions))!, x => x.Id == assessment!.Id); + var update = await client.PutAsJsonAsync($"/api/assessments/{assessment!.Id}", new SaveAssessmentRequest(group.Id, "Физика", "Чужой тест", AssessmentKind.Test, date), JsonOptions); + Assert.Equal(HttpStatusCode.NotFound, update.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/api/assessments/{assessment.Id}/grades", new GradeRequest(999999, 50), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/api/assessments/{assessment.Id}")).StatusCode); + } + + private async Task PostAsync(string path, object value) + { + var response = await client.PostAsJsonAsync(path, value, JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(JsonOptions); + return result!; + } + + private async Task<(GroupDto Group, StudentDto Student, SlotDto Slot, DateOnly Date)> LessonAsync() + { + var group = await PostAsync("/api/groups", new CreateGroupRequest("Группа оценок")); + var student = await PostAsync("/api/students", new SaveStudentRequest("Первый", group.Id)); + var date = DateOnly.FromDateTime(DateTime.Today).AddDays(-7); + var slot = await PostAsync("/api/schedule/slots", new SaveSlotRequest(group.Id, null, "Алгебра", null, date.DayOfWeek, new TimeOnly(2, 0), new TimeOnly(3, 0), WeekParity.Every, date)); + return (group, student, slot, date); + } + + private async Task AuthenticateAsync() + { + var token = await LoginAsync(client, "teacher", "teacher123"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private static async Task LoginAsync(HttpClient target, string login, string password) + { + var response = await target.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(token); + return token; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class GradeFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/GradeTests.cs b/BiStudent.Tests/GradeTests.cs new file mode 100644 index 0000000..06e33c2 --- /dev/null +++ b/BiStudent.Tests/GradeTests.cs @@ -0,0 +1,168 @@ +using BiStudent.Contracts; +using BiStudent.Server.Services; +using ClosedXML.Excel; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class GradeTests +{ + [Fact] + public async Task CameraMarksPresentAfterLessonStart() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var started = DateTime.Now.AddMinutes(-30); + var slot = test.AddSlot(teacher, group, start: TimeOnly.FromDateTime(started)); + await test.Attendance.MarkAsync(slot.Id, DateOnly.FromDateTime(started), group.Students[0].Id, + AttendanceStatus.Absent, MarkMethod.Camera); + Assert.Equal(AttendanceStatus.Present, Assert.Single(test.Db.AttendanceMarks).Status); + } + + [Fact] + public async Task ExcusedDefaultsReasonAndPresentClearsDetails() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var studentId = group.Students[0].Id; + var date = new DateOnly(2026, 9, 1); + await test.Attendance.MarkAsync(slot.Id, date, studentId, AttendanceStatus.Excused, MarkMethod.Manual, + comment: " Причина ", hasDocument: true); + var mark = Assert.Single(test.Db.AttendanceMarks); + Assert.Equal(ExcuseReason.Other, mark.ExcuseReason); + Assert.Equal("Причина", mark.Comment); + await test.Attendance.MarkAsync(slot.Id, date, studentId, AttendanceStatus.Present, MarkMethod.Manual, + ExcuseReason.Illness, "Не сохранять", true); + Assert.Null(mark.ExcuseReason); + Assert.Null(mark.Comment); + Assert.False(mark.HasDocument); + } + + [Fact] + public async Task LessonGradeRejectsOutOfRangeAndNullDeletes() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var date = new DateOnly(2026, 9, 1); + var studentId = group.Students[0].Id; + await Assert.ThrowsAsync(() => test.Attendance.SetLessonGradeAsync(slot.Id, date, studentId, 0)); + await Assert.ThrowsAsync(() => test.Attendance.SetLessonGradeAsync(slot.Id, date, studentId, 101)); + await test.Attendance.SetLessonGradeAsync(slot.Id, date, studentId, 85); + Assert.Equal(85, Assert.Single(test.Db.LessonGrades).Score); + await test.Attendance.SetLessonGradeAsync(slot.Id, date, studentId, null); + Assert.Empty(test.Db.LessonGrades); + } + + [Fact] + public async Task JournalCombinesLessonsAssessmentsAndFinal() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var slot = test.AddSlot(teacher, group); + var studentId = group.Students[0].Id; + var first = new DateOnly(2026, 9, 1); + var second = first.AddDays(7); + await test.Attendance.MarkAsync(slot.Id, first, studentId, AttendanceStatus.Present, MarkMethod.Manual); + await test.Attendance.SetLessonGradeAsync(slot.Id, first, studentId, 85); + await test.Attendance.MarkAsync(slot.Id, second, studentId, AttendanceStatus.Excused, MarkMethod.Manual, + ExcuseReason.Illness); + var assessment = await test.Grades.SaveAsync(teacher.Id, null, + new SaveAssessmentRequest(group.Id, "Алгебра", "Тест", AssessmentKind.Test, first)); + await test.Grades.SetGradeAsync(teacher.Id, assessment.Id, studentId, 95); + await test.Grades.SetFinalAsync(teacher.Id, group.Id, "Алгебра", studentId, 90); + var journal = await test.Attendance.JournalAsync(teacher.Id, group.Id, first, second, "Алгебра"); + Assert.Equal(new[] { JournalColumnKind.Lesson, JournalColumnKind.Assessment, JournalColumnKind.Lesson }, + journal.Columns.Select(x => x.Kind)); + var row = Assert.Single(journal.Students); + Assert.Equal(1, row.Present); + Assert.Equal(0, row.Absent); + Assert.Equal(1, row.Excused); + Assert.Equal(2, row.MissedHours); + Assert.Equal(2, row.ExcusedHours); + Assert.Equal(50d, row.Percent); + Assert.Equal(90d, row.Average); + Assert.Equal(90, row.Final); + } + + [Fact] + public async Task AssessmentRejectsSubjectOutsideSchedule() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + test.AddSlot(teacher, group); + await Assert.ThrowsAsync(() => test.Grades.SaveAsync(teacher.Id, null, + new SaveAssessmentRequest(group.Id, "Физика", "Тест", AssessmentKind.Test, new DateOnly(2026, 9, 1)))); + } + + [Fact] + public async Task AssessmentWithGradesCannotChangeGroupOrSubject() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван"); + var other = test.AddGroup("ИВТ-2", "Анна"); + test.AddSlot(teacher, group); + test.AddSlot(teacher, group, subject: "Физика"); + test.AddSlot(teacher, other); + var date = new DateOnly(2026, 9, 1); + var assessment = await test.Grades.SaveAsync(teacher.Id, null, + new SaveAssessmentRequest(group.Id, "Алгебра", "Тест", AssessmentKind.Test, date)); + await test.Grades.SetGradeAsync(teacher.Id, assessment.Id, group.Students[0].Id, 85); + var groupError = await Assert.ThrowsAsync(() => test.Grades.SaveAsync(teacher.Id, assessment.Id, + new SaveAssessmentRequest(other.Id, "Алгебра", "Тест", AssessmentKind.Test, date))); + Assert.Equal("Нельзя перенести мероприятие с выставленными баллами в другую группу.", groupError.Message); + await Assert.ThrowsAsync(() => test.Grades.SaveAsync(teacher.Id, assessment.Id, + new SaveAssessmentRequest(group.Id, "Физика", "Тест", AssessmentKind.Test, date))); + Assert.Equal(group.Id, test.Db.Assessments.Single(x => x.Id == assessment.Id).GroupId); + } + + [Fact] + public async Task FillFinalRoundsAverageAndPreservesExisting() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван", "Анна"); + var slot = test.AddSlot(teacher, group); + var date = new DateOnly(2026, 9, 1); + var ivan = group.Students[0].Id; + var anna = group.Students[1].Id; + await test.Attendance.SetLessonGradeAsync(slot.Id, date, ivan, 84); + await test.Attendance.SetLessonGradeAsync(slot.Id, date, anna, 60); + var assessment = await test.Grades.SaveAsync(teacher.Id, null, + new SaveAssessmentRequest(group.Id, "Алгебра", "Тест", AssessmentKind.Test, date)); + await test.Grades.SetGradeAsync(teacher.Id, assessment.Id, ivan, 85); + await test.Grades.SetFinalAsync(teacher.Id, group.Id, "Алгебра", anna, 100); + Assert.Equal(1, await test.Grades.FillFinalAsync(teacher.Id, group.Id, "Алгебра")); + Assert.Equal(85, test.Db.FinalGrades.Single(x => x.StudentId == ivan).Score); + Assert.Equal(100, test.Db.FinalGrades.Single(x => x.StudentId == anna).Score); + } + + [Fact] + public void ExportContainsScoresAndExcusedSymbol() + { + using var test = new TestDb(); + var matrix = new JournalMatrix( + "Ирина Иванова", "ИВТ-1", "Алгебра", new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 8), 2, + [new JournalColumn(JournalColumnKind.Lesson, 1, new DateOnly(2026, 9, 1), + new TimeOnly(9, 0), "Алгебра"), + new JournalColumn(JournalColumnKind.Lesson, 1, new DateOnly(2026, 9, 8), + new TimeOnly(9, 0), "Алгебра")], + [new JournalStudent(1, "Иван Иванов", + [new JournalCell(AttendanceStatus.Present, null, null, false, 85), + new JournalCell(AttendanceStatus.Excused, ExcuseReason.Illness, null, true, null)], + 1, 0, 1, 2, 2, 50, 85, null)]); + using var stream = new MemoryStream(test.Export.JournalXlsx(matrix, "Журнал учёта посещаемости и успеваемости")); + using var book = new XLWorkbook(stream); + var sheet = book.Worksheet("Журнал"); + Assert.Equal("Журнал учёта посещаемости и успеваемости", sheet.Cell(1, 1).GetString()); + Assert.Equal("П 85", sheet.Cell(8, 3).GetString()); + Assert.Contains("Н(у)", test.Export.JournalCsv(matrix)); + } +} diff --git a/BiStudent.Tests/GroupMembershipTests.cs b/BiStudent.Tests/GroupMembershipTests.cs new file mode 100644 index 0000000..84fa7e0 --- /dev/null +++ b/BiStudent.Tests/GroupMembershipTests.cs @@ -0,0 +1,127 @@ +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class GroupMembershipTests +{ + [Fact] + public async Task JournalsKeepPastMarksAndExcludeEarlierLessonsInNewGroup() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var groupA = test.AddGroup("Группа А", "Анна Иванова"); + var groupB = test.AddGroup("Группа Б"); + var student = Assert.Single(groupA.Students); + var today = DateOnly.FromDateTime(DateTime.Today); + var past = today.AddDays(-2); + var slotA = test.AddSlot(teacher, groupA, past.DayOfWeek, validFrom: past, oneOffDate: past); + test.AddSlot(teacher, groupB, past.DayOfWeek, validFrom: past, oneOffDate: past); + + await test.Attendance.MarkAsync(slotA.Id, past, student.Id, AttendanceStatus.Present, MarkMethod.Manual); + await test.Students.MoveStudentAsync(student.Id, groupB.Id); + + var journalA = await test.Attendance.JournalAsync(teacher.Id, groupA.Id, past, past, null); + var rowA = Assert.Single(journalA.Students); + Assert.Equal(student.Id, rowA.StudentId); + Assert.Equal(AttendanceStatus.Present, Assert.Single(rowA.Cells).Status); + Assert.Equal(100, rowA.Percent); + + var pastJournalB = await test.Attendance.JournalAsync(teacher.Id, groupB.Id, past, past, null); + Assert.Empty(pastJournalB.Students); + var journalB = await test.Attendance.JournalAsync(teacher.Id, groupB.Id, past, today, null); + var rowB = Assert.Single(journalB.Students); + Assert.False(Assert.Single(rowB.Cells).InGroup); + Assert.Null(rowB.Cells[0].Status); + Assert.Equal(0, rowB.Percent); + } + + [Fact] + public async Task PastLessonAcceptsFormerMemberButTodayDoesNot() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var groupA = test.AddGroup("Группа А", "Анна Иванова"); + var groupB = test.AddGroup("Группа Б"); + var student = Assert.Single(groupA.Students); + var today = DateOnly.FromDateTime(DateTime.Today); + var past = today.AddDays(-2); + var pastSlot = test.AddSlot(teacher, groupA, past.DayOfWeek, validFrom: past, oneOffDate: past); + var todaySlot = test.AddSlot(teacher, groupA, today.DayOfWeek, validFrom: today, oneOffDate: today); + + await test.Students.MoveStudentAsync(student.Id, groupB.Id); + + Assert.True(await test.Attendance.MarkAsync(pastSlot.Id, past, student.Id, + AttendanceStatus.Present, MarkMethod.Manual)); + Assert.Contains(await test.Attendance.ForLessonAsync(pastSlot.Id, past), x => x.StudentId == student.Id); + await Assert.ThrowsAsync(() => test.Attendance.MarkAsync( + todaySlot.Id, today, student.Id, AttendanceStatus.Present, MarkMethod.Manual)); + } + + [Fact] + public async Task TwoMovesTodayKeepOnlyOriginalAndCurrentMemberships() + { + using var test = new TestDb(); + var groupA = test.AddGroup("Группа А", "Анна Иванова"); + var groupB = test.AddGroup("Группа Б"); + var groupC = test.AddGroup("Группа В"); + var student = Assert.Single(groupA.Students); + var today = DateOnly.FromDateTime(DateTime.Today); + + await test.Students.MoveStudentAsync(student.Id, groupB.Id); + await test.Students.MoveStudentAsync(student.Id, groupC.Id); + + var memberships = await test.Db.GroupMemberships.AsNoTracking() + .Where(x => x.StudentId == student.Id).OrderBy(x => x.From).ToListAsync(); + Assert.Equal(2, memberships.Count); + Assert.Equal(groupA.Id, memberships[0].GroupId); + Assert.Equal(DateOnly.MinValue, memberships[0].From); + Assert.Equal(today.AddDays(-1), memberships[0].To); + Assert.Equal(groupC.Id, memberships[1].GroupId); + Assert.Equal(today, memberships[1].From); + Assert.Null(memberships[1].To); + Assert.Equal(groupC.Id, (await test.Db.Students.FindAsync(student.Id))!.GroupId); + } + + [Fact] + public async Task NewStudentsReceiveInitialMembershipFromServiceAndTestDb() + { + using var test = new TestDb(); + var group = test.AddGroup("Группа А", "Анна Иванова"); + var fromTestDb = Assert.Single(group.Students); + var fromService = await test.Students.AddStudentAsync(group.Id, "Борис Петров"); + + foreach (var student in new[] { fromTestDb, fromService }) + { + var membership = Assert.Single(await test.Db.GroupMemberships.AsNoTracking() + .Where(x => x.StudentId == student.Id).ToListAsync()); + Assert.Equal(group.Id, membership.GroupId); + Assert.Equal(DateOnly.MinValue, membership.From); + Assert.Null(membership.To); + } + } + + [Fact] + public async Task FillFinalIgnoresScoresAfterStudentLeftGroup() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var groupA = test.AddGroup("Группа А", "Анна Иванова"); + var groupB = test.AddGroup("Группа Б"); + var student = Assert.Single(groupA.Students); + var today = DateOnly.FromDateTime(DateTime.Today); + var yesterday = today.AddDays(-1); + var yesterdaySlot = test.AddSlot(teacher, groupA, yesterday.DayOfWeek, validFrom: yesterday, oneOffDate: yesterday); + var todaySlot = test.AddSlot(teacher, groupA, today.DayOfWeek, validFrom: today, oneOffDate: today); + await test.Attendance.SetLessonGradeAsync(yesterdaySlot.Id, yesterday, student.Id, 100); + await test.Attendance.SetLessonGradeAsync(todaySlot.Id, today, student.Id, 20); + + await test.Students.MoveStudentAsync(student.Id, groupB.Id); + + Assert.Equal(1, await test.Grades.FillFinalAsync(teacher.Id, groupA.Id, "Алгебра")); + var final = await test.Db.FinalGrades.AsNoTracking().SingleAsync(x => x.StudentId == student.Id); + Assert.Equal(100, final.Score); + } +} diff --git a/BiStudent.Tests/ImageQualityTests.cs b/BiStudent.Tests/ImageQualityTests.cs new file mode 100644 index 0000000..750df85 --- /dev/null +++ b/BiStudent.Tests/ImageQualityTests.cs @@ -0,0 +1,55 @@ +using Xunit; +using OpenCvSharp; + +namespace BiStudent.Tests; + +public sealed class ImageQualityTests +{ + private static byte[] Encode(string extension, int width, int height) + { + using var image = new Mat(new OpenCvSharp.Size(width, height), MatType.CV_8UC3, Scalar.Gray); + Cv2.ImEncode(extension, image, out var bytes); + return bytes; + } + + [Theory] + [InlineData(".jpg")] + [InlineData(".png")] + public void ReadsSizeFromHeader(string extension) + { + Assert.True(ImageHeader.TryReadSize(Encode(extension, 640, 480), out var width, out var height)); + Assert.Equal((640, 480), (width, height)); + Assert.Null(ImageHeader.Validate(Encode(extension, 640, 480))); + } + + [Fact] + public void RejectsOversizedImageBeforeDecoding() + { + var png = Encode(".png", 16, 16); + png[16] = 0; png[17] = 0; png[18] = 0x3E; png[19] = 0x80; + png[20] = 0; png[21] = 0; png[22] = 0x3E; png[23] = 0x80; + Assert.Contains("слишком большой", ImageHeader.Validate(png)); + } + + [Fact] + public void RejectsUnknownFormat() => + Assert.NotNull(ImageHeader.Validate("not an image"u8.ToArray())); + + [Fact] + public void AssessFrameReportsMissingFace() + { + using var engine = new FaceEngine(); + using var frame = new Mat(new OpenCvSharp.Size(640, 480), MatType.CV_8UC3, Scalar.Black); + var quality = engine.AssessFrame(frame); + Assert.False(quality.Ok); + Assert.Equal("В кадре нет лица", quality.Problem); + Assert.Throws(() => engine.CaptureChecked(frame)); + } + + [Fact] + public void SimilarityOfVectorWithItselfIsOne() + { + var vector = new[] { 0.6f, 0.8f }; + Assert.Equal(1, FaceEngine.Similarity(vector, vector), 5); + } +} diff --git a/BiStudent.Tests/ImportTests.cs b/BiStudent.Tests/ImportTests.cs new file mode 100644 index 0000000..f6b3cf5 --- /dev/null +++ b/BiStudent.Tests/ImportTests.cs @@ -0,0 +1,241 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using BiStudent.Server.Services; +using ClosedXML.Excel; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +// Импорт расписания колледжа и списков групп из Excel. +public sealed class ImportTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public ImportTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ImportFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + /// Мини-копия формата расписания колледжа: две группы, объединённые ячейки дня и звонка, строки преподавателей. + internal static byte[] CollegeSchedule() + { + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("4 курс"); + sheet.Cell(3, 1).Value = "САБАҚ КЕСТЕСІ · 4 курс · 1 СЕМЕСТР (недели 1-18, 31.08.2026–01.01.2027)"; + foreach (var (column, group) in new[] { (5, "23-ПО-103-4р-1"), (10, "23-ПО-103-4р-2") }) + { + sheet.Cell(6, column - 4).Value = "Күні Дни"; + sheet.Cell(6, column - 3).Value = "Қоңырау уақыты Время звонка"; + sheet.Cell(6, column - 2).Value = "No ; пары"; + sheet.Cell(6, column - 1).Value = "Дәрісхана ; Аудитория"; + sheet.Cell(6, column).Value = group; + sheet.Cell(7, column - 4).Value = "Дүйсенбі / Понедельник"; + sheet.Range(7, column - 4, 10, column - 4).Merge(); + sheet.Cell(7, column - 3).Value = "08:00 09:20"; + sheet.Range(7, column - 3, 8, column - 3).Merge(); + sheet.Cell(9, column - 3).Value = "09:30 10:50"; + sheet.Range(9, column - 3, 10, column - 3).Merge(); + sheet.Cell(7, column - 2).Value = 1; + sheet.Cell(9, column - 2).Value = 2; + } + sheet.Cell(7, 5).Value = "Искусственный интеллект OpenCV · 711 · нед. 6-7\n[ПРАКТИКА] База данных PL SQL/MONGODB · уточнить · нед. 3 (разово)"; + sheet.Cell(8, 5).Value = "Ниязова М.Е. · 711\nХамит А.Ү. · уточнить"; + sheet.Cell(9, 5).Value = "[СРОП] СРОП · Автоматизация DevOps · 504 · с 23-ПО-103-4р-2 · нед. 1 (разово), 5-7"; + sheet.Cell(10, 5).Value = "вакансия (Основы экономики и предпринимательской деятельности) · 504"; + sheet.Cell(7, 10).Value = "[ПРАКТИКА] Программирование С# · 505 · нед. 2-4 Искусственный интеллект OpenCV -уточнить нед, 14-15"; + sheet.Cell(8, 10).Value = "Ниязова -505"; + sheet.Cell(9, 10).Value = "Непонятная запись без недель"; + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + [Fact] + public void Parser_ReadsCollegeFormat() + { + using var stream = new MemoryStream(CollegeSchedule()); + var parsed = CollegeScheduleParser.Parse(stream, new DateOnly(2026, 9, 1)); + Assert.Equal(new DateOnly(2026, 8, 31), parsed.SemesterStart); + Assert.Equal(["23-ПО-103-4р-1", "23-ПО-103-4р-2"], parsed.Groups.Select(x => x.Group)); + var first = parsed.Lessons.Where(x => x.Group == "23-ПО-103-4р-1").ToList(); + Assert.Contains(first, x => x is { Subject: "Искусственный интеллект OpenCV", Room: "711", Teacher: "Ниязова М.Е.", FromWeek: 6, ToWeek: 7, Once: false, + Day: DayOfWeek.Monday } && x.Start == new TimeOnly(8, 0) && x.End == new TimeOnly(9, 20)); + Assert.Contains(first, x => x is { Subject: "База данных PL SQL/MONGODB", Room: null, Teacher: "Хамит А.Ү.", FromWeek: 3, ToWeek: 3, Once: true }); + // СРОП: дисциплина — второе поле, «с группой» — не аудитория; строка вакансии с двойным пробелом не рвётся. + var srop = first.Where(x => x.Subject == "Автоматизация DevOps").ToList(); + Assert.Equal(2, srop.Count); + Assert.All(srop, x => Assert.Equal(("504", CollegeScheduleParser.Vacancy, new TimeOnly(9, 30)), (x.Room, x.Teacher, x.Start))); + Assert.Contains(srop, x => x is { FromWeek: 1, Once: true }); + Assert.Contains(srop, x => x is { FromWeek: 5, ToWeek: 7, Once: false }); + var second = parsed.Lessons.Where(x => x.Group == "23-ПО-103-4р-2").ToList(); + Assert.Contains(second, x => x is { Subject: "Программирование С#", Room: "505", FromWeek: 2, ToWeek: 4 }); + Assert.Contains(second, x => x is { Subject: "Искусственный интеллект OpenCV", Room: null, FromWeek: 14, ToWeek: 15 }); + // «Ниязова -505» сводится к единственной полной записи с той же фамилией. + Assert.All(second, x => Assert.Equal("Ниязова М.Е.", x.Teacher)); + Assert.Single(parsed.Warnings); + Assert.Contains("Непонятная запись", parsed.Warnings[0]); + } + + [Theory] + [InlineData("[ПРАКТИКА] Программирование С# · 505 · нед.5,6,7", "Программирование С#", "505", "5-5,6-6,7-7")] + [InlineData("[ПРАКТИКА] Программирование С# -нед.5-7", "Программирование С#", null, "5-7")] + [InlineData("[ПРАКТИКА] Программирование С# -5-7", "Программирование С#", null, "5-7")] + [InlineData("Автоматизация DevOps · 711 · нед. 8 (разово)", "Автоматизация DevOps", "711", "8-8")] + [InlineData("[ПРАКТИКА] Программирование С# ·504-нед. 9 (разово)", "Программирование С#", "504", "9-9")] + [InlineData("[ПРАКТИКА] Программирование С# -уточн-нед. 8-9", "Программирование С#", null, "8-9")] + [InlineData("[ЛЕКЦИЯ] Основы экономики · 804 · с 23-ПО-103-4р-1 · нед. 5-7, 14 (разово)", "Основы экономики", "804", "5-7,14-14")] + public void Parser_ToleratesHandEditedEntries(string entry, string subject, string? room, string weeks) + { + Assert.True(CollegeScheduleParser.TryParseEntry(entry, out var parsedSubject, out var parsedRoom, out var parsedWeeks)); + Assert.Equal(subject, parsedSubject); + Assert.Equal(room, parsedRoom); + Assert.Equal(weeks, string.Join(",", parsedWeeks.Select(x => $"{x.From}-{x.To}"))); + } + + [Theory] + [InlineData("Ниязова М.Е.", "niyazova.me")] + [InlineData("Хамит А.Ү.", "khamit.au")] + [InlineData("Байтукешов Т.С.", "baytukeshov.ts")] + [InlineData("Халель", "khalel")] + public void Login_IsTransliterated(string name, string login) => Assert.Equal(login, ScheduleImportService.Login(name)); + + [Fact] + public async Task ScheduleImport_CreatesTeachersGroupsAndLessons_Idempotently() + { + await AuthenticateAsync("teacher", "teacher123"); + var file = CollegeSchedule(); + var preview = await PostFileAsync("/api/schedule/import-xlsx/preview", file); + Assert.Equal(new DateOnly(2026, 8, 31), preview.SemesterStart); + Assert.Equal(2, preview.Groups.Count); + Assert.Contains("Ниязова М.Е.", preview.Teachers); + Assert.Single(preview.Warnings); + + var result = await PostFileAsync("/api/schedule/import-xlsx?groups=23-ПО-103-4р-1", file); + Assert.Equal(4, result.Added); + Assert.Equal(0, result.Skipped); + var niyazova = Assert.Single(result.Teachers, x => x.FullName == "Ниязова М.Е."); + Assert.True(niyazova is { Created: true, Active: true, Login: "niyazova.me" }); + Assert.NotNull(niyazova.TemporaryPassword); + var vacancy = Assert.Single(result.Teachers, x => x.FullName == CollegeScheduleParser.Vacancy); + Assert.True(vacancy is { Active: false, TemporaryPassword: null }); + + var again = await PostFileAsync("/api/schedule/import-xlsx?groups=23-ПО-103-4р-1", file); + Assert.Equal(0, again.Added); + Assert.Equal(4, again.Skipped); + Assert.All(again.Teachers, x => Assert.False(x.Created)); + + // Новый преподаватель входит временным паролем и видит своё занятие в 6-ю неделю (понедельник 05.10.2026). + using var teacherClient = factory.CreateClient(); + var token = await LoginAsync(teacherClient, "niyazova.me", niyazova.TemporaryPassword!); + teacherClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var day = await teacherClient.GetFromJsonAsync>("/api/schedule/day?date=2026-10-05", JsonOptions); + Assert.Contains(day!, x => x.Slot is { Subject: "Искусственный интеллект OpenCV", Room: "711", GroupName: "23-ПО-103-4р-1" }); + Assert.DoesNotContain((await teacherClient.GetFromJsonAsync>("/api/schedule/day?date=2026-10-19", JsonOptions))!, + x => x.Slot.Subject == "Искусственный интеллект OpenCV"); + // Импорт создаёт учётные записи — неадмину запрещён. + var forbidden = await teacherClient.PostAsync("/api/schedule/import-xlsx?groups=23-ПО-103-4р-1", new ByteArrayContent(file)); + Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); + } + + [Fact] + public async Task ScheduleImport_RejectsUnknownGroupsAndBrokenFiles() + { + await AuthenticateAsync("teacher", "teacher123"); + var unknown = await client.PostAsync("/api/schedule/import-xlsx?groups=99-XX-000-1", new ByteArrayContent(CollegeSchedule())); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + var none = await client.PostAsync("/api/schedule/import-xlsx", new ByteArrayContent(CollegeSchedule())); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + var broken = await client.PostAsync("/api/schedule/import-xlsx/preview", new ByteArrayContent("не excel"u8.ToArray())); + Assert.Equal(HttpStatusCode.BadRequest, broken.StatusCode); + } + + [Fact] + public async Task StudentImport_ReadsJournalWorkbook() + { + await AuthenticateAsync("teacher", "teacher123"); + using var workbook = new XLWorkbook(); + foreach (var (group, names) in new[] { ("99-ИМ-001-1", new[] { "Абдиев Артур ", " Ковалев Тимур" }), ("99-ИМ-001-2", new[] { "Ли Кирилл" }) }) + { + var sheet = workbook.Worksheets.Add(group); + sheet.Cell(2, 1).Value = "#"; sheet.Cell(2, 2).Value = "ФИО"; sheet.Cell(2, 3).Value = 16; + for (var i = 0; i < names.Length; i++) { sheet.Cell(3 + i, 1).Value = i + 1; sheet.Cell(3 + i, 2).Value = names[i]; } + sheet.Cell(4 + names.Length, 2).Value = "куратор:Рахимкужаева Алия Рахимберлиевна"; + } + workbook.Worksheets.Add("Пустой").Cell(1, 1).Value = "заметки"; + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + + var result = await PostFileAsync("/api/students/import", stream.ToArray()); + Assert.Equal(3, result.Added); + Assert.Equal(0, result.Skipped); + Assert.Single(result.Errors, x => x.Contains("Пустой")); + var groups = await client.GetFromJsonAsync>("/api/groups", JsonOptions); + var first = Assert.Single(groups!, x => x.Name == "99-ИМ-001-1"); + var students = await client.GetFromJsonAsync>($"/api/groups/{first.Id}/students", JsonOptions); + Assert.Equal(["Абдиев Артур", "Ковалев Тимур"], students!.Select(x => x.FullName).Order()); + + var again = await PostFileAsync("/api/students/import", stream.ToArray()); + Assert.Equal((0, 3), (again.Added, again.Skipped)); + // CSV по-прежнему поддерживается тем же маршрутом. + var csv = await PostFileAsync("/api/students/import", "ФИО;Группа\r\nНовиков Пётр;99-ИМ-001-2\r\n"u8.ToArray()); + Assert.Equal(1, csv.Added); + } + + private async Task PostFileAsync(string path, byte[] file) + { + var response = await client.PostAsync(path, new ByteArrayContent(file)); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path}: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task AuthenticateAsync(string login, string password) + { + var token = await LoginAsync(client, login, password); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private static async Task LoginAsync(HttpClient target, string login, string password) + { + var response = await target.PostAsJsonAsync("/api/auth/login", new LoginRequest(login, password), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ImportFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/RecognitionFlowTests.cs b/BiStudent.Tests/RecognitionFlowTests.cs new file mode 100644 index 0000000..812392a --- /dev/null +++ b/BiStudent.Tests/RecognitionFlowTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class RecognitionFlowTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.RecognitionFlowTests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public RecognitionFlowTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new RecognitionFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task Enrollment_AcceptsPortraitsAndFlagsSimilarStudent() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var first = await CreateStudentAsync(group.Id, "Первый"); + var second = await CreateStudentAsync(group.Id, "Второй"); + var images = FaceFixtures.EnrollmentSet("armstrong"); + var enrolled = await EnrollAsync(first.Id, images); + Assert.True(enrolled.Ok, enrolled.Message); + Assert.True((await StudentsAsync(group.Id)).Single(x => x.Id == first.Id).HasFace); + var similar = await EnrollAsync(second.Id, images); + Assert.False(similar.Ok); + Assert.Equal(first.FullName, similar.SimilarTo); + var allowed = await EnrollAsync(second.Id, images, true); + Assert.True(allowed.Ok, allowed.Message); + } + + [Fact] + public async Task Enrollment_RejectsMissingRepeatedFarAndCorruptFrames() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Студент"); + var images = FaceFixtures.EnrollmentSet("armstrong"); + Assert.False((await EnrollAsync(student.Id, images.Take(2).ToList())).Ok); + var repeated = await EnrollAsync(student.Id, [images[0], images[0], images[0]]); + Assert.False(repeated.Ok); + Assert.Contains(repeated.Problems!, problem => problem.Contains("почти одинаковые")); + using var portrait = FaceFixtures.Load("armstrong"); + using var far = FaceFixtures.Far(portrait); + var farImages = Enumerable.Repeat(FaceFixtures.Jpeg(far), 3).ToList(); + var distant = await EnrollAsync(student.Id, farImages); + Assert.False(distant.Ok); + Assert.Contains(distant.Problems!, problem => problem.Contains("далеко")); + var corrupt = await EnrollAsync(student.Id, [[1, 2, 3], [1, 2, 3], [1, 2, 3]]); + Assert.False(corrupt.Ok); + Assert.NotEmpty(corrupt.Problems!); + } + + [Fact] + public async Task Recognition_MarksOnlyAfterRequiredFrames() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Армстронг"); + var slot = await CreateSlotAsync(group.Id); + Assert.True((await EnrollAsync(student.Id, FaceFixtures.EnrollmentSet("armstrong"))).Ok); + var settings = await client.GetFromJsonAsync("/api/settings/automation", JsonOptions); + Assert.NotNull(settings); + using var portrait = FaceFixtures.Load("armstrong"); + using var bright = FaceFixtures.Brighter(portrait); + var jpeg = FaceFixtures.Jpeg(bright); + var date = slot.OneOffDate!.Value; + for (var frame = 1; frame < settings.FramesInRow; frame++) + { + var result = await RecognizeAsync(slot.Id, date, jpeg); + Assert.False(Assert.Single(result.Faces).Marked); + Assert.Null((await AttendanceAsync(slot.Id, date)).Single(row => row.StudentId == student.Id).Status); + } + var final = await RecognizeAsync(slot.Id, date, jpeg); + var face = Assert.Single(final.Faces); + Assert.Equal(student.Id, face.StudentId); + Assert.True(face.Marked || face.AlreadyMarked); + var row = (await AttendanceAsync(slot.Id, date)).Single(item => item.StudentId == student.Id); + Assert.Equal(AttendanceStatus.Present, row.Status); + Assert.Equal(MarkMethod.Camera, row.Method); + Assert.True(Assert.Single((await RecognizeAsync(slot.Id, date, jpeg)).Faces).AlreadyMarked); + } + + [Fact] + public async Task Recognition_DoesNotOverrideManualAbsence() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Армстронг"); + var slot = await CreateSlotAsync(group.Id); + Assert.True((await EnrollAsync(student.Id, FaceFixtures.EnrollmentSet("armstrong"))).Ok); + var date = slot.OneOffDate!.Value; + var response = await client.PutAsJsonAsync($"/api/lessons/{slot.Id}/{date:yyyy-MM-dd}/attendance", + new MarkRequest(student.Id, AttendanceStatus.Absent), JsonOptions); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var settings = await client.GetFromJsonAsync("/api/settings/automation", JsonOptions); + using var portrait = FaceFixtures.Load("armstrong"); + using var bright = FaceFixtures.Brighter(portrait); + var jpeg = FaceFixtures.Jpeg(bright); + for (var i = 0; i < settings!.FramesInRow; i++) + await RecognizeAsync(slot.Id, date, jpeg); + var row = (await AttendanceAsync(slot.Id, date)).Single(item => item.StudentId == student.Id); + Assert.Equal(AttendanceStatus.Absent, row.Status); + Assert.Equal(MarkMethod.Manual, row.Method); + } + + [Fact] + public async Task Recognition_LeavesUnknownAndOtherGroupUnmarked() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var own = await CreateStudentAsync(group.Id, "Свой студент"); + var slot = await CreateSlotAsync(group.Id); + var otherGroup = await CreateGroupAsync(); + var visitor = await CreateStudentAsync(otherGroup.Id, "Студент другой группы"); + Assert.True((await EnrollAsync(visitor.Id, FaceFixtures.EnrollmentSet("aldrin"))).Ok); + var date = slot.OneOffDate!.Value; + using var ride = FaceFixtures.Load("ride"); + var unknown = Assert.Single((await RecognizeAsync(slot.Id, date, FaceFixtures.Jpeg(ride))).Faces); + Assert.Null(unknown.StudentId); + using var aldrin = FaceFixtures.Load("aldrin"); + var outside = Assert.Single((await RecognizeAsync(slot.Id, date, FaceFixtures.Jpeg(aldrin))).Faces); + Assert.Equal(visitor.Id, outside.StudentId); + Assert.True(outside.OtherGroup); + Assert.False(outside.Marked); + Assert.Null((await AttendanceAsync(slot.Id, date)).Single(row => row.StudentId == own.Id).Status); + } + + [Fact] + public async Task RecognitionStatus_IsAvailable() + { + await AuthenticateAsync(); + var status = await client.GetFromJsonAsync("/api/recognition/status", JsonOptions); + Assert.True(status!.Ok, status.Message); + } + + [Fact] + public async Task PublicSubmission_CanBeReviewedAndRecognized() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Райд"); + var slot = await CreateSlotAsync(group.Id); + var code = await CreateCodeAsync(group.Id); + var images = PublicImages("ride"); + var submitted = await SubmitAsync(code.Code, student.Id, images); + Assert.True(submitted.Ok, submitted.Message); + var submissions = await client.GetFromJsonAsync>("/api/face-submissions", JsonOptions); + var submission = Assert.Single(submissions!, item => item.StudentId == student.Id && item.Status == FaceSubmissionStatus.Pending); + Assert.Equal(3, submission.ImageCount); + for (var index = 0; index < 3; index++) + { + var response = await client.GetAsync($"/api/face-submissions/{submission.Id}/images/{index}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("image/jpeg", response.Content.Headers.ContentType?.MediaType); + Assert.NotEmpty(await response.Content.ReadAsByteArrayAsync()); + } + Assert.Equal(HttpStatusCode.NotFound, + (await client.GetAsync($"/api/face-submissions/{submission.Id}/images/3")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, + (await client.PostAsync($"/api/face-submissions/{submission.Id}/approve", null)).StatusCode); + Assert.True((await StudentsAsync(group.Id)).Single(item => item.Id == student.Id).HasFace); + var settings = await client.GetFromJsonAsync("/api/settings/automation", JsonOptions); + using var ride = FaceFixtures.Load("ride"); + using var bright = FaceFixtures.Brighter(ride); + var jpeg = FaceFixtures.Jpeg(bright); + var date = slot.OneOffDate!.Value; + RecognitionResultDto? result = null; + for (var frame = 0; frame < settings!.FramesInRow; frame++) + result = await RecognizeAsync(slot.Id, date, jpeg); + Assert.Equal(student.Id, Assert.Single(result!.Faces).StudentId); + var row = (await AttendanceAsync(slot.Id, date)).Single(item => item.StudentId == student.Id); + Assert.Equal(AttendanceStatus.Present, row.Status); + Assert.Equal(MarkMethod.Camera, row.Method); + } + + [Fact] + public async Task PublicSubmission_ResubmissionSupersedesPending() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Райд"); + var code = await CreateCodeAsync(group.Id); + var images = PublicImages("ride"); + Assert.True((await SubmitAsync(code.Code, student.Id, images)).Ok); + Assert.True((await SubmitAsync(code.Code, student.Id, images)).Ok); + var submissions = await client.GetFromJsonAsync>("/api/face-submissions", JsonOptions); + Assert.Single(submissions!, item => item.StudentId == student.Id && item.Status == FaceSubmissionStatus.Pending); + } + + [Fact] + public async Task UnreadableSample_IsReportedWithoutFace() + { + await AuthenticateAsync(); + var group = await CreateGroupAsync(); + var student = await CreateStudentAsync(group.Id, "Студент"); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.FaceSamples.Add(new FaceSample { StudentId = student.Id, ProtectedImage = [1, 2, 3] }); + await db.SaveChangesAsync(); + var listed = (await StudentsAsync(group.Id)).Single(item => item.Id == student.Id); + Assert.True(listed.FaceUnreadable); + Assert.False(listed.HasFace); + var status = await client.GetFromJsonAsync("/api/recognition/status", JsonOptions); + Assert.True(status!.Ok); + Assert.Contains("не расшифровываются", status.Message); + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123"), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + private async Task CreateGroupAsync() + { + var response = await client.PostAsJsonAsync("/api/groups", + new CreateGroupRequest($"Тестовая группа {Guid.NewGuid():N}"), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task CreateStudentAsync(int groupId, string name) + { + var response = await client.PostAsJsonAsync("/api/students", new SaveStudentRequest(name, groupId), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task CreateSlotAsync(int groupId) + { + var now = DateTime.Now; + var date = DateOnly.FromDateTime(now); + var minutes = now.Hour * 60 + now.Minute; + var start = Math.Max(0, minutes - 10); + var end = Math.Min(23 * 60 + 59, minutes + 60); + var request = new SaveSlotRequest(groupId, null, "Распознавание", null, now.DayOfWeek, + new TimeOnly(start / 60, start % 60), new TimeOnly(end / 60, end % 60), WeekParity.Every, date); + var lessons = await client.GetFromJsonAsync>( + $"/api/schedule/day?date={date:yyyy-MM-dd}", JsonOptions); + foreach (var lesson in lessons!.Where(lesson => lesson.Slot.Start < request.End && lesson.Slot.End > request.Start)) + Assert.Equal(HttpStatusCode.NoContent, + (await client.DeleteAsync($"/api/schedule/slots/{lesson.Slot.Id}")).StatusCode); + var response = await client.PostAsJsonAsync("/api/schedule/slots", request, JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task EnrollAsync(int studentId, List images, bool allowSimilar = false) + { + var response = await client.PostAsJsonAsync($"/api/students/{studentId}/enroll", + new EnrollRequest(images, allowSimilar), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task> StudentsAsync(int groupId) => + (await client.GetFromJsonAsync>($"/api/groups/{groupId}/students", JsonOptions))!; + + private async Task RecognizeAsync(int slotId, DateOnly date, byte[] image) + { + var response = await client.PostAsJsonAsync("/api/recognize", new RecognizeRequest(slotId, date, image), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(result); + Assert.Null(result.Error); + return result; + } + + private async Task> AttendanceAsync(int slotId, DateOnly date) => + (await client.GetFromJsonAsync>( + $"/api/lessons/{slotId}/{date:yyyy-MM-dd}/attendance", JsonOptions))!; + + private async Task CreateCodeAsync(int groupId) + { + var response = await client.PostAsJsonAsync($"/api/groups/{groupId}/enrollment-code", + new CreateEnrollmentCodeRequest(), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private async Task SubmitAsync(string code, int studentId, List images) + { + var response = await client.PostAsJsonAsync("/api/public/enrollment/submit", + new PublicSubmitRequest(code, studentId, images, true), JsonOptions); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions))!; + } + + private static List PublicImages(string name) => + FaceFixtures.EnrollmentSet(name).Select(Convert.ToBase64String).ToList(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class RecognitionFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/RecognitionSettingsTests.cs b/BiStudent.Tests/RecognitionSettingsTests.cs new file mode 100644 index 0000000..1f23d4a --- /dev/null +++ b/BiStudent.Tests/RecognitionSettingsTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BiStudent.Contracts; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class RecognitionSettingsTests : IDisposable +{ + private readonly string temporaryDirectory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + private readonly WebApplicationFactory factory; + private readonly HttpClient client; + + public RecognitionSettingsTests() + { + Directory.CreateDirectory(temporaryDirectory); + factory = new ApiFactory(temporaryDirectory); + client = factory.CreateClient(); + } + + [Fact] + public async Task GetSettings_ReturnsDefaults() + { + await AuthenticateAsync(); + var settings = await client.GetFromJsonAsync("/api/settings/recognition"); + Assert.NotNull(settings); + Assert.Equal(0.43, settings.MatchThreshold); + Assert.Equal(0.06, settings.MinimumMargin); + Assert.Equal(3, settings.FramesInRow); + } + + [Fact] + public async Task SaveSettings_PersistsValues() + { + await AuthenticateAsync(); + var expected = new RecognitionSettingsDto(0.55, 0.08, 5); + var response = await client.PutAsJsonAsync("/api/settings/recognition", expected); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saved = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expected, saved); + var loaded = await client.GetFromJsonAsync("/api/settings/recognition"); + Assert.Equal(expected, loaded); + } + + [Fact] + public async Task SaveSettings_WithInvalidThresholdReturnsBadRequest() + { + await AuthenticateAsync(); + var response = await client.PutAsJsonAsync("/api/settings/recognition", new RecognitionSettingsDto(0.9, 0.06, 3)); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(error); + Assert.Contains("0.30–0.70", error.Message); + } + + [Fact] + public async Task Automation_UsesSavedFramesInRow() + { + await AuthenticateAsync(); + var response = await client.PutAsJsonAsync("/api/settings/recognition", new RecognitionSettingsDto(0.43, 0.06, 5)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var automation = await client.GetFromJsonAsync("/api/settings/automation"); + Assert.NotNull(automation); + Assert.Equal(5, automation.FramesInRow); + } + + private async Task AuthenticateAsync() + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + + public void Dispose() + { + client.Dispose(); + factory.Dispose(); + Directory.Delete(temporaryDirectory, recursive: true); + } + + private sealed class ApiFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("Demo:Enabled", "true"); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/ScheduleServiceTests.cs b/BiStudent.Tests/ScheduleServiceTests.cs new file mode 100644 index 0000000..f9baf8a --- /dev/null +++ b/BiStudent.Tests/ScheduleServiceTests.cs @@ -0,0 +1,94 @@ +using System.Text; +using BiStudent.Contracts; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class ScheduleServiceTests +{ + [Fact] + public void WeekParityOf_AlternatesFromSemesterStart() + { + using var test = new TestDb(); + Assert.Equal(WeekParity.Odd, test.Schedule.WeekParityOf(new DateOnly(2026, 9, 1))); + Assert.Equal(WeekParity.Even, test.Schedule.WeekParityOf(new DateOnly(2026, 9, 8))); + } + + [Fact] + public async Task ForDayAsync_AppliesParityDatesOneOffAndCancellation() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1"); + var first = new DateOnly(2026, 9, 1); + var odd = test.AddSlot(teacher, group, parity: WeekParity.Odd, subject: "Odd"); + test.AddSlot(teacher, group, parity: WeekParity.Even, subject: "Even"); + test.AddSlot(teacher, group, validFrom: first.AddDays(7), validTo: first.AddDays(7), subject: "Window"); + test.AddSlot(teacher, group, day: DayOfWeek.Sunday, oneOffDate: first, subject: "OneOff"); + await test.Schedule.CancelAsync(odd.Id, first, "Cancelled"); + var weekOne = await test.Schedule.ForDayAsync(teacher.Id, first); + Assert.Contains(weekOne, x => x.Slot.Id == odd.Id && x.Cancelled); + Assert.Contains(weekOne, x => x.Slot.Subject == "OneOff"); + Assert.DoesNotContain(weekOne, x => x.Slot.Subject == "Even"); + Assert.DoesNotContain(weekOne, x => x.Slot.Subject == "Window"); + var weekTwo = await test.Schedule.ForDayAsync(teacher.Id, first.AddDays(7)); + Assert.Contains(weekTwo, x => x.Slot.Subject == "Even"); + Assert.Contains(weekTwo, x => x.Slot.Subject == "Window"); + Assert.DoesNotContain(weekTwo, x => x.Slot.Subject == "Odd"); + Assert.DoesNotContain(weekTwo, x => x.Slot.Subject == "OneOff"); + Assert.DoesNotContain(await test.Schedule.ForDayAsync(teacher.Id, first.AddDays(14)), + x => x.Slot.Subject == "Window"); + } + + [Fact] + public async Task CurrentAsync_ExcludesEndAndAmbiguousOverlap() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1"); + var slot = test.AddSlot(teacher, group); + var now = new DateTime(2026, 9, 1, 9, 30, 0); + Assert.Equal(slot.Id, (await test.Schedule.CurrentAsync(teacher.Id, now))?.Slot.Id); + Assert.Null(await test.Schedule.CurrentAsync(teacher.Id, new DateTime(2026, 9, 1, 10, 0, 0))); + test.AddSlot(teacher, group, start: new TimeOnly(9, 15), end: new TimeOnly(9, 45), subject: "Overlap"); + Assert.Null(await test.Schedule.CurrentAsync(teacher.Id, now)); + } + + [Fact] + public async Task AddSlotAsync_RejectsOverlapAndInvalidTimesButAllowsOppositeParity() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1"); + await test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, parity: WeekParity.Odd)); + await Assert.ThrowsAsync(() => + test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, parity: WeekParity.Odd))); + await Assert.ThrowsAsync(() => + test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, start: new TimeOnly(10, 0), + end: new TimeOnly(10, 0), parity: WeekParity.Even))); + var other = await test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, parity: WeekParity.Even)); + Assert.Equal(WeekParity.Even, other.Parity); + } + + [Fact] + public async Task ImportCsvAsync_AddsSlotsAndGroup() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + using var csv = new MemoryStream(Encoding.UTF8.GetBytes("День;Начало;Конец;Предмет;Группа;Аудитория;Чётность\n1;09:00;10:00;Алгебра;ИВТ-1;305;каждая\n2;11:00;12:00;Физика;ИВТ-2;;чёт\n")); + Assert.Equal(2, await test.Schedule.ImportCsvAsync(teacher.Id, csv)); + Assert.Equal(2, test.Db.ScheduleSlots.Count()); + Assert.Contains(test.Db.Groups, x => x.Name == "ИВТ-2"); + } + + [Fact] + public async Task ImportCsvAsync_ReportsAllBadLineNumbers() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + using var csv = new MemoryStream(Encoding.UTF8.GetBytes("День;Начало;Конец;Предмет;Группа;Аудитория;Чётность\nbad;09:00;10:00;Алгебра;ИВТ-1;305;каждая\n2;09:00\n")); + var error = await Assert.ThrowsAsync(() => test.Schedule.ImportCsvAsync(teacher.Id, csv)); + Assert.Contains("Строка 2", error.Message); + Assert.Contains("Строка 3", error.Message); + } +} diff --git a/BiStudent.Tests/ServiceCoverageTests.cs b/BiStudent.Tests/ServiceCoverageTests.cs new file mode 100644 index 0000000..584b889 --- /dev/null +++ b/BiStudent.Tests/ServiceCoverageTests.cs @@ -0,0 +1,388 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class ServiceCoverageTests +{ + private static readonly DateOnly FirstTuesday = new(2026, 9, 1); + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + [Fact] + public async Task ScheduleHandlesOneOffParityOverlapAndWeek() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("10-А"); + var once = new DateOnly(2026, 9, 3); + var oneOff = await test.Schedule.AddOneOffAsync(test.NewSlot(teacher, group), once); + + Assert.DoesNotContain(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday), x => x.Slot.Id == oneOff.Id); + Assert.Contains(await test.Schedule.ForDayAsync(teacher.Id, once), x => x.Slot.Id == oneOff.Id); + Assert.DoesNotContain(await test.Schedule.ForDayAsync(teacher.Id, once.AddDays(7)), x => x.Slot.Id == oneOff.Id); + + var odd = await test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, parity: WeekParity.Odd)); + var even = await test.Schedule.AddSlotAsync(test.NewSlot(teacher, group, parity: WeekParity.Even)); + Assert.Equal(WeekParity.Odd, test.Schedule.WeekParityOf(FirstTuesday)); + Assert.Equal(WeekParity.Even, test.Schedule.WeekParityOf(FirstTuesday.AddDays(7))); + Assert.Contains(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday), x => x.Slot.Id == odd.Id); + Assert.DoesNotContain(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday), x => x.Slot.Id == even.Id); + Assert.Contains(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday.AddDays(7)), x => x.Slot.Id == even.Id); + Assert.Equal(2, (await test.Schedule.ForWeekAsync(teacher.Id, new DateOnly(2026, 8, 31))).Count); + + var overlap = test.NewSlot(teacher, group, start: new TimeOnly(9, 30), end: new TimeOnly(10, 30)); + var error = await Assert.ThrowsAsync(() => test.Schedule.AddSlotAsync(overlap)); + Assert.Contains("уже есть пара", error.Message); + } + + [Fact] + public async Task ScheduleCancellationCurrentLessonAndDeletion() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("10-А", "Анна"); + var slot = await test.Schedule.AddSlotAsync(test.NewSlot(teacher, group)); + var student = await test.Db.Students.SingleAsync(); + var now = FirstTuesday.ToDateTime(new TimeOnly(9, 30)); + + Assert.Equal(slot.Id, (await test.Schedule.CurrentAsync(teacher.Id, now))?.Slot.Id); + await test.Schedule.CancelAsync(slot.Id, FirstTuesday, "Каникулы"); + Assert.True(Assert.Single(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday)).Cancelled); + Assert.Null(await test.Schedule.CurrentAsync(teacher.Id, now)); + await test.Schedule.RestoreAsync(slot.Id, FirstTuesday); + Assert.False(Assert.Single(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday)).Cancelled); + + await test.Attendance.MarkAsync(slot.Id, FirstTuesday, student.Id, AttendanceStatus.Present, MarkMethod.Manual); + await test.Schedule.DeleteSlotAsync(slot.Id); + Assert.Empty(await test.Schedule.ForDayAsync(teacher.Id, FirstTuesday)); + Assert.False(await test.Db.AttendanceMarks.AnyAsync(x => x.SlotId == slot.Id)); + } + + [Fact] + public async Task ScheduleImportReportsEachInvalidRow() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + const string csv = "День;Начало;Конец;Предмет;Группа;Кабинет;Чётность\n" + + "неизвестно;09:00;10:00;Алгебра;10-А;;каждая\n" + + "вт;11:00;10:00;Алгебра;10-А;;каждая\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); + + var error = await Assert.ThrowsAsync(() => test.Schedule.ImportCsvAsync(teacher.Id, stream)); + Assert.Contains("Строка 2", error.Message); + Assert.Contains("Неверный день", error.Message); + Assert.Contains("Строка 3", error.Message); + Assert.Contains("раньше окончания", error.Message); + } + + [Fact] + public async Task StudentValidationImportAndFaceCounts() + { + using var test = new TestDb(); + await Assert.ThrowsAsync(() => test.Students.AddGroupAsync(" ")); + var first = await test.Students.AddGroupAsync("10-А"); + var second = await test.Students.AddGroupAsync("10-Б"); + await Assert.ThrowsAsync(() => test.Students.AddGroupAsync(" 10-А ")); + var student = await test.Students.AddStudentAsync(first.Id, " Анна "); + Assert.Equal("Анна", student.FullName); + await Assert.ThrowsAsync(() => test.Students.AddStudentAsync(first.Id, "Анна")); + await Assert.ThrowsAsync(() => test.Students.DeleteGroupAsync(first.Id)); + + await test.Students.RenameStudentAsync(student.Id, "Анна Иванова"); + await test.Students.MoveStudentAsync(student.Id, second.Id); + Assert.Equal(second.Id, (await test.Students.ForGroupAsync(second.Id)).Single().GroupId); + + test.Db.FaceSamples.Add(new FaceSample { StudentId = student.Id, ProtectedImage = [1] }); + await test.Db.SaveChangesAsync(); + Assert.True(await test.Students.HasFace(student.Id)); + Assert.Contains(student.Id, await test.Students.WithFaceAsync(second.Id)); + Assert.Empty(await test.Students.WithFaceAsync(first.Id)); + + using var csv = new MemoryStream(Encoding.UTF8.GetBytes( + "ФИО;Группа\nАнна Иванова;10-Б\nБорис;10-Б\nБорис;10-Б\n")); + var imported = await test.Students.ImportCsvAsync(csv); + Assert.Equal(1, imported.Added); + Assert.Equal(2, imported.Skipped); + } + + [Fact] + public async Task DeletingStudentRemovesRelatedRecords() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("10-А"); + var student = await test.Students.AddStudentAsync(group.Id, "Анна"); + var slot = test.AddSlot(teacher, group); + var assessment = new Assessment { + TeacherId = teacher.Id, GroupId = group.Id, Subject = slot.Subject, + Title = "Контрольная", Date = FirstTuesday + }; + test.Db.Assessments.Add(assessment); + await test.Db.SaveChangesAsync(); + test.Db.FaceSamples.Add(new FaceSample { StudentId = student.Id, ProtectedImage = [1] }); + test.Db.FaceSubmissions.Add(new FaceSubmission { + StudentId = student.Id, Images = [new FaceSubmissionImage { ProtectedImage = [2] }] + }); + test.Db.AssessmentGrades.Add(new AssessmentGrade { AssessmentId = assessment.Id, StudentId = student.Id, Score = 80 }); + test.Db.FinalGrades.Add(new FinalGrade { + TeacherId = teacher.Id, GroupId = group.Id, Subject = slot.Subject, StudentId = student.Id, Score = 80 + }); + await test.Db.SaveChangesAsync(); + await test.Attendance.MarkAsync(slot.Id, FirstTuesday, student.Id, AttendanceStatus.Present, MarkMethod.Manual); + await test.Attendance.SetLessonGradeAsync(slot.Id, FirstTuesday, student.Id, 80); + + await test.Students.DeleteStudentAsync(student.Id); + + Assert.False(await test.Db.Students.AnyAsync(x => x.Id == student.Id)); + Assert.False(await test.Db.AttendanceMarks.AnyAsync(x => x.StudentId == student.Id)); + Assert.False(await test.Db.LessonGrades.AnyAsync(x => x.StudentId == student.Id)); + Assert.False(await test.Db.AssessmentGrades.AnyAsync(x => x.StudentId == student.Id)); + Assert.False(await test.Db.FinalGrades.AnyAsync(x => x.StudentId == student.Id)); + Assert.False(await test.Db.FaceSamples.AnyAsync(x => x.StudentId == student.Id)); + Assert.False(await test.Db.FaceSubmissions.AnyAsync(x => x.StudentId == student.Id)); + } + + [Fact] + public async Task AttendanceValidatesAndNotifiesOnChanges() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("10-А", "Анна", "Борис"); + var other = test.AddGroup("10-Б", "Вера"); + var slot = test.AddSlot(teacher, group); + var students = await test.Students.ForGroupAsync(group.Id); + var foreign = Assert.Single(await test.Students.ForGroupAsync(other.Id)); + var changed = 0; + test.Notifier.Changed += (id, date) => { + Assert.Equal(slot.Id, id); + Assert.Equal(FirstTuesday, date); + changed++; + }; + + await Assert.ThrowsAsync(() => test.Attendance.MarkAsync( + slot.Id, FirstTuesday, foreign.Id, AttendanceStatus.Present, MarkMethod.Manual)); + await Assert.ThrowsAsync(() => test.Attendance.MarkAsync( + slot.Id, FirstTuesday, students[0].Id, AttendanceStatus.Excused, MarkMethod.Manual, + ExcuseReason.Illness, new string('x', 501))); + await test.Attendance.MarkAsync(slot.Id, FirstTuesday, students[0].Id, AttendanceStatus.Present, MarkMethod.Manual); + await test.Attendance.ClearAsync(slot.Id, FirstTuesday, students[0].Id); + await test.Attendance.MarkAsync(slot.Id, FirstTuesday, students[0].Id, AttendanceStatus.Present, MarkMethod.Manual); + Assert.Equal(1, await test.Attendance.MarkRestAbsentAsync(slot.Id, FirstTuesday)); + await test.Attendance.SetLessonGradeAsync(slot.Id, FirstTuesday, students[0].Id, 75); + Assert.Equal(5, changed); + Assert.Equal(AttendanceStatus.Absent, + (await test.Attendance.ForLessonAsync(slot.Id, FirstTuesday)).Single(x => x.StudentId == students[1].Id).Status); + } + + [Fact] + public async Task GradesValidateMoveFillAndDelete() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var first = test.AddGroup("10-А", "Анна"); + var second = test.AddGroup("10-Б", "Борис"); + test.AddSlot(teacher, first); + test.AddSlot(teacher, second, start: new TimeOnly(11, 0), end: new TimeOnly(12, 0)); + var anna = Assert.Single(await test.Students.ForGroupAsync(first.Id)); + var boris = Assert.Single(await test.Students.ForGroupAsync(second.Id)); + Assert.Contains("Алгебра", await test.Grades.SubjectsAsync(teacher.Id, first.Id)); + Assert.Equal(0, await test.Grades.FillFinalAsync(teacher.Id, first.Id, "Алгебра")); + + var request = new SaveAssessmentRequest(first.Id, "Алгебра", "Контрольная", AssessmentKind.Test, FirstTuesday); + await Assert.ThrowsAsync(() => test.Grades.SaveAsync( + teacher.Id, null, request with { Title = " " })); + await Assert.ThrowsAsync(() => test.Grades.SaveAsync( + teacher.Id, null, request with { Title = new string('x', 121) })); + var assessment = await test.Grades.SaveAsync(teacher.Id, null, request); + Assert.Single(await test.Grades.ListAsync(teacher.Id, first.Id, " Алгебра ")); + await Assert.ThrowsAsync(() => + test.Grades.SetGradeAsync(teacher.Id, assessment.Id, boris.Id, 80)); + await test.Grades.SetGradeAsync(teacher.Id, assessment.Id, anna.Id, 80); + Assert.Equal(1, await test.Grades.FillFinalAsync(teacher.Id, first.Id, "Алгебра")); + Assert.Equal(80, (await test.Db.FinalGrades.SingleAsync()).Score); + await test.Grades.SetFinalAsync(teacher.Id, first.Id, "Алгебра", anna.Id, null); + Assert.Empty(await test.Db.FinalGrades.ToListAsync()); + + var movable = await test.Grades.SaveAsync(teacher.Id, null, request with { Title = "Без баллов" }); + var moved = await test.Grades.SaveAsync(teacher.Id, movable.Id, + request with { GroupId = second.Id, Title = "Перенесённая" }); + Assert.Equal(second.Id, moved.GroupId); + await test.Grades.DeleteAsync(teacher.Id, movable.Id); + await test.Grades.DeleteAsync(teacher.Id, assessment.Id); + Assert.Empty(await test.Grades.ListAsync(teacher.Id, null, null)); + } + + [Fact] + public async Task ExportsEscapeFormulaeAndCountBothFormats() + { + using var test = new TestDb(); + var matrix = new JournalMatrix("Преподаватель", "10-А", null, FirstTuesday, FirstTuesday, 2, [], + [new JournalStudent(1, "=1+1", [], 0, 0, 0, 0, 0, 0, null, null)]); + + var csv = test.Export.JournalCsv(matrix); + Assert.StartsWith("\uFEFF", csv); + Assert.Contains("\"'=1+1\"", csv); + Assert.NotEmpty(test.Export.JournalXlsx(matrix, "Журнал")); + Assert.Equal("Болезнь", ExportService.ExcuseReasonText(ExcuseReason.Illness)); + Assert.Equal("Семейные обстоятельства", ExportService.ExcuseReasonText(ExcuseReason.Family)); + Assert.Equal("Учебная/служебная необходимость", ExportService.ExcuseReasonText(ExcuseReason.Official)); + Assert.Equal("Другая причина", ExportService.ExcuseReasonText(ExcuseReason.Other)); + Assert.Equal("2", (await test.Db.AppSettings.SingleAsync(x => x.Key == "stats.reports_exported")).Value); + } + + [Fact] + public async Task AdminAccountValidationAndDisabledRefresh() + { + var directory = Path.Combine(Path.GetTempPath(), $"BiStudent.Tests.{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + using var factory = new AdminFactory(directory); + using var client = factory.CreateClient(); + var login = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("teacher", "teacher123"), JsonOptions); + login.EnsureSuccessStatusCode(); + var adminToken = (await login.Content.ReadFromJsonAsync(JsonOptions))!; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken.AccessToken); + var created = await client.PostAsJsonAsync("/api/admin/teachers", + new SaveTeacherRequest("second", "Второй преподаватель", false, true), JsonOptions); + created.EnsureSuccessStatusCode(); + var password = (await created.Content.ReadFromJsonAsync(JsonOptions))!; + var accounts = (await client.GetFromJsonAsync>("/api/admin/teachers", JsonOptions))!; + var admin = accounts.Single(x => x.Login == "teacher"); + var second = accounts.Single(x => x.Login == "second"); + + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/admin/teachers", + new SaveTeacherRequest("a b", "Ошибка", false, true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/admin/teachers", + new SaveTeacherRequest("ab", "Ошибка", false, true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/api/admin/teachers/{second.Id}", + new SaveTeacherRequest("teacher", "Второй преподаватель", false, true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/api/admin/teachers/{admin.Id}", + new SaveTeacherRequest("teacher", admin.FullName, false, true), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/api/admin/teachers/{admin.Id}", + new SaveTeacherRequest("teacher", admin.FullName, true, false), JsonOptions)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await client.PostAsync($"/api/admin/teachers/{admin.Id}/reset-password", null)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync("/api/admin/teachers/999999", + new SaveTeacherRequest("missing", "Нет", false, true), JsonOptions)).StatusCode); + + client.DefaultRequestHeaders.Authorization = null; + var secondLogin = await client.PostAsJsonAsync("/api/auth/login", + new LoginRequest("second", password.Password, true), JsonOptions); + secondLogin.EnsureSuccessStatusCode(); + var secondToken = (await secondLogin.Content.ReadFromJsonAsync(JsonOptions))!; + Assert.NotNull(secondToken.RefreshToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken.AccessToken); + Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/api/admin/teachers/{second.Id}", + new SaveTeacherRequest("second", "Второй преподаватель", false, false), JsonOptions)).StatusCode); + client.DefaultRequestHeaders.Authorization = null; + Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/api/auth/refresh", + new RefreshRequest(secondToken.RefreshToken!), JsonOptions)).StatusCode); + } + finally + { + SqliteConnection.ClearAllPools(); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task BackupAutoCreationIsDueOnceAndCorruptArchiveIsRejected() + { + using var fixture = new BackupFixture(); + Assert.Empty(fixture.Backups.List()); + Assert.Null(fixture.Backups.Resolve("..\\x")); + await fixture.Backups.CreateAsync(false); + Assert.Single(fixture.Backups.List()); + await fixture.Backups.AutoBackupIfDueAsync(CancellationToken.None); + Assert.Equal(2, fixture.Backups.List().Count); + await fixture.Backups.AutoBackupIfDueAsync(CancellationToken.None); + Assert.Equal(2, fixture.Backups.List().Count); + using var damaged = new MemoryStream(Encoding.UTF8.GetBytes("not a zip archive")); + var error = await Assert.ThrowsAsync(() => fixture.Backups.RestoreAsync(damaged)); + Assert.Contains("Это не резервная копия BiStudent", error.Message); + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private sealed class AdminFactory(string directory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", + $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "true"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } + + private sealed class BackupFixture : IDisposable + { + private readonly string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly ServiceProvider services; + private readonly RecognitionService recognition; + public BackupService Backups { get; } + + public BackupFixture() + { + Directory.CreateDirectory(root); + var database = Path.Combine(root, "bistudent.db"); + var paths = new StoragePaths(database, new SqliteConnectionStringBuilder { + DataSource = database, Pooling = false + }.ToString()); + services = new ServiceCollection() + .AddDbContext(options => options.UseSqlite(paths.ConnectionString)) + .BuildServiceProvider(); + using (var scope = services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + DatabaseInitializer.Initialize(db); + db.Groups.Add(new Group { Name = "10-А", Students = [new Student { FullName = "Анна" }] }); + db.SaveChanges(); + } + recognition = new RecognitionService(services.GetRequiredService(), + DataProtectionProvider.Create("tests"), new ConfigurationBuilder().Build()); + Backups = new BackupService(paths, services.GetRequiredService(), + recognition, new SessionGeneration(paths), NullLogger.Instance); + } + + public void Dispose() + { + recognition.Dispose(); + services.Dispose(); + SqliteConnection.ClearAllPools(); + Directory.Delete(root, recursive: true); + } + } +} diff --git a/BiStudent.Tests/StartupTests.cs b/BiStudent.Tests/StartupTests.cs new file mode 100644 index 0000000..3200217 --- /dev/null +++ b/BiStudent.Tests/StartupTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class StartupTests +{ + [Fact] + public async Task ConfiguredDataDirectoryContainsCreatedDatabase() + { + var root = NewRoot(); + try + { + var data = Path.Combine(root, "data"); + using (var factory = new StartupFactory(ServerRoot(), data)) + using (var client = factory.CreateClient()) + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/health")).StatusCode); + + Assert.True(File.Exists(Path.Combine(data, "bistudent.db"))); + } + finally { DeleteRoot(root); } + } + + [Fact] + public async Task LegacyDatabaseAndKeysAreCopiedBeforeLogin() + { + var root = NewRoot(); + try + { + var legacy = Path.Combine(root, "App_Data"); + var keys = Path.Combine(legacy, "keys"); + var data = Path.Combine(root, "new-data"); + Directory.CreateDirectory(keys); + var oldDatabase = Path.Combine(legacy, "bistudent.db"); + var oldKey = Path.Combine(keys, "marker.bin"); + File.WriteAllText(oldKey, "legacy key"); + var options = new DbContextOptionsBuilder() + .UseSqlite(new SqliteConnectionStringBuilder { DataSource = oldDatabase, Pooling = false }.ToString()) + .Options; + using (var db = new AppDbContext(options)) + { + DatabaseInitializer.Initialize(db); + var teacher = new Teacher { + Login = "legacy", FullName = "Старый преподаватель", IsAdmin = true + }; + teacher.PasswordHash = new PasswordHasher().HashPassword(teacher, "legacy123"); + db.Teachers.Add(teacher); + db.SaveChanges(); + } + + using (var factory = new StartupFactory(root, data)) + using (var client = factory.CreateClient()) + { + var response = await client.PostAsJsonAsync("/api/auth/login", + new LoginRequest("legacy", "legacy123")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(await response.Content.ReadFromJsonAsync()); + } + + Assert.True(File.Exists(oldDatabase)); + Assert.True(File.Exists(Path.Combine(data, "bistudent.db"))); + Assert.Equal(File.ReadAllText(oldKey), File.ReadAllText(Path.Combine(data, "keys", "marker.bin"))); + } + finally { DeleteRoot(root); } + } + + [Fact] + public void InvalidLegacyDatabaseStopsStartupWithoutCreatingReplacement() + { + var root = NewRoot(); + try + { + var legacy = Path.Combine(root, "App_Data"); + var data = Path.Combine(root, "new-data"); + Directory.CreateDirectory(legacy); + File.WriteAllText(Path.Combine(legacy, "bistudent.db"), "not a SQLite database"); + + using var factory = new StartupFactory(root, data); + Assert.ThrowsAny(() => factory.CreateClient()); + Assert.False(File.Exists(Path.Combine(data, "bistudent.db"))); + } + finally { DeleteRoot(root); } + } + + private static string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"BiStudent.Startup.{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteRoot(string root) + { + SqliteConnection.ClearAllPools(); + Directory.Delete(root, recursive: true); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + + private sealed class StartupFactory(string contentRoot, string dataDirectory) : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(contentRoot); + builder.UseSetting("Storage:DataDirectory", dataDirectory); + builder.UseSetting("Demo:Enabled", "false"); + } + } +} diff --git a/BiStudent.Tests/StatsServiceTests.cs b/BiStudent.Tests/StatsServiceTests.cs new file mode 100644 index 0000000..02368d4 --- /dev/null +++ b/BiStudent.Tests/StatsServiceTests.cs @@ -0,0 +1,53 @@ +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class StatsServiceTests +{ + [Fact] + public async Task DashboardAsync_CountsCameraSavingsReportsAndConsecutiveMisses() + { + using var test = new TestDb(); + var teacher = test.AddTeacher(); + var group = test.AddGroup("ИВТ-1", "Иван", "Анна"); + var slot = test.AddSlot(teacher, group, validFrom: new DateOnly(2026, 9, 8)); + var risk = group.Students[0]; + var regular = group.Students[1]; + var second = new DateOnly(2026, 9, 15); + var fourth = new DateOnly(2026, 9, 29); + test.Db.AttendanceMarks.AddRange( + new AttendanceMark { SlotId = slot.Id, Date = second, StudentId = regular.Id, Status = AttendanceStatus.Present, Method = MarkMethod.Camera, MarkedAt = second.ToDateTime(new TimeOnly(9, 5)) }, + new AttendanceMark { SlotId = slot.Id, Date = fourth, StudentId = regular.Id, Status = AttendanceStatus.Present, Method = MarkMethod.Camera, MarkedAt = fourth.ToDateTime(new TimeOnly(9, 5)) }, + new AttendanceMark { SlotId = slot.Id, Date = fourth, StudentId = risk.Id, Status = AttendanceStatus.Present, Method = MarkMethod.Manual, MarkedAt = fourth.ToDateTime(new TimeOnly(9, 5)) }); + test.Db.AppSettings.AddRange( + new AppSetting { Key = "stats.reports_exported.2026-09-10", Value = "1" }, + new AppSetting { Key = "stats.reports_exported.2026-09-29", Value = "2" }, + new AppSetting { Key = "stats.reports_exported.2026-08-31", Value = "9" }, + new AppSetting { Key = "stats.reports_exported.2026-10-01", Value = "9" }); + test.Db.SaveChanges(); + var dashboard = await test.Stats.DashboardAsync(teacher.Id, new DateTime(2026, 9, 30, 18, 0, 0)); + Assert.Equal(1, dashboard.CameraMarks7Days); + Assert.Equal(2, dashboard.CameraMarks30Days); + Assert.True(Math.Abs(dashboard.SavedMinutes7Days - 40.1) < 0.0001); + Assert.True(Math.Abs(dashboard.SavedMinutes30Days - 60.2) < 0.0001); + Assert.Contains(dashboard.AtRisk, x => x.StudentId == risk.Id && + x.Reason == "Три и более пропуска подряд без уважительной причины"); + } + + [Fact] + public async Task ReportsExportedAsync_SumsOnlyDatesInPeriod() + { + using var test = new TestDb(); + test.Db.AppSettings.AddRange( + new AppSetting { Key = "stats.reports_exported.2026-09-09", Value = "9" }, + new AppSetting { Key = "stats.reports_exported.2026-09-10", Value = "2" }, + new AppSetting { Key = "stats.reports_exported.2026-09-20", Value = "3" }, + new AppSetting { Key = "stats.reports_exported.2026-09-21", Value = "9" }, + new AppSetting { Key = "stats.reports_exported.invalid", Value = "7" }, + new AppSetting { Key = "stats.reports_exported.2026-09-15", Value = "invalid" }); + test.Db.SaveChanges(); + Assert.Equal(5, await test.Stats.ReportsExportedAsync(new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 20))); + } +} diff --git a/BiStudent.Tests/StudentAndExportTests.cs b/BiStudent.Tests/StudentAndExportTests.cs new file mode 100644 index 0000000..2eb82ff --- /dev/null +++ b/BiStudent.Tests/StudentAndExportTests.cs @@ -0,0 +1,63 @@ +using System.Text; +using BiStudent.Contracts; +using BiStudent.Server.Services; +using ClosedXML.Excel; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class StudentAndExportTests +{ + [Fact] + public async Task ImportCsvAsync_AddsStudentsSkipsDuplicatesAndCreatesGroups() + { + using var test = new TestDb(); + test.AddGroup("ИВТ-1", "Иван Иванов"); + using var csv = new MemoryStream(Encoding.UTF8.GetBytes("ФИО;Группа\nИван Иванов;ИВТ-1\nАнна Петрова;ИВТ-1\nПётр Сидоров;ИВТ-2\nПётр Сидоров;ИВТ-2\n")); + var result = await test.Students.ImportCsvAsync(csv); + Assert.Equal(2, result.Added); + Assert.Equal(2, result.Skipped); + Assert.Equal(2, test.Db.Groups.Count()); + Assert.Equal(3, test.Db.Students.Count()); + Assert.Contains(test.Db.Groups, x => x.Name == "ИВТ-2"); + } + + [Fact] + public async Task AddGroupAsync_RejectsDuplicateName() + { + using var test = new TestDb(); + await test.Students.AddGroupAsync("ИВТ-1"); + await Assert.ThrowsAsync(() => test.Students.AddGroupAsync(" ИВТ-1 ")); + } + + [Fact] + public void JournalXlsx_OpensAndContainsStudentAndPercent() + { + using var test = new TestDb(); + var bytes = test.Export.JournalXlsx(Matrix(), "Журнал"); + using var stream = new MemoryStream(bytes); + using var book = new XLWorkbook(stream); + var sheet = book.Worksheet("Журнал"); + Assert.Equal("Иван Иванов", sheet.Cell(8, 2).GetString()); + Assert.Equal(50d, sheet.Cell(8, 7).GetDouble()); + } + + [Fact] + public void JournalCsv_StartsWithBomAndUsesSemicolons() + { + using var test = new TestDb(); + var csv = test.Export.JournalCsv(Matrix()); + Assert.StartsWith("\uFEFF", csv); + Assert.Contains("\"№\";\"ФИО студента\";\"01.09.2026 09:00 Алгебра\";", csv); + Assert.Contains("\"1\";\"Иван Иванов\";\"П\";\"Н\";\"2\";\"0\";\"50.0\"", csv); + } + + private static JournalMatrix Matrix() => new( + "Ирина Иванова", "ИВТ-1", null, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 8), 2, + [new JournalColumn(JournalColumnKind.Lesson, 1, new DateOnly(2026, 9, 1), new TimeOnly(9, 0), "Алгебра"), + new JournalColumn(JournalColumnKind.Lesson, 2, new DateOnly(2026, 9, 8), new TimeOnly(9, 0), "Физика")], + [new JournalStudent(1, "Иван Иванов", + [new JournalCell(AttendanceStatus.Present, null, null, false, null), + new JournalCell(AttendanceStatus.Absent, null, null, false, null)], + 1, 1, 0, 2, 0, 50d, null, null)]); +} diff --git a/BiStudent.Tests/TestDb.cs b/BiStudent.Tests/TestDb.cs new file mode 100644 index 0000000..9b8f06b --- /dev/null +++ b/BiStudent.Tests/TestDb.cs @@ -0,0 +1,94 @@ +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace BiStudent.Tests; + +internal sealed class TestDb : IDisposable +{ + private readonly SqliteConnection connection = new("Data Source=:memory:"); + private readonly ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + private readonly RecognitionService recognition; + + public AppDbContext Db { get; } + public IConfiguration Configuration { get; } + public ScheduleService Schedule { get; } + public AttendanceNotifier Notifier { get; } = new(); + public AttendanceService Attendance { get; } + public StatsService Stats { get; } + public StudentService Students { get; } + public ExportService Export { get; } + + public GradeService Grades { get; } + + public TestDb() + { + connection.Open(); + Db = new AppDbContext(new DbContextOptionsBuilder().UseSqlite(connection).Options); + DatabaseInitializer.Initialize(Db); + Configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Semester:Start"] = "2026-09-01", + ["Attendance:HoursPerLesson"] = "2", + ["Automation:SecondsPerManualMark"] = "6", + ["Automation:MinutesPerManualReport"] = "20" + }).Build(); + Schedule = new ScheduleService(Db, Configuration); + Attendance = new AttendanceService(Db, Configuration, Schedule, Notifier); + Stats = new StatsService(Db, Schedule, Configuration); + recognition = new RecognitionService(services.GetRequiredService(), + DataProtectionProvider.Create("tests"), Configuration); + Students = new StudentService(Db, recognition); + Export = new ExportService(Db); + Grades = new GradeService(Db, Configuration); + } + + public Teacher AddTeacher() + { + var teacher = new Teacher { Login = "teacher", FullName = "Ирина Иванова" }; + Db.Teachers.Add(teacher); + Db.SaveChanges(); + return teacher; + } + + public Group AddGroup(string name, params string[] studentNames) + { + var group = new Group { Name = name, Students = studentNames.Select(x => new Student { FullName = x }).ToList() }; + Db.Groups.Add(group); + Db.SaveChanges(); + return group; + } + + public ScheduleSlot NewSlot(Teacher teacher, Group group, DayOfWeek day = DayOfWeek.Tuesday, + TimeOnly? start = null, TimeOnly? end = null, WeekParity parity = WeekParity.Every, + DateOnly? validFrom = null, DateOnly? validTo = null, DateOnly? oneOffDate = null, string subject = "Алгебра") => new() + { + TeacherId = teacher.Id, GroupId = group.Id, DayOfWeek = day, + Start = start ?? new TimeOnly(9, 0), End = end ?? new TimeOnly(10, 0), + Parity = parity, ValidFrom = validFrom ?? new DateOnly(2026, 9, 1), + ValidTo = validTo, OneOffDate = oneOffDate, Subject = subject + }; + + public ScheduleSlot AddSlot(Teacher teacher, Group group, DayOfWeek day = DayOfWeek.Tuesday, + TimeOnly? start = null, TimeOnly? end = null, WeekParity parity = WeekParity.Every, + DateOnly? validFrom = null, DateOnly? validTo = null, DateOnly? oneOffDate = null, string subject = "Алгебра") + { + var slot = NewSlot(teacher, group, day, start, end, parity, validFrom, validTo, oneOffDate, subject); + Db.ScheduleSlots.Add(slot); + Db.SaveChanges(); + return slot; + } + + public void Dispose() + { + recognition.Dispose(); + Db.Dispose(); + connection.Dispose(); + services.Dispose(); + } +} diff --git a/BiStudent.slnx b/BiStudent.slnx index 35ea76b..5584f3c 100644 --- a/BiStudent.slnx +++ b/BiStudent.slnx @@ -1,3 +1,7 @@ + + + + diff --git a/BiStudent/Api/ApiClient.cs b/BiStudent/Api/ApiClient.cs new file mode 100644 index 0000000..6a491f1 --- /dev/null +++ b/BiStudent/Api/ApiClient.cs @@ -0,0 +1,483 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class ApiException : Exception +{ + public HttpStatusCode? StatusCode { get; } + + public ApiException(HttpStatusCode? statusCode, string message, Exception? inner = null) : base(message, inner) + { + StatusCode = statusCode; + } +} + +internal sealed class ApiClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan LongTimeout = TimeSpan.FromSeconds(60); + private readonly HttpClient _http; + private readonly SynchronizationContext? _uiContext; + private readonly SemaphoreSlim _tokenLock = new(1, 1); + private readonly object _sessionGate = new(); + private string? _accessToken; + private string? _refreshToken; + private int _generation; + private bool _ended; + private int _unauthorizedRaised; + + public TeacherDto? Teacher { get; private set; } + public string? RefreshToken => _refreshToken; + public event Action? Unauthorized; + public event Action? RefreshTokenChanged; + + public ApiClient() : this(ClientSettings.Load()) { } + + public ApiClient(ClientSettings settings) : this(settings, null) { } + + // handler подменяется в тестах, в приложении используется обычный HttpClientHandler. + internal ApiClient(ClientSettings settings, HttpMessageHandler? handler) + { + var address = settings.ServerUrl?.Trim(); + if (string.IsNullOrWhiteSpace(address) || !Uri.TryCreate(address.TrimEnd('/') + "/", UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + throw new ApiException(null, "Неверный адрес сервера."); + _http = new HttpClient(handler ?? new HttpClientHandler()) { BaseAddress = uri, Timeout = Timeout.InfiniteTimeSpan }; + _uiContext = SynchronizationContext.Current; + } + + public async Task LoginAsync(string login, string password, bool remember = false) + { + await _tokenLock.WaitAsync(); + try + { + var generation = Volatile.Read(ref _generation); + var token = await RequestAsync(HttpMethod.Post, "api/auth/login", Json(new LoginRequest(login, password, remember))); + if (SetTokens(token, generation)) Teacher = null; + } + finally { _tokenLock.Release(); } + } + + public async Task TryResumeAsync(string refreshToken) + { + if (!await RefreshAsync(refreshToken, CancellationToken.None)) return false; + await LoadMeAsync(); + return true; + } + + public async Task ChangePasswordAsync(string current, string next) + { + for (var attempt = 0; attempt < 2; attempt++) + { + string? failedToken = null; + await _tokenLock.WaitAsync(); + try + { + var generation = Volatile.Read(ref _generation); + failedToken = _accessToken; + var token = await RequestAsync(HttpMethod.Post, "api/auth/change-password", + Json(new ChangePasswordRequest(current, next))); + SetTokens(token, generation); + break; + } + catch (ApiException ex) when (attempt == 0 && ex.StatusCode == HttpStatusCode.Unauthorized && failedToken is not null) { } + finally { _tokenLock.Release(); } + if (!await RefreshAfterUnauthorizedAsync(failedToken!, CancellationToken.None)) + { + RaiseUnauthorized(); + throw new ApiException(HttpStatusCode.Unauthorized, "Сессия истекла — войдите снова."); + } + } + try { await LoadMeAsync(); } + catch (Exception) + { + try { await LoadMeAsync(); } + catch (Exception) { } + } + } + + public async Task LoadMeAsync() + { + Teacher = await GetAsync("api/me"); + return Teacher; + } + + public Task AutomationAsync() => GetAsync("api/settings/automation"); + public Task RecognitionStatusAsync() => GetAsync("api/recognition/status"); + public Task> BackupsAsync() => GetAsync>("api/admin/backups"); + public Task CreateBackupAsync() => + RequestAsync(HttpMethod.Post, "api/admin/backups", timeout: LongTimeout); + public Task RestoreAsync(byte[] zip) => + RequestAsync(HttpMethod.Post, "api/admin/restore", new ByteArrayContent(zip) { Headers = { ContentType = new MediaTypeHeaderValue("application/zip") } }, timeout: TimeSpan.FromMinutes(5)); + + public Task> TeachersAsync() => GetAsync>("api/admin/teachers"); + public Task CreateTeacherAsync(SaveTeacherRequest request) => + RequestAsync(HttpMethod.Post, "api/admin/teachers", Json(request)); + public Task UpdateTeacherAsync(int id, SaveTeacherRequest request) => + RequestAsync(HttpMethod.Put, $"api/admin/teachers/{id}", Json(request)); + public Task ResetTeacherPasswordAsync(int id) => + RequestAsync(HttpMethod.Post, $"api/admin/teachers/{id}/reset-password"); + + public async Task<(byte[] Content, string FileName)> DownloadBackupAsync(string name) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var response = await SendWithDeadlineAsync(HttpMethod.Get, $"api/admin/backups/{Uri.EscapeDataString(name)}", null, deadline.Token, CancellationToken.None); + try { return (await response.Content.ReadAsByteArrayAsync(deadline.Token), name); } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) { throw TimeoutError(ex); } + } + public Task DashboardAsync(CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "api/dashboard", cancellationToken: cancellationToken); + public Task ReportAsync() => GetAsync("api/report"); + public Task> DayAsync(DateOnly date, CancellationToken cancellationToken = default) => + RequestAsync>(HttpMethod.Get, $"api/schedule/day?date={QueryDate(date)}", cancellationToken: cancellationToken); + public Task> WeekAsync(DateOnly monday) => GetAsync>($"api/schedule/week?monday={QueryDate(monday)}"); + public Task ParityAsync(DateOnly date) => GetAsync($"api/schedule/parity?date={QueryDate(date)}"); + + public Task SaveSlotAsync(int? id, SaveSlotRequest request) => + RequestAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post, id.HasValue ? $"api/schedule/slots/{id.Value}" : "api/schedule/slots", Json(request)); + public Task DeleteSlotAsync(int id) => RequestAsync(HttpMethod.Delete, $"api/schedule/slots/{id}"); + public Task CancelLessonAsync(int slotId, DateOnly date, string? reason) => + RequestAsync(HttpMethod.Post, $"api/schedule/slots/{slotId}/cancel", Json(new CancelLessonRequest(date, reason))); + public Task RestoreLessonAsync(int slotId, DateOnly date) => + RequestAsync(HttpMethod.Post, $"api/schedule/slots/{slotId}/restore", Json(new RestoreLessonRequest(date))); + public Task ImportScheduleAsync(string csv) => + RequestAsync(HttpMethod.Post, "api/schedule/import", new StringContent(csv, Encoding.UTF8, "text/csv")); + + public Task> GroupsAsync() => GetAsync>("api/groups"); + public Task CreateGroupAsync(CreateGroupRequest request) => RequestAsync(HttpMethod.Post, "api/groups", Json(request)); + public Task DeleteGroupAsync(int id) => RequestAsync(HttpMethod.Delete, $"api/groups/{id}"); + public Task> StudentsAsync(int groupId) => GetAsync>($"api/groups/{groupId}/students"); + public Task SaveStudentAsync(int? id, SaveStudentRequest request) => + RequestAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post, id.HasValue ? $"api/students/{id.Value}" : "api/students", Json(request)); + public Task DeleteStudentAsync(int id) => RequestAsync(HttpMethod.Delete, $"api/students/{id}"); + public Task ImportStudentsAsync(string csv) => + RequestAsync(HttpMethod.Post, "api/students/import", new StringContent(csv, Encoding.UTF8, "text/csv")); + // Файл списка как есть: CSV или журнал Excel (.xlsx), формат сервер определяет сам. + public Task ImportStudentsAsync(byte[] file) => + RequestAsync(HttpMethod.Post, "api/students/import", Binary(file), timeout: LongTimeout); + public Task PreviewScheduleXlsxAsync(byte[] xlsx) => + RequestAsync(HttpMethod.Post, "api/schedule/import-xlsx/preview", Binary(xlsx), timeout: LongTimeout); + public Task ImportScheduleXlsxAsync(byte[] xlsx, IEnumerable groups) => + RequestAsync(HttpMethod.Post, + "api/schedule/import-xlsx?" + string.Join("&", groups.Select(g => "groups=" + Uri.EscapeDataString(g))), Binary(xlsx), timeout: LongTimeout); + + private static ByteArrayContent Binary(byte[] bytes) + { + var content = new ByteArrayContent(bytes); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + return content; + } + public Task EnrollAsync(int studentId, List images, bool allowSimilar = false) => + RequestAsync(HttpMethod.Post, $"api/students/{studentId}/enroll", Json(new EnrollRequest(images, allowSimilar)), timeout: LongTimeout); + + public async Task EnrollmentCodeAsync(int groupId) + { + using var deadline = new CancellationTokenSource(DefaultTimeout); + using var response = await SendWithDeadlineAsync(HttpMethod.Get, $"api/groups/{groupId}/enrollment-code", null, + deadline.Token, CancellationToken.None); + if (response.StatusCode == HttpStatusCode.NoContent) return null; + try + { + await using var stream = await response.Content.ReadAsStreamAsync(deadline.Token); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, deadline.Token) + ?? throw new ApiException(response.StatusCode, "Пустой ответ сервера."); + } + catch (JsonException ex) { throw new ApiException(response.StatusCode, "Некорректный ответ сервера.", ex); } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) { throw TimeoutError(ex); } + } + + public Task CreateEnrollmentCodeAsync(int groupId, int hours) => + RequestAsync(HttpMethod.Post, $"api/groups/{groupId}/enrollment-code", Json(new CreateEnrollmentCodeRequest(hours))); + public Task RevokeEnrollmentCodeAsync(int groupId) => + RequestAsync(HttpMethod.Delete, $"api/groups/{groupId}/enrollment-code"); + public Task> FaceSubmissionsAsync() => GetAsync>("api/face-submissions"); + public async Task FaceSubmissionImageAsync(int id, int index) + { + using var deadline = new CancellationTokenSource(LongTimeout); + using var response = await SendWithDeadlineAsync(HttpMethod.Get, $"api/face-submissions/{id}/images/{index}", null, + deadline.Token, CancellationToken.None); + try { return await response.Content.ReadAsByteArrayAsync(deadline.Token); } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) { throw TimeoutError(ex); } + } + public Task ApproveSubmissionAsync(int id) => RequestAsync(HttpMethod.Post, $"api/face-submissions/{id}/approve"); + public Task RejectSubmissionAsync(int id, string? reason) => + RequestAsync(HttpMethod.Post, $"api/face-submissions/{id}/reject", Json(new RejectSubmissionRequest(reason))); + + public Task> AttendanceAsync(int slotId, DateOnly date, CancellationToken cancellationToken = default) => + RequestAsync>(HttpMethod.Get, $"api/lessons/{slotId}/{QueryDate(date)}/attendance", cancellationToken: cancellationToken); + public Task MarkAsync(int slotId, DateOnly date, int studentId, AttendanceStatus? status, + ExcuseReason? reason = null, string? comment = null, bool hasDocument = false) => + RequestAsync(HttpMethod.Put, $"api/lessons/{slotId}/{QueryDate(date)}/attendance", + Json(new MarkRequest(studentId, status, reason, comment, hasDocument))); + public Task LessonGradeAsync(int slotId, DateOnly date, int studentId, int? score) => + RequestAsync(HttpMethod.Put, $"api/lessons/{slotId}/{QueryDate(date)}/grade", Json(new GradeRequest(studentId, score))); + public Task FinishAsync(int slotId, DateOnly date) => + RequestAsync(HttpMethod.Post, $"api/lessons/{slotId}/{QueryDate(date)}/finish"); + public Task AllPresentAsync(int slotId, DateOnly date) => + RequestAsync(HttpMethod.Post, $"api/lessons/{slotId}/{QueryDate(date)}/all-present"); + public Task RecognizeAsync(int slotId, DateOnly date, byte[] jpeg, CancellationToken cancellationToken) => + RequestAsync(HttpMethod.Post, "api/recognize", Json(new RecognizeRequest(slotId, date, jpeg)), cancellationToken); + + public Task> SubjectsAsync(int groupId) => GetAsync>($"api/journal/{groupId}/subjects"); + public Task> AssessmentsAsync(int? groupId, string? subject) + { + var query = new List(); + if (groupId.HasValue) query.Add($"groupId={groupId.Value}"); + if (!string.IsNullOrWhiteSpace(subject)) query.Add($"subject={Uri.EscapeDataString(subject)}"); + return GetAsync>("api/assessments" + (query.Count == 0 ? "" : "?" + string.Join("&", query))); + } + public Task SaveAssessmentAsync(int? id, SaveAssessmentRequest request) => + RequestAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post, + id.HasValue ? $"api/assessments/{id.Value}" : "api/assessments", Json(request)); + public Task DeleteAssessmentAsync(int id) => RequestAsync(HttpMethod.Delete, $"api/assessments/{id}"); + public Task AssessmentGradeAsync(int assessmentId, int studentId, int? score) => + RequestAsync(HttpMethod.Put, $"api/assessments/{assessmentId}/grades", Json(new GradeRequest(studentId, score))); + public Task FinalGradeAsync(int groupId, string subject, int studentId, int? score) => + RequestAsync(HttpMethod.Put, $"api/journal/{groupId}/final", Json(new FinalGradeRequest(subject, studentId, score))); + public Task FillFinalAsync(int groupId, string subject) => + RequestAsync(HttpMethod.Post, $"api/journal/{groupId}/final/fill", Json(new FillFinalRequest(subject))); + + public Task JournalAsync(int groupId, DateOnly from, DateOnly to, string? subject, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, JournalPath(groupId, from, to, subject, ""), cancellationToken: cancellationToken, timeout: LongTimeout); + + public async Task<(byte[] Content, string FileName)> JournalFileAsync(int groupId, DateOnly from, DateOnly to, string? subject, string format) + { + if (format is not ("xlsx" or "csv")) throw new ArgumentException("Формат должен быть xlsx или csv.", nameof(format)); + using var deadline = new CancellationTokenSource(LongTimeout); + using var response = await SendWithDeadlineAsync(HttpMethod.Get, JournalPath(groupId, from, to, subject, $"/{format}"), null, deadline.Token, CancellationToken.None); + try + { + var content = await response.Content.ReadAsByteArrayAsync(deadline.Token); + var name = response.Content.Headers.ContentDisposition?.FileNameStar + ?? response.Content.Headers.ContentDisposition?.FileName; + name = string.IsNullOrWhiteSpace(name) ? "" : Path.GetFileName(name.Trim('"')); + if (string.IsNullOrWhiteSpace(name)) + name = $"journal-{groupId}-{from.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}-{to.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}.{format}"; + return (content, name); + } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) { throw TimeoutError(ex); } + } + + private static string QueryDate(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + private static string JournalPath(int groupId, DateOnly from, DateOnly to, string? subject, string suffix) + { + var path = $"api/journal/{groupId}{suffix}?from={QueryDate(from)}&to={QueryDate(to)}"; + return string.IsNullOrWhiteSpace(subject) ? path : $"{path}&subject={Uri.EscapeDataString(subject)}"; + } + + private static HttpContent Json(T value) => + new StringContent(JsonSerializer.Serialize(value, JsonOptions), Encoding.UTF8, "application/json"); + + private Task GetAsync(string path) => RequestAsync(HttpMethod.Get, path); + + private async Task RequestAsync(HttpMethod method, string path, HttpContent? content = null, CancellationToken cancellationToken = default, TimeSpan? timeout = null) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout ?? DefaultTimeout); + using var response = await SendWithDeadlineAsync(method, path, content, deadline.Token, cancellationToken); + try + { + await using var stream = await response.Content.ReadAsStreamAsync(deadline.Token); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, deadline.Token) + ?? throw new ApiException(response.StatusCode, "Пустой ответ сервера."); + } + catch (JsonException ex) { throw new ApiException(response.StatusCode, "Некорректный ответ сервера.", ex); } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { throw TimeoutError(ex); } + } + + private async Task RequestAsync(HttpMethod method, string path, HttpContent? content = null) + { + using var deadline = new CancellationTokenSource(DefaultTimeout); + using var response = await SendWithDeadlineAsync(method, path, content, deadline.Token, CancellationToken.None); + } + + private async Task SendWithDeadlineAsync(HttpMethod method, string path, HttpContent? content, + CancellationToken deadline, CancellationToken external) + { + try { return await SendAsync(method, path, content, deadline); } + catch (OperationCanceledException ex) when (!external.IsCancellationRequested) { throw TimeoutError(ex); } + } + + private async Task SendAsync(HttpMethod method, string path, HttpContent? content = null, CancellationToken cancellationToken = default) + { + using var source = content; + try + { + var body = source is null ? null : await source.ReadAsByteArrayAsync(cancellationToken); + var headers = source?.Headers.ToArray(); + var token = _accessToken; + var response = await SendOnceAsync(method, path, body, headers, token, cancellationToken); + if (response.IsSuccessStatusCode) return response; + var authRequest = path.StartsWith("api/auth/", StringComparison.OrdinalIgnoreCase); + if (response.StatusCode == HttpStatusCode.Unauthorized && !authRequest && token is not null) + { + response.Dispose(); + if (await RefreshAfterUnauthorizedAsync(token, cancellationToken)) + { + response = await SendOnceAsync(method, path, body, headers, _accessToken, cancellationToken); + if (response.IsSuccessStatusCode) return response; + } + else + { + RaiseUnauthorized(); + throw new ApiException(HttpStatusCode.Unauthorized, "Сессия истекла — войдите снова."); + } + } + using (response) + { + throw new ApiException(response.StatusCode, await ErrorMessageAsync(response, cancellationToken)); + } + } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { throw TimeoutError(ex); } + } + + private async Task SendOnceAsync(HttpMethod method, string path, byte[]? body, + KeyValuePair>[]? headers, string? token, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path); + if (body is not null) + { + request.Content = new ByteArrayContent(body); + if (headers is not null) + foreach (var header in headers) request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + if (token is not null && path != "api/auth/login" && path != "api/auth/refresh") + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + + private async Task RefreshAfterUnauthorizedAsync(string failedToken, CancellationToken cancellationToken) + { + var generation = Volatile.Read(ref _generation); + await _tokenLock.WaitAsync(cancellationToken); + try + { + if (Volatile.Read(ref _ended)) return false; + if (_accessToken is not null && _accessToken != failedToken) return true; + return IsCurrentGeneration(generation) && _refreshToken is { } token + && await RefreshCoreAsync(token, generation, cancellationToken); + } + finally { _tokenLock.Release(); } + } + + private async Task RefreshAsync(string token, CancellationToken cancellationToken) + { + var generation = Volatile.Read(ref _generation); + await _tokenLock.WaitAsync(cancellationToken); + try + { + return IsCurrentGeneration(generation) + && await RefreshCoreAsync(token, generation, cancellationToken); + } + finally { _tokenLock.Release(); } + } + + private async Task RefreshCoreAsync(string token, int generation, CancellationToken cancellationToken) + { + try + { + var refreshed = await RequestAsync(HttpMethod.Post, "api/auth/refresh", + Json(new RefreshRequest(token)), cancellationToken); + return SetTokens(refreshed, generation); + } + catch (ApiException ex) when (ex.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized) + { + ClearTokens(generation); + return false; + } + } + + private bool IsCurrentGeneration(int generation) => + !Volatile.Read(ref _ended) && Volatile.Read(ref _generation) == generation; + + private bool SetTokens(TokenResponse token, int generation) + { + lock (_sessionGate) + { + if (_ended || _generation != generation) return false; + _accessToken = token.AccessToken; + _refreshToken = token.RefreshToken; + _generation++; + _unauthorizedRaised = 0; + RefreshTokenChanged?.Invoke(_refreshToken); + return true; + } + } + + private void ClearTokens(int generation) + { + lock (_sessionGate) + { + if (_ended || _generation != generation) return; + _accessToken = null; + _refreshToken = null; + _generation++; + Teacher = null; + RefreshTokenChanged?.Invoke(null); + } + } + + public void EndSession() + { + lock (_sessionGate) + { + if (_ended) return; + _ended = true; + _generation++; + _accessToken = null; + _refreshToken = null; + Teacher = null; + RefreshTokenChanged = null; + } + } + + private static async Task ErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim(); + if (!string.IsNullOrEmpty(body)) + { + try + { + var error = JsonSerializer.Deserialize(body, JsonOptions); + if (!string.IsNullOrWhiteSpace(error?.Message)) return error.Message; + } + catch (JsonException) { } + return body; + } + return $"Ошибка сервера ({(int)response.StatusCode})."; + } + + private ApiException NetworkError(Exception ex) => + new(null, $"Сервер недоступен ({_http.BaseAddress!.ToString().TrimEnd('/')}): {ex.Message}", ex); + + private ApiException TimeoutError(Exception ex) => + new(null, $"Сервер недоступен ({_http.BaseAddress!.ToString().TrimEnd('/')}): превышено время ожидания.", ex); + + private void RaiseUnauthorized() + { + if (Volatile.Read(ref _ended)) return; + if (Interlocked.Exchange(ref _unauthorizedRaised, 1) != 0) return; + if (_uiContext is null || _uiContext == SynchronizationContext.Current) Unauthorized?.Invoke(); + else _uiContext.Post(_ => { if (!Volatile.Read(ref _ended)) Unauthorized?.Invoke(); }, null); + } + + public void Dispose() { EndSession(); _tokenLock.Dispose(); _http.Dispose(); } +} diff --git a/BiStudent/Api/ClientSettings.cs b/BiStudent/Api/ClientSettings.cs new file mode 100644 index 0000000..ba7a661 --- /dev/null +++ b/BiStudent/Api/ClientSettings.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace BiStudent; + +internal sealed class ClientSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + // Тесты перенаправляют настройки во временный файл, чтобы не трогать настоящий client.json. + internal static string FilePath { get; set; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BiStudent", "client.json"); + private static readonly byte[] TokenEntropy = Encoding.UTF8.GetBytes("BiStudent.Refresh"); + + public string ServerUrl { get; set; } = "http://localhost:5080"; + public string LastLogin { get; set; } = ""; + public bool RememberMe { get; set; } + public string? ProtectedRefreshToken { get; set; } + public string? RefreshTokenServer { get; set; } + public string? CameraId { get; set; } + public string? CameraName { get; set; } + public int? CameraIndex { get; set; } + + public static ClientSettings Load() + { + try + { + var settings = JsonSerializer.Deserialize(File.ReadAllText(FilePath, Encoding.UTF8), JsonOptions) + ?? new ClientSettings(); + if (string.IsNullOrWhiteSpace(settings.ServerUrl)) settings.ServerUrl = "http://localhost:5080"; + settings.LastLogin ??= ""; + return settings; + } + catch { return new ClientSettings(); } + } + + public static bool SameServer(string? left, string? right) => + string.Equals(left?.Trim().TrimEnd('/'), right?.Trim().TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + + public string? LoadRefreshToken() + { + if (string.IsNullOrEmpty(ProtectedRefreshToken) || string.IsNullOrWhiteSpace(RefreshTokenServer) + || !SameServer(RefreshTokenServer, ServerUrl)) return null; + try + { + var bytes = Convert.FromBase64String(ProtectedRefreshToken); + return Encoding.UTF8.GetString(ProtectedData.Unprotect(bytes, TokenEntropy, DataProtectionScope.CurrentUser)); + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return null; + } + } + + public void StoreRefreshToken(string? token) + { + RefreshTokenServer = token is null ? null : ServerUrl; + ProtectedRefreshToken = token is null ? null : Convert.ToBase64String( + ProtectedData.Protect(Encoding.UTF8.GetBytes(token), TokenEntropy, DataProtectionScope.CurrentUser)); + } + + public void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions), Encoding.UTF8); + } +} diff --git a/BiStudent/AttendanceChecks.cs b/BiStudent/AttendanceChecks.cs deleted file mode 100644 index 7c210df..0000000 --- a/BiStudent/AttendanceChecks.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; - -namespace BiStudent; - -internal static class AttendanceChecks -{ - public static async Task RunAsync(string directory) - { - var handler = new JournalHandler(); - using var cloud = new CloudFirestore(new FirebaseSettings { ProjectId = "test", GroupId = "test" }, - new TeacherSession("test", "test@example.invalid", "Тест", "test-token", "", DateTimeOffset.UtcNow.AddHours(1)), new HttpClient(handler)); - var snapshot = new CloudSnapshot("Т-1", [new("s01",1,"Тест")], [new("csharp","C#")], []); - var store = new AttendanceStore(Path.Combine(directory, "attendance-test-" + Guid.NewGuid().ToString("N"))); - store.ApplyCloudRoster(snapshot,"test"); var student = store.Students().Single(); - store.AddLesson("C#","Т-1",DayOfWeek.Monday,TimeSpan.FromHours(9),TimeSpan.FromHours(10)); - var lesson = store.Lessons().Single(); var date = new DateOnly(2026,10,12); - var sync = new AttendanceSync(store,cloud,snapshot); - await sync.SaveAsync(); if (handler.Calls != 0) throw new Exception("Idle attendance sends requests."); - store.MarkPresent(lesson.Id,date,student.Id,"Камера"); - if (!sync.HasChanges) throw new Exception("Camera attendance not pending."); - handler.Fail = true; - try { await sync.SaveAsync(); throw new Exception("Offline unexpectedly saved."); } catch (HttpRequestException) { } - var restarted = new AttendanceSync(store,cloud,snapshot); - if (!restarted.HasChanges) throw new Exception("Offline attendance lost on restart."); - handler.Fail = false; await restarted.SaveAsync(); var count = handler.Calls; - for (var i=0;i<10;i++) await restarted.SaveAsync(); - if (restarted.HasChanges || handler.Calls != count || handler.Rows.Count != 1) throw new Exception("Repeated attendance writes."); - var remote = snapshot with { Attendance = handler.Rows.Values.ToList() }; - var other = new AttendanceStore(Path.Combine(directory,"attendance-device-" + Guid.NewGuid().ToString("N"))); - other.ApplyCloudRoster(snapshot,"test"); - var second = new AttendanceSync(other,cloud,remote); - if (second.HasChanges || other.ExportAttendance("Т-1").Single().SeenAt != store.ExportAttendance("Т-1").Single().SeenAt) throw new Exception("New device did not restore attendance/time."); - var otherLesson = other.Lessons().Single(); - other.RemoveMark(otherLesson.Id,date,other.Students().Single().Id); - await second.SaveAsync(); - var removed = snapshot with { Attendance = handler.Rows.Values.ToList() }; - var firstAgain = new AttendanceSync(store,cloud,removed); - if (firstAgain.HasChanges || store.ExportAttendance("Т-1").Count != 0) throw new Exception("Removed mark did not sync."); - store.MarkPresent(lesson.Id,date,student.Id,"Вручную"); - await firstAgain.SaveAsync(); - if (!handler.Rows.Values.Single().Present || handler.Rows.Count != 1) throw new Exception("Re-mark created duplicate."); - File.WriteAllText(Path.Combine(directory,"attendance-check.txt"),"OK: idle zero requests; offline/restart retains mark; batch save; second device restores lesson/time; removal syncs; re-mark no duplicates"); - } - private sealed class JournalHandler : HttpMessageHandler - { - public int Calls; public bool Fail; - public Dictionary Rows = []; - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Calls++; if (Fail) throw new HttpRequestException("Offline"); - if (request.Method != HttpMethod.Post || !request.RequestUri!.AbsolutePath.EndsWith(":commit")) throw new Exception("Wrong attendance request."); - using var json = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); - foreach (var write in json.RootElement.GetProperty("writes").EnumerateArray()) { - var update = write.GetProperty("update"); - if (!update.GetProperty("name").GetString()!.Contains("/attendance/")) throw new Exception("Wrong collection."); - var row = JsonSerializer.Deserialize(update.GetProperty("fields").GetProperty("payload").GetProperty("stringValue").GetString()!)!; - Rows[row.Key] = row; - } - return new(HttpStatusCode.OK) { Content = new StringContent("{}",Encoding.UTF8,"application/json") }; - } - } -} diff --git a/BiStudent/AttendanceStore.cs b/BiStudent/AttendanceStore.cs deleted file mode 100644 index 1afdb0d..0000000 --- a/BiStudent/AttendanceStore.cs +++ /dev/null @@ -1,478 +0,0 @@ -using Microsoft.Data.Sqlite; -using System.Security.Cryptography; - -namespace BiStudent; - -internal sealed record Student(int Id, string Name, string GroupName, int SampleCount, string CloudId = ""); -internal sealed record Lesson(int Id, string Subject, string GroupName, DayOfWeek Day, TimeSpan Start, TimeSpan End, - int Parity = 0, string Room = "", DateOnly? ValidFrom = null, DateOnly? ValidTo = null, DateOnly? OneOffDate = null, DateOnly? SemesterStart = null) -{ - public override string ToString() => $"{Subject} · {GroupName} · {Start:hh\\:mm}–{End:hh\\:mm}"; - public bool OccursOn(DateOnly date) - { - if (ValidFrom is { } first && date < first || ValidTo is { } last && date > last) return false; - if (OneOffDate is { } once) return date == once; - if (date.DayOfWeek != Day) return false; - if (Parity == 0) return true; - var week = (int)Math.Floor((date.DayNumber - (SemesterStart ?? ValidFrom ?? new DateOnly(2026, 9, 1)).DayNumber) / 7d) + 1; - return Parity == (Math.Abs(week % 2) == 1 ? 1 : 2); - } -} -internal sealed record AttendanceRow(int StudentId, string Name, string GroupName, DateTime? SeenAt, string Method); -internal sealed record GradeRow(int Id, int StudentId, string Subject, DateOnly Date, int Score, string Comment, string TeacherUid, string TeacherName); - -internal sealed class AttendanceStore -{ - internal string DataDirectory => directory; - public List ExportAttendance(string group) - { - var lessons = Lessons().ToDictionary(x => x.Id); - var result = new List(); - using var connection = Open(); using var command = connection.CreateCommand(); - command.CommandText = "SELECT s.cloud_id,a.lesson_id,a.lesson_date,a.seen_at,a.method FROM attendance a JOIN students s ON s.id=a.student_id WHERE s.group_name=$group AND s.cloud_id<>''"; - command.Parameters.AddWithValue("$group", group); using var reader = command.ExecuteReader(); - while (reader.Read()) { - var lesson = lessons[reader.GetInt32(1)]; - if (lesson.GroupName == group) result.Add(new(reader.GetString(0).Split('/').Last(), lesson with { Id = 0 }, reader.GetString(2), reader.GetString(3), reader.GetString(4))); - } - return result; - } - public void ApplyAttendance(SyncedAttendance row) - { - var student = Students().FirstOrDefault(x => x.GroupName == row.Lesson.GroupName && x.CloudId.Split('/').Last() == row.StudentId); - if (student is null) return; - var lesson = Lessons().FirstOrDefault(x => (x with { Id = 0 }) == row.Lesson); - if (lesson is null) { - if (!row.Present) return; - using var connection = Open(); using var insert = connection.CreateCommand(); - insert.CommandText = "INSERT INTO lessons(subject,group_name,weekday,start_minutes,end_minutes,parity,room,valid_from,valid_to,one_off_date,semester_start) VALUES($subject,$group,$day,$start,$end,$parity,$room,$from,$to,$once,$semester)"; - insert.Parameters.AddWithValue("$subject",row.Lesson.Subject); insert.Parameters.AddWithValue("$group",row.Lesson.GroupName); - insert.Parameters.AddWithValue("$day",(int)row.Lesson.Day); insert.Parameters.AddWithValue("$start",(int)row.Lesson.Start.TotalMinutes); insert.Parameters.AddWithValue("$end",(int)row.Lesson.End.TotalMinutes); - insert.Parameters.AddWithValue("$parity",row.Lesson.Parity); insert.Parameters.AddWithValue("$room",row.Lesson.Room); - insert.Parameters.AddWithValue("$from",(object?)row.Lesson.ValidFrom?.ToString("yyyy-MM-dd") ?? DBNull.Value); insert.Parameters.AddWithValue("$to",(object?)row.Lesson.ValidTo?.ToString("yyyy-MM-dd") ?? DBNull.Value); - insert.Parameters.AddWithValue("$once",(object?)row.Lesson.OneOffDate?.ToString("yyyy-MM-dd") ?? DBNull.Value); insert.Parameters.AddWithValue("$semester",(object?)row.Lesson.SemesterStart?.ToString("yyyy-MM-dd") ?? DBNull.Value); - insert.ExecuteNonQuery(); lesson = Lessons().First(x => (x with { Id = 0 }) == row.Lesson); - } - var date = DateOnly.ParseExact(row.Date,"yyyy-MM-dd"); - if (!row.Present) { RemoveMark(lesson.Id,date,student.Id); return; } - using var db = Open(); using var save = db.CreateCommand(); - save.CommandText = "INSERT INTO attendance(lesson_id,lesson_date,student_id,seen_at,method) VALUES($lesson,$date,$student,$seen,$method) ON CONFLICT(lesson_id,lesson_date,student_id) DO UPDATE SET seen_at=$seen,method=$method"; - save.Parameters.AddWithValue("$lesson",lesson.Id); save.Parameters.AddWithValue("$date",row.Date); save.Parameters.AddWithValue("$student",student.Id); save.Parameters.AddWithValue("$seen",row.SeenAt); save.Parameters.AddWithValue("$method",row.Method); save.ExecuteNonQuery(); - } - public List PublicAttendance(string groupName) - { - using var connection = Open(); using var command = connection.CreateCommand(); - command.CommandText = "SELECT s.cloud_id,l.id,l.subject,a.lesson_date,a.seen_at FROM attendance a JOIN students s ON s.id=a.student_id JOIN lessons l ON l.id=a.lesson_id WHERE s.group_name=$group AND l.group_name=$group AND s.cloud_id<>'' ORDER BY a.lesson_date,l.id,s.id"; - command.Parameters.AddWithValue("$group", groupName); using var reader = command.ExecuteReader(); var rows = new List(); - while (reader.Read()) rows.Add(new(reader.GetString(0), reader.GetInt32(1), reader.GetString(2), reader.GetString(3), DateTime.Parse(reader.GetString(4)).ToString("HH:mm"))); - return rows; - } - public int RenameTeacher(string uid, string name) - { - using var connection = Open(); using var command = connection.CreateCommand(); - command.CommandText = "UPDATE grades SET teacher_name=$name WHERE teacher_uid=$uid AND teacher_name IN ('Марал Апай','Марал','Мадина Апай')"; - command.Parameters.AddWithValue("$name", name); command.Parameters.AddWithValue("$uid", uid); - return command.ExecuteNonQuery(); - } - private readonly string connectionString; - public void MigrateTeacher(string oldUid,string newUid,string name) - { - using var connection=Open(); using var command=connection.CreateCommand(); - command.CommandText="UPDATE grades SET teacher_uid=$new,teacher_name=$name WHERE teacher_uid=$old"; - command.Parameters.AddWithValue("$new",newUid); command.Parameters.AddWithValue("$old",oldUid); command.Parameters.AddWithValue("$name",name); command.ExecuteNonQuery(); - var oldDraft=Path.Combine(directory,"pending-grades-"+oldUid+".json"); var newDraft=Path.Combine(directory,"pending-grades-"+newUid+".json"); - if (File.Exists(oldDraft) && !File.Exists(newDraft)) File.Copy(oldDraft,newDraft); - } - private readonly string directory; - - public AttendanceStore(string? dataDirectory = null) - { - directory = dataDirectory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StudentAttendance"); - Directory.CreateDirectory(directory); - connectionString = new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "attendance.db") }.ToString(); - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = """ - PRAGMA foreign_keys=ON; - CREATE TABLE IF NOT EXISTS students ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - group_name TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS face_samples ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, - image BLOB NOT NULL - ); - CREATE TABLE IF NOT EXISTS lessons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - subject TEXT NOT NULL, - group_name TEXT NOT NULL, - weekday INTEGER NOT NULL, - start_minutes INTEGER NOT NULL, - end_minutes INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS attendance ( - lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, - lesson_date TEXT NOT NULL, - student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, - seen_at TEXT NOT NULL, - method TEXT NOT NULL, - PRIMARY KEY(lesson_id, lesson_date, student_id) - ); - CREATE TABLE IF NOT EXISTS grades ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, - subject TEXT NOT NULL, - grade_date TEXT NOT NULL, - score INTEGER NOT NULL CHECK(score BETWEEN 0 AND 100), - comment TEXT NOT NULL DEFAULT '', - teacher_uid TEXT NOT NULL, - teacher_name TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS ix_grades_student_date ON grades(student_id,grade_date); - CREATE TABLE IF NOT EXISTS lesson_cancellations ( - lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, - lesson_date TEXT NOT NULL, - reason TEXT NOT NULL, - PRIMARY KEY(lesson_id,lesson_date) - ); - CREATE TABLE IF NOT EXISTS data_imports (version TEXT PRIMARY KEY, imported_at TEXT NOT NULL); - """; - command.ExecuteNonQuery(); - using var schema = connection.CreateCommand(); - schema.CommandText = "PRAGMA table_info(face_samples)"; - using var columns = schema.ExecuteReader(); - var hasVersion = false; - while (columns.Read()) if (columns.GetString(1) == "version") hasVersion = true; - columns.Close(); - if (!hasVersion) - { - using var migration = connection.CreateCommand(); - migration.CommandText = "ALTER TABLE face_samples ADD COLUMN version INTEGER NOT NULL DEFAULT 1"; - migration.ExecuteNonQuery(); - } - using var lessonSchema = connection.CreateCommand(); - lessonSchema.CommandText = "PRAGMA table_info(lessons)"; - using var lessonColumns = lessonSchema.ExecuteReader(); - var existingColumns = new HashSet(); - while (lessonColumns.Read()) existingColumns.Add(lessonColumns.GetString(1)); - lessonColumns.Close(); - foreach (var (name, definition) in new[] { ("parity", "INTEGER NOT NULL DEFAULT 0"), ("room", "TEXT NOT NULL DEFAULT ''"), ("valid_from", "TEXT"), ("valid_to", "TEXT"), ("one_off_date", "TEXT"), ("semester_start", "TEXT") }) - { - if (existingColumns.Contains(name)) continue; - using var migration = connection.CreateCommand(); migration.CommandText = $"ALTER TABLE lessons ADD COLUMN {name} {definition}"; migration.ExecuteNonQuery(); - } - using var studentSchema = connection.CreateCommand(); studentSchema.CommandText = "PRAGMA table_info(students)"; - using var studentColumns = studentSchema.ExecuteReader(); var hasCloudId = false; - while (studentColumns.Read()) if (studentColumns.GetString(1) == "cloud_id") hasCloudId = true; - studentColumns.Close(); - if (!hasCloudId) { using var migration = connection.CreateCommand(); migration.CommandText = "ALTER TABLE students ADD COLUMN cloud_id TEXT NOT NULL DEFAULT ''"; migration.ExecuteNonQuery(); } - var cloudEnabled = File.Exists(Path.Combine(AppContext.BaseDirectory, "firebase.json")) && FirebaseSettings.Load().UseCloudData; - if (!cloudEnabled && dataDirectory is null && File.Exists(Path.Combine(AppContext.BaseDirectory, "starter-data.json"))) - ImportStarterData(StarterData.Load(Path.Combine(AppContext.BaseDirectory, "starter-data.json"))); - } - - public (int Students, int Lessons) ImportStarterData(StarterData data) - { - if (string.IsNullOrWhiteSpace(data.Version)) throw new InvalidOperationException("Не указана версия импортируемых данных."); - using var connection = Open(); - using var check = connection.CreateCommand(); check.CommandText = "SELECT COUNT(*) FROM data_imports WHERE version=$version"; check.Parameters.AddWithValue("$version", data.Version); - if (Convert.ToInt32(check.ExecuteScalar()) > 0) return (0, 0); - var backupDirectory = Path.Combine(directory, "backups"); Directory.CreateDirectory(backupDirectory); - using (var backup = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(backupDirectory, $"attendance-before-friend-data-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.db") }.ToString())) - { backup.Open(); connection.BackupDatabase(backup); } - using var transaction = connection.BeginTransaction(); - var addedStudents = 0; var addedLessons = 0; - foreach (var student in data.Students) - { - if (string.IsNullOrWhiteSpace(student.Name) || string.IsNullOrWhiteSpace(student.GroupName)) throw new InvalidOperationException("В списке есть студент без имени или группы."); - using var insert = connection.CreateCommand(); insert.Transaction = transaction; - insert.CommandText = "INSERT INTO students(name,group_name) SELECT $name,$group WHERE NOT EXISTS(SELECT 1 FROM students WHERE name=$name AND group_name=$group)"; - insert.Parameters.AddWithValue("$name", student.Name.Trim()); insert.Parameters.AddWithValue("$group", student.GroupName.Trim()); addedStudents += insert.ExecuteNonQuery(); - } - var mapping = new Dictionary(); - foreach (var lesson in data.Lessons) - { - if (lesson.Start >= lesson.End || lesson.Parity is < 0 or > 2 || string.IsNullOrWhiteSpace(lesson.Subject) || string.IsNullOrWhiteSpace(lesson.GroupName)) throw new InvalidOperationException("В импортируемом расписании некорректное занятие."); - using var find = connection.CreateCommand(); find.Transaction = transaction; - find.CommandText = "SELECT id FROM lessons WHERE subject=$subject AND group_name=$group AND weekday=$day AND start_minutes=$start AND end_minutes=$end AND parity=$parity AND (valid_from IS NULL OR valid_from=$from) AND COALESCE(valid_to,'')=$to AND COALESCE(one_off_date,'')=$once LIMIT 1"; - AddLessonParameters(find, lesson, data.SemesterStart); - var existing = find.ExecuteScalar(); - using var save = connection.CreateCommand(); save.Transaction = transaction; - save.CommandText = existing is null - ? "INSERT INTO lessons(subject,group_name,weekday,start_minutes,end_minutes,parity,room,valid_from,valid_to,one_off_date,semester_start) VALUES($subject,$group,$day,$start,$end,$parity,$room,$from,NULLIF($to,''),NULLIF($once,''),$semester); SELECT last_insert_rowid();" - : "UPDATE lessons SET room=$room,valid_from=$from,valid_to=NULLIF($to,''),one_off_date=NULLIF($once,''),semester_start=$semester WHERE id=$id RETURNING id"; - AddLessonParameters(save, lesson, data.SemesterStart); - if (existing is not null) save.Parameters.AddWithValue("$id", existing); - mapping.Add(lesson.SourceId, Convert.ToInt32(save.ExecuteScalar())); if (existing is null) addedLessons++; - } - foreach (var cancelled in data.Cancellations) - { - if (!mapping.TryGetValue(cancelled.SourceId, out var lessonId)) throw new InvalidOperationException("Отмена ссылается на неизвестное занятие."); - using var insert = connection.CreateCommand(); insert.Transaction = transaction; - insert.CommandText = "INSERT OR IGNORE INTO lesson_cancellations(lesson_id,lesson_date,reason) VALUES($lesson,$date,$reason)"; - insert.Parameters.AddWithValue("$lesson", lessonId); insert.Parameters.AddWithValue("$date", cancelled.Date.ToString("yyyy-MM-dd")); insert.Parameters.AddWithValue("$reason", cancelled.Reason); insert.ExecuteNonQuery(); - } - using var mark = connection.CreateCommand(); mark.Transaction = transaction; mark.CommandText = "INSERT INTO data_imports(version,imported_at) VALUES($version,$date)"; - mark.Parameters.AddWithValue("$version", data.Version); mark.Parameters.AddWithValue("$date", DateTime.UtcNow.ToString("O")); mark.ExecuteNonQuery(); - transaction.Commit(); return (addedStudents, addedLessons); - } - - private static void AddLessonParameters(SqliteCommand command, StarterLesson lesson, DateOnly semester) - { - command.Parameters.AddWithValue("$subject", lesson.Subject.Trim()); command.Parameters.AddWithValue("$group", lesson.GroupName.Trim()); command.Parameters.AddWithValue("$day", (int)lesson.Day); - command.Parameters.AddWithValue("$start", (int)lesson.Start.TotalMinutes); command.Parameters.AddWithValue("$end", (int)lesson.End.TotalMinutes); command.Parameters.AddWithValue("$parity", lesson.Parity); - command.Parameters.AddWithValue("$room", lesson.Room ?? ""); command.Parameters.AddWithValue("$from", lesson.ValidFrom.ToString("yyyy-MM-dd")); command.Parameters.AddWithValue("$to", lesson.ValidTo?.ToString("yyyy-MM-dd") ?? ""); - command.Parameters.AddWithValue("$once", lesson.OneOffDate?.ToString("yyyy-MM-dd") ?? ""); command.Parameters.AddWithValue("$semester", semester.ToString("yyyy-MM-dd")); - } - - private SqliteConnection Open() - { - var connection = new SqliteConnection(connectionString); - connection.Open(); - using var pragma = connection.CreateCommand(); - pragma.CommandText = "PRAGMA foreign_keys=ON"; - pragma.ExecuteNonQuery(); - return connection; - } - - public List Grades(int studentId) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT id,student_id,subject,grade_date,score,comment,teacher_uid,teacher_name FROM grades WHERE student_id=$student ORDER BY grade_date DESC,id DESC"; - command.Parameters.AddWithValue("$student", studentId); - using var reader = command.ExecuteReader(); - var result = new List(); - while (reader.Read()) result.Add(new GradeRow(reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2), DateOnly.ParseExact(reader.GetString(3), "yyyy-MM-dd"), reader.GetInt32(4), reader.GetString(5), reader.GetString(6), reader.GetString(7))); - return result; - } - - public Dictionary GradeAverages() - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT student_id,AVG(score) FROM grades GROUP BY student_id"; - using var reader = command.ExecuteReader(); - var result = new Dictionary(); - while (reader.Read()) result.Add(reader.GetInt32(0), reader.GetDouble(1)); - return result; - } - - public int SaveGrade(int? gradeId, int studentId, string subject, DateOnly date, int score, string comment, TeacherSession teacher) - { - if (score is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(score), "Оценка должна быть от 0 до 100."); - if (string.IsNullOrWhiteSpace(subject) || subject.Trim().Length > 120) throw new ArgumentException("Укажите предмет (до 120 символов)."); - if (comment.Length > 500) throw new ArgumentException("Комментарий должен быть не длиннее 500 символов."); - if (string.IsNullOrWhiteSpace(teacher.Uid)) throw new ArgumentException("Нужно войти в учётную запись преподавателя."); - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = gradeId is null - ? "INSERT INTO grades(student_id,subject,grade_date,score,comment,teacher_uid,teacher_name) VALUES($student,$subject,$date,$score,$comment,$uid,$name); SELECT last_insert_rowid();" - : "UPDATE grades SET subject=$subject,grade_date=$date,score=$score,comment=$comment,teacher_uid=$uid,teacher_name=$name WHERE id=$id AND student_id=$student RETURNING id"; - if (gradeId is not null) command.Parameters.AddWithValue("$id", gradeId.Value); - command.Parameters.AddWithValue("$student", studentId); - command.Parameters.AddWithValue("$subject", subject.Trim()); - command.Parameters.AddWithValue("$date", date.ToString("yyyy-MM-dd")); - command.Parameters.AddWithValue("$score", score); - command.Parameters.AddWithValue("$comment", comment.Trim()); - command.Parameters.AddWithValue("$uid", teacher.Uid); - command.Parameters.AddWithValue("$name", teacher.Name); - var result = command.ExecuteScalar() ?? throw new InvalidOperationException("Оценка уже удалена. Обновите список."); - return Convert.ToInt32(result); - } - - public void DeleteGrade(int gradeId, int studentId) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM grades WHERE id=$id AND student_id=$student"; - command.Parameters.AddWithValue("$id", gradeId); - command.Parameters.AddWithValue("$student", studentId); - command.ExecuteNonQuery(); - } - - public List Students() - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT s.id,s.name,s.group_name,COUNT(f.id),s.cloud_id - FROM students s LEFT JOIN face_samples f ON f.student_id=s.id AND f.version=2 - GROUP BY s.id ORDER BY s.name COLLATE NOCASE - """; - using var reader = command.ExecuteReader(); - var result = new List(); - while (reader.Read()) result.Add(new Student(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetInt32(3), reader.GetString(4))); - return result; - } - - public void ApplyCloudRoster(CloudSnapshot snapshot, string groupId) - { - using var connection = Open(); using var transaction = connection.BeginTransaction(); - foreach (var student in snapshot.Students) - { - var cloudId = groupId + "/" + student.Id; - using var find = connection.CreateCommand(); find.Transaction = transaction; - find.CommandText = "SELECT id FROM students WHERE cloud_id=$cloud OR (name=$name AND group_name=$group) ORDER BY cloud_id DESC LIMIT 1"; - find.Parameters.AddWithValue("$cloud", cloudId); find.Parameters.AddWithValue("$name", student.Name); find.Parameters.AddWithValue("$group", snapshot.GroupName); - var id = find.ExecuteScalar(); - using var save = connection.CreateCommand(); save.Transaction = transaction; - save.CommandText = id is null ? "INSERT INTO students(name,group_name,cloud_id) VALUES($name,$group,$cloud)" : "UPDATE students SET name=$name,group_name=$group,cloud_id=$cloud WHERE id=$id"; - if (id is not null) save.Parameters.AddWithValue("$id", id); - save.Parameters.AddWithValue("$name", student.Name); save.Parameters.AddWithValue("$group", snapshot.GroupName); save.Parameters.AddWithValue("$cloud", cloudId); save.ExecuteNonQuery(); - } - transaction.Commit(); - } - - public int AddStudent(string name, string groupName, IReadOnlyList samples) - { - using var connection = Open(); - using var transaction = connection.BeginTransaction(); - using var insert = connection.CreateCommand(); - insert.Transaction = transaction; - insert.CommandText = "INSERT INTO students(name,group_name) VALUES($name,$group); SELECT last_insert_rowid();"; - insert.Parameters.AddWithValue("$name", name.Trim()); - insert.Parameters.AddWithValue("$group", groupName.Trim()); - var id = Convert.ToInt32(insert.ExecuteScalar()); - foreach (var sample in samples) - { - using var image = connection.CreateCommand(); - image.Transaction = transaction; - image.CommandText = "INSERT INTO face_samples(student_id,image,version) VALUES($id,$image,2)"; - image.Parameters.AddWithValue("$id", id); - image.Parameters.AddWithValue("$image", ProtectedData.Protect(sample, null, DataProtectionScope.CurrentUser)); - image.ExecuteNonQuery(); - } - transaction.Commit(); - return id; - } - - public void DeleteStudent(int id) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM students WHERE id=$id"; - command.Parameters.AddWithValue("$id", id); - command.ExecuteNonQuery(); - } - - public void ReplaceSamples(int studentId, IReadOnlyList samples) - { - using var connection = Open(); - using var transaction = connection.BeginTransaction(); - using (var delete = connection.CreateCommand()) - { - delete.Transaction = transaction; - delete.CommandText = "DELETE FROM face_samples WHERE student_id=$id"; - delete.Parameters.AddWithValue("$id", studentId); - delete.ExecuteNonQuery(); - } - foreach (var sample in samples) - { - using var insert = connection.CreateCommand(); - insert.Transaction = transaction; - insert.CommandText = "INSERT INTO face_samples(student_id,image,version) VALUES($id,$image,2)"; - insert.Parameters.AddWithValue("$id", studentId); - insert.Parameters.AddWithValue("$image", ProtectedData.Protect(sample, null, DataProtectionScope.CurrentUser)); - insert.ExecuteNonQuery(); - } - transaction.Commit(); - } - - public List<(int StudentId, byte[] Image)> Samples() - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT student_id,image FROM face_samples WHERE version=2"; - using var reader = command.ExecuteReader(); - var result = new List<(int, byte[])>(); - while (reader.Read()) result.Add((reader.GetInt32(0), ProtectedData.Unprotect((byte[])reader[1], null, DataProtectionScope.CurrentUser))); - return result; - } - - public List Lessons() - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT id,subject,group_name,weekday,start_minutes,end_minutes,parity,room,valid_from,valid_to,one_off_date,semester_start FROM lessons ORDER BY weekday,start_minutes"; - using var reader = command.ExecuteReader(); - var result = new List(); - while (reader.Read()) result.Add(new Lesson(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), (DayOfWeek)reader.GetInt32(3), TimeSpan.FromMinutes(reader.GetInt32(4)), TimeSpan.FromMinutes(reader.GetInt32(5)), reader.GetInt32(6), reader.GetString(7), ReadDate(reader, 8), ReadDate(reader, 9), ReadDate(reader, 10), ReadDate(reader, 11))); - return result; - } - - private static DateOnly? ReadDate(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : DateOnly.ParseExact(reader.GetString(index), "yyyy-MM-dd"); - - public List LessonsOn(DateOnly date) - { - using var connection = Open(); using var command = connection.CreateCommand(); command.CommandText = "SELECT lesson_id FROM lesson_cancellations WHERE lesson_date=$date"; command.Parameters.AddWithValue("$date", date.ToString("yyyy-MM-dd")); - using var reader = command.ExecuteReader(); var cancelled = new HashSet(); while (reader.Read()) cancelled.Add(reader.GetInt32(0)); - return Lessons().Where(x => x.OccursOn(date) && !cancelled.Contains(x.Id)).ToList(); - } - - public void AddLesson(string subject, string groupName, DayOfWeek day, TimeSpan start, TimeSpan end) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "INSERT INTO lessons(subject,group_name,weekday,start_minutes,end_minutes) VALUES($subject,$group,$day,$start,$end)"; - command.Parameters.AddWithValue("$subject", subject.Trim()); - command.Parameters.AddWithValue("$group", groupName.Trim()); - command.Parameters.AddWithValue("$day", (int)day); - command.Parameters.AddWithValue("$start", (int)start.TotalMinutes); - command.Parameters.AddWithValue("$end", (int)end.TotalMinutes); - command.ExecuteNonQuery(); - } - - public void DeleteLesson(int id) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM lessons WHERE id=$id"; - command.Parameters.AddWithValue("$id", id); - command.ExecuteNonQuery(); - } - - public bool MarkPresent(int lessonId, DateOnly date, int studentId, string method) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "INSERT OR IGNORE INTO attendance(lesson_id,lesson_date,student_id,seen_at,method) VALUES($lesson,$date,$student,$seen,$method)"; - command.Parameters.AddWithValue("$lesson", lessonId); - command.Parameters.AddWithValue("$date", date.ToString("yyyy-MM-dd")); - command.Parameters.AddWithValue("$student", studentId); - command.Parameters.AddWithValue("$seen", DateTime.Now.ToString("O")); - command.Parameters.AddWithValue("$method", method); - return command.ExecuteNonQuery() > 0; - } - - public void RemoveMark(int lessonId, DateOnly date, int studentId) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM attendance WHERE lesson_id=$lesson AND lesson_date=$date AND student_id=$student"; - command.Parameters.AddWithValue("$lesson", lessonId); - command.Parameters.AddWithValue("$date", date.ToString("yyyy-MM-dd")); - command.Parameters.AddWithValue("$student", studentId); - command.ExecuteNonQuery(); - } - - public List Attendance(int lessonId, DateOnly date, string groupName) - { - using var connection = Open(); - using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT s.id,s.name,s.group_name,a.seen_at,COALESCE(a.method,'') - FROM students s LEFT JOIN attendance a ON a.student_id=s.id AND a.lesson_id=$lesson AND a.lesson_date=$date - WHERE s.group_name=$group ORDER BY s.name COLLATE NOCASE - """; - command.Parameters.AddWithValue("$lesson", lessonId); - command.Parameters.AddWithValue("$date", date.ToString("yyyy-MM-dd")); - command.Parameters.AddWithValue("$group", groupName); - using var reader = command.ExecuteReader(); - var result = new List(); - while (reader.Read()) result.Add(new AttendanceRow(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.IsDBNull(3) ? null : DateTime.Parse(reader.GetString(3)), reader.GetString(4))); - return result; - } -} diff --git a/BiStudent/AttendanceSync.cs b/BiStudent/AttendanceSync.cs deleted file mode 100644 index bf5ac77..0000000 --- a/BiStudent/AttendanceSync.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace BiStudent; - -internal sealed record SyncedAttendance(string StudentId, Lesson Lesson, string Date, string SeenAt, string Method, bool Present = true) -{ - public string Key => "a_" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { - StudentId, Lesson.Subject, Lesson.GroupName, Lesson.Day, Lesson.Start, Lesson.End, - Lesson.Parity, Lesson.Room, Lesson.ValidFrom, Lesson.ValidTo, Lesson.OneOffDate, Lesson.SemesterStart, Date - })))).ToLowerInvariant(); -} - -// Only changed records are committed, so saving from one laptop cannot erase another laptop's journal. -internal sealed class AttendanceSync -{ - private readonly AttendanceStore store; - private readonly CloudFirestore cloud; - private readonly string group; - private readonly string file; - private Dictionary baseline; - public AttendanceSync(AttendanceStore store, CloudFirestore cloud, CloudSnapshot snapshot) - { - this.store = store; this.cloud = cloud; group = snapshot.GroupName; - file = Path.Combine(store.DataDirectory, "attendance-cloud-baseline.json"); - baseline = File.Exists(file) ? JsonSerializer.Deserialize>(File.ReadAllText(file)) ?? [] : []; - var pending = Pending().Select(x => x.Key).ToHashSet(); - foreach (var row in snapshot.Attendance) - { - if (pending.Contains(row.Key)) continue; - store.ApplyAttendance(row); baseline[row.Key] = row; - } - WriteBaseline(); - } - private List Pending() - { - var current = store.ExportAttendance(group).ToDictionary(x => x.Key); - var result = current.Where(x => !baseline.TryGetValue(x.Key, out var old) || old != x.Value).Select(x => x.Value).ToList(); - result.AddRange(baseline.Where(x => x.Value.Present && !current.ContainsKey(x.Key)).Select(x => x.Value with { Present = false })); - return result; - } - public bool HasChanges => Pending().Count > 0; - private void WriteBaseline() { File.WriteAllText(file + ".tmp", JsonSerializer.Serialize(baseline)); File.Move(file + ".tmp", file, true); } - public async Task SaveAsync() - { - var pending = Pending(); - foreach (var batch in pending.Chunk(200)) - { - await cloud.SaveAttendanceAsync(batch); - foreach (var row in batch) baseline[row.Key] = row; - WriteBaseline(); - } - } -} diff --git a/BiStudent/BiStudent.csproj b/BiStudent/BiStudent.csproj index a7bb825..8908c0a 100644 --- a/BiStudent/BiStudent.csproj +++ b/BiStudent/BiStudent.csproj @@ -7,18 +7,14 @@ enable false x64 - $(NoWarn);CA1416 + $(NoWarn);CA1416;WFO1000 - + - - - - - + diff --git a/BiStudent/CacheChecks.cs b/BiStudent/CacheChecks.cs deleted file mode 100644 index 51e73df..0000000 --- a/BiStudent/CacheChecks.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace BiStudent; - -internal static class CacheChecks -{ - public static async Task RunAsync(string directory) - { - var loads = 0; var commits = 0; var fail = false; - var anchor = new CloudGrade("s01_csharp", "s01", "csharp", null, null, "", "v1"); - var server = new List { anchor }; - Task Load() { loads++; return Task.FromResult(new CloudSnapshot("Тест", [new("s01", 1, "Тест")], [new("csharp", "C#")], server.ToList())); } - Task> Save(List original, List changed) - { - commits++; if (fail) throw new HttpRequestException("Offline"); - var result = changed.Select(x => x with { UpdateTime = "v" + commits }).ToList(); - foreach (var row in result) { server.RemoveAll(x => x.Id == row.Id); server.Add(row); } - return Task.FromResult(result); - } - var path = Path.Combine(directory, "cache-test-" + Guid.NewGuid().ToString("N"), "draft.json"); - var cache = new CloudWorkspace(Load, Save, path); await cache.LoadAsync(); - for (var i = 0; i < 10; i++) await cache.SaveAsync(); - if (loads != 1 || commits != 0 || cache.HasChanges) throw new InvalidOperationException("Idle вызывает запросы."); - var day = DateOnly.FromDateTime(DateTime.Today); - foreach (var score in new[] { 100, 100, 85 }) cache.Add("s01", "csharp", score, day, ""); - if (loads != 1 || commits != 0 || cache.PendingCount != 3 || CloudFirestore.Average(cache.Snapshot!.Grades) != 95) throw new InvalidOperationException("Локальное редактирование вызывает запросы или неверное среднее."); - await cache.SaveAsync(); await cache.SaveAsync(); - if (commits != 1 || loads != 1 || cache.HasChanges) throw new InvalidOperationException("Оценки не сохраняются пакетом."); - var grade = cache.Snapshot!.Grades.First(x => x.Score is not null); - cache.Edit(grade.Id, 10, day, ""); cache.Edit(grade.Id, grade.Score, grade.Date, grade.Comment); - if (cache.HasChanges) throw new InvalidOperationException("Возврат к исходной оценке остаётся изменением."); - cache.Add("s01", "csharp", 50, day, ""); var added = cache.Snapshot.Grades.Last(); cache.Edit(added.Id, null, null, ""); - if (cache.HasChanges) throw new InvalidOperationException("Добавление и удаление черновика вызывает запись."); - cache.Edit(grade.Id, 20, day, "Черновик"); fail = true; await cache.SaveAsync(); - if (!cache.HasChanges || cache.Error is null) throw new InvalidOperationException("Ошибка сети потеряла изменения."); - var restored = new CloudWorkspace(Load, Save, path); await restored.LoadAsync(); - if (!restored.HasChanges || restored.Snapshot!.Grades.Single(x => x.Id == grade.Id).Score != 20) throw new InvalidOperationException("Черновик не восстановлен."); - fail = false; await restored.SaveAsync(); - if (restored.HasChanges || server.Single(x => x.Id == grade.Id).Score != 20) throw new InvalidOperationException("Повторное сохранение не работает."); - File.WriteAllText(Path.Combine(directory, "cache-check.txt"), "OK: preload once; idle zero requests; 3 local edits zero requests; one batch commit; revert no writes; deleted draft no writes; offline changes retained; restart restored; retry saved"); - } -} diff --git a/BiStudent/CameraCatalog.cs b/BiStudent/CameraCatalog.cs deleted file mode 100644 index 2766043..0000000 --- a/BiStudent/CameraCatalog.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.ComTypes; -using System.Text.Json; - -namespace BiStudent; - -internal sealed record CameraChoice(int Index, string Name, string DeviceId) { public override string ToString() => Name; } -internal static class CameraCatalog -{ - [ComImport, Guid("29840822-5B84-11D0-BD3B-00A0C911CE86"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - private interface ICreateDevEnum { [PreserveSig] int CreateClassEnumerator(ref Guid category, out IEnumMoniker? enumerator, int flags); } - [ComImport, Guid("55272A00-42CB-11CE-8135-00AA004BB851"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - private interface IPropertyBag { - [PreserveSig] int Read([MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.Struct)] out object value, IntPtr errorLog); - [PreserveSig] int Write([MarshalAs(UnmanagedType.LPWStr)] string name, ref object value); - } - private static string FilePath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"StudentAttendance","camera.json"); - public static List List() - { - var result = new List(); object? deviceEnum = null; IEnumMoniker? iterator = null; - try { - deviceEnum = Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("62BE5D10-60EB-11D0-BD3B-00A0C911CE86"))!); - var category = new Guid("860BB310-5D01-11D0-BD3B-00A0C911CE86"); - if (((ICreateDevEnum)deviceEnum!).CreateClassEnumerator(ref category,out iterator,0) != 0 || iterator is null) return result; - var monikers = new IMoniker[1]; var index = 0; - while (iterator.Next(1,monikers,IntPtr.Zero) == 0) { - var moniker = monikers[0]; object? bag = null; - try { - var guid = typeof(IPropertyBag).GUID; moniker.BindToStorage(null!,null!,ref guid,out bag); - var properties = (IPropertyBag)bag; properties.Read("FriendlyName",out var name,IntPtr.Zero); - properties.Read("DevicePath",out var path,IntPtr.Zero); - moniker.GetDisplayName(null!,null!,out var display); - result.Add(new CameraChoice(index, name?.ToString() ?? $"Камера {index+1}",path?.ToString() ?? display)); - } catch { result.Add(new CameraChoice(index,$"Камера {index+1}","index:" + index)); } - finally { if (bag is not null) Marshal.ReleaseComObject(bag); Marshal.ReleaseComObject(moniker); index++; } - } - } catch { if (result.Count == 0) result.Add(new CameraChoice(0,"Камера по умолчанию","index:0")); } - finally { if (iterator is not null) Marshal.ReleaseComObject(iterator); if (deviceEnum is not null) Marshal.ReleaseComObject(deviceEnum); } - return result; - } - public static CameraChoice? Preferred(List choices) - { - try { var id = JsonSerializer.Deserialize(File.ReadAllText(FilePath)); return choices.FirstOrDefault(x => x.DeviceId == id) ?? choices.FirstOrDefault(); } - catch { return choices.FirstOrDefault(); } - } - public static void Save(CameraChoice choice) { Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); File.WriteAllText(FilePath,JsonSerializer.Serialize(choice.DeviceId)); } -} - -internal sealed class CameraSelectionForm : Form -{ - public CameraChoice? Selected { get; private set; } - public CameraSelectionForm() - { - FormStyle.Dialog(this,"Выбор камеры",new Size(590,260)); - var layout = new TableLayoutPanel { Dock=DockStyle.Fill, Padding=new Padding(24),RowCount=3 }; - layout.RowStyles.Add(new RowStyle(SizeType.Absolute,50)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute,46)); layout.RowStyles.Add(new RowStyle(SizeType.Percent,100)); - var caption = FormStyle.Caption("Подключённые веб-камеры Windows"); layout.Controls.Add(caption,0,0); - var box = new ComboBox { Dock=DockStyle.Fill,DropDownStyle=ComboBoxStyle.DropDownList,FlatStyle=FlatStyle.Flat,BackColor=Theme.Surface,ForeColor=Theme.Text,Font=Theme.Font(11),DrawMode=DrawMode.OwnerDrawFixed,ItemHeight=27 }; - box.DrawItem += (_,e) => { using var brush=new SolidBrush((e.State & DrawItemState.Selected)!=0 ? Theme.SurfaceHover : Theme.Surface); e.Graphics.FillRectangle(brush,e.Bounds); if (e.Index>=0) TextRenderer.DrawText(e.Graphics,box.Items[e.Index]?.ToString() ?? "",box.Font,e.Bounds,Theme.Text,TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); }; - var cameras = CameraCatalog.List(); box.Items.AddRange(cameras.Cast().ToArray()); box.SelectedItem=CameraCatalog.Preferred(cameras); layout.Controls.Add(box,0,1); - var save = new ModernButton { Text="Использовать камеру",Primary=true,Width=220,Enabled=cameras.Count>0 }; - if (cameras.Count == 0) caption.Text="Камеры не найдены. Подключите камеру и повторите."; - save.Click += (_,_) => { if (box.SelectedItem is not CameraChoice camera) return; Selected=camera; CameraCatalog.Save(camera); DialogResult=DialogResult.OK; Close(); }; - layout.Controls.Add(save,0,2); Controls.Add(layout); - } -} diff --git a/BiStudent/CameraDevice.cs b/BiStudent/CameraDevice.cs deleted file mode 100644 index 313631a..0000000 --- a/BiStudent/CameraDevice.cs +++ /dev/null @@ -1,60 +0,0 @@ -using OpenCvSharp; - -namespace BiStudent; - -internal static class CameraDevice -{ - // DirectShow avoids the slow Media Foundation negotiation on many Windows laptops. - public static VideoCapture Open(CancellationToken token) - { - var choices = CameraCatalog.List(); var selected = CameraCatalog.Preferred(choices); - if (selected is null) throw new InvalidOperationException("Камеры не найдены. Подключите веб-камеру."); - // Enumeration is DirectShow: never silently open a different MSMF index. - foreach (var api in new[] { VideoCaptureAPIs.DSHOW }) - { - token.ThrowIfCancellationRequested(); - var camera = new VideoCapture(); - try - { - if (camera.Open(selected.Index, api)) - { - // Keep detail for faces across the classroom. Unsupported modes - // are negotiated by DirectShow without probing/opening again. - camera.Set(VideoCaptureProperties.FourCC, VideoWriter.FourCC('M', 'J', 'P', 'G')); - camera.Set(VideoCaptureProperties.FrameWidth, 1280); - camera.Set(VideoCaptureProperties.FrameHeight, 720); - token.ThrowIfCancellationRequested(); - return camera; - } - } - catch { camera.Dispose(); throw; } - camera.Dispose(); - } - throw new InvalidOperationException("Камера не найдена или занята. Закройте другие приложения с камерой."); - } -} - -internal sealed class CameraLoader : Control -{ - private readonly System.Windows.Forms.Timer animation = new() { Interval = 30 }; - private int angle; - public CameraLoader() - { - SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); - DoubleBuffered = true; Dock = DockStyle.Fill; BackColor = Theme.Sidebar; ForeColor = Theme.Text; - Font = Theme.Font(11); - Text = "Подключение камеры…"; - animation.Tick += (_, _) => { angle = (angle + 9) % 360; Invalidate(); }; - animation.Start(); - } - protected override void OnPaint(PaintEventArgs e) - { - base.OnPaint(e); - e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; - using var pen = new Pen(Theme.Accent, 4); - e.Graphics.DrawArc(pen, Width / 2 - 18, Height / 2 - 42, 36, 36, angle, 260); - TextRenderer.DrawText(e.Graphics, Text, Font, new Rectangle(12, Height / 2 + 6, Width - 24, 58), ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.WordBreak); - } - protected override void OnVisibleChanged(EventArgs e) { base.OnVisibleChanged(e); if (Visible) animation.Start(); else animation.Stop(); } - protected override void Dispose(bool disposing) { if (disposing) animation.Dispose(); base.Dispose(disposing); } -} diff --git a/BiStudent/CameraDevices.cs b/BiStudent/CameraDevices.cs new file mode 100644 index 0000000..f33e522 --- /dev/null +++ b/BiStudent/CameraDevices.cs @@ -0,0 +1,140 @@ +using OpenCvSharp; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +namespace BiStudent; + +internal sealed record CameraDevice(int Index, string Name, string Id) +{ + public override string ToString() => Name; +} + +internal static class CameraDevices +{ + [ComImport, Guid("29840822-5B84-11D0-BD3B-00A0C911CE86"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface ICreateDevEnum + { + [PreserveSig] + int CreateClassEnumerator(ref Guid type, out IEnumMoniker enumerator, int flags); + } + + [ComImport, Guid("55272A00-42CB-11CE-8135-00AA004BB851"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPropertyBag + { + [PreserveSig] + int Read([MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.Struct)] out object value, IntPtr errorLog); + } + + public static List List() + { + object? deviceEnumerator = null; + IEnumMoniker? enumerator = null; + try + { + deviceEnumerator = Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("62BE5D10-60EB-11D0-BD3B-00A0C911CE86"))!); + var category = new Guid("860BB310-5D01-11D0-BD3B-00A0C911CE86"); + if (((ICreateDevEnum)deviceEnumerator!).CreateClassEnumerator(ref category, out enumerator!, 0) != 0 || enumerator is null) + return []; + + var devices = new List(); + var names = new HashSet(StringComparer.Ordinal); + var occurrences = new Dictionary(StringComparer.Ordinal); + var monikers = new IMoniker[1]; + while (enumerator.Next(1, monikers, IntPtr.Zero) == 0) + { + var moniker = monikers[0]; + try + { + string? friendlyName = null; + string? devicePath = null; + object? storage = null; + try + { + var propertyBag = new Guid("55272A00-42CB-11CE-8135-00AA004BB851"); + moniker.BindToStorage(null!, null!, ref propertyBag, out storage!); + if (((IPropertyBag)storage).Read("FriendlyName", out var value, IntPtr.Zero) == 0) + friendlyName = value as string; + if (((IPropertyBag)storage).Read("DevicePath", out var path, IntPtr.Zero) == 0) + devicePath = path as string; + } + catch { } + finally { if (storage is not null) Marshal.ReleaseComObject(storage); } + + var index = devices.Count; + var name = string.IsNullOrWhiteSpace(friendlyName) ? $"Камера {index + 1}" : friendlyName; + var idName = friendlyName ?? name; + var ordinal = occurrences.GetValueOrDefault(idName) + 1; + occurrences[idName] = ordinal; + var uniqueName = name; + for (var suffix = 2; !names.Add(uniqueName); suffix++) + uniqueName = $"{name} ({suffix})"; + devices.Add(new CameraDevice(index, uniqueName, string.IsNullOrWhiteSpace(devicePath) ? $"{idName}#{ordinal}" : devicePath)); + } + finally { Marshal.ReleaseComObject(moniker); } + } + return devices; + } + catch { return []; } + finally + { + if (enumerator is not null) Marshal.ReleaseComObject(enumerator); + if (deviceEnumerator is not null) Marshal.ReleaseComObject(deviceEnumerator); + } + } + + public static VideoCapture Open(int index, int width, int height, bool allowFallback) + { + var capture = new VideoCapture(index, VideoCaptureAPIs.DSHOW); + if (!capture.IsOpened() && allowFallback) + { + capture.Dispose(); + capture = new VideoCapture(index); + } + if (!capture.IsOpened()) + { + capture.Dispose(); + throw new InvalidOperationException("Камера не найдена или занята другой программой."); + } + try + { + capture.FrameWidth = width; + capture.FrameHeight = height; + } + catch + { + capture.Dispose(); + throw; + } + return capture; + } + + public static CameraDevice? Find(IReadOnlyList devices, string id) + { + foreach (var device in devices) + if (device.Id == id) return device; + return null; + } + + public static int ResolveIndex(ClientSettings settings, IReadOnlyList devices) + { + var id = settings.CameraId; + if (id is not null && Find(devices, id) is { } selected) return selected.Index; + var name = settings.CameraName; + if (name is not null) + { + foreach (var device in devices) + if (device.Name == name) return device.Index; + } + if (settings.CameraIndex is { } index + && index >= 0 && (devices.Count == 0 || index < devices.Count)) return index; + return 0; + } + + public static void Save(ClientSettings settings, CameraDevice device) + { + settings.CameraId = device.Id; + settings.CameraName = device.Name; + settings.CameraIndex = device.Index; + settings.Save(); + } +} diff --git a/BiStudent/CloudDiagnostics.cs b/BiStudent/CloudDiagnostics.cs deleted file mode 100644 index ccbb451..0000000 --- a/BiStudent/CloudDiagnostics.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Net.Http; -using System.Text.Json; - -namespace BiStudent; - -internal static class CloudDiagnostics -{ - public static async Task CheckCacheCloudAsync(string credentialsFile) - { - using var cloud = await ConnectAsync(credentialsFile).ConfigureAwait(false); - var directory = Path.Combine(AppContext.BaseDirectory, "diagnostics"); Directory.CreateDirectory(directory); - var workspace = new CloudWorkspace(cloud.LoadAsync, cloud.SaveChangesAsync, Path.Combine(directory, "cache-cloud-" + Guid.NewGuid().ToString("N"), "draft.json")); - await workspace.LoadAsync().ConfigureAwait(false); var snapshot = workspace.Snapshot!; - var anchor = snapshot.Grades.First(x => x.Score is null && !snapshot.Grades.Any(g => g.StudentId == x.StudentId && g.SubjectId == x.SubjectId && g.Score is not null)); - var preloaded = cloud.RequestsSent; var created = new List(); - try - { - foreach (var score in new[] { 100, 100, 85 }) { workspace.Add(anchor.StudentId, anchor.SubjectId, score, DateOnly.FromDateTime(DateTime.Today), "Проверка пакетного сохранения"); created.Add(snapshot.Grades.Last().Id); } - if (cloud.RequestsSent != preloaded) throw new InvalidOperationException("Редактирование делает запросы."); - await workspace.SaveAsync().ConfigureAwait(false); - if (workspace.HasChanges || cloud.RequestsSent != preloaded + 1) throw new InvalidOperationException(workspace.Error ?? "Оценки не сохранены одним запросом."); - for (var i = 0; i < 10; i++) await workspace.SaveAsync().ConfigureAwait(false); - if (cloud.RequestsSent != preloaded + 1) throw new InvalidOperationException("Idle делает запросы."); - using var other = await ConnectAsync(credentialsFile).ConfigureAwait(false); - var rows = (await other.LoadAsync().ConfigureAwait(false)).Grades.Where(g => created.Contains(g.Id)).ToList(); - if (rows.Count != 3 || CloudFirestore.Average(rows) != 95) throw new InvalidOperationException("Облако не получило пакет оценок."); - } - finally - { - foreach (var id in workspace.Snapshot!.Grades.Where(x => x.Comment == "Проверка пакетного сохранения").Select(x => x.Id).ToList()) workspace.Edit(id, null, null, ""); - await workspace.SaveAsync().ConfigureAwait(false); - if (workspace.HasChanges) throw new InvalidOperationException(workspace.Error ?? "Не очищены тестовые оценки."); - } - File.WriteAllText(Path.Combine(directory, "cache-cloud-check.txt"), $"OK: preload requests={preloaded}; 3 edits=0 requests; batch save=1 request; 10 idle checks=0 requests; second login average=95; test grades cleared"); - } - - public static async Task CheckJournalAsync(string credentialsFile) - { - using var cloud = await ConnectAsync(credentialsFile).ConfigureAwait(false); - var snapshot = await cloud.LoadAsync().ConfigureAwait(false); - var directory = Path.Combine(AppContext.BaseDirectory, "diagnostics"); Directory.CreateDirectory(directory); - File.WriteAllText(Path.Combine(directory, "grades-before-private.json"), JsonSerializer.Serialize(snapshot)); - var anchor = snapshot.Grades.First(x => x.Score is null && !snapshot.Grades.Any(g => g.StudentId == x.StudentId && g.SubjectId == x.SubjectId && g.Score is not null)); - var created = new List(); - try - { - foreach (var score in new[] { 100, 100, 85 }) created.Add(await cloud.AddGradeAsync(anchor.StudentId, anchor.SubjectId, score, DateOnly.FromDateTime(DateTime.Today), "Проверка журнала").ConfigureAwait(false)); - using var other = await ConnectAsync(credentialsFile).ConfigureAwait(false); - var loaded = (await other.LoadAsync().ConfigureAwait(false)).Grades.Where(x => x.StudentId == anchor.StudentId && x.SubjectId == anchor.SubjectId && x.Score is not null).ToList(); - if (loaded.Count != 3 || Math.Abs(CloudFirestore.Average(loaded)!.Value - 95) > 0.00001) throw new InvalidOperationException("Журнал не сохранил 100,100,85 со средним 95."); - var first = loaded.Single(x => x.Id == created[0].Id); - await cloud.SaveGradeAsync(first, 70, first.Date, first.Comment).ConfigureAwait(false); - loaded = (await other.LoadAsync().ConfigureAwait(false)).Grades.Where(x => x.StudentId == anchor.StudentId && x.SubjectId == anchor.SubjectId && x.Score is not null).ToList(); - if (CloudFirestore.Average(loaded) != 85) throw new InvalidOperationException("Среднее после изменения неверно."); - var hundred = Enumerable.Range(0, 100).Select(i => anchor with { Id = i.ToString(), Score = 100 }).ToList(); - CloudFirestore.CheckCapacity(hundred.Take(99)); - var rejected = false; try { CloudFirestore.CheckCapacity(hundred); } catch (InvalidOperationException) { rejected = true; } - if (!rejected) throw new InvalidOperationException("101-я оценка не запрещена."); - } - finally - { - var latest = await cloud.LoadAsync().ConfigureAwait(false); - foreach (var row in latest.Grades.Where(x => created.Any(g => g.Id == x.Id))) await cloud.SaveGradeAsync(row, null, null, "").ConfigureAwait(false); - } - var after = await cloud.LoadAsync().ConfigureAwait(false); - foreach (var old in snapshot.Grades.Where(x => x.Score is not null)) - if (after.Grades.Single(x => x.Id == old.Id) is var row && (row.Score != old.Score || row.Date != old.Date || row.Comment != old.Comment)) throw new InvalidOperationException("Изменилась прежняя оценка."); - File.WriteAllText(Path.Combine(directory, "journal-check.txt"), "OK: 100,100,85 persisted separately; other login average=95; edit average=85; limit 100; test grades cleared; existing grades preserved"); - } - - public static async Task CheckAsync(string credentialsFile) - { - using var cloud = await ConnectAsync(credentialsFile).ConfigureAwait(false); - var snapshot = await cloud.LoadAsync().ConfigureAwait(false); - if (snapshot.Students.Count != 23 || snapshot.Grades.Count != 138) throw new InvalidOperationException("Неверный размер группы."); - var original = snapshot.Grades.First(); - var current = original; - try - { - current = await cloud.SaveGradeAsync(current, 0, DateOnly.FromDateTime(DateTime.Today), "Проверка синхронизации").ConfigureAwait(false); - using var other = await ConnectAsync(credentialsFile).ConfigureAwait(false); - if ((await other.LoadAsync().ConfigureAwait(false)).Grades.Single(x => x.Id == current.Id).Score != 0) throw new InvalidOperationException("Другой вход не получил оценку."); - var rejected = false; - try { await cloud.SaveGradeAsync(original, 50, DateOnly.FromDateTime(DateTime.Today), "").ConfigureAwait(false); } - catch (InvalidOperationException ex) when (ex.Message.Contains("изменились")) { rejected = true; } - if (!rejected) throw new InvalidOperationException("Нет защиты от перезаписи старой версии."); - current = await cloud.SaveGradeAsync(current, 100, DateOnly.FromDateTime(DateTime.Today), "").ConfigureAwait(false); - if ((await other.LoadAsync().ConfigureAwait(false)).Grades.Single(x => x.Id == current.Id).Score != 100) throw new InvalidOperationException("Не сохранён балл 100."); - } - finally { await cloud.SaveGradeAsync(current, original.Score, original.Date, original.Comment).ConfigureAwait(false); } - using var anonymous = new HttpClient(); - using var response = await anonymous.GetAsync("https://firestore.googleapis.com/v1/projects/bistudent/databases/(default)/documents/groups/23-po-103-4r-2").ConfigureAwait(false); - if (response.StatusCode != System.Net.HttpStatusCode.Forbidden) throw new InvalidOperationException("Анонимный доступ не закрыт."); - var settings = FirebaseSettings.Load(); - var directory = Path.Combine(AppContext.BaseDirectory, "diagnostics", "fresh-device-" + Guid.NewGuid().ToString("N")); - var store = new AttendanceStore(directory); - store.ApplyCloudRoster(snapshot, settings.GroupId); store.ApplyCloudRoster(snapshot, settings.GroupId); - if (store.Students().Count != 23 || store.Students().Any(x => x.SampleCount != 0)) throw new InvalidOperationException("Импорт на чистое устройство неверен."); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "diagnostics", "cloud-check.txt"), "OK: new login; scores 0/100; stale update rejected; original grade restored; anonymous access denied; fresh device 23 students; repeated import no duplicates"); - } - - public static async Task ConnectAsync(string credentialsFile) - { - var settings = FirebaseSettings.Load(); - using var credentials = JsonDocument.Parse(File.ReadAllText(credentialsFile)); - if (credentials.RootElement.GetProperty("email").GetString() != settings.TeacherEmail) throw new InvalidOperationException("Неверная учётная запись в файле входа."); - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; - var teacher = await new FirebaseAuth(settings, http).SignInAsync(credentials.RootElement.GetProperty("password").GetString()!).ConfigureAwait(false); - return new CloudFirestore(settings, teacher); - } - - public static async Task SeedAsync(string seedFile, string credentialsFile) - { - var seed = JsonSerializer.Deserialize(File.ReadAllText(seedFile), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; - if (seed.Students.Count != 23 || seed.Subjects.Count != 6) throw new InvalidOperationException("Ожидалось 23 студента и 6 предметов."); - using var cloud = await ConnectAsync(credentialsFile).ConfigureAwait(false); - await cloud.SeedAsync(seed).ConfigureAwait(false); - var snapshot = await cloud.LoadAsync().ConfigureAwait(false); - if (snapshot.Students.Count != 23 || snapshot.Subjects.Count != 6 || snapshot.Grades.Count != 138 || snapshot.Students.Any(s => seed.Students.All(x => x.Id != s.Id || x.Name != s.Name))) - throw new InvalidOperationException("Проверка данных Firestore не прошла."); - var directory = Path.Combine(AppContext.BaseDirectory, "diagnostics"); Directory.CreateDirectory(directory); - File.WriteAllText(Path.Combine(directory, "cloud-result.txt"), "OK: students=23; subjects=6; grade-slots=138"); - } -} diff --git a/BiStudent/CloudFirestore.cs b/BiStudent/CloudFirestore.cs deleted file mode 100644 index 2944ad6..0000000 --- a/BiStudent/CloudFirestore.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using System.Text; - -namespace BiStudent; - -internal sealed record CloudStudent(string Id, int Ordinal, string Name); -internal sealed record CloudSubject(string Id, string Name) { public override string ToString() => Name; } -internal sealed record CloudGrade(string Id, string StudentId, string SubjectId, int? Score, DateOnly? Date, string Comment, string UpdateTime); -internal sealed record CloudSnapshot(string GroupName, List Students, List Subjects, List Grades) -{ - public List Attendance { get; init; } = []; - public List Faces { get; init; } = []; - public List FaceSubmissions { get; init; } = []; -} -internal sealed record CloudSeed(string GroupName, List Students, List Subjects); - -internal sealed class CloudFirestore : IDisposable -{ - internal int RequestsSent { get; private set; } - public async Task CreateFaceInviteAsync(CloudStudent student) - { - var existing=await ListAsync("faceInvites"); - var revoke=existing.Where(x=>Text(x.GetProperty("fields"),"studentId")==student.Id).Select(x=>new { update=new { name=x.GetProperty("name").GetString(),fields=Fields(new() { ["active"]=0 }) },updateMask=new { fieldPaths=new[] { "active" } } }).ToList(); - foreach (var batch in revoke.Chunk(200)) { using var revoked=await SendAsync(HttpMethod.Post,DocumentRoot+":commit",new { writes=batch }); } - var code=Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(20)).ToLowerInvariant(); - using var result=await SendAsync(HttpMethod.Post,DocumentRoot+":commit",new { writes=new[] { new { update=new { name=GroupPath+"/faceInvites/"+code, fields=Fields(new() { ["studentId"]=student.Id,["studentName"]=student.Name,["active"]=1,["expiresAt"]=DateTimeOffset.UtcNow.AddHours(24) }) }, currentDocument=new { exists=false } } } }); - return code; - } - public async Task RevokeFaceInviteAsync(string code) - { - using var result=await SendAsync(HttpMethod.Post,DocumentRoot+":commit",new { writes=new object[] { - new { update=new { name=GroupPath+"/faceInvites/"+code,fields=Fields(new() { ["active"]=0 }) },updateMask=new { fieldPaths=new[] { "active" } } }, - new { delete=GroupPath+"/faceSubmissions/"+code } - } }); - } - public async Task SaveFaceAsync(CloudFace face) - { - if (face.Samples.Count!=3 || face.Format!="aligned112") throw new ArgumentException("Нужно три образца лица."); - var payload=JsonSerializer.Serialize(face with { UpdateTime="",SubmissionCode="" }); - if (Encoding.UTF8.GetByteCount(payload)>750000) throw new ArgumentException("Образцы лица слишком большие."); - var writes=new List(); - var fields=Fields(new() { ["studentId"]=face.StudentId,["payload"]=payload }); - if (face.UpdateTime.Length==0) writes.Add(new { update=new { name=GroupPath+"/faces/"+face.StudentId,fields },currentDocument=new { exists=false } }); - else writes.Add(new { update=new { name=GroupPath+"/faces/"+face.StudentId,fields },currentDocument=new { updateTime=face.UpdateTime } }); - if (face.SubmissionCode.Length>0) { - writes.Add(new { update=new { name=GroupPath+"/faceInvites/"+face.SubmissionCode,fields=Fields(new() { ["active"]=0 }) },updateMask=new { fieldPaths=new[] { "active" } } }); - writes.Add(new { delete=GroupPath+"/faceSubmissions/"+face.SubmissionCode }); - } - using var result=await SendAsync(HttpMethod.Post,DocumentRoot+":commit",new { writes }); - return face with { UpdateTime=result!.RootElement.GetProperty("writeResults")[0].GetProperty("updateTime").GetString()! }; - } - public async Task SaveAttendanceAsync(IEnumerable changes) - { - var writes = changes.Select(x => new { update = new { name = GroupPath + "/attendance/" + x.Key, fields = Fields(new() { - ["studentId"] = x.StudentId, ["date"] = x.Date, ["present"] = x.Present ? 1 : 0, ["payload"] = JsonSerializer.Serialize(x) - }) } }).ToArray(); - if (writes.Length == 0) return; - using var result = await SendAsync(HttpMethod.Post, DocumentRoot + ":commit", new { writes }).ConfigureAwait(false); - } - public async Task PublishPublicStatsAsync(string payload) - { - using var result = await SendAsync(HttpMethod.Patch, DocumentRoot + "/publicStats/" + settings.GroupId, - new { fields = Fields(new() { ["payload"] = payload, ["updatedAt"] = DateTimeOffset.UtcNow.ToString("O") }) }).ConfigureAwait(false); - } - public async Task RenameTeacherAsync() - { - using var request = new HttpRequestMessage(HttpMethod.Post, "https://identitytoolkit.googleapis.com/v1/accounts:update?key=" + Uri.EscapeDataString(settings.ApiKey)); - request.Content = JsonContent.Create(new { idToken = await TokenAsync().ConfigureAwait(false), displayName = settings.TeacherName, returnSecureToken = false }); - using var profile = await http.SendAsync(request).ConfigureAwait(false); - if (!profile.IsSuccessStatusCode) throw new InvalidOperationException("Не удалось изменить имя в Firebase Authentication."); - var documents = await ListAsync("grades").ConfigureAwait(false); - var old = documents.Where(d => Text(d.GetProperty("fields"), "teacherUid") == settings.TeacherUid && Text(d.GetProperty("fields"), "teacherName").StartsWith("Марал", StringComparison.Ordinal)).ToList(); - foreach (var batch in old.Chunk(400)) - { - var writes = batch.Select(d => new { update = new { name = d.GetProperty("name").GetString(), fields = Fields(new() { ["teacherName"] = settings.TeacherName }) }, updateMask = new { fieldPaths = new[] { "teacherName" } }, currentDocument = new { updateTime = d.GetProperty("updateTime").GetString() } }).ToArray(); - using var result = await SendAsync(HttpMethod.Post, DocumentRoot + ":commit", new { writes }).ConfigureAwait(false); - } - return old.Count; - } - public const int MaximumGradesPerSubject = 100; - public static double? Average(IEnumerable grades) - { - var values = grades.Where(x => x.Score is not null).Select(x => x.Score!.Value).ToArray(); - return values.Length == 0 ? null : values.Average(); - } - internal static void CheckCapacity(IEnumerable grades) - { - if (grades.Count(x => x.Score is not null) >= MaximumGradesPerSubject) throw new InvalidOperationException("По этому предмету уже сохранено 100 оценок. Измените или удалите одну из записей."); - } - public async Task> SaveChangesAsync(List baseline, List changes) - { - if (changes.Count == 0) return []; - var writes = new List(); var documents = new List(); - foreach (var subject in changes.GroupBy(x => (x.StudentId, x.SubjectId))) - { - var rows = baseline.Where(x => x.StudentId == subject.Key.StudentId && x.SubjectId == subject.Key.SubjectId).ToDictionary(x => x.Id); - foreach (var row in subject) rows[row.Id] = row; - if (rows.Values.Count(x => x.Score is not null) > MaximumGradesPerSubject) throw new InvalidOperationException("Максимум 100 оценок по одному предмету."); - var anchorId = subject.Key.StudentId + "_" + subject.Key.SubjectId; - var anchor = baseline.Single(x => x.Id == anchorId); - if (subject.All(x => x.Id != anchorId)) - { - writes.Add(new { update = new { name = GroupPath + "/grades/" + anchorId, fields = Fields(new() { ["journalRevision"] = Guid.NewGuid().ToString("N") }) }, updateMask = new { fieldPaths = new[] { "journalRevision" } }, currentDocument = new { updateTime = anchor.UpdateTime } }); - documents.Add(anchor); - } - foreach (var grade in subject) - { - if (grade.Score is < 0 or > 100 || grade.Score is not null && grade.Date is null || grade.Comment.Length > 500) throw new ArgumentException("Некорректная оценка."); - var fields = Fields(new Dictionary { ["studentId"] = grade.StudentId, ["subjectId"] = grade.SubjectId, ["score"] = grade.Score, ["date"] = grade.Date?.ToString("yyyy-MM-dd") ?? "", ["comment"] = grade.Comment, ["teacherUid"] = teacher.Uid, ["teacherName"] = teacher.Name }); - var original = baseline.FirstOrDefault(x => x.Id == grade.Id); - if (original is null) writes.Add(new { update = new { name = GroupPath + "/grades/" + grade.Id, fields }, currentDocument = new { exists = false } }); - else writes.Add(new { update = new { name = GroupPath + "/grades/" + grade.Id, fields }, currentDocument = new { updateTime = original.UpdateTime } }); - documents.Add(grade); - } - } - using var response = await SendAsync(HttpMethod.Post, DocumentRoot + ":commit", new { writes }).ConfigureAwait(false); - var results = response!.RootElement.GetProperty("writeResults").EnumerateArray().ToList(); - return documents.Select((g, i) => g with { UpdateTime = results[i].GetProperty("updateTime").GetString()! }).ToList(); - } - - public async Task AddGradeAsync(string studentId, string subjectId, int score, DateOnly date, string comment) - { - if (score is < 0 or > 100 || comment.Length > 500) throw new ArgumentException("Оценка должна быть от 0 до 100, комментарий — до 500 символов."); - var snapshot = await LoadAsync().ConfigureAwait(false); - if (!snapshot.Students.Any(x => x.Id == studentId) || !snapshot.Subjects.Any(x => x.Id == subjectId)) throw new ArgumentException("Студент или предмет не найден."); - var existing = snapshot.Grades.Where(x => x.StudentId == studentId && x.SubjectId == subjectId).ToList(); - CheckCapacity(existing); - var anchor = existing.Single(x => x.Id == studentId + "_" + subjectId); - var empty = existing.FirstOrDefault(x => x.Score is null); - var id = empty?.Id ?? studentId + "_" + subjectId + "_" + Guid.NewGuid().ToString("N"); - var fields = Fields(new Dictionary { ["studentId"] = studentId, ["subjectId"] = subjectId, ["score"] = score, ["date"] = date.ToString("yyyy-MM-dd"), ["comment"] = comment.Trim(), ["teacherUid"] = teacher.Uid, ["teacherName"] = teacher.Name }); - var writes = new List(); - if (id == anchor.Id) - { - writes.Add(new { update = new { name = GroupPath + "/grades/" + id, fields }, currentDocument = new { updateTime = anchor.UpdateTime } }); - } - else - { - // Serialize additions to this student's subject to enforce the cap across devices. - writes.Add(new { update = new { name = GroupPath + "/grades/" + anchor.Id, fields = Fields(new() { ["journalRevision"] = Guid.NewGuid().ToString("N") }) }, updateMask = new { fieldPaths = new[] { "journalRevision" } }, currentDocument = new { updateTime = anchor.UpdateTime } }); - if (empty is not null) writes.Add(new { update = new { name = GroupPath + "/grades/" + id, fields }, currentDocument = new { updateTime = empty.UpdateTime } }); - else writes.Add(new { update = new { name = GroupPath + "/grades/" + id, fields }, currentDocument = new { exists = false } }); - } - using var result = await SendAsync(HttpMethod.Post, DocumentRoot + ":commit", new { writes }).ConfigureAwait(false); - using var saved = await SendAsync(HttpMethod.Get, GroupPath + "/grades/" + id).ConfigureAwait(false); - return ParseGrade(saved!.RootElement); - } - - private readonly FirebaseSettings settings; - private readonly TeacherSession teacher; - private readonly HttpClient http; - private readonly SemaphoreSlim tokenGate = new(1, 1); - private string token; - private string refreshToken; - private DateTimeOffset expiresAt; - private string DocumentRoot => $"projects/{settings.ProjectId}/databases/(default)/documents"; - private string GroupPath => $"{DocumentRoot}/groups/{settings.GroupId}"; - private const string Api = "https://firestore.googleapis.com/v1/"; - - public CloudFirestore(FirebaseSettings settings, TeacherSession teacher, HttpClient? http = null) - { - this.settings = settings; this.teacher = teacher; - this.http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; - token = teacher.IdToken; refreshToken = teacher.RefreshToken; expiresAt = teacher.ExpiresAt; - } - - private async Task TokenAsync() - { - await tokenGate.WaitAsync().ConfigureAwait(false); - try - { - if (string.IsNullOrEmpty(token)) throw new InvalidOperationException("Войдите в Firebase заново."); - if (DateTimeOffset.UtcNow < expiresAt.AddMinutes(-2)) return token; - using var response = await http.PostAsync("https://securetoken.googleapis.com/v1/token?key=" + Uri.EscapeDataString(settings.ApiKey), - new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken })).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) throw new InvalidOperationException("Сеанс Firebase завершён. Выйдите и войдите заново."); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - if (json.RootElement.GetProperty("user_id").GetString() != settings.TeacherUid) throw new InvalidOperationException("Firebase вернул другую учётную запись."); - token = json.RootElement.GetProperty("id_token").GetString()!; - refreshToken = json.RootElement.GetProperty("refresh_token").GetString()!; - expiresAt = DateTimeOffset.UtcNow.AddSeconds(int.Parse(json.RootElement.GetProperty("expires_in").GetString()!)); - if (RememberedSession.Exists) RememberedSession.Save(settings,new TeacherSession(teacher.Uid,settings.TeacherEmail,settings.TeacherName,token,refreshToken,expiresAt)); - return token; - } - finally { tokenGate.Release(); } - } - - private async Task SendAsync(HttpMethod method, string path, object? body = null, bool missingAllowed = false) - { - using var request = new HttpRequestMessage(method, Api + path); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await TokenAsync().ConfigureAwait(false)); - if (body is not null) request.Content = JsonContent.Create(body); - RequestsSent++; - using var response = await http.SendAsync(request).ConfigureAwait(false); - var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NotFound && missingAllowed) return null; - if (!response.IsSuccessStatusCode) - { - throw new InvalidOperationException(response.StatusCode switch - { - HttpStatusCode.Forbidden => "Firestore отклонил доступ. Проверьте правила для Ниязова Мадина Ержанкызы.", - HttpStatusCode.NotFound => "Группа не найдена в Firestore. Проверьте, что данные загружены.", - HttpStatusCode.Unauthorized => "Сеанс Firebase завершён. Войдите заново.", - HttpStatusCode.Conflict or HttpStatusCode.PreconditionFailed => "Данные изменились на другом устройстве. Обновите список и повторите.", - _ when text.Contains("FAILED_PRECONDITION", StringComparison.Ordinal) => "Данные изменились на другом устройстве. Обновите список и повторите.", - _ => "Не удалось получить или сохранить данные в Firestore. Попробуйте ещё раз." - }); - } - return JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text); - } - - private async Task> ListAsync(string collection) - { - var result = new List(); string? page = null; - do - { - using var json = await SendAsync(HttpMethod.Get, GroupPath + "/" + collection + "?pageSize=300" + (page is null ? "" : "&pageToken=" + Uri.EscapeDataString(page))).ConfigureAwait(false); - if (json!.RootElement.TryGetProperty("documents", out var docs)) result.AddRange(docs.EnumerateArray().Select(x => x.Clone())); - page = json.RootElement.TryGetProperty("nextPageToken", out var next) ? next.GetString() : null; - } while (!string.IsNullOrEmpty(page)); - return result; - } - - public async Task LoadAsync() - { - using var group = await SendAsync(HttpMethod.Get, GroupPath).ConfigureAwait(false); - var fields = group!.RootElement.GetProperty("fields"); - var subjects = fields.GetProperty("subjects").GetProperty("arrayValue").GetProperty("values").EnumerateArray() - .Select(x => { var item = x.GetProperty("mapValue").GetProperty("fields"); return new CloudSubject(Text(item, "id"), Text(item, "name")); }).ToList(); - var studentsTask = ListAsync("students"); var gradesTask = ListAsync("grades"); var attendanceTask = ListAsync("attendance"); var facesTask=ListAsync("faces"); var submissionsTask=ListAsync("faceSubmissions"); - await Task.WhenAll(studentsTask, gradesTask, attendanceTask,facesTask,submissionsTask).ConfigureAwait(false); - var students = studentsTask.Result.Select(doc => { var f = doc.GetProperty("fields"); return new CloudStudent(Id(doc), int.Parse(f.GetProperty("ordinal").GetProperty("integerValue").GetString()!), Text(f, "name")); }).OrderBy(x => x.Ordinal).ToList(); - var grades = gradesTask.Result.Select(ParseGrade).ToList(); - var activeCodes = submissionsTask.Result.Count>0 ? (await ListAsync("faceInvites").ConfigureAwait(false)).Where(x=>x.GetProperty("fields").TryGetProperty("active",out var active) && active.GetProperty("integerValue").GetString()=="1").Select(Id).ToHashSet() : []; - if (subjects.Count != 6 || subjects.Select(x => x.Id).Distinct().Count() != 6) throw new InvalidOperationException("В Firestore должен быть список из шести предметов."); - return new CloudSnapshot(Text(fields, "name"), students, subjects, grades) { - Attendance = attendanceTask.Result.Select(x => JsonSerializer.Deserialize(Text(x.GetProperty("fields"), "payload"))!).ToList(), - Faces=facesTask.Result.Select(x=>JsonSerializer.Deserialize(Text(x.GetProperty("fields"),"payload"))! with { UpdateTime=x.GetProperty("updateTime").GetString()! }).ToList(), - FaceSubmissions=submissionsTask.Result.Where(x=>activeCodes.Contains(Id(x))).Select(x=> { - var fields=x.GetProperty("fields"); - try { using var payload=JsonDocument.Parse(Text(fields,"payload")); - return new CloudFace(Text(fields,"studentId"),Id(x),"photos",payload.RootElement.GetProperty("samples").EnumerateArray().Select(s=>s.GetString()!).ToList(),"",Id(x)); - } catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException) { - return new CloudFace(Text(fields,"studentId"),Id(x),"photos",[],"",Id(x)); - } - }).ToList() - }; - } - - public async Task SaveGradeAsync(CloudGrade grade, int? score, DateOnly? date, string comment) - { - if (score is < 0 or > 100 || comment.Length > 500) throw new ArgumentException("Оценка должна быть от 0 до 100, комментарий — до 500 символов."); - if (score is not null && date is null) throw new ArgumentException("Укажите дату оценки."); - var fields = Fields(new Dictionary { ["score"] = score, ["date"] = date?.ToString("yyyy-MM-dd") ?? "", ["comment"] = comment.Trim(), ["teacherUid"] = teacher.Uid, ["teacherName"] = teacher.Name }); - var mask = string.Join("&", fields.Keys.Select(x => "updateMask.fieldPaths=" + x)); - using var result = await SendAsync(HttpMethod.Patch, GroupPath + "/grades/" + Uri.EscapeDataString(grade.Id) + "?" + mask + "¤tDocument.updateTime=" + Uri.EscapeDataString(grade.UpdateTime), new { fields }).ConfigureAwait(false); - return ParseGrade(result!.RootElement); - } - - public async Task SeedAsync(CloudSeed seed) - { - var existingStudents = await ListAsync("students").ConfigureAwait(false); - var existingGrades = await ListAsync("grades").ConfigureAwait(false); - using var group = await SendAsync(HttpMethod.Get, GroupPath, missingAllowed: true).ConfigureAwait(false); - var writes = new List(); - void Add(string path, Dictionary values) => writes.Add(new { update = new { name = path, fields = Fields(values) }, currentDocument = new { exists = false } }); - if (group is null) Add(GroupPath, new() { ["name"] = seed.GroupName, ["subjects"] = seed.Subjects.Select(x => new Dictionary { ["id"] = x.Id, ["name"] = x.Name }).ToArray() }); - var studentIds = existingStudents.Select(Id).ToHashSet(); var gradeIds = existingGrades.Select(Id).ToHashSet(); - foreach (var student in seed.Students) - { - if (!studentIds.Contains(student.Id)) Add(GroupPath + "/students/" + student.Id, new() { ["name"] = student.Name, ["ordinal"] = student.Ordinal }); - foreach (var subject in seed.Subjects) - { - var id = student.Id + "_" + subject.Id; - if (!gradeIds.Contains(id)) Add(GroupPath + "/grades/" + id, new() { ["studentId"] = student.Id, ["subjectId"] = subject.Id, ["score"] = null, ["date"] = "", ["comment"] = "", ["teacherUid"] = teacher.Uid, ["teacherName"] = teacher.Name }); - } - } - if (writes.Count > 500) throw new InvalidOperationException("Слишком много данных для тестового импорта."); - if (writes.Count > 0) { using var result = await SendAsync(HttpMethod.Post, DocumentRoot + ":commit", new { writes }).ConfigureAwait(false); } - } - - private static string Id(JsonElement document) => document.GetProperty("name").GetString()!.Split('/').Last(); - private static string Text(JsonElement fields, string key) => fields.TryGetProperty(key, out var value) && value.TryGetProperty("stringValue", out var text) ? text.GetString() ?? "" : ""; - private static CloudGrade ParseGrade(JsonElement document) - { - var f = document.GetProperty("fields"); - var score = f.GetProperty("score"); var date = Text(f, "date"); - return new CloudGrade(Id(document), Text(f, "studentId"), Text(f, "subjectId"), score.TryGetProperty("integerValue", out var number) ? int.Parse(number.GetString()!) : null, - date.Length > 0 ? DateOnly.ParseExact(date, "yyyy-MM-dd") : null, Text(f, "comment"), document.GetProperty("updateTime").GetString()!); - } - - private static Dictionary Fields(Dictionary values) => values.ToDictionary(x => x.Key, x => Value(x.Value)); - private static object Value(object? value) => value switch - { - null => new { nullValue = (object?)null }, - int number => new { integerValue = number.ToString(System.Globalization.CultureInfo.InvariantCulture) }, - string text => new { stringValue = text }, - DateTimeOffset date => new { timestampValue = date.ToUniversalTime().ToString("O") }, - Dictionary map => new { mapValue = new { fields = Fields(map) } }, - IEnumerable> array => new { arrayValue = new { values = array.Select(Value).ToArray() } }, - _ => throw new ArgumentException("Неизвестный тип значения Firestore.") - }; - public void Dispose() { http.Dispose(); tokenGate.Dispose(); token = ""; refreshToken = ""; } -} diff --git a/BiStudent/CloudGradesForm.cs b/BiStudent/CloudGradesForm.cs deleted file mode 100644 index 82702c3..0000000 --- a/BiStudent/CloudGradesForm.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Globalization; - -namespace BiStudent; - -internal sealed class CloudGradesForm : Form -{ - private readonly CloudWorkspace workspace; - private readonly string studentId; - private readonly List subjects; - private List grades = []; - private readonly DataGridView table = MainForm.Grid(); - private readonly ComboBox subject = new() { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList, FlatStyle = FlatStyle.Flat, BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(10), DropDownWidth = 640 }; - private readonly TextBox score = FormStyle.Input("0–100"); - private readonly TextBox date = FormStyle.Input("ГГГГ-ММ-ДД"); - private readonly TextBox comment = FormStyle.Input("Комментарий"); - private readonly Label feedback = FormStyle.Caption(""); - private readonly ModernButton save = new() { Text = "Добавить новую оценку", Primary = true, Dock = DockStyle.Fill }; - private readonly ModernButton edit = new() { Text = "Изменить выбранную", Dock = DockStyle.Fill }; - private readonly ModernButton clear = new() { Text = "Удалить выбранную", Dock = DockStyle.Fill }; - private readonly ModernButton refresh = new() { Text = "Сохранить изменения", Dock = DockStyle.Fill }; - private bool busy; - private string? selectedGradeId; - - public CloudGradesForm(CloudWorkspace workspace, string studentId, string studentName, List subjects) - { - this.workspace = workspace; this.studentId = studentId; this.subjects = subjects; - FormStyle.Dialog(this, "BiStudent · Оценки в Firebase", new Size(1260, 740)); FormBorderStyle = FormBorderStyle.Sizable; MaximizeBox = true; DoubleBuffered = true; - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, Padding = new Padding(24) }; - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 70)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); - var title = FormStyle.Caption(studentName); title.Font = Theme.Font(20, FontStyle.Bold); title.ForeColor = Theme.Text; title.TextAlign = ContentAlignment.MiddleLeft; layout.Controls.Add(title, 0, 0); - var body = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1 }; - body.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 365)); body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - var entry = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(16), Margin = new Padding(0, 0, 16, 0) }; - var fields = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 13, BackColor = Theme.Surface }; - foreach (var height in new[] { 26, 36, 26, 36, 26, 36, 26, 45, 42, 42, 42, 42 }) fields.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); fields.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - subject.DrawMode = DrawMode.OwnerDrawFixed; subject.ItemHeight = 24; - subject.DrawItem += (_, e) => - { - using var brush = new SolidBrush((e.State & DrawItemState.Selected) != 0 ? Theme.SurfaceHover : Theme.Surface); - e.Graphics.FillRectangle(brush, e.Bounds); - if (e.Index >= 0) TextRenderer.DrawText(e.Graphics, subjects[e.Index].Name, subject.Font, e.Bounds, Theme.Text, TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); - }; - subject.DisplayMember = nameof(CloudSubject.Name); - subject.Items.AddRange(subjects.Cast().ToArray()); subject.SelectedIndex = 0; - fields.Controls.Add(FormStyle.Caption("ПРЕДМЕТ"), 0, 0); fields.Controls.Add(subject, 0, 1); - fields.Controls.Add(FormStyle.Caption("ДАТА · ГГГГ-ММ-ДД"), 0, 2); fields.Controls.Add(date, 0, 3); - fields.Controls.Add(FormStyle.Caption("ОЦЕНКА · ОТ 0 ДО 100"), 0, 4); fields.Controls.Add(score, 0, 5); - fields.Controls.Add(FormStyle.Caption("КОММЕНТАРИЙ"), 0, 6); comment.Multiline = true; comment.MaxLength = 500; fields.Controls.Add(comment, 0, 7); - fields.Controls.Add(save, 0, 8); fields.Controls.Add(edit, 0, 9); fields.Controls.Add(clear, 0, 10); fields.Controls.Add(refresh, 0, 11); - entry.Controls.Add(fields); body.Controls.Add(entry, 0, 0); - var card = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(4) }; card.Controls.Add(table); body.Controls.Add(card, 1, 0); layout.Controls.Add(body, 0, 1); - feedback.Font = Theme.Font(9); feedback.TextAlign = ContentAlignment.MiddleLeft; layout.Controls.Add(feedback, 0, 2); Controls.Add(layout); - subject.SelectedIndexChanged += (_, _) => { selectedGradeId = null; FillEntry(); DrawRows(); ShowSummary(); }; - table.CellClick += (_, e) => { if (e.RowIndex >= 0 && table.Rows[e.RowIndex].Cells["Id"].Value is string id) { selectedGradeId = id; FillEntry(); } }; - save.Click += async (_, _) => await SaveAsync(false, true); edit.Click += async (_, _) => await SaveAsync(false, false); clear.Click += async (_, _) => await SaveAsync(true, false); refresh.Click += async (_, _) => await workspace.SaveAsync(); - Shown += (_, _) => RefreshFromCache(); workspace.Changed += RefreshFromCache; - FillEntry(); SetBusy(false); - } - - private void SetBusy(bool value) { busy = value; save.Enabled = edit.Enabled = clear.Enabled = refresh.Enabled = subject.Enabled = score.Enabled = date.Enabled = comment.Enabled = table.Enabled = !value; } - private CloudGrade? Selected() => grades.FirstOrDefault(x => x.Id == selectedGradeId && x.Score is not null); - private List SubjectGrades() => grades.Where(x => x.SubjectId == (subject.SelectedItem as CloudSubject)?.Id && x.Score is not null).OrderByDescending(x => x.Date).ThenByDescending(x => x.UpdateTime).ToList(); - private void ShowSummary() - { - var rows = SubjectGrades(); var average = CloudFirestore.Average(rows); - feedback.ForeColor = Theme.Muted; feedback.Text = $"Оценок по предмету: {rows.Count}/100 · Средний балл: {average?.ToString("0.00") ?? "—"} · Добавление сохраняет новую запись. Для изменения выберите строку."; - } - private void FillEntry() - { - var grade = Selected(); score.Text = grade?.Score?.ToString(CultureInfo.InvariantCulture) ?? ""; - date.Text = (grade?.Date ?? DateOnly.FromDateTime(DateTime.Today)).ToString("yyyy-MM-dd"); comment.Text = grade?.Comment ?? ""; - } - private void DrawRows() - { - table.DataSource = SubjectGrades().Select(g => new { g.Id, Предмет = subjects.First(s => s.Id == g.SubjectId).Name, Оценка = g.Score!.Value, Дата = g.Date?.ToString("dd.MM.yyyy") ?? "—", Комментарий = g.Comment }).ToList(); - table.Columns["Id"]!.Visible = false; table.Columns["Предмет"]!.FillWeight = 260; table.Columns["Оценка"]!.FillWeight = 55; table.Columns["Дата"]!.FillWeight = 75; table.Columns["Комментарий"]!.FillWeight = 110; - table.DefaultCellStyle.WrapMode = DataGridViewTriState.True; table.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; - foreach (DataGridViewRow row in table.Rows) row.Height = Math.Max(row.Height, 48); - table.ClearSelection(); - } - private void RefreshFromCache() - { - if (IsDisposed) return; - grades = workspace.Snapshot?.Grades.Where(x => x.StudentId == studentId).ToList() ?? []; - DrawRows(); ShowSummary(); SetBusy(workspace.IsSaving); - refresh.Enabled = workspace.HasChanges && !workspace.IsSaving; - if (workspace.Error is not null) { feedback.ForeColor = Theme.Red; feedback.Text = workspace.Error; } - else if (workspace.IsSaving) feedback.Text = "Сохранение изменений в Firebase…"; - else if (workspace.HasChanges) feedback.Text += " · Есть несохранённые изменения"; - } - private async Task SaveAsync(bool clearing, bool adding) - { - if (busy) return; - var grade = Selected(); - if (!adding && grade is null) { feedback.Text = "Сначала выберите оценку в журнале."; return; } - if (subject.SelectedItem is not CloudSubject item) return; - int? value = null; DateOnly? day = null; - if (!clearing) - { - if (!int.TryParse(score.Text.Trim(), out var number) || number is < 0 or > 100) { feedback.ForeColor = Theme.Red; feedback.Text = "Введите целую оценку от 0 до 100."; return; } - if (!DateOnly.TryParseExact(date.Text.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) { feedback.ForeColor = Theme.Red; feedback.Text = "Введите дату в формате ГГГГ-ММ-ДД."; return; } - value = number; day = parsed; - } - await Task.CompletedTask; - try - { - if (adding) workspace.Add(studentId, item.Id, value!.Value, day!.Value, comment.Text); - else workspace.Edit(grade!.Id, value, day, clearing ? "" : comment.Text); - selectedGradeId = null; FillEntry(); RefreshFromCache(); - } - catch (Exception ex) { if (!IsDisposed) ShowError(ex); } - finally { if (!IsDisposed) refresh.Enabled = workspace.HasChanges && !workspace.IsSaving; } - } - protected override void OnFormClosed(FormClosedEventArgs e) { workspace.Changed -= RefreshFromCache; base.OnFormClosed(e); } - private void ShowError(Exception ex) { feedback.ForeColor = Theme.Red; feedback.Text = ex is InvalidOperationException or ArgumentException ? ex.Message : "Нет соединения с Firebase. Проверьте интернет и обновите список."; } - protected override void OnFormClosing(FormClosingEventArgs e) { if (busy && Visible) { e.Cancel = true; feedback.Text = "Дождитесь завершения запроса Firebase."; } base.OnFormClosing(e); } -} diff --git a/BiStudent/CloudWorkspace.cs b/BiStudent/CloudWorkspace.cs deleted file mode 100644 index a5d67cc..0000000 --- a/BiStudent/CloudWorkspace.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Text.Json; - -namespace BiStudent; - -internal sealed record GradeDraft(List Original, List Changed); - -// One session cache shared by all windows. Timer checks this cache, not Firebase. -internal sealed class CloudWorkspace -{ - private readonly Func> load; - private readonly Func, List, Task>> persist; - private readonly string draftPath; - private List baseline = []; - public CloudSnapshot? Snapshot { get; private set; } - public bool IsSaving { get; private set; } - public string? Error { get; private set; } - public event Action? Changed; - public int PendingCount => Pending().Count; - public bool HasChanges => PendingCount > 0; - - public CloudWorkspace(CloudFirestore cloud, string uid) : this(cloud.LoadAsync, cloud.SaveChangesAsync, - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StudentAttendance", "pending-grades-" + uid + ".json")) { } - - internal CloudWorkspace(Func> load, Func, List, Task>> persist, string draftPath) - { this.load = load; this.persist = persist; this.draftPath = draftPath; } - - private static bool Equal(CloudGrade a, CloudGrade b) => a.StudentId == b.StudentId && a.SubjectId == b.SubjectId && a.Score == b.Score && a.Date == b.Date && a.Comment == b.Comment; - private List Pending() - { - var original = baseline.ToDictionary(x => x.Id); - return Snapshot?.Grades.Where(g => !original.TryGetValue(g.Id, out var old) ? g.Score is not null : !Equal(g, old)).ToList() ?? []; - } - - public async Task LoadAsync() - { - if (IsSaving || HasChanges) throw new InvalidOperationException("Сначала сохраните изменения. Обновление не должно удалить несохранённые оценки."); - var snapshot = await load(); - baseline = snapshot.Grades.ToList(); Snapshot = snapshot; Error = null; - if (File.Exists(draftPath)) - { - var draft = JsonSerializer.Deserialize(File.ReadAllText(draftPath)); - if (draft is not null) - { - foreach (var original in draft.Original) { var server=baseline.FirstOrDefault(x=>x.Id==original.Id); baseline.RemoveAll(x => x.Id == original.Id); baseline.Add(server is not null && Equal(server,original) ? server : original); } - foreach (var grade in draft.Changed) { snapshot.Grades.RemoveAll(x => x.Id == grade.Id); snapshot.Grades.Add(grade); } - } - } - Changed?.Invoke(); - } - - public void Add(string studentId, string subjectId, int score, DateOnly date, string comment) - { - Validate(score, date, comment); - if (Snapshot is null || !Snapshot.Students.Any(x => x.Id == studentId) || !Snapshot.Subjects.Any(x => x.Id == subjectId)) throw new InvalidOperationException("Группа ещё не загружена."); - CloudFirestore.CheckCapacity(Snapshot.Grades.Where(x => x.StudentId == studentId && x.SubjectId == subjectId)); - Snapshot.Grades.Add(new CloudGrade(studentId + "_" + subjectId + "_" + Guid.NewGuid().ToString("N"), studentId, subjectId, score, date, comment.Trim(), "")); - LocalChanged(); - } - - public void Edit(string id, int? score, DateOnly? date, string comment) - { - Validate(score, date, comment); - var grades = Snapshot?.Grades ?? throw new InvalidOperationException("Группа ещё не загружена."); - var index = grades.FindIndex(x => x.Id == id); if (index < 0) throw new InvalidOperationException("Оценка не найдена."); - var old = grades[index]; var edited = old with { Score = score, Date = date, Comment = comment.Trim() }; - if (Equal(old, edited)) return; - if (score is null && baseline.All(x => x.Id != id)) grades.RemoveAt(index); else grades[index] = edited; - LocalChanged(); - } - - private static void Validate(int? score, DateOnly? date, string comment) - { if (score is < 0 or > 100 || score is not null && date is null || comment.Length > 500) throw new ArgumentException("Оценка: 0–100, дата обязательна, комментарий до 500 символов."); } - - private void LocalChanged() { Error = null; WriteDraft(); Changed?.Invoke(); } - private void WriteDraft() - { - var pending = Pending(); Directory.CreateDirectory(Path.GetDirectoryName(draftPath)!); - var text = JsonSerializer.Serialize(new GradeDraft(baseline.Where(x => pending.Any(g => g.Id == x.Id) || pending.Any(g => x.Id == g.StudentId + "_" + g.SubjectId)).ToList(), pending)); - for (var attempt = 0; ; attempt++) - { - try { File.WriteAllText(draftPath + ".tmp", text); File.Move(draftPath + ".tmp", draftPath, true); break; } - catch (Exception ex) when (attempt < 3 && ex is IOException or UnauthorizedAccessException) { Thread.Sleep(30); } - } - } - - public async Task SaveAsync() - { - if (IsSaving || !HasChanges) return; - var pending = Pending(); IsSaving = true; Error = null; Changed?.Invoke(); - try - { - foreach (var batch in pending.GroupBy(x => (x.StudentId, x.SubjectId))) - { - var saved = await persist(baseline.ToList(), batch.ToList()); - foreach (var grade in saved) - { - baseline.RemoveAll(x => x.Id == grade.Id); baseline.Add(grade); - var index = Snapshot!.Grades.FindIndex(x => x.Id == grade.Id); - if (index >= 0) Snapshot.Grades[index] = Snapshot.Grades[index] with { UpdateTime = grade.UpdateTime }; - } - WriteDraft(); - } - } - catch (Exception ex) { Error = ex is InvalidOperationException ? ex.Message : "Не удалось сохранить в Firebase. Изменения остались на компьютере; повторите сохранение."; } - finally { IsSaving = false; Changed?.Invoke(); } - } -} - -internal sealed class SavePreferences -{ - public int Seconds { get; set; } = 5; - private static string FilePath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StudentAttendance", "autosave.json"); - public static SavePreferences Load() { try { var value = JsonSerializer.Deserialize(File.ReadAllText(FilePath)); if (value?.Seconds is >= 1 and <= 3600) return value; } catch { } return new(); } - public void Save() { Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); File.WriteAllText(FilePath, JsonSerializer.Serialize(this)); } -} diff --git a/BiStudent/CrashGuard.cs b/BiStudent/CrashGuard.cs new file mode 100644 index 0000000..6238663 --- /dev/null +++ b/BiStudent/CrashGuard.cs @@ -0,0 +1,43 @@ +namespace BiStudent; + +/// Последняя линия защиты: необработанные исключения пишутся в журнал и показываются сообщением, +/// а не закрывают приложение посреди занятия. +internal static class CrashGuard +{ + private static int showing; + + public static string LogPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BiStudent", "crash.log"); + + public static void Install() + { + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + Application.ThreadException += (_, e) => Report(e.Exception, fatal: false); + AppDomain.CurrentDomain.UnhandledException += (_, e) => Report(e.ExceptionObject as Exception, fatal: e.IsTerminating); + TaskScheduler.UnobservedTaskException += (_, e) => { Log(e.Exception, "unobserved task"); e.SetObserved(); }; + } + + public static void Log(Exception? exception, string kind) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); + File.AppendAllText(LogPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {kind}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}"); + } + catch { } + } + + private static void Report(Exception? exception, bool fatal) + { + Log(exception, fatal ? "fatal" : "ui"); + if (fatal || Interlocked.Exchange(ref showing, 1) == 1) return; + try + { + var message = exception is ApiException api ? api.Message : exception?.Message ?? "Неизвестная ошибка"; + MessageBox.Show($"Произошла ошибка, но приложение продолжает работать:{Environment.NewLine}{message}{Environment.NewLine}{Environment.NewLine}Подробности записаны в {LogPath}", + "BiStudent", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + catch { } + finally { Interlocked.Exchange(ref showing, 0); } + } +} diff --git a/BiStudent/DeviceFaceChecks.cs b/BiStudent/DeviceFaceChecks.cs deleted file mode 100644 index 5f7f9c4..0000000 --- a/BiStudent/DeviceFaceChecks.cs +++ /dev/null @@ -1,62 +0,0 @@ -using OpenCvSharp; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; - -namespace BiStudent; - -internal static class DeviceFaceChecks -{ - public static async Task RunAsync(string directory) - { - var settings=new FirebaseSettings { ProjectId="test",GroupId="test",TeacherUid="test" }; - var session=new TeacherSession("test","test@example.invalid","Тест","test-token","secret-refresh",DateTimeOffset.UtcNow.AddHours(1)); - var tokenFile=Path.Combine(directory,"device-test-"+Guid.NewGuid().ToString("N"),"session.bin"); - RememberedSession.Save(settings,session,tokenFile); - if (Encoding.UTF8.GetString(File.ReadAllBytes(tokenFile)).Contains("secret-refresh") || RememberedSession.Load(settings,tokenFile)!="secret-refresh") throw new Exception("Device session is not encrypted or cannot be restored."); - if (RememberedSession.Load(new FirebaseSettings { ProjectId="other",TeacherUid="test" },tokenFile) is not null) throw new Exception("Cross-project saved session accepted."); - RememberedSession.Forget(tokenFile); if (File.Exists(tokenFile)) throw new Exception("Signout did not clear device session."); - var handler=new FaceHandler(); using var cloud=new CloudFirestore(settings,session,new HttpClient(handler)); - var snapshot=new CloudSnapshot("Т-1",[new("s1",1,"Тест")],[],[]); - var store=new AttendanceStore(Path.Combine(directory,"face-test-"+Guid.NewGuid().ToString("N"))); store.ApplyCloudRoster(snapshot,"test"); - using var image=new Mat(new OpenCvSharp.Size(112,112),MatType.CV_8UC3,new Scalar(20,60,80)); Cv2.ImEncode(".png",image,out var bytes); - var id=store.Students().Single().Id; store.ReplaceSamples(id,[bytes,bytes,bytes]); - var sync=new FaceSync(store,cloud,snapshot.GroupName); await sync.ImportAsync(snapshot); - if (!sync.HasChanges) throw new Exception("Local face not queued for cloud."); - await sync.SaveAsync(); var requests=handler.Calls; - for (var i=0;i<10;i++) await sync.SaveAsync(); - if (requests!=1 || handler.Calls!=requests || sync.HasChanges) throw new Exception("Idle face sync sends requests."); - var remote=snapshot with { Faces=handler.Faces.Values.ToList() }; - var second=new AttendanceStore(Path.Combine(directory,"face-device-"+Guid.NewGuid().ToString("N"))); second.ApplyCloudRoster(remote,"test"); - var restored=new FaceSync(second,cloud,remote.GroupName); await restored.ImportAsync(remote); - if (restored.HasChanges || second.Samples().Count!=3 || second.Samples().Any(x=>!x.Image.SequenceEqual(bytes))) throw new Exception("Fresh device face restore failed."); - using var replacement=new Mat(new OpenCvSharp.Size(112,112),MatType.CV_8UC3,new Scalar(60,30,15)); Cv2.ImEncode(".png",replacement,out var changed); - store.ReplaceSamples(id,[changed,bytes,bytes]); handler.Fail=true; - try { await sync.SaveAsync(); throw new Exception("Offline face saved unexpectedly."); } catch (HttpRequestException) { } - var restarted=new FaceSync(store,cloud,snapshot.GroupName); if (!restarted.HasChanges) throw new Exception("Offline face lost on restart."); - handler.Fail=false; await restarted.SaveAsync(); - if (restarted.HasChanges || handler.Faces.Count!=1) throw new Exception("Face retry duplicated a student."); - var bad=remote with { FaceSubmissions=[new("s1","code","photos",["bad","bad","bad"],"","code")] }; - await restored.ImportAsync(bad); - if (restored.Error is null || second.Samples().Count!=3) throw new Exception("Bad website photo replaced valid samples."); - File.WriteAllText(Path.Combine(directory,"device-face-check.txt"),"OK: DPAPI token roundtrip and signout; face migration; idle zero writes; second device restores 3 samples; offline/restart/retry; malformed website samples preserve valid face"); - } - private sealed class FaceHandler : HttpMessageHandler - { - public int Calls; public bool Fail; - public Dictionary Faces=[]; - protected override async Task SendAsync(HttpRequestMessage request,CancellationToken cancellationToken) - { - Calls++; if (Fail) throw new HttpRequestException("Offline"); - using var json=JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); - var results=new List(); - foreach (var write in json.RootElement.GetProperty("writes").EnumerateArray()) { - var update=write.GetProperty("update"); - var face=JsonSerializer.Deserialize(update.GetProperty("fields").GetProperty("payload").GetProperty("stringValue").GetString()!)!; - Faces[face.StudentId]=face with { UpdateTime="v"+Calls }; results.Add(new { updateTime="v"+Calls }); - } - return new(HttpStatusCode.OK) { Content=new StringContent(JsonSerializer.Serialize(new { writeResults=results }),Encoding.UTF8,"application/json") }; - } - } -} diff --git a/BiStudent/FaceInviteForm.cs b/BiStudent/FaceInviteForm.cs deleted file mode 100644 index 477b513..0000000 --- a/BiStudent/FaceInviteForm.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace BiStudent; - -internal sealed class FaceInviteForm : Form -{ - public FaceInviteForm(string name,string code) - { - FormStyle.Dialog(this,"Регистрация лица на сайте",new Size(660,320)); - var layout=new TableLayoutPanel { Dock=DockStyle.Fill,Padding=new Padding(24),RowCount=4 }; - foreach (var height in new[] { 50,50,80,48 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute,height)); - layout.Controls.Add(FormStyle.Caption(name),0,0); - var value=FormStyle.Input(""); value.Text=code; value.ReadOnly=true; layout.Controls.Add(value,0,1); - layout.Controls.Add(FormStyle.Caption("Передайте этот код только выбранному студенту.\nНа сайте: «Зарегистрировать лицо». Код действует 24 часа и принимает один набор снимков."),0,2); - var copy=new ModernButton { Text="Скопировать код",Primary=true,Width=220 }; - copy.Click += (_,_) => { Clipboard.SetText(code); copy.Text="Скопировано"; }; layout.Controls.Add(copy,0,3); Controls.Add(layout); - } -} diff --git a/BiStudent/FaceSync.cs b/BiStudent/FaceSync.cs deleted file mode 100644 index 648f421..0000000 --- a/BiStudent/FaceSync.cs +++ /dev/null @@ -1,76 +0,0 @@ -using OpenCvSharp; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace BiStudent; - -internal sealed record CloudFace(string StudentId, string Version, string Format, List Samples, string UpdateTime = "", string SubmissionCode = ""); -internal sealed record FaceStamp(string Hash, string UpdateTime); -internal sealed record FaceState(Dictionary Baseline, Dictionary Codes); -internal sealed class FaceSync -{ - private readonly AttendanceStore store; - private readonly CloudFirestore cloud; - private readonly string group; - private readonly string path; - private FaceState state; - public string? Error { get; private set; } - public FaceSync(AttendanceStore store, CloudFirestore cloud, string group) - { - this.store=store; this.cloud=cloud; this.group=group; path=Path.Combine(store.DataDirectory,"face-cloud-state.bin"); - try { state=JsonSerializer.Deserialize(ProtectedData.Unprotect(File.ReadAllBytes(path),null,DataProtectionScope.CurrentUser)) ?? new([],[]); } - catch { state=new([],[]); } - } - private static string Hash(List images) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(images)))); - private Dictionary> Current() - { - var students=store.Students().Where(s => s.GroupName==group && s.CloudId.Contains('/')).ToDictionary(x=>x.Id,x=>x.CloudId.Split('/').Last()); - return store.Samples().Where(x=>students.ContainsKey(x.StudentId)).GroupBy(x=>students[x.StudentId]).ToDictionary(x=>x.Key,x=>x.Select(s=>Convert.ToBase64String(s.Image)).ToList()); - } - private List Pending() => Current().Where(x=>x.Value.Count==3 && (!state.Baseline.TryGetValue(x.Key,out var stamp) || Hash(x.Value)!=stamp.Hash || state.Codes.ContainsKey(x.Key))) - .Select(x=>new CloudFace(x.Key,Hash(x.Value),"aligned112",x.Value,state.Baseline.GetValueOrDefault(x.Key)?.UpdateTime ?? "",state.Codes.GetValueOrDefault(x.Key) ?? "")).ToList(); - public bool HasChanges => Pending().Count>0; - private void SaveState() { File.WriteAllBytes(path+".tmp",ProtectedData.Protect(JsonSerializer.SerializeToUtf8Bytes(state),null,DataProtectionScope.CurrentUser)); File.Move(path+".tmp",path,true); } - public async Task ImportAsync(CloudSnapshot snapshot) - { - await Task.Run(() => { - Error=null; var pending=Pending().Select(x=>x.StudentId).ToHashSet(); - foreach (var face in snapshot.Faces) { - if (pending.Contains(face.StudentId) && state.Baseline.ContainsKey(face.StudentId)) continue; - var student=store.Students().FirstOrDefault(s=>s.CloudId.Split('/').Last()==face.StudentId && s.GroupName==group); - if (student is null) continue; - if (face.Format!="aligned112" || face.Samples.Count!=3) { Error="Некорректный облачный образец лица."; continue; } - try { - var bytes=face.Samples.Select(Convert.FromBase64String).ToList(); - foreach (var image in bytes) { using var mat=Cv2.ImDecode(image,ImreadModes.Color); if (mat.Width!=112 || mat.Height!=112) throw new InvalidOperationException("Некорректный размер образца."); } - store.ReplaceSamples(student.Id,bytes); state.Baseline[face.StudentId]=new(Hash(face.Samples),face.UpdateTime); - } catch { Error="Не удалось загрузить один из образцов лица. Остальные данные доступны."; } - } - using var engine=snapshot.FaceSubmissions.Count>0 ? new FaceEngine() : null; - foreach (var submission in snapshot.FaceSubmissions) { - if (pending.Contains(submission.StudentId)) continue; - var student=store.Students().FirstOrDefault(s=>s.CloudId.Split('/').Last()==submission.StudentId && s.GroupName==group); - if (student is null || submission.Samples.Count!=3) { Error="Отклонён некорректный набор снимков с сайта."; continue; } - try { - var aligned=new List(); - foreach (var sample in submission.Samples) { - var bytes=Convert.FromBase64String(sample); if (bytes.Length>250000) throw new InvalidOperationException("Снимок слишком большой."); - using var frame=Cv2.ImDecode(bytes,ImreadModes.Color); - if (frame.Empty() || frame.Width>1280 || frame.Height>1280) throw new InvalidOperationException("Некорректный снимок."); - aligned.Add(engine!.CaptureSample(frame,requireSingle:true)); - } - store.ReplaceSamples(student.Id,aligned); state.Codes[submission.StudentId]=submission.SubmissionCode; - } catch { Error=$"Снимки {student.Name} не прошли проверку лица. Выдайте новый код и переснимите лицо."; } - } - SaveState(); - }); - } - public async Task SaveAsync() - { - foreach (var face in Pending()) { - var saved=await cloud.SaveFaceAsync(face); - state.Baseline[face.StudentId]=new(face.Version,saved.UpdateTime); state.Codes.Remove(face.StudentId); SaveState(); - } - } -} diff --git a/BiStudent/FeatureChecks.cs b/BiStudent/FeatureChecks.cs deleted file mode 100644 index c6269f0..0000000 --- a/BiStudent/FeatureChecks.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; - -namespace BiStudent; - -internal static class FeatureChecks -{ - public static void Run(string directory) - { - var dataDirectory = Path.Combine(directory, "grade-test-" + Guid.NewGuid().ToString("N")); - var store = new AttendanceStore(dataDirectory); - var student = store.AddStudent("Тест оценок", "Т-1", []); - var other = store.AddStudent("Другой студент", "Т-1", []); - var teacher = new TeacherSession("test-uid", "teacher@example.invalid", "Тестовый преподаватель"); - var date = DateOnly.FromDateTime(DateTime.Today); - var grade = store.SaveGrade(null, student, "Математика", date, 0, "Нулевой балл", teacher); - var reopened = new AttendanceStore(dataDirectory); - if (reopened.Grades(student).Single().Score != 0) throw new InvalidOperationException("Нулевая оценка не сохранена."); - store.SaveGrade(grade, student, "Математика", date, 100, "Исправленная работа", teacher); - if (store.GradeAverages()[student] != 100 || store.Grades(student).Single().TeacherUid != teacher.Uid) - throw new InvalidOperationException("Оценка или преподаватель сохранены неверно."); - foreach (var invalid in new[] { -1, 101 }) - { - var rejected = false; - try { store.SaveGrade(null, student, "Математика", date, invalid, "", teacher); } - catch (ArgumentOutOfRangeException) { rejected = true; } - if (!rejected) throw new InvalidOperationException("Принята оценка за пределами 0–100."); - } - var mismatched = false; - try { store.SaveGrade(grade, other, "Математика", date, 50, "", teacher); } - catch (InvalidOperationException) { mismatched = true; } - if (!mismatched || store.Grades(student).Single().Score != 100) throw new InvalidOperationException("Изменена оценка другого студента."); - store.DeleteGrade(grade, student); - if (store.Grades(student).Count != 0) throw new InvalidOperationException("Оценка не удалена."); - store.SaveGrade(null, student, "Математика", date, 80, "", teacher); - store.DeleteStudent(student); - if (store.Grades(student).Count != 0) throw new InvalidOperationException("Оценки удалённого студента остались в базе."); - CheckAuthAsync().GetAwaiter().GetResult(); - CacheChecks.RunAsync(directory).GetAwaiter().GetResult(); - AttendanceChecks.RunAsync(directory).GetAwaiter().GetResult(); - DeviceFaceChecks.RunAsync(directory).GetAwaiter().GetResult(); - CheckStarterData(directory); - } - - private static void CheckStarterData(string directory) - { - var data = StarterData.Load(Path.Combine(AppContext.BaseDirectory, "starter-data.json")); - var store = new AttendanceStore(Path.Combine(directory, "import-test-" + Guid.NewGuid().ToString("N"))); - var first = data.Students.First(); - var studentId = store.AddStudent(first.Name, first.GroupName, []); - var slot = data.Lessons.First(x => x.Parity == 0 && x.GroupName == first.GroupName); - store.AddLesson(slot.Subject, slot.GroupName, slot.Day, slot.Start, slot.End); - var lessonId = store.Lessons().Single().Id; - var day = DateOnly.FromDateTime(DateTime.Today); - store.MarkPresent(lessonId, day, studentId, "Тест"); - store.SaveGrade(null, studentId, slot.Subject, day, 75, "До импорта", new TeacherSession("test", "test@example.invalid", "Тест")); - var result = store.ImportStarterData(data); - if (result.Students != data.Students.Count - 1 || result.Lessons != data.Lessons.Count - 1 || store.Students().Count != data.Students.Count || store.Lessons().Count != data.Lessons.Count) - throw new InvalidOperationException("Импорт потерял данные или создал дубликаты."); - if (store.Grades(studentId).Single().Score != 75 || store.Attendance(lessonId, day, first.GroupName).Single(x => x.StudentId == studentId).SeenAt is null) - throw new InvalidOperationException("Импорт изменил оценки или посещаемость."); - if (store.ImportStarterData(data) != (0, 0)) throw new InvalidOperationException("Повторный импорт создал записи."); - foreach (var lesson in store.Lessons().Where(x => x.Parity != 0)) - { - var start = data.SemesterStart; - var weekday = start.AddDays(((int)lesson.Day - (int)start.DayOfWeek + 7) % 7); - if (lesson.OccursOn(weekday) == lesson.OccursOn(weekday.AddDays(7)) || lesson.OccursOn(weekday) != (lesson.Parity == 1)) - throw new InvalidOperationException("Чётность недели импортирована неверно."); - } - foreach (var cancellation in data.Cancellations) - { - var source = data.Lessons.Single(x => x.SourceId == cancellation.SourceId); - if (store.LessonsOn(cancellation.Date).Any(x => x.Subject == source.Subject && x.GroupName == source.GroupName && x.Start == source.Start)) - throw new InvalidOperationException("Импортированная отмена занятия не учитывается."); - } - } - - private static async Task CheckAuthAsync() - { - var settings = new FirebaseSettings { ApiKey = "test-key", TeacherUid = "allowed-uid", TeacherEmail = "teacher@example.invalid", TeacherName = "Тест" }; - foreach (var scenario in new[] { "success", "other-user", "wrong-password" }) - { - using var http = new HttpClient(new AuthHandler(scenario)); - var accepted = false; - try - { - var session = await new FirebaseAuth(settings, http).SignInAsync("test-password").ConfigureAwait(false); - accepted = session.Uid == settings.TeacherUid; - } - catch (InvalidOperationException) { } - if (accepted != (scenario == "success")) throw new InvalidOperationException("Проверка доступа Firebase не прошла: " + scenario); - } - } - - private sealed class AuthHandler(string scenario) : HttpMessageHandler - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (request.Method != HttpMethod.Post || request.RequestUri?.AbsolutePath != "/v1/accounts:signInWithPassword") - throw new InvalidOperationException("Неверный запрос Firebase."); - using var payload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); - if (payload.RootElement.GetProperty("email").GetString() != "teacher@example.invalid" || payload.RootElement.GetProperty("password").GetString() != "test-password") - throw new InvalidOperationException("Неверные данные входа."); - var body = scenario == "wrong-password" ? "{\"error\":{\"message\":\"INVALID_LOGIN_CREDENTIALS\"}}" - : JsonSerializer.Serialize(new { localId = scenario == "success" ? "allowed-uid" : "other-uid", email = "teacher@example.invalid" }); - return new HttpResponseMessage(scenario == "wrong-password" ? HttpStatusCode.BadRequest : HttpStatusCode.OK) - { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - } - } -} diff --git a/BiStudent/FirebaseAuth.cs b/BiStudent/FirebaseAuth.cs deleted file mode 100644 index 33b3821..0000000 --- a/BiStudent/FirebaseAuth.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; - -namespace BiStudent; - -internal sealed record TeacherSession(string Uid, string Email, string Name, string IdToken = "", string RefreshToken = "", DateTimeOffset ExpiresAt = default) -{ - public override string ToString() => Name + " · " + Email; -} - -internal sealed class FirebaseSettings -{ - public string ApiKey { get; set; } = ""; - public string ProjectId { get; set; } = ""; - public string TeacherEmail { get; set; } = ""; - public string TeacherUid { get; set; } = ""; - public string PreviousTeacherUid { get; set; } = ""; - public string TeacherName { get; set; } = "Ниязова Мадина Ержанкызы"; - public bool UseCloudData { get; set; } - public string GroupId { get; set; } = "23-po-103-4r-2"; - public string GroupName { get; set; } = "23-ПО-103-4р-2"; - - public static FirebaseSettings Load() => JsonSerializer.Deserialize( - File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "firebase.json")), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? throw new InvalidOperationException("Не найден конфигурационный файл Firebase."); -} - -internal sealed class FirebaseAuth(FirebaseSettings settings, HttpClient http) -{ - public async Task RestoreAsync(string refreshToken, CancellationToken cancellationToken = default) - { - using var response = await http.PostAsync("https://securetoken.googleapis.com/v1/token?key=" + Uri.EscapeDataString(settings.ApiKey), new FormUrlEncodedContent(new Dictionary { ["grant_type"]="refresh_token", ["refresh_token"]=refreshToken }),cancellationToken); - if (!response.IsSuccessStatusCode) throw new InvalidOperationException("Сохранённый вход завершён. Введите пароль заново."); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var root = json.RootElement; - if (root.GetProperty("user_id").GetString() != settings.TeacherUid) throw new InvalidOperationException("У сохранённой учётной записи нет доступа."); - return new TeacherSession(settings.TeacherUid,settings.TeacherEmail,settings.TeacherName,root.GetProperty("id_token").GetString()!,root.GetProperty("refresh_token").GetString()!,DateTimeOffset.UtcNow.AddSeconds(int.Parse(root.GetProperty("expires_in").GetString()!))); - } - public async Task SignInAsync(string password, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(settings.TeacherUid) || string.IsNullOrWhiteSpace(settings.ApiKey)) - throw new InvalidOperationException("Учётная запись преподавателя ещё не настроена."); - if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("Введите пароль."); - using var response = await http.PostAsJsonAsync( - "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=" + Uri.EscapeDataString(settings.ApiKey), - new { email = settings.TeacherEmail, password, returnSecureToken = true }, cancellationToken).ConfigureAwait(false); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); - if (!response.IsSuccessStatusCode) - { - var code = json.RootElement.TryGetProperty("error", out var error) && error.TryGetProperty("message", out var message) - ? message.GetString() ?? "" : ""; - throw new InvalidOperationException(code switch - { - "INVALID_LOGIN_CREDENTIALS" or "INVALID_PASSWORD" or "EMAIL_NOT_FOUND" => "Неверный пароль.", - "USER_DISABLED" => "Учётная запись отключена в Firebase.", - "OPERATION_NOT_ALLOWED" or "CONFIGURATION_NOT_FOUND" => "В Firebase нужно включить вход Email/Password.", - "TOO_MANY_ATTEMPTS_TRY_LATER" => "Слишком много попыток. Попробуйте позже.", - _ => "Firebase не смог выполнить вход. Проверьте настройки проекта." - }); - } - var uid = json.RootElement.GetProperty("localId").GetString(); - var email = json.RootElement.GetProperty("email").GetString(); - if (uid != settings.TeacherUid || !string.Equals(email, settings.TeacherEmail, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("У этой учётной записи нет доступа к BiStudent."); - var root = json.RootElement; - return new TeacherSession(uid!, email!, settings.TeacherName, - root.TryGetProperty("idToken", out var token) ? token.GetString() ?? "" : "", - root.TryGetProperty("refreshToken", out var refresh) ? refresh.GetString() ?? "" : "", - DateTimeOffset.UtcNow.AddSeconds(root.TryGetProperty("expiresIn", out var expires) && int.TryParse(expires.GetString(), out var seconds) ? seconds : 3600)); - } -} diff --git a/BiStudent/GradesForm.cs b/BiStudent/GradesForm.cs deleted file mode 100644 index 5fdac0f..0000000 --- a/BiStudent/GradesForm.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Globalization; - -namespace BiStudent; - -internal sealed class GradesForm : Form -{ - private readonly AttendanceStore store; - private readonly Student student; - private readonly TeacherSession teacher; - private readonly TextBox subject = FormStyle.Input("Например, Математика"); - private readonly TextBox date = FormStyle.Input("ГГГГ-ММ-ДД"); - private readonly TextBox score = FormStyle.Input("0–100"); - private readonly TextBox comment = FormStyle.Input("Тема работы или комментарий"); - private readonly DataGridView table = MainForm.Grid(); - private readonly Label summary = FormStyle.Caption(""); - private readonly Label feedback = FormStyle.Caption(""); - private readonly ModernButton save = new() { Text = "Добавить оценку", Primary = true, Dock = DockStyle.Fill }; - private int? editingId; - private List rows = []; - - public GradesForm(AttendanceStore store, Student student, TeacherSession teacher) - { - this.store = store; this.student = student; this.teacher = teacher; - FormStyle.Dialog(this, "BiStudent · Оценки", new Size(1060, 740)); - FormBorderStyle = FormBorderStyle.Sizable; MaximizeBox = true; DoubleBuffered = true; - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, Padding = new Padding(24), BackColor = Theme.Background }; - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 70)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); - var title = FormStyle.Caption(student.Name + " · " + student.GroupName); title.Font = Theme.Font(20, FontStyle.Bold); title.ForeColor = Theme.Text; title.TextAlign = ContentAlignment.MiddleLeft; - layout.Controls.Add(title, 0, 0); - var body = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, BackColor = Theme.Background }; - body.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 300)); body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - var entry = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(0, 0, 16, 0), Padding = new Padding(16) }; - var fields = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 12, BackColor = Theme.Surface }; - foreach (var height in new[] { 22, 36, 22, 36, 22, 36, 22, 65, 46, 46, 50 }) fields.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); - fields.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - fields.Controls.Add(FormStyle.Caption("ПРЕДМЕТ"), 0, 0); fields.Controls.Add(subject, 0, 1); - fields.Controls.Add(FormStyle.Caption("ДАТА · ГГГГ-ММ-ДД"), 0, 2); fields.Controls.Add(date, 0, 3); - fields.Controls.Add(FormStyle.Caption("ОЦЕНКА · ОТ 0 ДО 100"), 0, 4); fields.Controls.Add(score, 0, 5); - fields.Controls.Add(FormStyle.Caption("КОММЕНТАРИЙ"), 0, 6); comment.Multiline = true; comment.MaxLength = 500; fields.Controls.Add(comment, 0, 7); - subject.MaxLength = 120; subject.AutoCompleteMode = AutoCompleteMode.SuggestAppend; subject.AutoCompleteSource = AutoCompleteSource.CustomSource; - subject.AutoCompleteCustomSource.AddRange(store.Lessons().Select(x => x.Subject).Distinct().ToArray()); - fields.Controls.Add(save, 0, 8); - var reset = new ModernButton { Text = "Новая оценка", Dock = DockStyle.Fill }; reset.Click += (_, _) => ResetEntry(); fields.Controls.Add(reset, 0, 9); - feedback.ForeColor = Theme.Red; feedback.TextAlign = ContentAlignment.MiddleLeft; fields.Controls.Add(feedback, 0, 10); - entry.Controls.Add(fields); body.Controls.Add(entry, 0, 0); - var history = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, BackColor = Theme.Background }; - history.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); history.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); history.RowStyles.Add(new RowStyle(SizeType.Absolute, 56)); - summary.TextAlign = ContentAlignment.MiddleLeft; summary.Font = Theme.Font(11, FontStyle.Bold); summary.ForeColor = Theme.Text; history.Controls.Add(summary, 0, 0); - var card = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(4) }; card.Controls.Add(table); history.Controls.Add(card, 0, 1); - var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false }; - var edit = new ModernButton { Text = "Изменить", Width = 140 }; edit.Click += (_, _) => EditSelected(); - var delete = new ModernButton { Text = "Удалить оценку", Width = 160 }; delete.Click += (_, _) => DeleteSelected(); - actions.Controls.Add(edit); actions.Controls.Add(delete); history.Controls.Add(actions, 0, 2); - body.Controls.Add(history, 1, 0); layout.Controls.Add(body, 0, 1); - var hint = FormStyle.Caption("Преподаватель: " + teacher.Name + " · Оценки сохраняются на этом компьютере."); hint.Font = Theme.Font(9); layout.Controls.Add(hint, 0, 2); - Controls.Add(layout); save.Click += (_, _) => Save(); table.CellDoubleClick += (_, e) => { if (e.RowIndex >= 0) EditSelected(); }; - ResetEntry(); RefreshRows(); - } - - private void ResetEntry() - { - editingId = null; save.Text = "Добавить оценку"; date.Text = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - score.Clear(); comment.Clear(); feedback.Text = ""; - } - - private void RefreshRows() - { - rows = store.Grades(student.Id); - table.DataSource = rows.Select(x => new { x.Id, Дата = x.Date.ToString("dd.MM.yyyy"), Предмет = x.Subject, Оценка = x.Score, Комментарий = x.Comment }).ToList(); - if (table.Columns["Id"] is { } id) id.Visible = false; - if (table.Columns["Оценка"] is { } grade) grade.FillWeight = 55; - summary.Text = rows.Count == 0 ? "Оценок пока нет" : $"Оценок: {rows.Count} · Средний балл: {rows.Average(x => x.Score):0.0} / 100"; - } - - private GradeRow? Selected() => table.CurrentRow?.Cells["Id"].Value is int id ? rows.FirstOrDefault(x => x.Id == id) : null; - - private void EditSelected() - { - if (Selected() is not { } row) return; - editingId = row.Id; subject.Text = row.Subject; date.Text = row.Date.ToString("yyyy-MM-dd"); score.Text = row.Score.ToString(CultureInfo.InvariantCulture); comment.Text = row.Comment; - save.Text = "Сохранить изменения"; feedback.Text = ""; - } - - private void Save() - { - if (!DateOnly.TryParseExact(date.Text.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) { feedback.Text = "Дата должна быть в формате ГГГГ-ММ-ДД."; return; } - if (!int.TryParse(score.Text.Trim(), out var value) || value is < 0 or > 100) { feedback.Text = "Введите целое число от 0 до 100."; return; } - try { store.SaveGrade(editingId, student.Id, subject.Text, day, value, comment.Text, teacher); ResetEntry(); RefreshRows(); } - catch (Exception ex) { feedback.Text = ex is ArgumentException or InvalidOperationException ? ex.Message : "Не удалось сохранить оценку."; } - } - - private void DeleteSelected() - { - if (Selected() is not { } row) return; - if (MessageBox.Show(this, $"Удалить оценку {row.Score} по предмету «{row.Subject}»?", "Оценки", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; - try { store.DeleteGrade(row.Id, student.Id); ResetEntry(); RefreshRows(); } - catch { feedback.Text = "Не удалось удалить оценку."; } - } -} diff --git a/BiStudent/InputForms.cs b/BiStudent/InputForms.cs deleted file mode 100644 index d73115c..0000000 --- a/BiStudent/InputForms.cs +++ /dev/null @@ -1,261 +0,0 @@ -using OpenCvSharp; -using OpenCvSharp.Extensions; -using System.Globalization; - -namespace BiStudent; - -internal static class FormStyle -{ - public static TextBox Input(string placeholder) => new() - { - PlaceholderText = placeholder, BorderStyle = BorderStyle.FixedSingle, BackColor = Theme.SurfaceHover, - ForeColor = Theme.Text, Font = Theme.Font(11), Dock = DockStyle.Fill, Margin = new Padding(0, 4, 0, 8) - }; - - public static Label Caption(string text) => new() - { - Text = text, ForeColor = Theme.Muted, Font = Theme.Font(9, FontStyle.Bold), - Dock = DockStyle.Fill, TextAlign = ContentAlignment.BottomLeft - }; - - public static void Dialog(Form form, string title, System.Drawing.Size size) - { - form.Text = title; - form.Size = size; - form.MinimumSize = size; - form.StartPosition = FormStartPosition.CenterParent; - form.FormBorderStyle = FormBorderStyle.FixedDialog; - form.MaximizeBox = false; - form.MinimizeBox = false; - form.BackColor = Theme.Background; - form.ForeColor = Theme.Text; - form.Font = Theme.Font(10); - } -} - -internal sealed class EnrollmentForm : Form -{ - private readonly AttendanceStore store; - private readonly Student? existing; - private readonly TextBox nameBox = FormStyle.Input("Фамилия и имя"); - private readonly TextBox groupBox = FormStyle.Input("Например, ИС-21"); - private readonly PictureBox preview = new() { Dock = DockStyle.Fill, BackColor = Theme.Sidebar, SizeMode = PictureBoxSizeMode.Zoom }; - private readonly Label feedback = FormStyle.Caption("Сделайте три снимка: прямо, немного влево и немного вправо."); - private readonly List samples = new(); - private VideoCapture? capture; - private FaceEngine? engine; - private readonly System.Windows.Forms.Timer timer = new() { Interval = 110 }; - private Mat? latestFrame; - private OpenCvSharp.Rect? latestFace; - private readonly CameraLoader loader = new(); - private readonly CancellationTokenSource cancellation = new(); - private Task? opening; - private bool closing; - private bool closeReady; - private readonly ModernButton captureButton = new() { Text = "Снять образец", Width = 156, Primary = true, Enabled = false }; - private readonly ModernButton save = new() { Text = "Сохранить", Width = 130, Enabled = false }; - - public EnrollmentForm(AttendanceStore store, Student? existing = null) - { - this.store = store; - this.existing = existing; - FormStyle.Dialog(this, existing is null ? "Регистрация студента" : "Обновление лица", new System.Drawing.Size(780, 710)); - if (existing is not null) - { - nameBox.Text = existing.Name; groupBox.Text = existing.GroupName; - nameBox.ReadOnly = true; groupBox.ReadOnly = true; - } - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), RowCount = 7, BackColor = Theme.Background }; - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 96)); - layout.Controls.Add(new Label { Text = existing is null ? "Новый студент" : "Обновить образцы лица", Dock = DockStyle.Fill, ForeColor = Theme.Text, Font = Theme.Font(20, FontStyle.Bold) }, 0, 0); - layout.Controls.Add(FormStyle.Caption("ФИО"), 0, 1); layout.Controls.Add(nameBox, 0, 2); - layout.Controls.Add(FormStyle.Caption("ГРУППА"), 0, 3); layout.Controls.Add(groupBox, 0, 4); - var video = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(6) }; video.Controls.Add(preview); preview.Controls.Add(loader); loader.BringToFront(); layout.Controls.Add(video, 0, 5); - var bottom = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1, BackColor = Theme.Background }; - bottom.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); bottom.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - feedback.Dock = DockStyle.Fill; bottom.Controls.Add(feedback, 0, 0); - var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, BackColor = Theme.Background }; - var cancel = new ModernButton { Text = "Отмена", Width = 110 }; - var cameraChoice = new ModernButton { Text = "Выбор камеры",Width=145 }; - cameraChoice.Click += async (_,_) => { - using var choice = new CameraSelectionForm(); if (choice.ShowDialog(this)!=DialogResult.OK || closing) return; - cameraChoice.Enabled=false; timer.Stop(); captureButton.Enabled=save.Enabled=false; - loader.Text="Переключаем камеру…"; loader.Visible=true; - if (opening is not null) await opening; - while (reading || processingSample) await Task.Delay(20); - if (closing) return; - capture?.Dispose(); capture=null; engine?.Dispose(); engine=null; latestFrame?.Dispose(); latestFrame=null; latestFace=null; - var old=preview.Image; preview.Image=null; old?.Dispose(); - opening=OpenCameraAsync(); await opening; - if (!closing) { cameraChoice.Enabled=true; save.Enabled=samples.Count==3; } - }; - captureButton.Click += async (_, _) => await CaptureSampleAsync(); - save.Click += (_, _) => Save(); - cancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; - actions.Controls.Add(captureButton); actions.Controls.Add(save); actions.Controls.Add(cancel); actions.Controls.Add(cameraChoice); - bottom.Controls.Add(actions, 0, 1); layout.Controls.Add(bottom, 0, 6); - Controls.Add(layout); - timer.Tick += (_, _) => ReadFrame(); - DoubleBuffered = true; - Shown += async (_, _) => { opening = OpenCameraAsync(); await opening; }; - } - - private async Task OpenCameraAsync() - { - try - { - var resources = await Task.Run(() => { - var device = CameraDevice.Open(cancellation.Token); - try { return (Device: device, Engine: new FaceEngine()); } - catch { device.Dispose(); throw; } - }); - capture = resources.Device; engine = resources.Engine; - if (closing) return; - timer.Start(); - } - catch (OperationCanceledException) { } - catch (Exception ex) { if (!closing) { feedback.Text = ex.Message; loader.Text = ex.Message; loader.Invalidate(); } } - } - - private bool reading; - private async void ReadFrame() - { - if (capture is null || engine is null || reading || closing) return; - reading = true; - try { - using var frame = new Mat(); - var faces = await Task.Run(() => { if (!capture.Read(frame) || frame.Empty()) return null; return engine.Detect(frame); }); - if (closing || faces is null) return; - latestFace = faces.Count == 1 ? faces[0] : null; - latestFrame?.Dispose(); latestFrame = frame.Clone(); - var bitmap = BitmapConverter.ToBitmap(frame); - using (var g = Graphics.FromImage(bitmap)) - using (var pen = new Pen(faces.Count == 1 ? Theme.Green : Theme.Red, 3)) - foreach (var face in faces) g.DrawRectangle(pen, face.X, face.Y, face.Width, face.Height); - var old = preview.Image; preview.Image = bitmap; old?.Dispose(); - loader.Visible = false; captureButton.Enabled = samples.Count < 3; - } - catch (Exception ex) { if (!closing) feedback.Text = ex.Message; } - finally { reading = false; } - } - - private bool processingSample; - private async Task CaptureSampleAsync() - { - if (processingSample || closing) return; - timer.Stop(); - while (reading) await Task.Delay(20); - if (closing) return; - if (latestFace is not { } face || latestFrame is null) - { feedback.Text = "Для снимка в кадре должно быть ровно одно хорошо видимое лицо."; timer.Start(); return; } - if (samples.Count >= 3) { feedback.Text = "Три образца уже получены. Сохраните студента."; timer.Start(); return; } - processingSample = true; captureButton.Enabled = save.Enabled = false; - loader.Text = "Обработка образца лица…"; loader.Visible = true; - using var frame = latestFrame.Clone(); - try { var sample = await Task.Run(() => engine!.CaptureSample(frame, requireSingle: true)); if (closing) return; samples.Add(sample); } - catch (Exception ex) { if (!closing) feedback.Text = ex.Message; return; } - finally { processingSample = false; if (!closing) { loader.Visible = false; captureButton.Enabled = samples.Count < 3; save.Enabled = samples.Count == 3; timer.Start(); } } - feedback.Text = samples.Count switch - { - 1 => "1/3 · Теперь немного поверните голову влево.", - 2 => "2/3 · Теперь немного поверните голову вправо.", - _ => "3/3 · Готово, сохраните образцы." - }; - save.Enabled = samples.Count == 3; - } - - private void Save() - { - if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(groupBox.Text)) - { feedback.Text = "Введите ФИО и группу."; return; } - if (samples.Count < 3) { feedback.Text = "Снимите три образца лица."; return; } - if (existing is null) store.AddStudent(nameBox.Text, groupBox.Text, samples); - else store.ReplaceSamples(existing.Id, samples); - DialogResult = DialogResult.OK; - Close(); - } - - protected override async void OnFormClosing(FormClosingEventArgs e) - { - if (closing && !closeReady) { e.Cancel = true; return; } - if (!closing) { - e.Cancel = true; closing = true; cancellation.Cancel(); timer.Stop(); - captureButton.Enabled = save.Enabled = false; loader.Text = "Остановка камеры…"; loader.Visible = true; - if (opening is not null) await opening; - while (reading || processingSample) await Task.Delay(30); - closeReady = true; - Close(); return; - } - base.OnFormClosing(e); - } - - protected override void OnFormClosed(FormClosedEventArgs e) - { - timer.Stop(); timer.Dispose(); capture?.Dispose(); engine?.Dispose(); latestFrame?.Dispose(); preview.Image?.Dispose(); - cancellation.Dispose(); - base.OnFormClosed(e); - } -} - -internal sealed class LessonForm : Form -{ - private readonly AttendanceStore store; - private readonly ComboBox subject = Choice(); - private readonly ComboBox group = Choice(); - private readonly ComboBox day = Choice(); - private readonly TextBox start = FormStyle.Input("09:00"); - private readonly TextBox end = FormStyle.Input("10:00"); - - private static ComboBox Choice() - { - var box = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill, BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Font = Theme.Font(11), DrawMode = DrawMode.OwnerDrawFixed, ItemHeight = 26 }; - box.DrawItem += (_, e) => { - using var brush = new SolidBrush((e.State & DrawItemState.Selected) != 0 ? Theme.SurfaceHover : Theme.Surface); - e.Graphics.FillRectangle(brush,e.Bounds); - if (e.Index >= 0) TextRenderer.DrawText(e.Graphics, box.Items[e.Index]?.ToString() ?? "",box.Font,new Rectangle(e.Bounds.X+6,e.Bounds.Y,e.Bounds.Width-6,e.Bounds.Height),Theme.Text,TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); - }; - return box; - } - - public LessonForm(AttendanceStore store, CloudSnapshot? snapshot = null) - { - this.store = store; - FormStyle.Dialog(this, "Новое занятие", new System.Drawing.Size(740, 490)); - subject.Items.AddRange((snapshot?.Subjects.Select(s => s.Name) ?? new[] { "Программирование на языке высокого уровня", "Программирование C#", "Искусственный интеллект OpenCV", "База данных PL SQL/MongoDB", "Автоматизация DevOps", "Основы экономики и предпринимательской деятельности" }).Cast().ToArray()); - subject.SelectedIndex = 0; - group.Items.AddRange((snapshot is null ? store.Students().Select(s => s.GroupName).Distinct() : new[] { snapshot.GroupName }).Cast().ToArray()); - if (group.Items.Count > 0) group.SelectedIndex = 0; - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), RowCount = 11, BackColor = Theme.Background }; - for (var i = 0; i < 10; i++) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, i % 2 == 0 ? 26 : 40)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - day.Items.AddRange(["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]); - day.SelectedIndex = ((int)DateTime.Today.DayOfWeek + 6) % 7; - start.Text = "09:00"; end.Text = "10:00"; - layout.Controls.Add(FormStyle.Caption("ПРЕДМЕТ"), 0, 0); layout.Controls.Add(subject, 0, 1); - layout.Controls.Add(FormStyle.Caption("ГРУППА"), 0, 2); layout.Controls.Add(group, 0, 3); - layout.Controls.Add(FormStyle.Caption("ДЕНЬ НЕДЕЛИ"), 0, 4); layout.Controls.Add(day, 0, 5); - layout.Controls.Add(FormStyle.Caption("НАЧАЛО"), 0, 6); layout.Controls.Add(start, 0, 7); - layout.Controls.Add(FormStyle.Caption("КОНЕЦ"), 0, 8); layout.Controls.Add(end, 0, 9); - var save = new ModernButton { Text = "Сохранить занятие", Primary = true, Width = 190 }; - save.Click += (_, _) => Save(); - layout.Controls.Add(save, 0, 10); - Controls.Add(layout); - } - - private void Save() - { - var validStart = TimeSpan.TryParseExact(start.Text.Trim(), @"hh\:mm", CultureInfo.InvariantCulture, out var startTime); - var validEnd = TimeSpan.TryParseExact(end.Text.Trim(), @"hh\:mm", CultureInfo.InvariantCulture, out var endTime); - if (string.IsNullOrWhiteSpace(subject.Text) || string.IsNullOrWhiteSpace(group.Text) || !validStart || !validEnd || endTime <= startTime) - { MessageBox.Show(this, "Укажите предмет, группу и корректное время."); return; } - var weekday = (DayOfWeek)((day.SelectedIndex + 1) % 7); - store.AddLesson(subject.Text, group.Text, weekday, startTime, endTime); - DialogResult = DialogResult.OK; Close(); - } -} diff --git a/BiStudent/LoadingForm.cs b/BiStudent/LoadingForm.cs deleted file mode 100644 index f5770a4..0000000 --- a/BiStudent/LoadingForm.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace BiStudent; - -internal sealed class LoadingForm : Form -{ - public LoadingForm(CloudWorkspace workspace, Func? afterLoad = null) - { - FormStyle.Dialog(this, "BiStudent · Загрузка", new Size(520, 240)); - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), ColumnCount = 1, RowCount = 3 }; - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 12)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); - var label = FormStyle.Caption("Загрузка студентов, предметов и оценок…"); label.Font = Theme.Font(13); layout.Controls.Add(label, 0, 0); - var progress = new ProgressBar { Dock = DockStyle.Fill, Style = ProgressBarStyle.Marquee }; layout.Controls.Add(progress, 0, 1); - var retry = new ModernButton { Text = "Повторить загрузку", Dock = DockStyle.Fill, Visible = false }; layout.Controls.Add(retry, 0, 2); Controls.Add(layout); - async Task Load() - { - retry.Visible = false; progress.Visible = true; - try { await workspace.LoadAsync(); if (afterLoad is not null) { label.Text="Синхронизация образцов лиц…"; await afterLoad(); } if (!IsDisposed) { DialogResult = DialogResult.OK; Close(); } } - catch (Exception ex) { if (!IsDisposed) { label.Text = ex is InvalidOperationException ? ex.Message : "Не удалось загрузить данные. Проверьте интернет."; retry.Visible = true; progress.Visible = false; } } - } - Shown += async (_, _) => await Load(); retry.Click += async (_, _) => await Load(); - } -} - -internal sealed class AutoSaveSettingsForm : Form -{ - public int Seconds { get; private set; } - public AutoSaveSettingsForm(int seconds) - { - FormStyle.Dialog(this, "Настройки автосохранения", new Size(480, 260)); - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), ColumnCount = 1, RowCount = 3 }; - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 52)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - layout.Controls.Add(FormStyle.Caption("Интервал автосохранения · от 1 до 3600 секунд"), 0, 0); - var value = FormStyle.Input("Секунды"); value.Text = seconds.ToString(); layout.Controls.Add(value, 0, 1); - var save = new ModernButton { Text = "Применить", Primary = true, Dock = DockStyle.Fill }; - save.Click += (_, _) => { if (!int.TryParse(value.Text, out var parsed) || parsed is < 1 or > 3600) { MessageBox.Show(this, "Введите число от 1 до 3600."); return; } Seconds = parsed; DialogResult = DialogResult.OK; Close(); }; - layout.Controls.Add(save, 0, 2); Controls.Add(layout); - } -} diff --git a/BiStudent/LoginForm.cs b/BiStudent/LoginForm.cs index d3d9688..6474d8f 100644 --- a/BiStudent/LoginForm.cs +++ b/BiStudent/LoginForm.cs @@ -1,85 +1,113 @@ -using System.Net.Http; +using System.Net; namespace BiStudent; -internal sealed class LoginForm : Form +internal sealed class LoginForm : DarkForm { - private readonly TextBox password = FormStyle.Input("Введите пароль"); - private readonly Label feedback = FormStyle.Caption(""); - private readonly ModernButton signIn = new() { Text = "Войти", Primary = true, Dock = DockStyle.Fill }; - private readonly CancellationTokenSource cancellation = new(); - private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(20) }; - private readonly FirebaseSettings? settings; - private bool busy; - private bool resourcesDisposed; - public TeacherSession? Session { get; private set; } - private readonly CheckBox remember = new() { Text = "Запомнить устройство", Checked = true, AutoSize = true, FlatStyle = FlatStyle.Flat, ForeColor = Theme.Muted, Font = Theme.Font(9) }; + private readonly ClientSettings settings = ClientSettings.Load(); + private readonly TextBox server = Ui.Input("http://localhost:5080"); + private readonly TextBox login = Ui.Input("Логин"); + private readonly TextBox password = Ui.Input("Пароль"); + private readonly CheckBox remember = new() { Text = "Запомнить меня на этом компьютере", ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Dock = DockStyle.Fill }; + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton button = new() { Text = "Войти", Primary = true, Height = 44 }; + private bool signingIn; + public ApiClient? Api { get; private set; } - public LoginForm(bool restore = true) + public LoginForm(string? initialError = null) { - FormStyle.Dialog(this, "BiStudent · Вход", new Size(510, 540)); - StartPosition = FormStartPosition.CenterScreen; - DoubleBuffered = true; - string? configurationError = null; - try { settings = FirebaseSettings.Load(); } - catch { configurationError = "Не удалось прочитать firebase.json рядом с приложением."; } - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, Padding = new Padding(36), BackColor = Theme.Background }; - foreach (var height in new[] { 58, 44, 40, 26, 42, 28, 48, 58 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - var brand = FormStyle.Caption("BiStudent"); brand.Font = Theme.Font(25, FontStyle.Bold); brand.ForeColor = Theme.Text; - layout.Controls.Add(brand, 0, 0); - var name = FormStyle.Caption(settings?.TeacherName ?? "Ниязова Мадина Ержанкызы"); name.Font = Theme.Font(13, FontStyle.Bold); name.ForeColor = Theme.Text; - layout.Controls.Add(name, 0, 1); - var email = FormStyle.Caption(settings?.TeacherEmail ?? ""); email.Font = Theme.Font(10); layout.Controls.Add(email, 0, 2); - layout.Controls.Add(FormStyle.Caption("ПАРОЛЬ"), 0, 3); - password.UseSystemPasswordChar = true; layout.Controls.Add(password, 0, 4); - var reveal = new CheckBox { Text = "Показать пароль", Dock = DockStyle.Fill, FlatStyle = FlatStyle.Flat, ForeColor = Theme.Muted, Font = Theme.Font(9) }; - reveal.CheckedChanged += (_, _) => password.UseSystemPasswordChar = !reveal.Checked; - var options = new FlowLayoutPanel { Dock = DockStyle.Fill, WrapContents = false }; reveal.Dock = DockStyle.None; reveal.AutoSize = true; - options.Controls.Add(reveal); options.Controls.Add(remember); layout.Controls.Add(options, 0, 5); - layout.Controls.Add(signIn, 0, 6); - feedback.ForeColor = Theme.Red; feedback.TextAlign = ContentAlignment.MiddleLeft; feedback.Text = configurationError ?? ""; - layout.Controls.Add(feedback, 0, 7); - var hint = FormStyle.Caption("Вход через Firebase Authentication\nДля входа требуется интернет."); hint.Font = Theme.Font(9); - layout.Controls.Add(hint, 0, 8); - Controls.Add(layout); - signIn.Enabled = settings is not null; - signIn.Click += async (_, _) => await SignInAsync(); - password.KeyDown += async (_, e) => { if (e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await SignInAsync(); } }; - Shown += async (_, _) => { - password.Focus(); - if (!restore || settings is null || RememberedSession.Load(settings) is not string refresh) return; - busy = true; signIn.Enabled = password.Enabled = false; feedback.Text = "Восстанавливаем вход на этом устройстве…"; feedback.ForeColor = Theme.Muted; - try { var session = await new FirebaseAuth(settings,http).RestoreAsync(refresh,cancellation.Token); if (IsDisposed || cancellation.IsCancellationRequested) return; RememberedSession.Save(settings,session); Session = session; DialogResult = DialogResult.OK; Close(); } - catch (InvalidOperationException ex) { RememberedSession.Forget(); if (!IsDisposed) ShowError(ex.Message); } - catch (Exception) { if (!IsDisposed && !cancellation.IsCancellationRequested) ShowError("Не удалось восстановить вход. Проверьте интернет или введите пароль."); } - finally { busy = false; if (!IsDisposed) signIn.Enabled = password.Enabled = true; } - }; + Text = "BiStudent · Вход"; ClientSize = new Size(920, 560); + FormBorderStyle = FormBorderStyle.FixedSingle; MaximizeBox = false; MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; AutoScaleMode = AutoScaleMode.Dpi; + BackColor = Theme.Background; ForeColor = Theme.Text; Font = Theme.Font(10); + Build(); + server.Text = settings.ServerUrl; + login.Text = settings.LastLogin; + remember.Checked = settings.RememberMe; + error.Text = initialError ?? ""; + password.UseSystemPasswordChar = true; + AcceptButton = button; + button.Click += async (_, _) => await SignInAsync(); + } + + private void Build() + { + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, BackColor = Theme.Background }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 54)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 46)); + var left = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(36, 28, 16, 28), Padding = new Padding(28) }; + var form = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 11, BackColor = Theme.Surface }; + foreach (var height in new[] { 48, 45, 22, 38, 22, 38, 22, 38, 36, 50, 44 }) form.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + var logo = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; + var mark = new Label { Text = "✓", Width = 42, Height = 42, BackColor = Theme.Accent, ForeColor = Theme.Text, Font = Theme.Font(20, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter }; + var brand = new Label { Text = "BiStudent", Width = 240, Height = 42, ForeColor = Theme.Text, Font = Theme.Font(19, FontStyle.Bold), TextAlign = ContentAlignment.MiddleLeft }; + logo.Controls.Add(mark); logo.Controls.Add(brand); form.Controls.Add(logo, 0, 0); + form.Controls.Add(Ui.Label("Кабинет преподавателя", 13, Theme.Text, FontStyle.Bold), 0, 1); + form.Controls.Add(Ui.Label("Адрес сервера", 9, Theme.Muted), 0, 2); + server.Dock = DockStyle.Fill; form.Controls.Add(server, 0, 3); + form.Controls.Add(Ui.Label("Логин", 9, Theme.Muted), 0, 4); + login.Dock = DockStyle.Fill; form.Controls.Add(login, 0, 5); + form.Controls.Add(Ui.Label("Пароль", 9, Theme.Muted), 0, 6); + password.Dock = DockStyle.Fill; form.Controls.Add(password, 0, 7); + form.Controls.Add(remember, 0, 8); + button.Dock = DockStyle.Fill; button.Margin = new Padding(0, 8, 0, 0); form.Controls.Add(button, 0, 9); + error.TextAlign = ContentAlignment.MiddleLeft; form.Controls.Add(error, 0, 10); + left.Controls.Add(form); layout.Controls.Add(left, 0, 0); + var right = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(16, 28, 36, 28), Padding = new Padding(20), BackColor = Theme.Sidebar }; + var features = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5, BackColor = Theme.Sidebar }; + foreach (var height in new[] { 80, 86, 86, 86, 86 }) features.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + features.Controls.Add(Ui.Label("BiStudent — учёт посещаемости и успеваемости", 14, Theme.Text, FontStyle.Bold), 0, 0); + AddFeature(features, 1, "Отметка посещаемости по камере\nРаспознавание лиц во время занятия"); + AddFeature(features, 2, "Электронный журнал\nПосещаемость, баллы, контрольные и итог"); + AddFeature(features, 3, "Расписание на семестр\nЧётные и нечётные недели, переносы"); + AddFeature(features, 4, "Регистрация лиц студентами\nПо коду группы, с подтверждением преподавателя"); + right.Controls.Add(features); layout.Controls.Add(right, 1, 0); Controls.Add(layout); + } + + private static void AddFeature(TableLayoutPanel container, int row, string text) + { + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, BackColor = Theme.Sidebar }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 28)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + panel.Controls.Add(Ui.Label("●", 12, Theme.Accent), 0, 0); + panel.Controls.Add(Ui.Label(text, 10, Theme.Text), 1, 0); + container.Controls.Add(panel, 0, row); } private async Task SignInAsync() { - if (busy || settings is null) return; - busy = true; signIn.Enabled = false; password.Enabled = false; - feedback.ForeColor = Theme.Muted; feedback.Text = "Проверяем учётную запись…"; + if (signingIn) return; + signingIn = true; button.Enabled = false; UseWaitCursor = true; error.Text = ""; + ApiClient? client = null; try { - var session = await new FirebaseAuth(settings, http).SignInAsync(password.Text, cancellation.Token); - if (IsDisposed || cancellation.IsCancellationRequested) return; - if (remember.Checked) RememberedSession.Save(settings,session); else RememberedSession.Forget(); - Session = session; password.Clear(); DialogResult = DialogResult.OK; Close(); + var address = server.Text.Trim(); + if (!ClientSettings.SameServer(settings.ServerUrl, address)) + settings.StoreRefreshToken(null); + settings.ServerUrl = address; settings.LastLogin = login.Text.Trim(); + settings.RememberMe = remember.Checked; + settings.Save(); + client = new ApiClient(settings); + await client.LoginAsync(settings.LastLogin, password.Text, settings.RememberMe); + await client.LoadMeAsync(); + settings.StoreRefreshToken(settings.RememberMe ? client.RefreshToken : null); + settings.Save(); + if (settings.RememberMe) + client.RefreshTokenChanged += token => { settings.StoreRefreshToken(token); settings.Save(); }; + Api = client; DialogResult = DialogResult.OK; Close(); + } + catch (ApiException ex) + { + client?.Dispose(); + error.Text = ex.StatusCode == HttpStatusCode.Unauthorized ? "Неверный логин или пароль" : ex.Message; + } + catch (Exception ex) + { + client?.Dispose(); + error.Text = $"Не удалось войти: {ex.Message}"; + } + finally + { + signingIn = false; + if (!IsDisposed) { button.Enabled = true; UseWaitCursor = false; } } - catch (OperationCanceledException) { if (!IsDisposed && !cancellation.IsCancellationRequested) ShowError("Firebase не ответил вовремя. Попробуйте ещё раз."); } - catch (HttpRequestException) { if (!IsDisposed) ShowError("Нет соединения с Firebase. Проверьте интернет."); } - catch (Exception ex) { if (!IsDisposed) ShowError(ex is InvalidOperationException ? ex.Message : "Не удалось выполнить вход. Попробуйте ещё раз."); } - finally { busy = false; if (!IsDisposed) { signIn.Enabled = true; password.Enabled = true; } } - } - - private void ShowError(string message) { feedback.ForeColor = Theme.Red; feedback.Text = message; password.Focus(); } - protected override void OnFormClosing(FormClosingEventArgs e) { base.OnFormClosing(e); if (!e.Cancel) cancellation.Cancel(); } - protected override void Dispose(bool disposing) - { - if (disposing && !resourcesDisposed) { resourcesDisposed = true; cancellation.Cancel(); http.Dispose(); cancellation.Dispose(); } - base.Dispose(disposing); } } diff --git a/BiStudent/MainForm.cs b/BiStudent/MainForm.cs index 2bfc0b2..0fcec4e 100644 --- a/BiStudent/MainForm.cs +++ b/BiStudent/MainForm.cs @@ -1,519 +1,202 @@ -using OpenCvSharp; -using OpenCvSharp.Extensions; -using System.Drawing.Drawing2D; -using System.Runtime.InteropServices; +using BiStudent.Contracts; namespace BiStudent; -internal sealed class MainForm : Form +internal sealed class MainForm : DarkForm { - [DllImport("dwmapi.dll")] - private static extern int DwmSetWindowAttribute(IntPtr handle, int attribute, ref int value, int size); - private readonly AttendanceStore store; - private readonly TeacherSession? teacher; - private readonly CloudFirestore? cloud; - private readonly CloudWorkspace? workspace; - private readonly SavePreferences savePreferences = SavePreferences.Load(); - private readonly System.Windows.Forms.Timer autoSaveTimer = new(); - private readonly ModernButton saveChanges = new() { Text = "Сохранить изменения", Width = 210, Enabled = false }; - private bool savingBeforeClose; - private bool closingWork; - private string? attendanceError; - private readonly PublicStatsPublisher? statsPublisher; - private bool statsDirty = true; - private bool publishingStats; - private AttendanceSync? attendanceSync; - private readonly FaceSync? faceSync; - private readonly CameraLoader cameraLoader = new() { Visible = false }; - private CloudSnapshot? cloudSnapshot; - private Label? pageSubtitle; - private bool cloudLoading; - public bool SignedOut { get; private set; } - private readonly Panel content = new() { Dock = DockStyle.Fill, BackColor = Theme.Background }; - private readonly ModernButton studentsNav = new() { Text = "Студенты", Width = 180, Height = 48 }; - private readonly ModernButton cameraNav = new() { Text = "Камера", Width = 180, Height = 48 }; - private readonly PictureBox preview = new() { Dock = DockStyle.Fill, BackColor = Theme.Sidebar, SizeMode = PictureBoxSizeMode.Zoom }; - private readonly Label status = Label("Камера остановлена", 10, Theme.Muted); - private readonly Label lessonLabel = Label("Нет занятия", 12, Theme.Text, FontStyle.Bold); - private readonly DataGridView studentsTable = Grid(); - private readonly DataGridView attendanceTable = Grid(); - private readonly DataGridView scheduleTable = Grid(); - private readonly System.Windows.Forms.Timer scheduleTimer = new() { Interval = 30000 }; - private CancellationTokenSource? cameraCancellation; - private Task? cameraTask; - private volatile Lesson? activeLesson; - private int trainingRevision; - private int pendingFrame; - private bool cameraPage; - - public MainForm(AttendanceStore? store = null, TeacherSession? teacher = null, CloudFirestore? cloud = null, CloudWorkspace? workspace = null,FaceSync? faceSync = null) - { - this.store = store ?? new AttendanceStore(); - this.teacher = teacher; - this.cloud = cloud; - this.faceSync=faceSync; - statsPublisher = cloud is null ? null : new PublicStatsPublisher(cloud, this.store); - this.workspace = workspace ?? (cloud is null ? null : new CloudWorkspace(cloud, teacher?.Uid ?? "test")); - cloudSnapshot = this.workspace?.Snapshot; - if (cloudSnapshot is not null) this.store.ApplyCloudRoster(cloudSnapshot, FirebaseSettings.Load().GroupId); - if (cloud is not null && cloudSnapshot is not null) attendanceSync = new AttendanceSync(this.store, cloud, cloudSnapshot); - Text = "BiStudent · Панель преподавателя"; - Size = new System.Drawing.Size(1320, 800); - MinimumSize = new System.Drawing.Size(1040, 680); + private static readonly (string Key, string Text, string Icon)[] Pages = + [ + ("today", "Сегодня", "◷"), ("camera", "Отметка", "◉"), ("schedule", "Расписание", "▦"), + ("journal", "Журнал", "☰"), ("groups", "Группы", "◍"), ("report", "Отчёт", "▲"), + ("admin", "Администрирование", "⚙") + ]; + + private readonly ApiClient api; + private readonly Panel host = new() { Dock = DockStyle.Fill, BackColor = Theme.Background }; + private readonly Dictionary nav = []; + private readonly ContextMenuStrip userMenu = new() { BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(10) }; + private Page? current; + private Label? userName; + private Label? userAvatar; + private bool switching; + private bool closingAfterLeave; + private bool leaving; + + public MainForm(ApiClient api) + { + this.api = api; + Text = "BiStudent · Кабинет преподавателя"; + Size = new Size(1440, 900); + MinimumSize = new Size(1180, 720); StartPosition = FormStartPosition.CenterScreen; - BackColor = Theme.Background; - Font = Theme.Font(10); - AutoScaleMode = AutoScaleMode.Dpi; BuildShell(); - scheduleTimer.Tick += (_, _) => { if (cameraPage) RefreshLesson(); }; - scheduleTimer.Start(); - ShowStudents(); - if (this.workspace is not null) - { - this.workspace.Changed += WorkspaceChanged; - WorkspaceChanged(); - saveChanges.Click += async (_, _) => await SaveAndPublishAsync(); - autoSaveTimer.Interval = savePreferences.Seconds * 1000; - autoSaveTimer.Tick += async (_, _) => await SaveAndPublishAsync(); autoSaveTimer.Start(); - if (cloudSnapshot is null) Shown += async (_, _) => await RefreshCloudAsync(); - } - } - - protected override void OnHandleCreated(EventArgs e) - { - base.OnHandleCreated(e); - var enabled = 1; - if (DwmSetWindowAttribute(Handle, 20, ref enabled, sizeof(int)) != 0) - DwmSetWindowAttribute(Handle, 19, ref enabled, sizeof(int)); + api.Unauthorized += OnUnauthorized; + Shown += async (_, _) => { if (AutoOpenFirstPage && current is null) await ShowPageAsync("today"); }; } - private static Label Label(string text, float size, Color color, FontStyle style = FontStyle.Regular) => new() - { Text = text, Font = Theme.Font(size, style), ForeColor = color, BackColor = Color.Transparent, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; - - internal static DataGridView Grid() - { - var grid = new DataGridView - { - Dock = DockStyle.Fill, BackgroundColor = Theme.Surface, BorderStyle = BorderStyle.None, - CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, GridColor = Theme.Line, - ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None, RowHeadersVisible = false, - EnableHeadersVisualStyles = false, AllowUserToAddRows = false, AllowUserToDeleteRows = false, - AllowUserToResizeRows = false, ReadOnly = true, SelectionMode = DataGridViewSelectionMode.FullRowSelect, - MultiSelect = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, - ColumnHeadersHeight = 43, ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, - Font = Theme.Font(10) - }; - grid.RowTemplate.Height = 43; - grid.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle { BackColor = Theme.Surface, ForeColor = Theme.Muted, Font = Theme.Font(9, FontStyle.Bold), SelectionBackColor = Theme.Surface, SelectionForeColor = Theme.Muted, Padding = new Padding(8, 0, 0, 0) }; - grid.DefaultCellStyle = new DataGridViewCellStyle { BackColor = Theme.Surface, ForeColor = Theme.Text, SelectionBackColor = Theme.SurfaceHover, SelectionForeColor = Theme.Text, Padding = new Padding(8, 0, 0, 0) }; - grid.AlternatingRowsDefaultCellStyle = grid.DefaultCellStyle; - return grid; - } + public bool SignedOut { get; private set; } + public bool AutoOpenFirstPage { get; set; } = true; + public LessonDto? PendingCameraLesson { get; set; } + internal Page? CurrentPage => current; + internal string? LastPageError { get; private set; } + internal ApiClient Api => api; private void BuildShell() { - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = Padding.Empty }; - layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 216)); - layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - var sidebar = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = Theme.Sidebar, ColumnCount = 1, RowCount = 3, Margin = Padding.Empty, Padding = new Padding(18, 28, 18, 18) }; - sidebar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 86)); + var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 240, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) }; + sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 84)); sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 110)); - sidebar.Controls.Add(Label("BiStudent", 17, Theme.Text, FontStyle.Bold), 0, 0); - var nav = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, BackColor = Theme.Sidebar, Margin = Padding.Empty }; - studentsNav.Margin = new Padding(0, 0, 0, 10); - cameraNav.Margin = Padding.Empty; - studentsNav.Click += async (_, _) => { await StopCameraAsync(); ShowStudents(); }; - cameraNav.Click += (_, _) => ShowCamera(); - nav.Controls.Add(studentsNav); nav.Controls.Add(cameraNav); - sidebar.Controls.Add(nav, 0, 1); - var account = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, BackColor = Theme.Sidebar, Margin = Padding.Empty }; - account.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - account.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); account.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); - var accountHeading = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = Padding.Empty }; - accountHeading.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); accountHeading.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 38)); - accountHeading.Controls.Add(Label(teacher?.Name ?? "Режим проверки", 10, Theme.Text, FontStyle.Bold), 0, 0); - var settingsButton = new ModernButton { Text = "⚙", Size = new System.Drawing.Size(36, 36), Anchor = AnchorStyles.None, Margin = Padding.Empty, Enabled = workspace is not null }; - settingsButton.Click += (_, _) => { using var settings = new AutoSaveSettingsForm(savePreferences.Seconds); if (settings.ShowDialog(this) == DialogResult.OK) { savePreferences.Seconds = settings.Seconds; savePreferences.Save(); autoSaveTimer.Interval = settings.Seconds * 1000; } }; - accountHeading.Controls.Add(settingsButton, 1, 0); account.Controls.Add(accountHeading, 0, 0); - var signOut = new ModernButton { Text = "Выйти", Dock = DockStyle.Fill, Margin = Padding.Empty, Enabled = teacher is not null }; - signOut.Click += async (_, _) => { signOut.Enabled = false; await StopCameraAsync(); RememberedSession.Forget(); SignedOut = true; Close(); }; - account.Controls.Add(signOut, 0, 1); sidebar.Controls.Add(account, 0, 2); - layout.Controls.Add(sidebar, 0, 0); - layout.Controls.Add(content, 1, 0); - Controls.Add(layout); - } - - private TableLayoutPanel Page(string title, string subtitle) - { - content.Controls.Clear(); - var page = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, Padding = new Padding(28), BackColor = Theme.Background }; - page.RowStyles.Add(new RowStyle(SizeType.Absolute, 62)); - page.RowStyles.Add(new RowStyle(SizeType.Absolute, 60)); - page.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - var heading = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Background, Margin = Padding.Empty }; - heading.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); - heading.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - heading.Controls.Add(Label(title, 23, Theme.Text, FontStyle.Bold), 0, 0); - pageSubtitle = Label(subtitle, 9, Theme.Muted); heading.Controls.Add(pageSubtitle, 0, 1); - page.Controls.Add(heading, 0, 0); - content.Controls.Add(page); - return page; - } - - private static FlowLayoutPanel Actions() => new() { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Theme.Background }; - - private void ShowStudents() - { - cameraPage = false; - studentsNav.Selected = true; studentsNav.Invalidate(); cameraNav.Selected = false; cameraNav.Invalidate(); - var page = Page("Студенты", cloud is null ? "Регистрация и список студентов" : cloudSnapshot is null ? "Загрузка группы из Firebase…" : cloudSnapshot.GroupName + " · Студенты и оценки в Firebase"); - var actions = Actions(); - var add = new ModernButton { Text = cloud is null ? "+ Добавить студента" : "Обновить из Firebase", Primary = true, Width = 192 }; - add.Click += async (_, _) => + sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); + + var brand = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 2, BackColor = Theme.Sidebar, Margin = Padding.Empty }; + brand.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 48)); brand.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + brand.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); brand.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); + var mark = new Label { Text = "✓", Size = new Size(38, 38), BackColor = Theme.Accent, ForeColor = Color.White, Font = Theme.Font(16, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter, Margin = new Padding(0, 4, 0, 0) }; + brand.Controls.Add(mark, 0, 0); brand.SetRowSpan(mark, 2); + brand.Controls.Add(Ui.Label("BiStudent", 15, Theme.Text, FontStyle.Bold), 1, 0); + brand.Controls.Add(Ui.Label("Посещаемость и успеваемость", 8.5f, Theme.Muted), 1, 1); + sidebar.Controls.Add(brand, 0, 0); + + var menu = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, BackColor = Theme.Sidebar }; + foreach (var (key, text, icon) in Pages) { - if (cloud is not null) { add.Enabled = false; await RefreshCloudAsync(); if (!add.IsDisposed) add.Enabled = true; return; } - using var form = new EnrollmentForm(store); if (form.ShowDialog(this) == DialogResult.OK) { trainingRevision++; RefreshStudents(); } - }; - var updateFace = new ModernButton { Text = "Обновить лицо", Width = 155 }; - updateFace.Click += (_, _) => - { - if (studentsTable.CurrentRow?.Cells["Id"].Value is not int id) return; - var student = VisibleStudents().FirstOrDefault(s => s.Id == id); - if (student is null) return; - using var form = new EnrollmentForm(store, student); - if (form.ShowDialog(this) == DialogResult.OK) { trainingRevision++; RefreshStudents(); WorkspaceChanged(); } - }; - var delete = new ModernButton { Text = "Удалить", Width = 120 }; - delete.Click += (_, _) => + if (key == "admin" && api.Teacher?.IsAdmin != true) continue; + var button = new ModernButton { Text = text, Icon = icon, Width = 206, Height = 44, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, key == "report" ? 14 : 0, 0, 6), Dashed = key == "report", BackColor = Theme.Sidebar }; + button.Click += async (_, _) => await ShowPageAsync(key); + nav[key] = button; + menu.Controls.Add(button); + } + sidebar.Controls.Add(menu, 0, 1); + + var user = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 2, BackColor = Theme.Sidebar, Margin = Padding.Empty }; + user.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 44)); user.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); user.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 44)); + user.RowStyles.Add(new RowStyle(SizeType.Percent, 55)); user.RowStyles.Add(new RowStyle(SizeType.Percent, 45)); + var name = api.Teacher?.FullName ?? "Преподаватель"; + var avatar = new Label { Text = Initials(name), Size = new Size(36, 36), BackColor = Theme.SurfaceAlt, ForeColor = Theme.Text, Font = Theme.Font(10, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter, Margin = new Padding(0, 12, 0, 0) }; + user.Controls.Add(avatar, 0, 0); user.SetRowSpan(avatar, 2); + userAvatar = avatar; + var nameLabel = Ui.Label(name, 9.5f, Theme.Text, FontStyle.Bold); nameLabel.AutoEllipsis = true; nameLabel.TextAlign = ContentAlignment.BottomLeft; + user.Controls.Add(nameLabel, 1, 0); + userName = nameLabel; + var roleLabel = Ui.Label(api.Teacher?.IsAdmin == true ? "Администратор" : "Преподаватель", 8.5f, Theme.Muted); roleLabel.TextAlign = ContentAlignment.TopLeft; + user.Controls.Add(roleLabel, 1, 1); + var exit = new ModernButton { Text = "↩", Width = 38, Height = 38, Font = Theme.Font(12, FontStyle.Bold), BackColor = Theme.Sidebar, Margin = new Padding(0, 12, 0, 0) }; + exit.Click += (_, _) => SignOut(); + new ToolTip().SetToolTip(exit, "Выйти"); + user.Controls.Add(exit, 2, 0); user.SetRowSpan(exit, 2); + userMenu.Items.Add("Сменить пароль…", null, (_, _) => { - if (studentsTable.CurrentRow?.Cells["Id"].Value is not int id) return; - if (MessageBox.Show(this, "Удалить студента, его отметки и оценки?", "Подтверждение", MessageBoxButtons.YesNo) != DialogResult.Yes) return; - store.DeleteStudent(id); trainingRevision++; RefreshStudents(); - }; - var grades = new ModernButton { Text = "Оценки", Width = 125, Enabled = teacher is not null }; - grades.Click += (_, _) => + using var dialog = new ChangePasswordDialog(api, forced: false); + if (dialog.ShowDialog(this) == DialogResult.OK) Ui.Toast(this, "Пароль изменён"); + }); + userMenu.Items.Add("Выйти", null, (_, _) => SignOut()); + void OpenUserMenu(object? sender, EventArgs e) { - if (teacher is null || studentsTable.CurrentRow?.Cells["Id"].Value is not int id) return; - var student = VisibleStudents().FirstOrDefault(s => s.Id == id); - if (student is null) return; - if (cloud is not null && cloudSnapshot is not null) - { - using var form = new CloudGradesForm(workspace!, student.CloudId.Split('/').Last(), student.Name, cloudSnapshot.Subjects); - form.ShowDialog(this); RefreshStudents(); - } - else { using var form = new GradesForm(store, student, teacher); form.ShowDialog(this); RefreshStudents(); } - }; - actions.Controls.Add(add); actions.Controls.Add(updateFace); actions.Controls.Add(grades); if (cloud is null) actions.Controls.Add(delete); else actions.Controls.Add(saveChanges); page.Controls.Add(actions, 0, 1); - var card = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(6) }; card.Controls.Add(studentsTable); page.Controls.Add(card, 0, 2); - if (cloud is not null) { - page.RowCount=4; page.RowStyles.Add(new RowStyle(SizeType.Absolute,48)); - var footer = new FlowLayoutPanel { Dock=DockStyle.Fill,WrapContents=false }; - var issueCode = new ModernButton { Text="Код регистрации лица на сайте",Width=290,Height=40 }; - issueCode.Click += async (_,_) => { - if (studentsTable.CurrentRow?.Cells["Id"].Value is not int id) return; - var student=VisibleStudents().FirstOrDefault(s=>s.Id==id); if (student is null) return; - issueCode.Enabled=false; - try { var cloudStudent=cloudSnapshot!.Students.Single(s=>s.Id==student.CloudId.Split('/').Last()); var code=await cloud.CreateFaceInviteAsync(cloudStudent); using var dialog=new FaceInviteForm(student.Name,code); dialog.ShowDialog(this); } - catch (Exception ex) { MessageBox.Show(this,ex is InvalidOperationException ? ex.Message : "Не удалось выдать код. Проверьте интернет."); } - finally { if (!issueCode.IsDisposed) issueCode.Enabled=true; } - }; - footer.Controls.Add(issueCode); page.Controls.Add(footer,0,3); + if (sender is Control control) userMenu.Show(control, new Point(0, control.Height)); } - RefreshStudents(); - } + avatar.Click += OpenUserMenu; nameLabel.Click += OpenUserMenu; roleLabel.Click += OpenUserMenu; + user.Click += OpenUserMenu; + sidebar.Controls.Add(user, 0, 2); - private void RefreshStudents() - { - var averages = store.GradeAverages(); - if (cloudSnapshot is not null) - averages = VisibleStudents().Select(s => new { s.Id, Values = cloudSnapshot.Grades.Where(g => g.StudentId == s.CloudId.Split('/').Last() && g.Score is not null).Select(g => g.Score!.Value).ToList() }).Where(x => x.Values.Count > 0).ToDictionary(x => x.Id, x => x.Values.Average()); - studentsTable.DataSource = VisibleStudents().Select(s => new { s.Id, Студент = s.Name, Группа = s.GroupName, Образцов = s.SampleCount, СреднийБалл = averages.TryGetValue(s.Id, out var average) ? average.ToString("0.0") : "—" }).ToList(); - if (studentsTable.Columns["Id"] is { } column) column.Visible = false; - if (studentsTable.Columns["СреднийБалл"] is { } averageColumn) averageColumn.HeaderText = "Средний балл"; + Controls.Add(host); + Controls.Add(sidebar); } - private List VisibleStudents() + private static string Initials(string name) => + string.Concat(name.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(p => char.IsLetter(p[0])).Take(2).Select(p => char.ToUpperInvariant(p[0]))); + + private Page CreatePage(string key) => key switch { - var students = store.Students(); - if (cloud is null) return students; - if (cloudSnapshot is null) return []; - var order = cloudSnapshot.Students.ToDictionary(x => x.Id, x => x.Ordinal); - return students.Where(s => s.GroupName == cloudSnapshot.GroupName && order.ContainsKey(s.CloudId.Split('/').Last())).OrderBy(s => order[s.CloudId.Split('/').Last()]).ToList(); - } + "today" => new TodayPage(api), + "schedule" => new SchedulePage(api), + "journal" => new JournalPage(api), + "groups" => new GroupsPage(api), + "camera" => new CameraPage(api), + "report" => new ReportPage(api), + "admin" => new AdminPage(api), + _ => throw new ArgumentOutOfRangeException(nameof(key), key, null) + }; - private async Task RefreshCloudAsync() + internal async Task ShowPageAsync(string key) { - if (workspace is null || cloudLoading || publishingStats) return; - cloudLoading = true; - content.Enabled=false; - if (pageSubtitle is not null) { pageSubtitle.Text = "Загрузка группы из Firebase…"; pageSubtitle.ForeColor = Theme.Muted; } + if (switching || IsDisposed) return; + switching = true; + if (api.Teacher is { } teacher && userName is not null && userAvatar is not null) { userName.Text = teacher.FullName; userAvatar.Text = Initials(teacher.FullName); } try { - await workspace.LoadAsync(); - var snapshot = workspace.Snapshot!; - if (IsDisposed) return; - store.ApplyCloudRoster(snapshot, FirebaseSettings.Load().GroupId); - if (faceSync is not null) await faceSync.ImportAsync(snapshot); - attendanceSync = new AttendanceSync(store, cloud!, snapshot); - cloudSnapshot = snapshot; trainingRevision++; - if (!cameraPage) ShowStudents(); else RefreshSchedule(); - } - catch (Exception ex) { if (!IsDisposed && pageSubtitle is not null) { pageSubtitle.ForeColor = Theme.Red; pageSubtitle.Text = ex is InvalidOperationException ? ex.Message : "Не удалось загрузить Firebase. Проверьте интернет и нажмите Обновить."; } } - finally { cloudLoading = false; if (!IsDisposed) content.Enabled=true; } - } - - private void WorkspaceChanged() - { - if (IsDisposed || workspace is null) return; - cloudSnapshot = workspace.Snapshot; - if (workspace.HasChanges) statsDirty = true; - saveChanges.Enabled = (workspace.HasChanges || attendanceSync?.HasChanges == true || faceSync?.HasChanges == true) && !workspace.IsSaving && !publishingStats; - if (!cameraPage) RefreshStudents(); - if (pageSubtitle is not null) { pageSubtitle.ForeColor = workspace.Error is null && attendanceError is null && faceSync?.Error is null ? Theme.Muted : Theme.Red; pageSubtitle.Text = workspace.Error ?? attendanceError ?? faceSync?.Error ?? (workspace.IsSaving ? "Сохранение изменений в Firebase…" : workspace.HasChanges || attendanceSync?.HasChanges == true || faceSync?.HasChanges == true ? $"Есть несохранённые изменения · Автосохранение каждые {savePreferences.Seconds} сек." : (cloudSnapshot?.GroupName ?? "") + " · Все изменения сохранены"); } - } - - private async Task SaveAndPublishAsync() - { - if (workspace is null || publishingStats || cloudLoading) return; - if (!workspace.HasChanges && attendanceSync?.HasChanges != true && faceSync?.HasChanges != true && !statsDirty) return; - publishingStats = true; - attendanceError = null; - try { - await workspace.SaveAsync(); - if (faceSync is not null) await faceSync.SaveAsync(); - if (attendanceSync is not null) await attendanceSync.SaveAsync(); - if (workspace.IsSaving || workspace.HasChanges || workspace.Snapshot is null || !statsDirty || statsPublisher is null) return; - statsDirty = false; - await statsPublisher.PublishAsync(workspace.Snapshot); - } - catch { statsDirty = true; attendanceError = "Не удалось сохранить лица, посещаемость или обновить сайт. Данные остались на компьютере; повторим автоматически."; } - finally { - publishingStats = false; - saveChanges.Enabled = (workspace.HasChanges || attendanceSync?.HasChanges == true || faceSync?.HasChanges == true) && !workspace.IsSaving; - if (pageSubtitle is not null && attendanceError is not null) { pageSubtitle.ForeColor = Theme.Red; pageSubtitle.Text = attendanceError; } + if (current is not null) + { + await current.OnLeaveAsync(); + host.Controls.Remove(current); + current.Dispose(); + current = null; + } + foreach (var (pageKey, button) in nav) button.Selected = pageKey == key; + var page = CreatePage(key); + current = page; + host.Controls.Add(page); + LastPageError = null; + try { await page.LoadAsync(); } + catch (ApiException ex) { LastPageError = ex.Message; Ui.Toast(this, ex.Message, true); } } + finally { switching = false; } } - private void ShowCamera() - { - cameraPage = true; - studentsNav.Selected = false; studentsNav.Invalidate(); cameraNav.Selected = true; cameraNav.Invalidate(); - var page = Page("Камера", "Видеопоток, текущий урок и посещаемость"); - var actions = Actions(); - var start = new ModernButton { Text = "▶ Запустить", Primary = true, Width = 145 }; - start.Click += async (_, _) => await StartCameraAsync(); - var stop = new ModernButton { Text = "Остановить", Width = 135 }; - stop.Click += async (_, _) => await StopCameraAsync(); - var addLesson = new ModernButton { Text = "+ Занятие", Width = 128 }; - addLesson.Click += (_, _) => { using var form = new LessonForm(store, cloudSnapshot); if (form.ShowDialog(this) == DialogResult.OK) RefreshSchedule(); }; - var removeLesson = new ModernButton { Text = "Удалить занятие", Width = 160 }; - removeLesson.Click += (_, _) => - { - if (scheduleTable.CurrentRow?.Cells["Id"].Value is not int id) return; - if (MessageBox.Show(this, "Удалить занятие и его отметки?", "Подтверждение", MessageBoxButtons.YesNo) != DialogResult.Yes) return; - store.DeleteLesson(id); statsDirty = true; WorkspaceChanged(); RefreshSchedule(); - }; - actions.Controls.Add(start); actions.Controls.Add(stop); actions.Controls.Add(addLesson); actions.Controls.Add(removeLesson); - page.Controls.Add(actions, 0, 1); - - var body = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, BackColor = Theme.Background }; - body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 61)); body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 39)); - var left = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, Margin = new Padding(0, 0, 12, 0) }; - left.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); left.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); left.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); - left.Controls.Add(lessonLabel, 0, 0); - var videoCard = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(6) }; videoCard.Controls.Add(preview); preview.Controls.Add(cameraLoader); cameraLoader.BringToFront(); left.Controls.Add(videoCard, 0, 1); - left.Controls.Add(status, 0, 2); body.Controls.Add(left, 0, 0); - var cameraFooter = new TableLayoutPanel { Dock=DockStyle.Fill,ColumnCount=2,RowCount=1 }; - cameraFooter.ColumnStyles.Add(new ColumnStyle(SizeType.Percent,100)); cameraFooter.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute,154)); - left.Controls.Remove(status); cameraFooter.Controls.Add(status,0,0); - var chooseCamera = new ModernButton { Text="Выбрать камеру",Dock=DockStyle.Fill,Height=38 }; - chooseCamera.Click += async (_,_) => { var running=cameraTask is { IsCompleted:false }; await StopCameraAsync(); using var choice=new CameraSelectionForm(); if (choice.ShowDialog(this)==DialogResult.OK && running) await StartCameraAsync(); }; - cameraFooter.Controls.Add(chooseCamera,1,0); left.Controls.Add(cameraFooter,0,2); - - var right = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 5, Margin = Padding.Empty }; - right.RowStyles.Add(new RowStyle(SizeType.Absolute, 39)); right.RowStyles.Add(new RowStyle(SizeType.Percent, 52)); right.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); right.RowStyles.Add(new RowStyle(SizeType.Absolute, 39)); right.RowStyles.Add(new RowStyle(SizeType.Percent, 48)); - right.Controls.Add(Label("Посещаемость", 13, Theme.Text, FontStyle.Bold), 0, 0); - var attendanceCard = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(4) }; attendanceCard.Controls.Add(attendanceTable); right.Controls.Add(attendanceCard, 0, 1); - var markActions = Actions(); - var mark = new ModernButton { Text = "Отметить", Width = 128, Height = 37 }; - var unmark = new ModernButton { Text = "Снять отметку", Width = 157, Height = 37 }; - mark.Click += (_, _) => ChangeMark(true); unmark.Click += (_, _) => ChangeMark(false); - markActions.Controls.Add(mark); markActions.Controls.Add(unmark); right.Controls.Add(markActions, 0, 2); - right.Controls.Add(Label("Расписание", 13, Theme.Text, FontStyle.Bold), 0, 3); - var scheduleCard = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(4) }; scheduleCard.Controls.Add(scheduleTable); right.Controls.Add(scheduleCard, 0, 4); - body.Controls.Add(right, 1, 0); page.Controls.Add(body, 0, 2); - RefreshSchedule(); - } - - private void RefreshSchedule() - { - scheduleTable.DataSource = store.Lessons().Where(l => cloud is null || l.GroupName == cloudSnapshot?.GroupName).Select(l => new { l.Id, День = l.Day switch { DayOfWeek.Monday => "Пн", DayOfWeek.Tuesday => "Вт", DayOfWeek.Wednesday => "Ср", DayOfWeek.Thursday => "Чт", DayOfWeek.Friday => "Пт", DayOfWeek.Saturday => "Сб", _ => "Вс" }, Предмет = l.Subject + (l.Room.Length > 0 ? " · ауд. " + l.Room : ""), Группа = l.GroupName, Время = $"{l.Start:hh\\:mm}–{l.End:hh\\:mm}", Неделя = l.Parity switch { 1 => "Нечётная", 2 => "Чётная", _ => "Каждая" } }).ToList(); - if (scheduleTable.Columns["Id"] is { } column) column.Visible = false; - RefreshLesson(); - } - - private void RefreshLesson() - { - var now = DateTime.Now; - var current = store.LessonsOn(DateOnly.FromDateTime(now)).Where(l => (cloud is null || l.GroupName == cloudSnapshot?.GroupName) && now.TimeOfDay >= l.Start && now.TimeOfDay < l.End).ToList(); - activeLesson = current.Count == 1 ? current[0] : null; - lessonLabel.Text = current.Count > 1 ? "Несколько занятий одновременно — уточните расписание" : activeLesson is null ? "Нет текущего занятия по расписанию" : $"Сейчас: {activeLesson}"; - RefreshAttendance(); - } - - private void RefreshAttendance() - { - if (activeLesson is null) { attendanceTable.DataSource = null; return; } - var rows = store.Attendance(activeLesson.Id, DateOnly.FromDateTime(DateTime.Today), activeLesson.GroupName); - attendanceTable.DataSource = rows.Select(r => new { r.StudentId, Студент = r.Name, Статус = r.SeenAt is null ? "Нет отметки" : "Присутствует", Время = r.SeenAt?.ToString("HH:mm") ?? "—" }).ToList(); - if (attendanceTable.Columns["StudentId"] is { } column) column.Visible = false; - status.Text = $"Присутствуют {rows.Count(r => r.SeenAt is not null)} из {rows.Count} · Фото на экране может обмануть распознавание"; - } - - private void ChangeMark(bool present) + internal Task OpenCamera(LessonDto lesson) { - if (activeLesson is null || attendanceTable.CurrentRow?.Cells["StudentId"].Value is not int id) return; - var date = DateOnly.FromDateTime(DateTime.Today); - if (present) store.MarkPresent(activeLesson.Id, date, id, "Вручную"); else store.RemoveMark(activeLesson.Id, date, id); - statsDirty = true; - WorkspaceChanged(); - RefreshAttendance(); + PendingCameraLesson = lesson; + return ShowPageAsync("camera"); } - private Task StartCameraAsync() + internal async Task OpenLessonAsync(LessonDto lesson) { - if (cameraTask is { IsCompleted: false }) return Task.CompletedTask; - cameraCancellation = new CancellationTokenSource(); - cameraTask = Task.Run(() => CameraLoop(cameraCancellation.Token)); - status.Text = "Подключение к камере…"; - cameraLoader.Text = "Подключение камеры…"; cameraLoader.Visible = true; cameraLoader.BringToFront(); - return Task.CompletedTask; + var page = current; + using var dialog = new LessonDialog(api, lesson); + dialog.ShowDialog(this); + if (page is null || !ReferenceEquals(current, page) || page.IsDisposed || IsDisposed) return; + try { await page.LoadAsync(); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } } - private async Task StopCameraAsync() + private void SignOut() { - cameraCancellation?.Cancel(); - if (cameraTask != null) { try { await cameraTask; } catch (OperationCanceledException) { } cameraTask = null; } - cameraCancellation?.Dispose(); cameraCancellation = null; - var old = preview.Image; preview.Image = null; old?.Dispose(); - status.Text = "Камера остановлена"; - cameraLoader.Visible = false; + api.EndSession(); + var settings = ClientSettings.Load(); + settings.StoreRefreshToken(null); + settings.Save(); + SignedOut = true; + Close(); } - private void CameraLoop(CancellationToken token) + private void OnUnauthorized() { - try + if (IsDisposed || SignedOut) return; + BeginInvoke(() => { - using var capture = CameraDevice.Open(token); - using (var first = new Mat()) { - if (capture.Read(first) && !first.Empty()) { - var image = BitmapConverter.ToBitmap(first); - SafeUi(() => { var old = preview.Image; preview.Image = image; old?.Dispose(); cameraLoader.Visible = false; status.Text = "Камера подключена · Подготовка распознавания…"; }, image.Dispose); - } - } - var allowed = VisibleStudents().Select(s => s.Id).ToHashSet(); - var samples = store.Samples().Where(x => allowed.Contains(x.StudentId)).ToList(); - var preparing = Task.Run(() => { - var faceEngine = new FaceEngine(); - try { faceEngine.Train(samples, token); return faceEngine; } - catch { faceEngine.Dispose(); throw; } - }); - using (var raw = new Mat()) { - while (!preparing.IsCompleted) { - if (!token.IsCancellationRequested && capture.Read(raw) && !raw.Empty() && Interlocked.Exchange(ref pendingFrame, 1) == 0) { - var image = BitmapConverter.ToBitmap(raw); - SafeUi(() => { if (!token.IsCancellationRequested) { var old = preview.Image; preview.Image = image; old?.Dispose(); } else image.Dispose(); Interlocked.Exchange(ref pendingFrame, 0); }, () => { image.Dispose(); Interlocked.Exchange(ref pendingFrame, 0); }); - } - Thread.Sleep(30); - } - } - using var engine = preparing.GetAwaiter().GetResult(); - token.ThrowIfCancellationRequested(); - SafeUi(() => { cameraLoader.Visible = false; status.Text = "Камера работает · Распознавание готово"; }); - var revision = trainingRevision; - var streak = new Dictionary(); - int? lastLessonId = activeLesson?.Id; - var students = VisibleStudents(); - var names = students.ToDictionary(s => s.Id, s => s.Name); - using var frame = new Mat(); - while (!token.IsCancellationRequested) - { - if (!capture.Read(frame) || frame.Empty()) { Thread.Sleep(80); continue; } - if (revision != trainingRevision) - { - allowed = VisibleStudents().Select(s => s.Id).ToHashSet(); - engine.Train(store.Samples().Where(x => allowed.Contains(x.StudentId)), token); revision = trainingRevision; streak.Clear(); - students = VisibleStudents(); names = students.ToDictionary(s => s.Id, s => s.Name); - } - if (lastLessonId != activeLesson?.Id) { streak.Clear(); lastLessonId = activeLesson?.Id; } - var matches = engine.Scan(frame); - var detectedIds = matches.Where(m => m.StudentId.HasValue).Select(m => m.StudentId!.Value).ToHashSet(); - foreach (var id in streak.Keys.ToArray()) if (!detectedIds.Contains(id)) streak.Remove(id); - foreach (var match in matches) - { - if (match.StudentId is not int id) continue; - streak[id] = streak.GetValueOrDefault(id) + 1; - var lesson = activeLesson; - if (streak[id] >= 5 && lesson != null && students.Any(s => s.Id == id && s.GroupName == lesson.GroupName)) - { - if (store.MarkPresent(lesson.Id, DateOnly.FromDateTime(DateTime.Today), id, "Камера")) SafeUi(() => { statsDirty = true; WorkspaceChanged(); RefreshAttendance(); }); - streak[id] = 0; - } - } - if (Interlocked.Exchange(ref pendingFrame, 1) != 0) continue; - var bitmap = BitmapConverter.ToBitmap(frame); - using (var g = Graphics.FromImage(bitmap)) - { - g.SmoothingMode = SmoothingMode.AntiAlias; - foreach (var match in matches) - { - var studentId = match.StudentId ?? -1; - var known = names.TryGetValue(studentId, out var matchedName); - var color = known ? Theme.Green : Theme.Red; - using var pen = new Pen(color, 3); - g.DrawRectangle(pen, match.Bounds.X, match.Bounds.Y, match.Bounds.Width, match.Bounds.Height); - var tooSmall = match.Bounds.Width < FaceEngine.MinimumRecognitionSize || match.Bounds.Height < FaceEngine.MinimumRecognitionSize; - var name = known ? matchedName! : tooSmall ? "Подойдите ближе" : "Неизвестный"; - using var font = Theme.Font(13, FontStyle.Bold); - using var brush = new SolidBrush(color); - using var bg = new SolidBrush(Color.FromArgb(210, 11, 18, 32)); - var y = Math.Max(0, match.Bounds.Y - 32); - g.FillRectangle(bg, match.Bounds.X, y, g.MeasureString(name, font).Width + 12, 30); - g.DrawString(name, font, brush, match.Bounds.X + 5, y + 2); - } - } - SafeUi(() => { var old = preview.Image; preview.Image = bitmap; old?.Dispose(); Interlocked.Exchange(ref pendingFrame, 0); }, () => { bitmap.Dispose(); Interlocked.Exchange(ref pendingFrame, 0); }); - } - } - catch (OperationCanceledException) { } - catch (Exception ex) { SafeUi(() => { status.Text = ex.Message; cameraLoader.Visible = false; }); } - } - - private void SafeUi(Action action, Action? failure = null) - { - try { if (IsHandleCreated && !IsDisposed) BeginInvoke(action); else failure?.Invoke(); } - catch { failure?.Invoke(); } + if (SignedOut) return; + SignedOut = true; + Ui.Toast(this, "Сессия истекла — войдите снова", true); + Close(); + }); } protected override async void OnFormClosing(FormClosingEventArgs e) { - if (closingWork) { e.Cancel = true; return; } - if (!savingBeforeClose && workspace is not null && (workspace.HasChanges || workspace.IsSaving || attendanceSync?.HasChanges == true || faceSync?.HasChanges == true || statsDirty || publishingStats)) + if (!closingAfterLeave && current is not null) { - e.Cancel = true; base.OnFormClosing(e); - closingWork = true; - await StopCameraAsync(); - while (workspace.IsSaving || publishingStats) await Task.Delay(100); - await SaveAndPublishAsync(); - if (workspace.HasChanges || attendanceSync?.HasChanges == true || faceSync?.HasChanges == true) MessageBox.Show(this, "Не удалось сохранить в Firebase. Данные сохранены на компьютере; отправка повторится после следующего входа.", "Сохранение"); - savingBeforeClose = true; closingWork = false; Close(); return; + e.Cancel = true; + if (leaving) return; + leaving = true; + try { await current.OnLeaveAsync(); } + finally + { + closingAfterLeave = true; + Close(); + } + return; } - autoSaveTimer.Stop(); autoSaveTimer.Dispose(); - if (workspace is not null) workspace.Changed -= WorkspaceChanged; - cameraCancellation?.Cancel(); scheduleTimer.Stop(); + api.Unauthorized -= OnUnauthorized; + userMenu.Dispose(); base.OnFormClosing(e); } } diff --git a/BiStudent/Models/face_recognition_sface_2021dec.onnx b/BiStudent/Models/face_recognition_sface_2021dec.onnx deleted file mode 100644 index 6767be0..0000000 Binary files a/BiStudent/Models/face_recognition_sface_2021dec.onnx and /dev/null differ diff --git a/BiStudent/ModernControls.cs b/BiStudent/ModernControls.cs index 59dc6a4..2a1bc40 100644 --- a/BiStudent/ModernControls.cs +++ b/BiStudent/ModernControls.cs @@ -1,5 +1,7 @@ -using System.Drawing.Drawing2D; +using System.Collections.Concurrent; using System.ComponentModel; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; namespace BiStudent; @@ -9,6 +11,7 @@ internal static class Theme public static readonly Color Sidebar = ColorTranslator.FromHtml("#0B1220"); public static readonly Color Surface = ColorTranslator.FromHtml("#1D2939"); public static readonly Color SurfaceHover = ColorTranslator.FromHtml("#273548"); + public static readonly Color SurfaceAlt = ColorTranslator.FromHtml("#31425A"); public static readonly Color Accent = ColorTranslator.FromHtml("#5B8DEF"); public static readonly Color AccentHover = ColorTranslator.FromHtml("#75A2FA"); public static readonly Color Text = ColorTranslator.FromHtml("#F4F7FB"); @@ -16,10 +19,37 @@ internal static class Theme public static readonly Color Line = ColorTranslator.FromHtml("#334155"); public static readonly Color Green = ColorTranslator.FromHtml("#36CFA5"); public static readonly Color Red = ColorTranslator.FromHtml("#F27D86"); - public static Font Font(float size, FontStyle style = FontStyle.Regular) => new("Segoe UI", size, style); + public static readonly Color Yellow = ColorTranslator.FromHtml("#F5C451"); + public static readonly Color Violet = ColorTranslator.FromHtml("#A78BFA"); + private static readonly ConcurrentDictionary<(float Size, FontStyle Style), Lazy> fonts = new(); + public static Font Font(float size, FontStyle style = FontStyle.Regular) => fonts.GetOrAdd((size, style), key => new Lazy(() => new Font("Segoe UI", key.Size, key.Style))).Value; + public static Color Mix(Color a, Color b, float amount) => Color.FromArgb((int)(a.R + (b.R - a.R) * amount), (int)(a.G + (b.G - a.G) * amount), (int)(a.B + (b.B - a.B) * amount)); } -internal sealed class ModernButton : Control +internal class DarkForm : Form +{ + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr handle, int attribute, ref int value, int size); + + public DarkForm() + { + BackColor = Theme.Background; + ForeColor = Theme.Text; + Font = Theme.Font(10); + AutoScaleMode = AutoScaleMode.Dpi; + DoubleBuffered = true; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + var enabled = 1; + if (DwmSetWindowAttribute(Handle, 20, ref enabled, sizeof(int)) != 0) + DwmSetWindowAttribute(Handle, 19, ref enabled, sizeof(int)); + } +} + +internal sealed class ModernButton : Control, IButtonControl { private bool hovered; private bool pressed; @@ -29,13 +59,23 @@ internal sealed class ModernButton : Control [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public bool Primary { get; set; } [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Danger { get; set; } + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Dashed { get; set; } + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string? Icon { get; set; } + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public bool Selected { get => selected; set { selected = value; hoverProgress = value ? 1f : 0f; Invalidate(); } } + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public ContentAlignment TextAlign { get; set; } = ContentAlignment.MiddleCenter; public ModernButton() { - SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.SupportsTransparentBackColor, true); DoubleBuffered = true; + BackColor = Color.Transparent; Size = new Size(148, 42); + Margin = new Padding(0, 0, 10, 0); Font = Theme.Font(10, FontStyle.Bold); ForeColor = Theme.Text; Cursor = Cursors.Hand; @@ -49,32 +89,69 @@ public ModernButton() }; } + protected override void OnEnabledChanged(EventArgs e) + { + if (!Enabled) { hovered = false; pressed = false; hoverProgress = Selected ? 1f : 0f; animation.Stop(); } + Cursor = Enabled ? Cursors.Hand : Cursors.Default; + Invalidate(); + base.OnEnabledChanged(e); + } + protected override void OnMouseEnter(EventArgs e) { hovered = true; animation.Start(); base.OnMouseEnter(e); } protected override void OnMouseLeave(EventArgs e) { hovered = false; pressed = false; animation.Start(); base.OnMouseLeave(e); } protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { pressed = true; Invalidate(); } base.OnMouseDown(e); } protected override void OnMouseUp(MouseEventArgs e) { pressed = false; Invalidate(); base.OnMouseUp(e); } + protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; - var baseColor = Primary ? Theme.Accent : Theme.Surface; - var hoverColor = Primary ? Theme.AccentHover : Theme.SurfaceHover; - var color = Mix(baseColor, hoverColor, hoverProgress); - if (pressed) color = ControlPaint.Dark(color, .12f); - using var path = Rounded(new Rectangle(0, 0, Width - 1, Height - 1), 12); - if (!Enabled) color = Theme.Surface; + var onSurface = SurfaceBehind() == Theme.Surface; + var plain = onSurface ? Theme.SurfaceHover : Theme.Surface; + var plainHover = onSurface ? Theme.SurfaceAlt : Theme.SurfaceHover; + var baseColor = Primary ? Theme.Accent : Danger ? Theme.Mix(plain, Theme.Red, .18f) : plain; + var hoverColor = Primary ? Theme.AccentHover : Danger ? Theme.Mix(plain, Theme.Red, .3f) : plainHover; + var color = Enabled ? Theme.Mix(baseColor, hoverColor, hoverProgress) : Theme.Mix(Theme.Surface, Theme.Background, .45f); + if (Enabled && pressed) color = ControlPaint.Dark(color, .12f); + using var path = Rounded(new Rectangle(0, 0, Width - 1, Height - 1), Math.Min(12, Height / 2)); using var brush = new SolidBrush(color); e.Graphics.FillPath(brush, path); - TextRenderer.DrawText(e.Graphics, Text, Font, ClientRectangle, Enabled ? ForeColor : Theme.Muted, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + if (Dashed) + { + using var pen = new Pen(Theme.Accent, 1) { DashStyle = DashStyle.Dash }; + e.Graphics.DrawPath(pen, path); + } + if (Selected && !Primary) + { + using var bar = new SolidBrush(Theme.Accent); + e.Graphics.FillRectangle(bar, 0, 8, 3, Height - 16); + } + var text = string.IsNullOrEmpty(Icon) ? Text : $"{Icon} {Text}"; + var foreground = !Enabled ? Theme.Muted : Danger ? Theme.Red : Dashed && !Selected ? Theme.AccentHover : ForeColor; + var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | + (TextAlign is ContentAlignment.MiddleLeft ? TextFormatFlags.Left : TextFormatFlags.HorizontalCenter); + var bounds = TextAlign is ContentAlignment.MiddleLeft ? new Rectangle(14, 0, Width - 18, Height) : ClientRectangle; + TextRenderer.DrawText(e.Graphics, text, Font, bounds, foreground, flags); + } + + private Color SurfaceBehind() + { + for (var parent = Parent; parent is not null; parent = parent.Parent) + if (parent.BackColor != Color.Transparent) return parent.BackColor; + return Theme.Background; } - protected override void OnEnabledChanged(EventArgs e) { Invalidate(); base.OnEnabledChanged(e); } - private static Color Mix(Color a, Color b, float amount) => Color.FromArgb((int)(a.R + (b.R - a.R) * amount), (int)(a.G + (b.G - a.G) * amount), (int)(a.B + (b.B - a.B) * amount)); + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public DialogResult DialogResult { get; set; } + public void NotifyDefault(bool value) { } + public void PerformClick() { if (Enabled && Visible) OnClick(EventArgs.Empty); } + protected override void Dispose(bool disposing) { if (disposing) animation.Dispose(); base.Dispose(disposing); } internal static GraphicsPath Rounded(Rectangle bounds, int radius) { var path = new GraphicsPath(); - var d = radius * 2; + if (radius <= 0 || bounds.Width <= 0 || bounds.Height <= 0) { path.AddRectangle(bounds); return path; } + var d = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)); path.AddArc(bounds.Left, bounds.Top, d, d, 180, 90); path.AddArc(bounds.Right - d, bounds.Top, d, d, 270, 90); path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); @@ -84,13 +161,392 @@ internal static GraphicsPath Rounded(Rectangle bounds, int radius) } } -internal sealed class SmoothPanel : Panel +internal class SmoothPanel : Panel { + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public int Radius { get; set; } = 14; + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Color? BorderColor { get; set; } + public SmoothPanel() { DoubleBuffered = true; - SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); + SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true); BackColor = Theme.Surface; Padding = new Padding(20); } + + protected override void OnPaintBackground(PaintEventArgs e) + { + var outside = Parent?.BackColor ?? Theme.Background; + e.Graphics.Clear(outside == Color.Transparent ? Theme.Background : outside); + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using var path = ModernButton.Rounded(new Rectangle(0, 0, Width - 1, Height - 1), Radius); + PaintSurface(e.Graphics, path); + if (BorderColor is { } border) + { + using var pen = new Pen(border, 1.4f); + e.Graphics.DrawPath(pen, path); + } + } + + protected virtual void PaintSurface(Graphics g, GraphicsPath path) + { + using var brush = new SolidBrush(BackColor); + g.FillPath(brush, path); + } +} + +internal sealed class KpiTile : SmoothPanel +{ + private readonly Label title = Ui.Label("", 9, Theme.Muted, FontStyle.Bold); + private readonly Label value = Ui.Label("—", 24, Theme.Text, FontStyle.Bold); + private readonly Label note = Ui.Label("", 9, Theme.Muted); + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Accent { get; set; } + + public KpiTile(string caption, float valueSize = 24) + { + Padding = new Padding(18, 14, 18, 12); + Margin = new Padding(0, 0, 14, 0); + title.Text = caption; + value.Font = Theme.Font(valueSize, FontStyle.Bold); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, BackColor = Color.Transparent }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); + layout.Controls.Add(title, 0, 0); layout.Controls.Add(value, 0, 1); layout.Controls.Add(note, 0, 2); + Controls.Add(layout); + } + + public void Set(string text, string? caption = null, Color? color = null) + { + value.Text = text; + value.ForeColor = color ?? Theme.Text; + note.Text = caption ?? ""; + } + + protected override void PaintSurface(Graphics g, GraphicsPath path) + { + if (!Accent) { base.PaintSurface(g, path); return; } + using var brush = new LinearGradientBrush(ClientRectangle.IsEmpty ? new Rectangle(0, 0, 1, 1) : ClientRectangle, + Theme.Mix(Theme.Surface, Theme.Accent, .42f), Theme.Mix(Theme.Surface, Theme.Violet, .25f), 35f); + g.FillPath(brush, path); + using var pen = new Pen(Color.FromArgb(110, Theme.Accent), 1.2f); + g.DrawPath(pen, path); + } +} + +internal sealed class CardPanel : SmoothPanel +{ + private readonly Label title = Ui.Label("", 12, Theme.Text, FontStyle.Bold); + private readonly Label hint = Ui.Label("", 9, Theme.Muted); + public Panel Body { get; } = new() { Dock = DockStyle.Fill, BackColor = Theme.Surface }; + public FlowLayoutPanel Tools { get; } = new() { Dock = DockStyle.Right, AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Theme.Surface }; + + public CardPanel(string caption, string? subtitle = null) + { + Padding = new Padding(18, 12, 18, 16); + title.Text = caption; + title.AutoSize = true; title.Dock = DockStyle.Left; + hint.Text = subtitle ?? ""; hint.Dock = DockStyle.Fill; hint.TextAlign = ContentAlignment.MiddleLeft; hint.Padding = new Padding(10, 3, 0, 0); + var head = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = Theme.Surface }; + head.Controls.Add(hint); head.Controls.Add(Tools); head.Controls.Add(title); + Controls.Add(Body); Controls.Add(head); + } + + public string Caption { get => title.Text; set => title.Text = value ?? ""; } +} + +internal sealed class ModernComboBox : ComboBox +{ + public ModernComboBox() + { + DropDownStyle = ComboBoxStyle.DropDownList; + FlatStyle = FlatStyle.Flat; + BackColor = Theme.SurfaceHover; + ForeColor = Theme.Text; + Font = Theme.Font(10.5f); + DrawMode = DrawMode.OwnerDrawFixed; + ItemHeight = Font.Height + 10; + Margin = new Padding(0, 4, 10, 0); + } + + protected override void OnDrawItem(DrawItemEventArgs e) + { + var selected = e.Index >= 0 && (e.State & DrawItemState.Selected) != 0 && (e.State & DrawItemState.ComboBoxEdit) == 0; + using var background = new SolidBrush(selected ? Theme.Accent : Theme.SurfaceHover); + e.Graphics.FillRectangle(background, e.Bounds); + if (e.Index < 0) return; + var bounds = new Rectangle(e.Bounds.X + 8, e.Bounds.Y, Math.Max(0, e.Bounds.Width - 16), e.Bounds.Height); + TextRenderer.DrawText(e.Graphics, GetItemText(Items[e.Index]), Font, bounds, Enabled ? Theme.Text : Theme.Muted, + TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } +} + +internal sealed class AutomationHint : SmoothPanel +{ + public AutomationHint(string text) + { + Dock = DockStyle.Top; + Height = 46; + Radius = 12; + Padding = new Padding(16, 0, 16, 0); + BackColor = Theme.Mix(Theme.Background, Theme.Accent, .14f); + BorderColor = Color.FromArgb(120, Theme.Accent); + var label = Ui.Label($"ⓘ {char.ToUpper(text[0])}{text[1..]}", 9.5f, Theme.Text); + label.AutoEllipsis = true; + Controls.Add(label); + } +} + +internal sealed class ProgressLine : Control +{ + private double value; + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public double Value { get => value; set { this.value = Math.Clamp(value, 0, 100); Invalidate(); } } + + public ProgressLine() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.SupportsTransparentBackColor, true); + BackColor = Color.Transparent; + Height = 10; + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + var track = new Rectangle(0, 0, Width - 1, Height - 1); + using (var path = ModernButton.Rounded(track, Height / 2)) + using (var brush = new SolidBrush(Theme.SurfaceAlt)) e.Graphics.FillPath(brush, path); + var width = (int)Math.Round((Width - 1) * value / 100); + if (width < Height) return; + var fill = new Rectangle(0, 0, width, Height - 1); + using var fillPath = ModernButton.Rounded(fill, Height / 2); + using var gradient = new LinearGradientBrush(new Rectangle(0, 0, Math.Max(1, Width), Height), Theme.Accent, Theme.Green, 0f); + e.Graphics.FillPath(gradient, fillPath); + } +} + +internal abstract class Page : UserControl +{ + private readonly Label title = Ui.Label("", 22, Theme.Text, FontStyle.Bold); + private readonly Label subtitle = Ui.Label("", 9.5f, Theme.Muted); + private bool busy; + protected ApiClient Api { get; } + protected FlowLayoutPanel Actions { get; } = new() { Dock = DockStyle.Right, AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Theme.Background, Padding = new Padding(0, 14, 0, 0) }; + protected Panel Body { get; } = new() { Dock = DockStyle.Fill, BackColor = Theme.Background }; + internal MainForm? Shell => FindForm() as MainForm; + + protected Page(ApiClient api, string pageTitle, string pageSubtitle) + { + Api = api; + Dock = DockStyle.Fill; + BackColor = Theme.Background; + DoubleBuffered = true; + Padding = new Padding(30, 22, 30, 22); + title.Text = pageTitle; + subtitle.Text = pageSubtitle; + var text = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Background }; + text.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + text.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + text.Controls.Add(title, 0, 0); text.Controls.Add(subtitle, 0, 1); + var head = new Panel { Dock = DockStyle.Top, Height = 78, BackColor = Theme.Background }; + head.Controls.Add(text); head.Controls.Add(Actions); + Controls.Add(Body); Controls.Add(head); + } + + public string Title { get => title.Text; protected set => title.Text = value; } + public string Subtitle { get => subtitle.Text; protected set => subtitle.Text = value; } + + public abstract Task LoadAsync(); + public virtual Task OnLeaveAsync() => Task.CompletedTask; + + protected async Task RunAsync(Func action, string? success = null) + { + if (busy) return; + busy = true; + UseWaitCursor = true; + try + { + await action(); + if (success is not null) Ui.Toast(this, success); + } + catch (ApiException ex) { Ui.Toast(this, ex.Message, true); } + catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException) { Ui.Toast(this, ex.Message, true); } + finally + { + busy = false; + if (!IsDisposed) UseWaitCursor = false; + } + } +} + +internal static class Ui +{ + /// Ждёт фоновую задачу не дольше timeout и гасит её исключения: зависший драйвер камеры не должен блокировать окно. + public static async Task SettleAsync(Task? task, int timeout = 3000) + { + if (task is null) return true; + if (await Task.WhenAny(task, Task.Delay(timeout)) != task) + { + _ = task.ContinueWith(t => _ = t.Exception, TaskScheduler.Default); + return false; + } + try { await task; } catch { } + return true; + } + + public static Label Label(string text, float size, Color color, FontStyle style = FontStyle.Regular) => new() + { + Text = text, Font = Theme.Font(size, style), ForeColor = color, BackColor = Color.Transparent, + Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, UseMnemonic = false + }; + + public static TextBox Input(string placeholder) => new() + { + PlaceholderText = placeholder, BorderStyle = BorderStyle.FixedSingle, BackColor = Theme.SurfaceHover, + ForeColor = Theme.Text, Font = Theme.Font(11), Margin = new Padding(0, 2, 0, 6) + }; + + public static FlowLayoutPanel Row() => new() + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Color.Transparent, AutoSize = false + }; + + public static DataGridView Grid() + { + var grid = new DataGridView + { + Dock = DockStyle.Fill, BackgroundColor = Theme.Surface, BorderStyle = BorderStyle.None, + CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, GridColor = Theme.Line, + ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None, RowHeadersVisible = false, + EnableHeadersVisualStyles = false, AllowUserToAddRows = false, AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, ReadOnly = true, SelectionMode = DataGridViewSelectionMode.FullRowSelect, + MultiSelect = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + ColumnHeadersHeight = 40, ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, + Font = Theme.Font(10) + }; + typeof(DataGridView).GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)?.SetValue(grid, true); + grid.RowTemplate.Height = 40; + grid.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle { BackColor = Theme.Surface, ForeColor = Theme.Muted, Font = Theme.Font(9, FontStyle.Bold), SelectionBackColor = Theme.Surface, SelectionForeColor = Theme.Muted, Padding = new Padding(8, 0, 0, 0) }; + grid.DefaultCellStyle = new DataGridViewCellStyle { BackColor = Theme.Surface, ForeColor = Theme.Text, SelectionBackColor = Theme.SurfaceHover, SelectionForeColor = Theme.Text, Padding = new Padding(8, 0, 0, 0) }; + grid.AlternatingRowsDefaultCellStyle = new DataGridViewCellStyle { BackColor = Theme.Surface }; + return grid; + } + + public static void FillRounded(Graphics g, Rectangle bounds, int radius, Color color) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + using var path = ModernButton.Rounded(bounds, radius); + using var brush = new SolidBrush(color); + g.FillPath(brush, path); + } + + public static void Toast(Control host, string text, bool error = false) + { + var form = host.FindForm(); + if (form is null || form.IsDisposed) return; + var toast = new ToastLabel(text, error); + form.Controls.Add(toast); + toast.Location = new Point(form.ClientSize.Width - toast.Width - 24, form.ClientSize.Height - toast.Height - 24 - 52 * form.Controls.OfType().Count(t => t != toast)); + toast.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + toast.BringToFront(); + var timer = new System.Windows.Forms.Timer { Interval = 3500 }; + timer.Tick += (_, _) => { timer.Dispose(); if (!toast.IsDisposed) { form.Controls.Remove(toast); toast.Dispose(); } }; + timer.Start(); + } + + public static bool Confirm(IWin32Window? owner, string title, string text, string ok = "Подтвердить", bool danger = false) + { + using var dialog = new DialogShell(title, 520, 230); + var message = Label(text, 10.5f, Theme.Text); + message.TextAlign = ContentAlignment.TopLeft; + dialog.Content.Controls.Add(message); + var confirm = dialog.AddButton(ok, DialogResult.OK, primary: !danger); + confirm.Danger = danger; + dialog.AddButton("Отмена", DialogResult.Cancel); + return dialog.ShowDialog(owner) == DialogResult.OK; + } + + public static string? Prompt(IWin32Window? owner, string title, string label, string initial = "") + { + using var dialog = new DialogShell(title, 520, 240); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); + var input = Input(""); input.Text = initial; input.Dock = DockStyle.Fill; + layout.Controls.Add(Label(label, 9.5f, Theme.Muted), 0, 0); layout.Controls.Add(input, 0, 1); + dialog.Content.Controls.Add(layout); + dialog.AddButton("Сохранить", DialogResult.OK, primary: true); + dialog.AddButton("Отмена", DialogResult.Cancel); + dialog.Shown += (_, _) => { input.Focus(); input.SelectAll(); }; + return dialog.ShowDialog(owner) == DialogResult.OK ? input.Text.Trim() : null; + } + + public static void Message(IWin32Window? owner, string title, string text) + { + using var dialog = new DialogShell(title, 620, 420); + var box = new TextBox { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, BorderStyle = BorderStyle.None, BackColor = Theme.Surface, ForeColor = Theme.Text, Font = Theme.Font(10), Text = text }; + dialog.Content.Controls.Add(box); + dialog.AddButton("Закрыть", DialogResult.OK, primary: true); + dialog.ShowDialog(owner); + } + + private sealed class ToastLabel : Control + { + private readonly bool error; + + public ToastLabel(string text, bool error) + { + this.error = error; + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); + Text = (error ? "⚠ " : "✓ ") + text; + Font = Theme.Font(10, FontStyle.Bold); + var size = TextRenderer.MeasureText(Text, Font); + Size = new Size(Math.Min(560, size.Width + 36), 44); + BackColor = Theme.Background; + } + + protected override void OnPaint(PaintEventArgs e) + { + FillRounded(e.Graphics, new Rectangle(0, 0, Width - 1, Height - 1), 12, error ? Theme.Mix(Theme.SurfaceHover, Theme.Red, .25f) : Theme.SurfaceAlt); + TextRenderer.DrawText(e.Graphics, Text, Font, new Rectangle(16, 0, Width - 24, Height), error ? Theme.Red : Theme.Text, + TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } + } +} + +internal class DialogShell : DarkForm +{ + private readonly FlowLayoutPanel buttons = new() { Dock = DockStyle.Bottom, Height = 58, FlowDirection = FlowDirection.RightToLeft, BackColor = Theme.Background, Padding = new Padding(16, 8, 6, 8) }; + public Panel Content { get; } = new() { Dock = DockStyle.Fill, BackColor = Theme.Background, Padding = new Padding(24, 8, 24, 8) }; + + public DialogShell(string title, int width, int height) + { + Text = title; + ClientSize = new Size(width, height); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + var heading = Ui.Label(title, 15, Theme.Text, FontStyle.Bold); + heading.Dock = DockStyle.Top; heading.Height = 56; heading.Padding = new Padding(24, 10, 0, 0); + Controls.Add(Content); Controls.Add(buttons); Controls.Add(heading); + } + + public ModernButton AddButton(string text, DialogResult result, bool primary = false) + { + var button = new ModernButton { Text = text, Primary = primary, Width = Math.Max(120, TextRenderer.MeasureText(text, Theme.Font(10, FontStyle.Bold)).Width + 40), Height = 40 }; + button.Click += (_, _) => { DialogResult = result; Close(); }; + buttons.Controls.Add(button); + if (primary) AcceptButton = button; + return button; + } + + public FlowLayoutPanel Buttons => buttons; + + protected override bool ProcessDialogKey(Keys keyData) + { + if (keyData == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); return true; } + return base.ProcessDialogKey(keyData); + } } diff --git a/BiStudent/Pages/AdminPage.cs b/BiStudent/Pages/AdminPage.cs new file mode 100644 index 0000000..2275c23 --- /dev/null +++ b/BiStudent/Pages/AdminPage.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class AdminPage : Page +{ + private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU"); + private readonly DataGridView grid = Ui.Grid(); + private readonly ContextMenuStrip menu = new() { BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(10) }; + private readonly ToolStripMenuItem editItem = new("Изменить…"); + private readonly ToolStripMenuItem resetItem = new("Сбросить пароль…"); + private readonly ToolStripMenuItem toggleItem = new(); + + public AdminPage(ApiClient api) : base(api, "Администрирование", "Учётные записи преподавателей и резервные копии") + { + var add = new ModernButton { Text = "Добавить преподавателя", Primary = true, Width = 225 }; + var backups = new ModernButton { Text = "Резервные копии", Width = 175 }; + Actions.Controls.Add(backups); + Actions.Controls.Add(add); + add.Click += async (_, _) => await AddAsync(); + backups.Click += (_, _) => { using var dialog = new BackupDialog(Api); dialog.ShowDialog(FindForm()); }; + + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "ФИО", FillWeight = 30, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Логин", FillWeight = 20, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Роль", FillWeight = 18, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Статус", FillWeight = 20, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Последний вход", FillWeight = 20, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.CellMouseDown += (_, e) => + { + if (e.Button == MouseButtons.Right && e.RowIndex >= 0) + grid.CurrentCell = grid.Rows[e.RowIndex].Cells[0]; + }; + grid.CellDoubleClick += async (_, e) => + { + if (e.RowIndex >= 0 && grid.Rows[e.RowIndex].Tag is TeacherAccountDto teacher) + await EditAsync(teacher); + }; + menu.Items.AddRange([editItem, resetItem, toggleItem]); + menu.Opening += (_, e) => + { + if (SelectedTeacher() is not { } teacher) { e.Cancel = true; return; } + toggleItem.Text = teacher.IsActive ? "Отключить" : "Включить"; + }; + editItem.Click += async (_, _) => { if (SelectedTeacher() is { } teacher) await EditAsync(teacher); }; + resetItem.Click += async (_, _) => { if (SelectedTeacher() is { } teacher) await ResetAsync(teacher); }; + toggleItem.Click += async (_, _) => { if (SelectedTeacher() is { } teacher) await ToggleAsync(teacher); }; + grid.ContextMenuStrip = menu; + + var card = new CardPanel("Преподаватели", "правый клик по строке — действия") { Dock = DockStyle.Fill }; + card.Body.Controls.Add(grid); + Body.Controls.Add(card); + } + + public override Task LoadAsync() => RunAsync(ReloadAsync); + + private TeacherAccountDto? SelectedTeacher() => grid.CurrentRow?.Tag as TeacherAccountDto; + + private async Task ReloadAsync() + { + var teachers = await Api.TeachersAsync(); + if (IsDisposed) return; + grid.Rows.Clear(); + foreach (var teacher in teachers) + { + var status = !teacher.IsActive ? "Отключена" : teacher.MustChangePassword ? "Ждёт смены пароля" : "Активна"; + var row = grid.Rows[grid.Rows.Add( + teacher.FullName, teacher.Login, teacher.IsAdmin ? "Администратор" : "Преподаватель", + status, teacher.LastLoginAt?.ToString("dd.MM.yyyy HH:mm", Ru) ?? "—")]; + row.Tag = teacher; + row.Cells[3].Style.ForeColor = !teacher.IsActive ? Theme.Red : teacher.MustChangePassword ? Theme.Yellow : Theme.Green; + } + grid.ClearSelection(); + } + + private async Task AddAsync() + { + using var dialog = new TeacherDialog(Api, null); + if (dialog.ShowDialog(FindForm()) != DialogResult.OK) return; + if (dialog.TemporaryPassword is { } password) ShowTemporaryPassword(password); + await ReloadWithToastAsync(); + } + + private async Task EditAsync(TeacherAccountDto teacher) + { + using var dialog = new TeacherDialog(Api, teacher); + if (dialog.ShowDialog(FindForm()) == DialogResult.OK) + await RunAsync(ReloadAsync, "Данные преподавателя обновлены"); + } + + private async Task ResetAsync(TeacherAccountDto teacher) + { + if (!Ui.Confirm(FindForm(), "Сбросить пароль", + $"Сбросить пароль преподавателя {teacher.FullName}?", "Сбросить", danger: true)) return; + TemporaryPasswordDto password; + try { password = await Api.ResetTeacherPasswordAsync(teacher.Id); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); return; } + if (IsDisposed) return; + ShowTemporaryPassword(password); + await ReloadWithToastAsync(); + } + + private async Task ReloadWithToastAsync() + { + try { await ReloadAsync(); } + catch (ApiException ex) + { + if (!IsDisposed) Ui.Toast(this, ex.Message, true); + } + } + + private async Task ToggleAsync(TeacherAccountDto teacher) + { + var action = teacher.IsActive ? "Отключить" : "Включить"; + if (!Ui.Confirm(FindForm(), action + " учётную запись", + $"{action} учётную запись преподавателя {teacher.FullName}?", action, danger: teacher.IsActive)) return; + await RunAsync(async () => + { + await Api.UpdateTeacherAsync(teacher.Id, + new SaveTeacherRequest(teacher.Login, teacher.FullName, teacher.IsAdmin, !teacher.IsActive)); + await ReloadAsync(); + }, teacher.IsActive ? "Учётная запись отключена" : "Учётная запись включена"); + } + + private void ShowTemporaryPassword(TemporaryPasswordDto password) + { + using var dialog = new DialogShell("Временный пароль", 600, 300); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 72)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.Controls.Add(Ui.Label($"Логин: {password.Login}", 10, Theme.Text), 0, 0); + var value = Ui.Label(password.Password, 22, Theme.Text, FontStyle.Bold); + value.Font = new Font(FontFamily.GenericMonospace, 22, FontStyle.Bold); + layout.Controls.Add(value, 0, 1); + layout.Controls.Add(Ui.Label("Передайте пароль преподавателю лично. При первом входе система попросит его сменить.", + 9.5f, Theme.Muted), 0, 2); + dialog.Content.Controls.Add(layout); + var copy = new ModernButton { Text = "Скопировать", Width = 145 }; + copy.Click += (_, _) => + { + try + { + Clipboard.SetText(password.Password); + Ui.Toast(dialog, "Пароль скопирован"); + } + catch (ExternalException) { Ui.Toast(dialog, "Не удалось скопировать — буфер обмена занят", true); } + }; + dialog.Buttons.Controls.Add(copy); + dialog.AddButton("Закрыть", DialogResult.OK, primary: true); + dialog.ShowDialog(FindForm()); + } + + protected override void Dispose(bool disposing) + { + if (disposing) menu.Dispose(); + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/AssessmentDialog.cs b/BiStudent/Pages/AssessmentDialog.cs new file mode 100644 index 0000000..85387aa --- /dev/null +++ b/BiStudent/Pages/AssessmentDialog.cs @@ -0,0 +1,113 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class AssessmentDialog : IDisposable +{ + private readonly DialogShell shell; + private readonly ApiClient api; + private readonly int groupId; + private readonly string subject; + private readonly AssessmentDto? assessment; + private readonly IReadOnlyList existing; + private readonly ModernComboBox kind = new() { Dock = DockStyle.Fill }; + private readonly TextBox title = Ui.Input("Название мероприятия"); + private readonly DateTimePicker date = new() + { + Format = DateTimePickerFormat.Custom, CustomFormat = "dd.MM.yyyy", Dock = DockStyle.Fill, + Font = Theme.Font(10), CalendarMonthBackground = Theme.Surface, CalendarForeColor = Theme.Text + }; + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true }; + private readonly ModernButton cancel; + private bool edited; + private bool settingTitle; + private bool saving; + + public AssessmentDialog(ApiClient api, int groupId, string groupName, string subject, + IReadOnlyList existing, AssessmentDto? assessment = null) + { + this.api = api; + this.groupId = groupId; + this.subject = subject; + this.existing = existing; + this.assessment = assessment; + shell = new DialogShell(assessment is null ? "Новое контрольное мероприятие" : "Изменить мероприятие", 540, 420); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, BackColor = Theme.Background }; + foreach (var height in new[] { 28, 28, 28, 42, 28, 42, 28, 42, 36 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.Controls.Add(Ui.Label($"Группа: {groupName}", 10, Theme.Text), 0, 0); + layout.Controls.Add(Ui.Label($"Дисциплина: {subject}", 10, Theme.Text), 0, 1); + layout.Controls.Add(Ui.Label("Тип", 9, Theme.Muted), 0, 2); + foreach (var value in Enum.GetValues()) kind.Items.Add(UiFormat.AssessmentKindText(value)); + kind.SelectedIndex = (int)(assessment?.Kind ?? AssessmentKind.Test); + layout.Controls.Add(kind, 0, 3); + layout.Controls.Add(Ui.Label("Название", 9, Theme.Muted), 0, 4); + title.Dock = DockStyle.Fill; + title.TextChanged += (_, _) => { if (!settingTitle) edited = true; }; + layout.Controls.Add(title, 0, 5); + layout.Controls.Add(Ui.Label("Дата", 9, Theme.Muted), 0, 6); + date.Value = assessment?.Date.ToDateTime(TimeOnly.MinValue) ?? DateTime.Today; + layout.Controls.Add(date, 0, 7); + layout.Controls.Add(error, 0, 8); + shell.Content.Controls.Add(layout); + if (assessment is null) SetSuggestedTitle(); + else { title.Text = assessment.Title; edited = true; } + kind.SelectedIndexChanged += (_, _) => { if (!edited) SetSuggestedTitle(); }; + save.Click += async (_, _) => await SaveAsync(); + shell.Buttons.Controls.Add(save); + cancel = shell.AddButton("Отмена", DialogResult.Cancel); + if (assessment is not null) + { + var delete = new ModernButton { Text = "Удалить", Danger = true }; + delete.Click += async (_, _) => await DeleteAsync(); + shell.Buttons.Controls.Add(delete); + } + shell.AcceptButton = save; + shell.FormClosing += (_, e) => + { + if (!saving) return; + e.Cancel = true; + shell.DialogResult = DialogResult.None; + }; + } + + private void SetSuggestedTitle() + { + var selected = (AssessmentKind)kind.SelectedIndex; + var number = existing.Count(x => x.Kind == selected && x.Id != assessment?.Id) + 1; + settingTitle = true; + title.Text = $"{UiFormat.AssessmentKindText(selected)} №{number}"; + settingTitle = false; + } + + private async Task SaveAsync() + { + if (saving) return; + if (string.IsNullOrWhiteSpace(title.Text)) { error.Text = "Укажите название мероприятия."; return; } + saving = true; save.Enabled = cancel.Enabled = false; error.Text = ""; + try + { + await api.SaveAssessmentAsync(assessment?.Id, new SaveAssessmentRequest(groupId, subject, + title.Text.Trim(), (AssessmentKind)kind.SelectedIndex, DateOnly.FromDateTime(date.Value))); + saving = false; + shell.DialogResult = DialogResult.OK; + shell.Close(); + } + catch (ApiException ex) { error.Text = ex.Message; } + finally { saving = false; if (!shell.IsDisposed) save.Enabled = cancel.Enabled = true; } + } + + private async Task DeleteAsync() + { + if (saving || assessment is null || !Ui.Confirm(shell, "Удалить мероприятие?", + $"Удалить «{assessment.Title}» и выставленные за него баллы?", "Удалить", danger: true)) return; + saving = true; save.Enabled = cancel.Enabled = false; error.Text = ""; + try { await api.DeleteAssessmentAsync(assessment.Id); saving = false; shell.DialogResult = DialogResult.OK; shell.Close(); } + catch (ApiException ex) { error.Text = ex.Message; } + finally { saving = false; if (!shell.IsDisposed) save.Enabled = cancel.Enabled = true; } + } + + public DialogResult ShowDialog(IWin32Window? owner) => shell.ShowDialog(owner); + public void Dispose() => shell.Dispose(); +} diff --git a/BiStudent/Pages/AttendanceList.cs b/BiStudent/Pages/AttendanceList.cs new file mode 100644 index 0000000..d87c9d0 --- /dev/null +++ b/BiStudent/Pages/AttendanceList.cs @@ -0,0 +1,177 @@ +using System.Drawing.Drawing2D; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class AttendanceList : Panel +{ + private const int RowHeight = 60; + private static readonly (AttendanceStatus Status, string Short, string Long)[] Buttons = + [ + (AttendanceStatus.Present, "П", "Был"), (AttendanceStatus.Absent, "Н", "Нет"), + (AttendanceStatus.Excused, "Ув.", "Уважит.") + ]; + + private readonly Canvas canvas; + private readonly Dictionary flashes = []; + private readonly System.Windows.Forms.Timer flashTimer = new() { Interval = 60 }; + private List rows = []; + private (int Row, int Button) hover = (-1, -1); + + public AttendanceList(bool wideButtons = false) + { + WideButtons = wideButtons; + AutoScroll = true; + BackColor = Theme.Surface; + DoubleBuffered = true; + canvas = new Canvas(this) { Location = Point.Empty }; + Controls.Add(canvas); + flashTimer.Tick += (_, _) => + { + foreach (var id in flashes.Where(f => (DateTime.Now - f.Value).TotalSeconds > 1.8).Select(f => f.Key).ToList()) flashes.Remove(id); + if (flashes.Count == 0) flashTimer.Stop(); + canvas.Invalidate(); + }; + Resize += (_, _) => Relayout(); + } + + public bool WideButtons { get; } + public string EmptyText { get; set; } = "Список пуст"; + public event Func? StatusChosen; + public event Func? GradeChosen; + public IReadOnlyList Rows => rows; + + public void SetRows(List value) + { + rows = value; + Relayout(); + canvas.Invalidate(); + } + + public void Flash(int studentId) + { + flashes[studentId] = DateTime.Now; + flashTimer.Start(); + } + + private void Relayout() + { + canvas.Width = Math.Max(10, ClientSize.Width); + canvas.Height = Math.Max(ClientSize.Height, rows.Count * RowHeight + 4); + } + + private int ButtonWidth => WideButtons ? 78 : 36; + + private Rectangle GradeBounds(int row) => + new(canvas.Width - 86, row * RowHeight + (RowHeight - 32) / 2, 74, 32); + + private Rectangle ButtonBounds(int row, int index) + { + var width = ButtonWidth; + var x = canvas.Width - 94 - (Buttons.Length - index) * (width + 4); + return new Rectangle(x, row * RowHeight + (RowHeight - 32) / 2, width, 32); + } + + private (int Row, int Button) HitTest(Point point) + { + var row = point.Y / RowHeight; + if (row < 0 || row >= rows.Count) return (-1, -1); + if (GradeBounds(row).Contains(point)) return (row, -2); + for (var i = 0; i < Buttons.Length; i++) + if (ButtonBounds(row, i).Contains(point)) return (row, i); + return (row, -1); + } + + protected override void Dispose(bool disposing) + { + if (disposing) flashTimer.Dispose(); + base.Dispose(disposing); + } + + private sealed class Canvas : Control + { + private readonly AttendanceList owner; + + public Canvas(AttendanceList owner) + { + this.owner = owner; + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Theme.Surface; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + var hit = owner.HitTest(e.Location); + if (hit != owner.hover) { owner.hover = hit; Invalidate(); } + Cursor = hit.Button != -1 ? Cursors.Hand : Cursors.Default; + base.OnMouseMove(e); + } + + protected override void OnMouseLeave(EventArgs e) { owner.hover = (-1, -1); Invalidate(); base.OnMouseLeave(e); } + + protected override async void OnMouseClick(MouseEventArgs e) + { + base.OnMouseClick(e); + if (e.Button != MouseButtons.Left) return; + var (row, button) = owner.HitTest(e.Location); + if (row >= 0 && button == -2) + { + if (owner.GradeChosen is not null) await owner.GradeChosen(owner.rows[row]); + return; + } + if (row < 0 || button < 0 || owner.StatusChosen is null) return; + var item = owner.rows[row]; + var status = Buttons[button].Status; + await owner.StatusChosen(item, item.Status == status ? null : status); + } + + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + if (owner.rows.Count == 0) + { + TextRenderer.DrawText(g, owner.EmptyText, Theme.Font(10), new Rectangle(0, 20, Width, 30), Theme.Muted, TextFormatFlags.HorizontalCenter); + return; + } + var first = Math.Max(0, e.ClipRectangle.Top / RowHeight); + var last = Math.Min(owner.rows.Count - 1, e.ClipRectangle.Bottom / RowHeight); + for (var i = first; i <= last; i++) + { + var row = owner.rows[i]; + var bounds = new Rectangle(0, i * RowHeight, Width, RowHeight); + if (owner.flashes.TryGetValue(row.StudentId, out var started)) + { + var alpha = (int)Math.Clamp(110 * (1 - (DateTime.Now - started).TotalSeconds / 1.8), 0, 110); + Ui.FillRounded(g, Rectangle.Inflate(bounds, -2, -3), 10, Color.FromArgb(alpha, Theme.Green)); + } + else if (owner.hover is var (hoverRow, _) && hoverRow == i) Ui.FillRounded(g, Rectangle.Inflate(bounds, -2, -3), 10, Theme.SurfaceHover); + var textWidth = owner.ButtonBounds(i, 0).Left - 18; + TextRenderer.DrawText(g, row.FullName, Theme.Font(10, FontStyle.Bold), new Rectangle(12, bounds.Top + 7, textWidth, 22), Theme.Text, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + var info = row.Status is null ? "ждём отметки" + : row.Status == AttendanceStatus.Excused + ? $"{UiFormat.ExcuseReasonText(row.Reason ?? ExcuseReason.Other)}{(row.HasDocument ? " · документ" : "")}" + : $"{UiFormat.StatusText(row.Status)} · {UiFormat.MethodText(row.Method)}{(row.MarkedAt is { } at ? $" · {at:HH:mm}" : "")}"; + TextRenderer.DrawText(g, info, Theme.Font(8.5f), new Rectangle(12, bounds.Top + 29, textWidth, 18), row.Status is null ? Theme.Muted : UiFormat.StatusColor(row.Status), TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + var grade = owner.GradeBounds(i); + Ui.FillRounded(g, grade, 9, owner.hover == (i, -2) ? Theme.SurfaceAlt : Theme.SurfaceHover); + TextRenderer.DrawText(g, row.Score?.ToString() ?? "Балл", Theme.Font(9.5f, FontStyle.Bold), grade, row.Score.HasValue ? Theme.Text : Theme.Muted, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + for (var b = 0; b < Buttons.Length; b++) + { + var rect = owner.ButtonBounds(i, b); + var status = Buttons[b].Status; + var active = row.Status == status; + var color = UiFormat.StatusColor(status); + var hovered = owner.hover; + var fill = active ? color : hovered == (i, b) ? Theme.SurfaceAlt : Theme.SurfaceHover; + Ui.FillRounded(g, rect, 9, fill); + TextRenderer.DrawText(g, owner.WideButtons ? Buttons[b].Long : Buttons[b].Short, Theme.Font(9.5f, FontStyle.Bold), rect, + active ? Theme.Background : color, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + } + using var line = new Pen(Theme.Line); + g.DrawLine(line, 8, bounds.Bottom - 1, Width - 8, bounds.Bottom - 1); + } + } + } +} diff --git a/BiStudent/Pages/BackupDialog.cs b/BiStudent/Pages/BackupDialog.cs new file mode 100644 index 0000000..3973825 --- /dev/null +++ b/BiStudent/Pages/BackupDialog.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class BackupDialog : DarkForm +{ + private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU"); + private readonly ApiClient api; + private readonly DataGridView grid = Ui.Grid(); + private readonly Label error = Ui.Label("", 9.5f, Theme.Red); + private readonly ModernButton create = new() { Text = "Создать копию сейчас", Primary = true, Width = 188 }; + private readonly ModernButton download = new() { Text = "Скачать…", Width = 112 }; + private readonly ModernButton restore = new() { Text = "Восстановить из файла…", Width = 204 }; + private readonly ModernButton close = new() { Text = "Закрыть", Width = 100 }; + private bool busy; + + public BackupDialog(ApiClient api) + { + this.api = api; + Text = "Резервные копии"; + ClientSize = new Size(760, 560); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + + var heading = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background }; + var title = Ui.Label("Резервные копии", 16, Theme.Text, FontStyle.Bold); + title.Dock = DockStyle.Top; + title.Height = 42; + var hint = Ui.Label("Сервер сам делает копию раз в сутки и хранит последние 7. Копия содержит ключи шифрования образцов лиц — храните файл как пароль.", 9.5f, Theme.Muted); + hint.TextAlign = ContentAlignment.TopLeft; + heading.Controls.Add(hint); + heading.Controls.Add(title); + + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Дата", FillWeight = 45, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Тип", FillWeight = 25, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Размер", FillWeight = 25, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.SelectionChanged += (_, _) => download.Enabled = !busy && SelectedBackup() is not null; + error.TextAlign = ContentAlignment.MiddleLeft; + + var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, BackColor = Theme.Background, Padding = new Padding(0, 10, 0, 0) }; + buttons.Controls.Add(close); + buttons.Controls.Add(restore); + buttons.Controls.Add(download); + buttons.Controls.Add(create); + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(24, 16, 24, 16), BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 102)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + layout.Controls.Add(heading, 0, 0); + layout.Controls.Add(grid, 0, 1); + layout.Controls.Add(error, 0, 2); + layout.Controls.Add(buttons, 0, 3); + Controls.Add(layout); + + create.Click += async (_, _) => await RunAsync(async () => + { + await api.CreateBackupAsync(); + await LoadAsync(); + Ui.Toast(this, "Копия создана"); + }); + download.Click += async (_, _) => await RunAsync(DownloadAsync); + restore.Click += async (_, _) => await RunAsync(RestoreAsync); + close.Click += (_, _) => Close(); + Shown += async (_, _) => await RunAsync(LoadAsync); + } + + private BackupFileDto? SelectedBackup() => grid.SelectedRows.Count == 0 ? null : grid.SelectedRows[0].Tag as BackupFileDto; + + private async Task LoadAsync() + { + var files = await api.BackupsAsync(); + grid.Rows.Clear(); + foreach (var file in files) + { + var row = grid.Rows[grid.Rows.Add( + file.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm", Ru), + file.Automatic ? "авто" : "вручную", + FormatSize(file.Size))]; + row.Tag = file; + } + grid.ClearSelection(); + if (grid.Rows.Count > 0) grid.Rows[0].Selected = true; + download.Enabled = !busy && SelectedBackup() is not null; + } + + private static string FormatSize(long size) + { + var unit = size >= 1024 * 1024 ? "МБ" : "КБ"; + var value = size >= 1024 * 1024 ? size / (1024d * 1024) : size / 1024d; + return $"{value.ToString("0.#", Ru)} {unit}"; + } + + private async Task DownloadAsync() + { + var selected = SelectedBackup(); + if (selected is null) return; + var (content, fileName) = await api.DownloadBackupAsync(selected.Name); + using var dialog = new SaveFileDialog { Filter = "Архив ZIP (*.zip)|*.zip", FileName = Path.GetFileName(fileName), DefaultExt = "zip" }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + await File.WriteAllBytesAsync(dialog.FileName, content); + Ui.Toast(this, "Копия сохранена"); + } + + private async Task RestoreAsync() + { + using var dialog = new OpenFileDialog { Filter = "Архив ZIP (*.zip)|*.zip" }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + if (!Ui.Confirm(this, "Восстановить данные?", + "Текущие данные будут заменены данными из копии. Перед заменой сервер сохранит их в отдельную копию.", + "Восстановить", danger: true)) return; + var content = await File.ReadAllBytesAsync(dialog.FileName); + var result = await api.RestoreAsync(content); + Ui.Message(this, "Восстановление", result.Message + Environment.NewLine + Environment.NewLine + + "Сейчас приложение вернётся к окну входа."); + try { await LoadAsync(); } + catch (ApiException) { } + } + + private async Task RunAsync(Func action) + { + if (busy) return; + busy = true; + SetBusy(true); + error.Text = ""; + UseWaitCursor = true; + try { await action(); } + catch (ApiException ex) { error.Text = ex.Message; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { error.Text = ex.Message; } + finally + { + busy = false; + if (!IsDisposed) + { + SetBusy(false); + UseWaitCursor = false; + } + } + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (busy && e.CloseReason == CloseReason.UserClosing) e.Cancel = true; + base.OnFormClosing(e); + } + + private void SetBusy(bool value) + { + create.Enabled = !value; + download.Enabled = !value && SelectedBackup() is not null; + restore.Enabled = !value; + close.Enabled = !value; + } +} diff --git a/BiStudent/Pages/CameraPage.cs b/BiStudent/Pages/CameraPage.cs new file mode 100644 index 0000000..3bea361 --- /dev/null +++ b/BiStudent/Pages/CameraPage.cs @@ -0,0 +1,592 @@ +using BiStudent.Contracts; +using OpenCvSharp; + +namespace BiStudent; + +internal sealed class CameraPage : Page +{ + private sealed record LessonChoice(LessonDto Lesson) + { + public override string ToString() => $"{UiFormat.Time(Lesson.Slot.Start)} · {Lesson.Slot.Subject} · {Lesson.Slot.GroupName}"; + } + + private readonly ModernComboBox lessons = new() { Dock = DockStyle.Fill }; + private readonly ModernComboBox cameras = new() { Dock = DockStyle.Fill }; + private readonly ModernButton start = new() { Text = "Начать отметку", Primary = true, Width = 170 }; + private readonly ModernButton stop = new() { Text = "Остановить", Width = 140 }; + private readonly ModernButton finish = new() { Text = "Завершить занятие", Danger = true, Width = 200 }; + private readonly PictureBox preview = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.Sidebar }; + private readonly Label placeholder = Ui.Label("Камера выключена. Выберите занятие и нажмите «Начать отметку».", 10, Theme.Muted); + private readonly Label banner = Ui.Label("", 9.5f, Theme.Red); + private readonly Label marked = Ui.Label("0 из 0 отмечено", 15, Theme.Text, FontStyle.Bold); + private readonly Label automatic = Ui.Label("0 камерой", 12, Theme.Green, FontStyle.Bold); + private readonly Label saved = Ui.Label("", 10, Theme.Muted); + private readonly Label indicator = Ui.Label("Камера выключена", 9.5f, Theme.Muted); + private readonly AttendanceList attendance = new() { Dock = DockStyle.Fill, EmptyText = "Выберите занятие, чтобы увидеть список" }; + private readonly CardPanel students = new("Студенты группы"); + private readonly System.Windows.Forms.Timer refresh = new() { Interval = 2000 }; + private readonly SemaphoreSlim attendanceWrite = new(1, 1); + private LessonDto? selectedLesson; + private DateOnly? loadedDate; + private AutomationSettingsDto? automation; + private RecognitionResultDto? lastResult; + private DateTime resultAt; + private CancellationTokenSource? cameraCts; + private Task? cameraTask; + private Task? recognitionTask; + private Task? stoppingTask; + private long recognitionPauseUntil; + private int pendingFrame; + private int recognitionBusy; + private bool recognitionOk; + private bool running; + private bool loading; + private bool leftPage; + private bool refreshing; + private bool refreshAgain; + private bool manualBusy; + private bool finishing; + + public CameraPage(ApiClient api) : base(api, "Отметка посещаемости", "Камера распознаёт студентов группы и отмечает присутствие во время занятия") + { + var left = new CardPanel("Камера", "выберите занятие и устройство") { Dock = DockStyle.Fill, Margin = new Padding(0, 0, 14, 0) }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 7, BackColor = Theme.Surface }; + foreach (var height in new[] { 48, 48, 50, 42 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 72)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 57)); + layout.Controls.Add(Field("Занятие", lessons), 0, 0); + layout.Controls.Add(Field("Камера", cameras), 0, 1); + var buttons = Ui.Row(); + buttons.Controls.Add(start); buttons.Controls.Add(stop); + layout.Controls.Add(buttons, 0, 2); + banner.TextAlign = ContentAlignment.MiddleLeft; + banner.AutoEllipsis = true; + layout.Controls.Add(banner, 0, 3); + var video = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Sidebar }; + placeholder.TextAlign = ContentAlignment.MiddleCenter; + video.Controls.Add(preview); preview.Controls.Add(placeholder); + placeholder.BringToFront(); + layout.Controls.Add(video, 0, 4); + var statistics = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 2, BackColor = Theme.Surface }; + statistics.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 55)); + statistics.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 45)); + statistics.RowStyles.Add(new RowStyle(SizeType.Percent, 55)); + statistics.RowStyles.Add(new RowStyle(SizeType.Percent, 45)); + statistics.Controls.Add(marked, 0, 0); + statistics.Controls.Add(automatic, 1, 0); + statistics.Controls.Add(saved, 0, 1); + statistics.SetColumnSpan(saved, 2); + layout.Controls.Add(statistics, 0, 5); + var foot = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Surface }; + foot.RowStyles.Add(new RowStyle(SizeType.Absolute, 23)); + foot.Controls.Add(indicator, 0, 0); + var warning = Ui.Label("Зелёная — студент группы · синяя — уже отмечен · серая — другая группа · красная — не распознан", 8, Theme.Muted); + warning.AutoEllipsis = true; + foot.Controls.Add(warning, 0, 1); + layout.Controls.Add(foot, 0, 6); + left.Body.Controls.Add(layout); + + students.Dock = DockStyle.Fill; + students.Margin = Padding.Empty; + var listArea = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Surface }; + finish.Dock = DockStyle.Bottom; + finish.Height = 42; + listArea.Controls.Add(attendance); + listArea.Controls.Add(finish); + students.Body.Controls.Add(listArea); + var columns = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, BackColor = Theme.Background }; + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 57)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 43)); + columns.Controls.Add(left, 0, 0); columns.Controls.Add(students, 1, 0); + Body.Controls.Add(columns); + + lessons.SelectedIndexChanged += async (_, _) => + { + if (!loading && !running) await SelectLessonAsync(); + }; + cameras.SelectedIndexChanged += (_, _) => + { + if (loading || cameras.SelectedItem is not CameraDevice device) return; + try { CameraDevices.Save(ClientSettings.Load(), device); } + catch (IOException ex) { Ui.Toast(this, ex.Message, true); } + }; + start.Click += (_, _) => StartCamera(); + stop.Click += async (_, _) => await StopAsync(); + finish.Click += async (_, _) => await FinishAsync(); + attendance.StatusChosen += MarkAsync; + attendance.GradeChosen += GradeAsync; + refresh.Tick += async (_, _) => + { + if (loading) return; + if (!running && loadedDate != DateOnly.FromDateTime(DateTime.Now)) await RunAsync(LoadAsync); + else await RefreshAttendanceAsync(); + }; + SetControls(); + } + + private static Control Field(string caption, Control input) + { + var row = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, BackColor = Theme.Surface }; + row.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 78)); + row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + row.Controls.Add(Ui.Label(caption, 9.5f, Theme.Muted), 0, 0); + row.Controls.Add(input, 1, 0); + return row; + } + + public override async Task LoadAsync() + { + if (loading || IsDisposed) return; + loading = true; + indicator.Text = "Загрузка расписания и настроек…"; + start.Enabled = false; + try + { + var pending = Shell?.PendingCameraLesson; + if (Shell is { } shell) shell.PendingCameraLesson = null; + var date = DateOnly.FromDateTime(DateTime.Now); + var dayTask = Api.DayAsync(date); + var automationTask = Api.AutomationAsync(); + var statusTask = Api.RecognitionStatusAsync(); + await Task.WhenAll(dayTask, automationTask, statusTask); + if (IsDisposed) return; + automation = await automationTask; + var status = await statusTask; + recognitionOk = status.Ok; + SetBanner(status.Ok ? "" : status.Message); + var day = (await dayTask).Where(item => !item.Cancelled).OrderBy(item => item.StartsAt).ToList(); + lessons.Items.Clear(); + foreach (var item in day) lessons.Items.Add(new LessonChoice(item)); + var now = DateTime.Now; + var choice = day.FirstOrDefault(item => pending is not null && item.Slot.Id == pending.Slot.Id && item.Date == pending.Date) + ?? day.FirstOrDefault(item => item.StartsAt <= now && now < item.EndsAt) + ?? day.FirstOrDefault(item => item.StartsAt > now) + ?? day.LastOrDefault(); + if (choice is not null) + lessons.SelectedIndex = day.FindIndex(item => ReferenceEquals(item, choice)); + selectedLesson = choice; + attendance.SetRows([]); + SetStatistics([]); + students.Caption = choice?.Slot.GroupName ?? "Студенты группы"; + placeholder.Text = choice is null + ? "Сегодня занятий нет" + : $"Камера выключена. Выберите занятие и нажмите «Начать отметку».\nОтметка ставится после {automation.FramesInRow} кадров подряд."; + placeholder.Visible = true; + var devices = CameraDevices.List(); + cameras.Items.Clear(); + foreach (var device in devices) cameras.Items.Add(device); + if (devices.Count > 0) + { + var index = CameraDevices.ResolveIndex(ClientSettings.Load(), devices); + cameras.SelectedIndex = Math.Max(0, devices.FindIndex(device => device.Index == index)); + } + indicator.Text = choice is null ? "Сегодня занятий нет" : devices.Count == 0 ? "Камера не найдена" : "Камера выключена"; + await RefreshAttendanceAsync(); + loadedDate = date; + if (!IsDisposed && !leftPage) refresh.Start(); + } + finally + { + loading = false; + if (!IsDisposed) SetControls(); + } + } + + private async Task SelectLessonAsync() + { + selectedLesson = (lessons.SelectedItem as LessonChoice)?.Lesson; + students.Caption = selectedLesson?.Slot.GroupName ?? "Студенты группы"; + attendance.SetRows([]); + SetStatistics([]); + SetControls(); + await RefreshAttendanceAsync(); + } + + private async Task RefreshAttendanceAsync(CancellationToken token = default) + { + if (refreshing) { refreshAgain = true; return; } + if (selectedLesson is null) return; + refreshing = true; + try + { + do + { + refreshAgain = false; + var lesson = selectedLesson; + if (lesson is null) break; + attendance.EmptyText = "Загрузка списка…"; + try + { + var rows = await Api.AttendanceAsync(lesson.Slot.Id, lesson.Date, token); + if (IsDisposed) return; + if (ReferenceEquals(lesson, selectedLesson)) + { + attendance.EmptyText = "В группе нет студентов"; + attendance.SetRows(rows); + SetStatistics(rows); + } + else refreshAgain = true; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { return; } + catch (ApiException ex) + { + if (!IsDisposed && !token.IsCancellationRequested) Ui.Toast(this, ex.Message, true); + return; + } + } while (refreshAgain); + } + finally { refreshing = false; } + } + + private void SetStatistics(IReadOnlyList rows) + { + var count = rows.Count(row => row.Status is not null); + var cameraCount = rows.Count(row => row.Status is not null && row.Method == MarkMethod.Camera); + marked.Text = $"{count} из {rows.Count} отмечено"; + automatic.Text = $"{cameraCount} камерой"; + saved.Text = ""; + } + + private async Task MarkAsync(AttendanceRowDto row, AttendanceStatus? status) + { + if (manualBusy || finishing || selectedLesson is not { } lesson) return; + manualBusy = true; + SetControls(); + try + { + await attendanceWrite.WaitAsync(); + try { await Api.MarkAsync(lesson.Slot.Id, lesson.Date, row.StudentId, status); } + finally { attendanceWrite.Release(); } + await RefreshAttendanceAsync(); + } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + finally + { + manualBusy = false; + if (!IsDisposed) SetControls(); + } + } + + private async Task GradeAsync(AttendanceRowDto row) + { + if (selectedLesson is not { } lesson) return; + var column = new JournalColumnDto(JournalColumnKind.Lesson, lesson.Slot.Id, lesson.Date, + lesson.Slot.Start, lesson.Slot.Subject); + var cell = new JournalCellDto(row.Status, row.Reason, row.Comment, row.Score); + using var editor = new GradeEditor(Api, column, cell, row.StudentId, row.FullName, scoreOnly: true, attendance: row); + var changed = false; + editor.Saved += () => changed = true; + editor.ShowDialog(FindForm(), Cursor.Position); + if (changed) await RefreshAttendanceAsync(); + } + + private async Task FinishAsync() + { + if (finishing || selectedLesson is not { } lesson) return; + if (!Ui.Confirm(FindForm(), "Завершить занятие", + "Всем студентам без отметки будет поставлено «Н».", + "Завершить занятие", danger: true)) return; + finishing = true; + SetControls(); + try + { + await StopAsync(); + await attendanceWrite.WaitAsync(); + try { await Api.FinishAsync(lesson.Slot.Id, lesson.Date); } + finally { attendanceWrite.Release(); } + await RefreshAttendanceAsync(); + Ui.Toast(this, "Занятие завершено"); + } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + finally + { + finishing = false; + if (!IsDisposed) SetControls(); + } + } + + private void StartCamera() + { + if (running || stoppingTask is not null || !recognitionOk || selectedLesson is not { } lesson + || cameras.SelectedItem is not CameraDevice device) return; + cameraCts = new CancellationTokenSource(); + var token = cameraCts.Token; + recognitionTask = null; + lastResult = null; + Interlocked.Exchange(ref recognitionBusy, 0); + Interlocked.Exchange(ref recognitionPauseUntil, 0); + running = true; + placeholder.Visible = false; + indicator.Text = "Подключение к камере…"; + SetControls(); + cameraTask = Task.Run(() => CameraLoopAsync(device, lesson, token), token); + } + + private async Task CameraLoopAsync(CameraDevice device, LessonDto lesson, CancellationToken token) + { + VideoCapture? capture = null; + var nextRetry = DateTime.MinValue; + var noFrameSince = DateTime.UtcNow; + var lastRequest = DateTime.MinValue; + var firstFrame = true; + try + { + while (!token.IsCancellationRequested) + { + var now = DateTime.UtcNow; + if (capture is null && now >= nextRetry) + { + try + { + var current = CameraDevices.Find(CameraDevices.List(), device.Id) + ?? throw new InvalidOperationException("Выбранная камера не найдена."); + capture = CameraDevices.Open(current.Index, 960, 540, allowFallback: true); + noFrameSince = DateTime.UtcNow; + firstFrame = true; + } + catch (Exception ex) + { + capture?.Dispose(); + capture = null; + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostUi(() => indicator.Text = $"Камера недоступна: {ex.Message}. Повтор через 2 с…"); + } + } + if (capture is not null) + { + try + { + using var frame = new Mat(); + if (capture.Read(frame) && !frame.Empty()) + { + noFrameSince = DateTime.UtcNow; + if (firstFrame) + { + firstFrame = false; + PostUi(() => indicator.Text = "● ищу лица"); + } + if (Interlocked.CompareExchange(ref pendingFrame, 1, 0) == 0) + { + Bitmap? bitmap = null; + byte[]? jpeg = null; + var reserved = false; + var posted = false; + try + { + Cv2.ImEncode(".bmp", frame, out var bytes); + using (var stream = new MemoryStream(bytes)) + using (var decoded = new Bitmap(stream)) bitmap = new Bitmap(decoded); + now = DateTime.UtcNow; + if ((now - lastRequest).TotalMilliseconds >= 350 + && now.Ticks >= Interlocked.Read(ref recognitionPauseUntil) + && Interlocked.CompareExchange(ref recognitionBusy, 1, 0) == 0) + { + reserved = true; + using var small = new Mat(); + Cv2.Resize(frame, small, new OpenCvSharp.Size(640, Math.Max(1, (int)Math.Round(frame.Height * 640d / frame.Width)))); + Cv2.ImEncode(".jpg", small, out jpeg, new ImageEncodingParam(ImwriteFlags.JpegQuality, 80)); + lastRequest = now; + } + var image = bitmap; + if (PostUi(() => FrameReady(image, jpeg, token))) { posted = true; bitmap = null; } + } + finally + { + bitmap?.Dispose(); + if (!posted) + { + Interlocked.Exchange(ref pendingFrame, 0); + if (reserved) Interlocked.Exchange(ref recognitionBusy, 0); + } + } + } + } + else if ((DateTime.UtcNow - noFrameSince).TotalSeconds > 3) + { + capture.Dispose(); + capture = null; + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostUi(() => indicator.Text = "Нет кадров с камеры — переподключение…"); + } + } + catch (Exception ex) when (!token.IsCancellationRequested) + { + capture?.Dispose(); + capture = null; + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostUi(() => indicator.Text = $"Ошибка камеры: {ex.Message}. Переподключение…"); + } + } + await Task.Delay(40, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + finally { capture?.Dispose(); } + } + + private bool PostUi(Action action) + { + try { BeginInvoke(action); return true; } + catch (InvalidOperationException) { return false; } + } + + private void FrameReady(Bitmap bitmap, byte[]? jpeg, CancellationToken token) + { + Interlocked.Exchange(ref pendingFrame, 0); + if (IsDisposed || token.IsCancellationRequested) + { + bitmap.Dispose(); + if (jpeg is not null) Interlocked.Exchange(ref recognitionBusy, 0); + return; + } + DrawFaces(bitmap); + var old = preview.Image; + preview.Image = bitmap; + old?.Dispose(); + if (jpeg is not null && selectedLesson is { } lesson) + recognitionTask = RecognizeFrameAsync(lesson, jpeg, token); + else if (jpeg is not null) Interlocked.Exchange(ref recognitionBusy, 0); + } + + private void DrawFaces(Bitmap bitmap) + { + if (lastResult is not { } result || (DateTime.UtcNow - resultAt).TotalSeconds > 1.5 || result.FrameWidth <= 0) return; + var scale = bitmap.Width / (float)result.FrameWidth; + using var graphics = Graphics.FromImage(bitmap); + graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + var fontPixels = Math.Clamp(bitmap.Width / 38, 14, 40); + var stroke = Math.Max(3, bitmap.Width / 260); + var accent = Math.Max(3, fontPixels / 5); + using var font = new Font("Segoe UI", fontPixels, FontStyle.Bold, GraphicsUnit.Pixel); + foreach (var face in result.Faces) + { + var color = face.StudentId is null ? Theme.Red : face.OtherGroup ? Theme.Muted + : face.Marked || face.AlreadyMarked ? Theme.Accent : Theme.Green; + var bounds = new Rectangle((int)(face.X * scale), (int)(face.Y * scale), + Math.Max(1, (int)(face.W * scale)), Math.Max(1, (int)(face.H * scale))); + using var pen = new Pen(color, stroke); + graphics.DrawRectangle(pen, bounds); + var text = face.StudentId is null ? "Не распознан" : face.Name ?? "Студент"; + var size = TextRenderer.MeasureText(graphics, text, font, System.Drawing.Size.Empty, TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix); + var pad = Math.Max(6, fontPixels / 3); + var width = Math.Min(bitmap.Width, size.Width + pad * 2 + accent); + var height = size.Height + pad; + var top = bounds.Top - height - stroke >= 0 ? bounds.Top - height - stroke : Math.Min(bitmap.Height - height, bounds.Bottom + stroke); + var label = new Rectangle(Math.Clamp(bounds.Left, 0, Math.Max(0, bitmap.Width - width)), Math.Max(0, top), width, height); + Ui.FillRounded(graphics, label, pad, Color.FromArgb(225, Theme.Sidebar)); + Ui.FillRounded(graphics, new Rectangle(label.X, label.Y, accent * 2, label.Height), accent, color); + TextRenderer.DrawText(graphics, text, font, new Rectangle(label.X + accent + pad, label.Y, label.Width - accent - pad, label.Height), color, + TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | TextFormatFlags.NoPadding); + } + } + + private async Task RecognizeFrameAsync(LessonDto lesson, byte[] jpeg, CancellationToken token) + { + try + { + var result = await Api.RecognizeAsync(lesson.Slot.Id, lesson.Date, jpeg, token); + if (IsDisposed || token.IsCancellationRequested) return; + lastResult = result; + resultAt = DateTime.UtcNow; + indicator.Text = result.Faces.Count == 0 ? "● ищу лица" : $"● в кадре лиц: {result.Faces.Count}"; + SetBanner(result.Error ?? ""); + if (!string.IsNullOrWhiteSpace(result.Error)) Interlocked.Exchange(ref recognitionPauseUntil, DateTime.UtcNow.AddSeconds(2).Ticks); + var newlyMarked = result.Faces.Where(face => face.Marked && face.StudentId is not null) + .DistinctBy(face => face.StudentId); + var any = false; + foreach (var face in newlyMarked) + { + any = true; + attendance.Flash(face.StudentId!.Value); + Ui.Toast(this, $"{face.Name ?? "Студент"} — отмечен камерой"); + } + if (any) await RefreshAttendanceAsync(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (ApiException ex) + { + if (!IsDisposed && !token.IsCancellationRequested) + { + indicator.Text = ex.Message; + Interlocked.Exchange(ref recognitionPauseUntil, DateTime.UtcNow.AddSeconds(2).Ticks); + } + } + finally { Interlocked.Exchange(ref recognitionBusy, 0); } + } + + private void SetBanner(string message) + { + banner.Text = string.IsNullOrWhiteSpace(message) ? "" : $"⚠ {message}"; + } + + private void SetControls() + { + lessons.Enabled = !running && !finishing && !manualBusy && lessons.Items.Count > 0; + cameras.Enabled = !running && cameras.Items.Count > 0; + start.Enabled = !running && !finishing && !manualBusy && recognitionOk && selectedLesson is not null && cameras.SelectedItem is CameraDevice; + stop.Enabled = running; + finish.Enabled = !finishing && !manualBusy && selectedLesson is not null; + attendance.Enabled = !manualBusy && !finishing; + } + + private async Task StopAsync() + { + if (stoppingTask is not null) { await stoppingTask; return; } + if (cameraTask is null) return; + var task = StopCoreAsync(); + stoppingTask = task; + try { await task; } + finally { if (ReferenceEquals(stoppingTask, task)) stoppingTask = null; } + } + + private async Task StopCoreAsync() + { + cameraCts?.Cancel(); + var finished = true; + try + { + finished &= await Ui.SettleAsync(cameraTask); + finished &= await Ui.SettleAsync(recognitionTask); + } + finally + { + if (finished) cameraCts?.Dispose(); + cameraCts = null; + cameraTask = null; + recognitionTask = null; + running = false; + lastResult = null; + if (!IsDisposed) + { + var old = preview.Image; + preview.Image = null; + old?.Dispose(); + placeholder.Visible = true; + placeholder.BringToFront(); + indicator.Text = "Камера выключена"; + SetControls(); + } + } + } + + public override async Task OnLeaveAsync() + { + leftPage = true; + refresh.Stop(); + await StopAsync(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + refresh.Dispose(); + cameraCts?.Cancel(); + var image = preview.Image; + preview.Image = null; + image?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/ChangePasswordDialog.cs b/BiStudent/Pages/ChangePasswordDialog.cs new file mode 100644 index 0000000..379c673 --- /dev/null +++ b/BiStudent/Pages/ChangePasswordDialog.cs @@ -0,0 +1,82 @@ +namespace BiStudent; + +internal sealed class ChangePasswordDialog : IDisposable +{ + private readonly ApiClient api; + private readonly DialogShell dialog; + private readonly TextBox current = Ui.Input(""); + private readonly TextBox next = Ui.Input(""); + private readonly TextBox repeat = Ui.Input(""); + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton save = new() { Text = "Сменить пароль", Primary = true, Width = 180 }; + private readonly ModernButton cancel; + private bool saving; + + public ChangePasswordDialog(ApiClient api, bool forced) + { + this.api = api; + dialog = new DialogShell(forced ? "Смена временного пароля" : "Смена пароля", 540, 470); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, BackColor = Theme.Background }; + foreach (var height in new[] { 52, 27, 40, 27, 40, 27, 40, 27, 38 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + current.UseSystemPasswordChar = true; + next.UseSystemPasswordChar = true; + repeat.UseSystemPasswordChar = true; + current.Dock = DockStyle.Fill; + next.Dock = DockStyle.Fill; + repeat.Dock = DockStyle.Fill; + layout.Controls.Add(Ui.Label(forced + ? "Администратор выдал временный пароль. Задайте свой, чтобы продолжить." + : "Укажите текущий и новый пароль.", 9.5f, Theme.Muted), 0, 0); + layout.Controls.Add(Ui.Label("Текущий пароль", 9.5f, Theme.Muted), 0, 1); + layout.Controls.Add(current, 0, 2); + layout.Controls.Add(Ui.Label("Новый пароль", 9.5f, Theme.Muted), 0, 3); + layout.Controls.Add(next, 0, 4); + layout.Controls.Add(Ui.Label("Повторите новый пароль", 9.5f, Theme.Muted), 0, 5); + layout.Controls.Add(repeat, 0, 6); + layout.Controls.Add(Ui.Label("Не короче 8 символов", 9, Theme.Muted), 0, 7); + layout.Controls.Add(error, 0, 8); + dialog.Content.Controls.Add(layout); + cancel = dialog.AddButton("Отмена", DialogResult.Cancel); + dialog.Buttons.Controls.Add(save); + dialog.AcceptButton = save; + save.Click += async (_, _) => await SaveAsync(); + dialog.Shown += (_, _) => current.Focus(); + dialog.FormClosing += (_, e) => + { + if (!saving) return; + e.Cancel = true; + dialog.DialogResult = DialogResult.None; + }; + } + + public DialogResult ShowDialog(IWin32Window? owner = null) => dialog.ShowDialog(owner); + + private async Task SaveAsync() + { + if (saving) return; + if (next.Text.Length < 8) { error.Text = "Новый пароль должен содержать не менее 8 символов."; return; } + if (next.Text != repeat.Text) { error.Text = "Новые пароли не совпадают."; return; } + saving = true; + save.Enabled = false; + cancel.Enabled = false; + dialog.UseWaitCursor = true; + error.Text = ""; + try + { + await api.ChangePasswordAsync(current.Text, next.Text); + if (dialog.IsDisposed) return; + saving = false; + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + } + catch (ApiException ex) { error.Text = ex.Message; } + finally + { + saving = false; + if (!dialog.IsDisposed) { save.Enabled = cancel.Enabled = true; dialog.UseWaitCursor = false; } + } + } + + public void Dispose() => dialog.Dispose(); +} diff --git a/BiStudent/Pages/EnrollmentCodeDialog.cs b/BiStudent/Pages/EnrollmentCodeDialog.cs new file mode 100644 index 0000000..1c7074e --- /dev/null +++ b/BiStudent/Pages/EnrollmentCodeDialog.cs @@ -0,0 +1,147 @@ +using BiStudent.Contracts; +using QRCoder; + +namespace BiStudent; + +internal sealed class EnrollmentCodeDialog : DarkForm +{ + private static readonly int[] Hours = [1, 24, 72, 168]; + private readonly ApiClient api; + private readonly int groupId; + private readonly Label codeText = Ui.Label("", 34, Theme.Text, FontStyle.Bold); + private readonly Label expires = Ui.Label("", 10, Theme.Muted); + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly PictureBox qr = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Color.White }; + private readonly TextBox links = new() { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(9.5f) }; + private readonly ModernComboBox duration = new() { Width = 140 }; + private readonly ModernButton issue = new() { Text = "Выдать код", Primary = true, Width = 175 }; + private readonly ModernButton revoke = new() { Text = "Отозвать код", Danger = true, Width = 165 }; + private readonly ModernButton copyCode = new() { Text = "Скопировать код", Width = 175 }; + private readonly ModernButton copyLink = new() { Text = "Скопировать ссылку", Width = 195 }; + private EnrollmentCodeDto? current; + private bool busy; + + public EnrollmentCodeDialog(ApiClient api, int groupId, string groupName) + { + this.api = api; + this.groupId = groupId; + Text = "Код регистрации лица"; + ClientSize = new Size(780, 740); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoScroll = true, RowCount = 9, BackColor = Theme.Background, Padding = new Padding(24, 10, 24, 10) }; + foreach (var height in new[] { 38, 65, 30, 210, 78, 52, 48, 126, 45 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.Controls.Add(Ui.Label(groupName, 16, Theme.Text, FontStyle.Bold), 0, 0); + codeText.Font = new Font("Consolas", 34, FontStyle.Bold); + layout.Controls.Add(codeText, 0, 1); + layout.Controls.Add(expires, 0, 2); + var qrHolder = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background, Padding = new Padding(0, 4, 0, 4) }; + qr.Width = 202; qr.Dock = DockStyle.Left; + qrHolder.Controls.Add(qr); + layout.Controls.Add(qrHolder, 0, 3); + layout.Controls.Add(links, 0, 4); + foreach (var text in new[] { "1 час", "1 день", "3 дня", "7 дней" }) duration.Items.Add(text); + duration.SelectedIndex = 1; + var controls = Ui.Row(); + controls.Controls.Add(duration); controls.Controls.Add(issue); controls.Controls.Add(revoke); + layout.Controls.Add(controls, 0, 5); + var copies = Ui.Row(); + copies.Controls.Add(copyCode); copies.Controls.Add(copyLink); + layout.Controls.Add(copies, 0, 6); + var instructions = Ui.Label("1. Студент открывает ссылку или сканирует QR-код (телефон и сервер — в одной сети).\n2. Вводит код и выбирает себя в списке группы.\n3. Делает три снимка лица.\n4. Вы проверяете снимки в разделе «Заявки на регистрацию» и подтверждаете.", 9.5f, Theme.Text); + instructions.TextAlign = ContentAlignment.TopLeft; + layout.Controls.Add(instructions, 0, 7); + var note = Ui.Label("Код действует для всей группы. Снимки не используются, пока вы их не подтвердите.", 8.5f, Theme.Muted); + layout.Controls.Add(note, 0, 8); + + var footer = new Panel { Dock = DockStyle.Bottom, Height = 58, BackColor = Theme.Background, Padding = new Padding(24, 6, 24, 6) }; + var close = new ModernButton { Text = "Закрыть", Width = 130, Dock = DockStyle.Right }; + close.Click += (_, _) => Close(); + error.Dock = DockStyle.Fill; + footer.Controls.Add(error); footer.Controls.Add(close); + Controls.Add(layout); Controls.Add(footer); + CancelButton = close; + issue.Click += async (_, _) => await IssueAsync(); + revoke.Click += async (_, _) => await RevokeAsync(); + copyCode.Click += (_, _) => Copy(current?.Code); + copyLink.Click += (_, _) => Copy(current is { Links.Count: > 0 } code ? code.Links[0] + "?code=" + code.Code : null); + Shown += async (_, _) => await LoadCodeAsync(); + ShowCode(); + } + + private void SetBusy(bool value) + { + busy = value; + issue.Enabled = revoke.Enabled = copyCode.Enabled = copyLink.Enabled = !value; + duration.Enabled = !value; + UseWaitCursor = value; + if (!value) ShowCode(); + } + + private void ShowCode() + { + codeText.Text = current?.Code ?? "Код не выдан"; + expires.Text = current is null ? "Выберите срок действия и выдайте код." : $"Действует до {current.ExpiresAt:dd.MM.yyyy HH:mm}"; + links.Text = current is null ? "" : string.Join(Environment.NewLine, current.Links); + issue.Text = current is null ? "Выдать код" : "Выдать новый код"; + revoke.Enabled = current is not null && !busy; + copyCode.Enabled = current is not null && !busy; + copyLink.Enabled = current is { Links.Count: > 0 } && !busy; + var old = qr.Image; + qr.Image = null; + old?.Dispose(); + if (current is not { Links.Count: > 0 } code) return; + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(code.Links[0] + "?code=" + code.Code, QRCodeGenerator.ECCLevel.Q); + var png = new PngByteQRCode(data).GetGraphic(8, [0, 0, 0, 255], [255, 255, 255, 255]); + using var stream = new MemoryStream(png); + using var image = Image.FromStream(stream); + qr.Image = new Bitmap(image); + } + + private async Task LoadCodeAsync() + { + SetBusy(true); + error.Text = ""; + try { current = await api.EnrollmentCodeAsync(groupId); } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { if (!IsDisposed) SetBusy(false); } + } + + private async Task IssueAsync() + { + if (busy || duration.SelectedIndex < 0) return; + if (current is not null && !Ui.Confirm(this, "Выдать новый код", "Текущий код перестанет действовать.", "Выдать новый код")) return; + SetBusy(true); + error.Text = ""; + try { current = await api.CreateEnrollmentCodeAsync(groupId, Hours[duration.SelectedIndex]); } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { if (!IsDisposed) SetBusy(false); } + } + + private async Task RevokeAsync() + { + if (busy || current is null || !Ui.Confirm(this, "Отозвать код", "Студенты больше не смогут отправить снимки по этому коду.", "Отозвать код", danger: true)) return; + SetBusy(true); + error.Text = ""; + try { await api.RevokeEnrollmentCodeAsync(groupId); current = null; } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { if (!IsDisposed) SetBusy(false); } + } + + private void Copy(string? text) + { + if (string.IsNullOrEmpty(text)) return; + try { Clipboard.SetText(text); Ui.Toast(this, "Скопировано"); } + catch (System.Runtime.InteropServices.ExternalException ex) { error.Text = ex.Message; } + } + + protected override void Dispose(bool disposing) + { + if (disposing) { qr.Image?.Dispose(); codeText.Font.Dispose(); } + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/EnrollmentDialog.cs b/BiStudent/Pages/EnrollmentDialog.cs new file mode 100644 index 0000000..1b404d9 --- /dev/null +++ b/BiStudent/Pages/EnrollmentDialog.cs @@ -0,0 +1,495 @@ +using BiStudent.Contracts; +using OpenCvSharp; + +namespace BiStudent; + +internal sealed class EnrollmentDialog : DarkForm +{ + private static readonly string[] Prompts = ["Смотрите прямо", "Немного влево", "Немного вправо"]; + private readonly ApiClient api; + private readonly StudentDto student; + private readonly PictureBox preview = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.Sidebar }; + private readonly PictureBox[] thumbs = new PictureBox[3]; + private readonly Label[] steps = new Label[3]; + private readonly Label hint = Ui.Label("", 9.5f, Theme.Muted); + private readonly Label banner = Ui.Label("", 9.5f, Theme.Red, FontStyle.Bold); + private readonly ModernButton take = new() { Text = "Снять", Primary = true, Width = 125 }; + private readonly ModernButton retake = new() { Text = "Переснять последний", Width = 190 }; + private readonly ModernButton save = new() { Text = "Сохранить образцы", Width = 200 }; + private readonly byte[]?[] shots = new byte[]?[3]; + private readonly object frameLock = new(); + private readonly object engineLock = new(); + private int? retakeSlot; + private CancellationTokenSource? cameraCts; + private Task? cameraTask; + private Mat? lastFrame; + private Bitmap? queuedBitmap; + private FaceEngine? engine; + private SampleQuality? quality; + private int faceCount; + private int pendingFrame; + private DateTime lastFrameAt; + private bool detectorReady; + private bool saving; + private bool stopping; + private bool closingAfterStop; + private DialogResult closeResult; + + public EnrollmentDialog(ApiClient api, StudentDto student) + { + this.api = api; + this.student = student; + Text = $"Регистрация лица · {student.FullName}"; + ClientSize = new System.Drawing.Size(980, 640); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + + var left = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(12), Margin = new Padding(0, 0, 16, 0) }; + left.Controls.Add(preview); + var right = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(18) }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 10, BackColor = Color.Transparent }; + foreach (var height in new[] { 38, 36, 36, 36, 112, 48, 50, 58, 58, 77 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.Controls.Add(Ui.Label(student.FullName, 14, Theme.Text, FontStyle.Bold), 0, 0); + for (var i = 0; i < 3; i++) + { + steps[i] = Ui.Label("", 10, Theme.Muted); + layout.Controls.Add(steps[i], 0, i + 1); + } + var tips = new ToolTip(); + var previews = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, BackColor = Color.Transparent }; + for (var i = 0; i < 3; i++) + { + previews.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 3)); + thumbs[i] = new PictureBox + { + Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.SurfaceHover, + Margin = new Padding(0, 5, 8, 5) + }; + var index = i; + thumbs[i].Click += (_, _) => RetakeAt(index); + tips.SetToolTip(thumbs[i], "Нажмите, чтобы переснять этот снимок"); + previews.Controls.Add(thumbs[i], i, 0); + } + layout.Controls.Add(previews, 0, 4); + hint.TextAlign = ContentAlignment.TopLeft; + layout.Controls.Add(hint, 0, 5); + banner.TextAlign = ContentAlignment.TopLeft; + banner.AutoEllipsis = true; + banner.Visible = false; + layout.Controls.Add(banner, 0, 6); + var actions = Ui.Row(); + actions.Controls.Add(take); actions.Controls.Add(retake); + layout.Controls.Add(actions, 0, 7); + layout.Controls.Add(save, 0, 8); + var consent = Ui.Label("Снимайте с согласия студента. На сервере сохраняются только зашифрованные образцы лица 112×112.", 8.5f, Theme.Muted); + consent.TextAlign = ContentAlignment.TopLeft; + layout.Controls.Add(consent, 0, 9); + right.Controls.Add(layout); + var columns = new TableLayoutPanel + { + Dock = DockStyle.Fill, ColumnCount = 2, Padding = new Padding(18), + BackColor = Theme.Background + }; + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 62)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 38)); + columns.Controls.Add(left, 0, 0); columns.Controls.Add(right, 1, 0); + Controls.Add(columns); + + take.Click += (_, _) => TakeShot(); + retake.Click += (_, _) => Retake(); + save.Click += async (_, _) => await SaveAsync(); + Shown += (_, _) => + { + cameraCts = new CancellationTokenSource(); + cameraTask = Task.Run(() => CameraLoopAsync(cameraCts.Token), cameraCts.Token); + UpdateState(); + }; + } + + private void CreateDetector() + { + try { engine = new FaceEngine(); } + catch { engine = null; } + } + + private async Task CameraLoopAsync(CancellationToken token) + { + VideoCapture? camera = null; + var noFrameSince = DateTime.UtcNow; + var nextRetry = DateTime.MinValue; + try + { + CreateDetector(); + var ready = engine is not null; + PostUi(() => { if (!IsDisposed && !token.IsCancellationRequested) { detectorReady = ready; UpdateState(); } }); + while (!token.IsCancellationRequested) + { + var now = DateTime.UtcNow; + if (camera is null && now >= nextRetry) + { + try + { + var devices = CameraDevices.List(); + var index = CameraDevices.ResolveIndex(ClientSettings.Load(), devices); + camera = CameraDevices.Open(index, 800, 500, allowFallback: true); + noFrameSince = DateTime.UtcNow; + PostStatus("Подключение к камере…", token); + } + catch (Exception ex) + { + camera?.Dispose(); + camera = null; + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostStatus($"Камера недоступна: {ex.Message}. Повторная попытка через 2 секунды.", token); + } + } + if (camera is not null) + { + try + { + using var frame = new Mat(); + if (!camera.Read(frame) || frame.Empty()) + { + if ((DateTime.UtcNow - noFrameSince).TotalSeconds > 3) + { + camera.Dispose(); + camera = null; + ClearLatestFrame(); + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostStatus("Камера не передаёт кадры. Переподключение через 2 секунды…", token); + } + } + else + { + noFrameSince = DateTime.UtcNow; + var faces = DetectFaces(frame); + StoreFrame(frame, faces.Count, Assess(frame, faces.Count)); + if (Interlocked.CompareExchange(ref pendingFrame, 1, 0) == 0) + { + Bitmap? bitmap = null; + var posted = false; + try + { + Cv2.ImEncode(".bmp", frame, out var bytes); + using var stream = new MemoryStream(bytes); + using var decoded = new Bitmap(stream); + bitmap = new Bitmap(decoded); + using (var graphics = Graphics.FromImage(bitmap)) + using (var pen = new Pen(faces.Count == 1 ? Theme.Green : Theme.Yellow, 3)) + foreach (var bounds in faces) graphics.DrawRectangle(pen, bounds); + Interlocked.Exchange(ref queuedBitmap, bitmap); + bitmap = null; + var available = engine is not null; + posted = PostUi(() => FrameReady(token, available)); + } + finally + { + bitmap?.Dispose(); + if (!posted) + { + Interlocked.Exchange(ref queuedBitmap, null)?.Dispose(); + Interlocked.Exchange(ref pendingFrame, 0); + } + } + } + } + } + catch (Exception ex) when (!token.IsCancellationRequested) + { + camera?.Dispose(); + camera = null; + ClearLatestFrame(); + nextRetry = DateTime.UtcNow.AddSeconds(2); + PostStatus($"Ошибка камеры: {ex.Message}. Переподключение через 2 секунды…", token); + } + } + await Task.Delay(40, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + finally + { + camera?.Dispose(); + ClearLatestFrame(); + lock (engineLock) + { + engine?.Dispose(); + engine = null; + } + } + } + + private bool PostUi(Action action) + { + try { BeginInvoke(action); return true; } + catch (InvalidOperationException) { return false; } + } + + private void PostStatus(string message, CancellationToken token) + { + PostUi(() => + { + if (IsDisposed || token.IsCancellationRequested) return; + hint.Text = message; + UpdateState(); + }); + } + + private void StoreFrame(Mat frame, int count, SampleQuality? check) + { + lock (frameLock) + { + lastFrame?.Dispose(); + lastFrame = frame.Clone(); + lastFrameAt = DateTime.UtcNow; + faceCount = count; + quality = check; + } + } + + private void ClearLatestFrame() + { + lock (frameLock) + { + lastFrame?.Dispose(); + lastFrame = null; + lastFrameAt = default; + faceCount = 0; + quality = null; + } + } + + private void FrameReady(CancellationToken token, bool available) + { + var bitmap = Interlocked.Exchange(ref queuedBitmap, null); + Interlocked.Exchange(ref pendingFrame, 0); + if (bitmap is null) return; + if (IsDisposed || token.IsCancellationRequested) { bitmap.Dispose(); return; } + detectorReady = available; + var old = preview.Image; + preview.Image = bitmap; + old?.Dispose(); + UpdateState(); + } + + private List DetectFaces(Mat frame) + { + lock (engineLock) + { + if (engine is null) return []; + try { return engine.Detect(frame).Select(r => new Rectangle(r.X, r.Y, r.Width, r.Height)).ToList(); } + catch + { + engine.Dispose(); + engine = null; + return []; + } + } + } + + private SampleQuality? Assess(Mat frame, int faces) + { + lock (engineLock) + { + if (engine is null) return null; + try { return engine.AssessFrame(frame); } + catch { return null; } + } + } + + private int Filled => shots.Count(s => s is not null); + private int NextSlot => retakeSlot is int slot && shots[slot] is null ? slot : Array.IndexOf(shots, null); + + private void UpdateState() + { + bool fresh; + int count; + SampleQuality? check; + lock (frameLock) + { + fresh = lastFrame is not null && (DateTime.UtcNow - lastFrameAt).TotalMilliseconds < 500; + count = faceCount; + check = quality; + } + var next = NextSlot; + take.Enabled = !saving && !stopping && next >= 0 && fresh && detectorReady && count == 1 && check is { Ok: true }; + retake.Enabled = !saving && !stopping && Filled > 0; + save.Enabled = !saving && !stopping && Filled == 3; + for (var i = 0; i < 3; i++) + { + steps[i].Text = shots[i] is not null ? $"✓ {i + 1}/3 {Prompts[i]}" : $"{i + 1}/3 {Prompts[i]}"; + steps[i].ForeColor = shots[i] is not null ? Theme.Green : i == next ? Theme.AccentHover : Theme.Muted; + thumbs[i].Cursor = shots[i] is not null && !saving ? Cursors.Hand : Cursors.Default; + } + if (saving) { hint.Text = "Сохранение образцов…"; return; } + if (!detectorReady) { hint.Text = "Проверка лица недоступна: не удалось загрузить модель."; return; } + if (!fresh) return; + hint.ForeColor = Theme.Muted; + if (next < 0) { hint.Text = "Три снимка готовы. Сохраните образцы или нажмите на снимок, чтобы переснять его."; return; } + if (check is { Ok: false, Problem: { } problem }) + { + hint.ForeColor = Theme.Yellow; + hint.Text = $"{problem}."; + return; + } + if (count == 0) { hint.Text = "Наведите камеру так, чтобы в кадре было ровно одно лицо."; return; } + if (count > 1) { hint.Text = "В кадре несколько лиц. Оставьте одного студента."; return; } + if (check is null) { hint.ForeColor = Theme.Yellow; hint.Text = "Не удалось оценить кадр — немного измените положение головы."; return; } + hint.Text = $"Кадр хороший: {Prompts[next].ToLowerInvariant()}. Нажмите «Снять»."; + } + + private void TakeShot() + { + if (!take.Enabled) return; + var slot = NextSlot; + if (slot < 0) return; + Mat frame; + lock (frameLock) + { + if (lastFrame is null || faceCount != 1 || quality is not { Ok: true } || (DateTime.UtcNow - lastFrameAt).TotalMilliseconds >= 500) return; + frame = lastFrame.Clone(); + } + using (frame) + { + Cv2.ImEncode(".jpg", frame, out var jpeg, new ImageEncodingParam(ImwriteFlags.JpegQuality, 92)); + shots[slot] = jpeg; + if (retakeSlot == slot) retakeSlot = null; + using var stream = new MemoryStream(jpeg); + using var decoded = new Bitmap(stream); + var old = thumbs[slot].Image; + thumbs[slot].Image = new Bitmap(decoded); + old?.Dispose(); + } + banner.Visible = false; + banner.Text = ""; + UpdateState(); + } + + private void Retake() + { + var last = Array.FindLastIndex(shots, s => s is not null); + if (last >= 0) RetakeAt(last); + } + + private void RetakeAt(int index) + { + if (saving || shots[index] is null) return; + shots[index] = null; + retakeSlot = index; + var image = thumbs[index].Image; + thumbs[index].Image = null; + image?.Dispose(); + UpdateState(); + } + + private void ResetShots() + { + Array.Clear(shots); + retakeSlot = null; + foreach (var thumb in thumbs) + { + var image = thumb.Image; + thumb.Image = null; + image?.Dispose(); + } + UpdateState(); + } + + private async Task SaveAsync() + { + if (saving || Filled != 3) return; + saving = true; + take.Enabled = false; retake.Enabled = false; save.Enabled = false; + UseWaitCursor = true; + UpdateState(); + try + { + var images = shots.Select(s => s!).ToList(); + var result = await api.EnrollAsync(student.Id, images); + if (IsDisposed) return; + if (!result.Ok && result.SimilarTo is not null && + Ui.Confirm(this, "Похожее лицо", result.Message, "Сохранить всё равно")) + result = await api.EnrollAsync(student.Id, images, allowSimilar: true); + if (IsDisposed) return; + if (result.Ok) { DialogResult = DialogResult.OK; Close(); return; } + if (result.Problems is { Count: > 0 } problems) + { + ShowError($"{result.Message} — подробности в окне со списком."); + Ui.Message(this, "Какие снимки переснять", string.Join(Environment.NewLine, problems)); + } + else if (result.SimilarTo is null) { ResetShots(); ShowError(result.Message); } + } + catch (ApiException ex) { if (!IsDisposed) ShowError(ex.Message); } + finally + { + saving = false; + if (!IsDisposed) { UseWaitCursor = false; UpdateState(); } + } + } + + private void ShowError(string message) + { + banner.Text = $"⚠ {message}"; + banner.Visible = true; + } + + private void ClearFrame() + { + ClearLatestFrame(); + Interlocked.Exchange(ref queuedBitmap, null)?.Dispose(); + var image = preview.Image; + preview.Image = null; + image?.Dispose(); + } + + private async Task StopAndCloseAsync() + { + cameraCts?.Cancel(); + var finished = true; + try { finished = await Ui.SettleAsync(cameraTask); } + finally + { + cameraTask = null; + if (finished) cameraCts?.Dispose(); + cameraCts = null; + ClearFrame(); + closingAfterStop = true; + stopping = false; + if (!IsDisposed) { DialogResult = closeResult; Close(); } + } + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (!closingAfterStop && cameraTask is not null) + { + e.Cancel = true; + if (!stopping) + { + stopping = true; + closeResult = DialogResult; + _ = StopAndCloseAsync(); + } + return; + } + base.OnFormClosing(e); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + cameraCts?.Cancel(); + ClearFrame(); + foreach (var thumb in thumbs) + { + var image = thumb?.Image; + if (thumb is not null) thumb.Image = null; + image?.Dispose(); + } + } + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/ExcuseDialog.cs b/BiStudent/Pages/ExcuseDialog.cs new file mode 100644 index 0000000..5e1e8ca --- /dev/null +++ b/BiStudent/Pages/ExcuseDialog.cs @@ -0,0 +1,58 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class ExcuseDialog : IDisposable +{ + private readonly DialogShell shell = new("Уважительная причина", 540, 430); + private readonly RadioButton[] reasons = new RadioButton[4]; + private readonly CheckBox document = new() { Text = "Подтверждающий документ предоставлен (справка, заявление, приказ)", AutoSize = true, ForeColor = Theme.Text, BackColor = Theme.Background }; + private readonly TextBox comment = Ui.Input("Комментарий"); + private readonly Label counter = Ui.Label("", 9, Theme.Muted); + + public ExcuseDialog(string studentName, ExcuseReason? reason = null, bool hasDocument = false, string? initialComment = null) + { + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, BackColor = Theme.Background }; + foreach (var height in new[] { 30, 24, 35, 35, 35, 35, 45, 24, 80 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.Controls.Add(Ui.Label(studentName, 10, Theme.Muted), 0, 0); + layout.Controls.Add(Ui.Label("Причина", 9, Theme.Muted), 0, 1); + var values = Enum.GetValues(); + for (var i = 0; i < values.Length; i++) + { + reasons[i] = new RadioButton + { + Text = UiFormat.ExcuseReasonText(values[i]), AutoSize = true, + ForeColor = Theme.Text, BackColor = Theme.Background, Checked = values[i] == (reason ?? ExcuseReason.Illness) + }; + layout.Controls.Add(reasons[i], 0, i + 2); + } + document.Checked = hasDocument; + layout.Controls.Add(document, 0, 6); + layout.Controls.Add(Ui.Label("Комментарий", 9, Theme.Muted), 0, 7); + comment.Multiline = true; + comment.ScrollBars = ScrollBars.Vertical; + comment.MaxLength = 500; + comment.Text = initialComment ?? ""; + comment.Dock = DockStyle.Fill; + layout.Controls.Add(comment, 0, 8); + var bottom = new Panel { Dock = DockStyle.Bottom, Height = 20, BackColor = Theme.Background }; + counter.Dock = DockStyle.Right; + counter.Width = 70; + counter.TextAlign = ContentAlignment.MiddleRight; + bottom.Controls.Add(counter); + comment.TextChanged += (_, _) => counter.Text = $"{comment.TextLength}/500"; + counter.Text = $"{comment.TextLength}/500"; + shell.Content.Controls.Add(layout); + shell.Content.Controls.Add(bottom); + shell.AddButton("Сохранить", DialogResult.OK, primary: true); + shell.AddButton("Отмена", DialogResult.Cancel); + } + + public (ExcuseReason Reason, bool HasDocument, string? Comment) Result => + ((ExcuseReason)Array.FindIndex(reasons, item => item.Checked), document.Checked, + string.IsNullOrWhiteSpace(comment.Text) ? null : comment.Text.Trim()); + + public DialogResult ShowDialog(IWin32Window? owner) => shell.ShowDialog(owner); + public void Dispose() => shell.Dispose(); +} diff --git a/BiStudent/Pages/FaceSubmissionsDialog.cs b/BiStudent/Pages/FaceSubmissionsDialog.cs new file mode 100644 index 0000000..f9292ba --- /dev/null +++ b/BiStudent/Pages/FaceSubmissionsDialog.cs @@ -0,0 +1,193 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class FaceSubmissionsDialog : DarkForm +{ + private readonly ApiClient api; + private readonly DataGridView grid = Ui.Grid(); + private readonly PictureBox[] images = new PictureBox[3]; + private readonly Label name = Ui.Label("Новых заявок нет", 17, Theme.Text, FontStyle.Bold); + private readonly Label details = Ui.Label("", 10, Theme.Yellow); + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton approve = new() { Text = "Подтвердить", Primary = true, Width = 160 }; + private readonly ModernButton reject = new() { Text = "Отклонить…", Width = 160 }; + private int imageVersion; + private int? displayedSubmissionId; + private bool filling; + private bool busy; + + public bool Changed { get; private set; } + + public FaceSubmissionsDialog(ApiClient api) + { + this.api = api; + Text = "Заявки на регистрацию лица"; + ClientSize = new Size(1140, 620); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + + var columns = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, Padding = new Padding(18), BackColor = Theme.Background }; + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 42)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 58)); + var left = new CardPanel("Заявки") { Dock = DockStyle.Fill, Margin = new Padding(0, 0, 14, 0) }; + grid.AutoGenerateColumns = false; + grid.RowTemplate.Height = 52; + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "ФИО", FillWeight = 40 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Группа", FillWeight = 18 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Получена", FillWeight = 18 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Примечание", FillWeight = 40 }); + left.Body.Controls.Add(grid); + + var right = new CardPanel("Снимки") { Dock = DockStyle.Fill, Margin = Padding.Empty }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 4, BackColor = Theme.Surface }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 54)); + layout.Controls.Add(name, 0, 0); + layout.Controls.Add(details, 0, 1); + var photos = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, BackColor = Theme.Surface }; + for (var i = 0; i < 3; i++) + { + photos.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 3)); + var holder = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SurfaceHover, Margin = new Padding(0, 0, 8, 0) }; + images[i] = new PictureBox { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Theme.SurfaceHover }; + var caption = Ui.Label(new[] { "Прямо", "Влево", "Вправо" }[i], 9, Theme.Muted); + caption.Dock = DockStyle.Bottom; caption.Height = 28; caption.TextAlign = ContentAlignment.MiddleCenter; + holder.Controls.Add(images[i]); holder.Controls.Add(caption); + photos.Controls.Add(holder, i, 0); + } + layout.Controls.Add(photos, 0, 2); + var actions = Ui.Row(); + actions.Controls.Add(approve); actions.Controls.Add(reject); + layout.Controls.Add(actions, 0, 3); + right.Body.Controls.Add(layout); + columns.Controls.Add(left, 0, 0); columns.Controls.Add(right, 1, 0); + + var footer = new Panel { Dock = DockStyle.Bottom, Height = 58, BackColor = Theme.Background, Padding = new Padding(18, 6, 18, 6) }; + var close = new ModernButton { Text = "Закрыть", Width = 130, Dock = DockStyle.Right }; + close.Click += (_, _) => Close(); + error.Dock = DockStyle.Fill; + footer.Controls.Add(error); footer.Controls.Add(close); + Controls.Add(columns); Controls.Add(footer); + CancelButton = close; + grid.CurrentCellChanged += async (_, _) => { if (!filling) await LoadImagesAsync(); }; + approve.Click += async (_, _) => await DecideAsync(true); + reject.Click += async (_, _) => await DecideAsync(false); + Shown += async (_, _) => await ReloadAsync(); + SetBusy(false); + } + + private FaceSubmissionDto? Selected() => grid.CurrentRow?.Tag as FaceSubmissionDto; + + private void SetBusy(bool value) + { + busy = value; + grid.Enabled = !value; + approve.Enabled = reject.Enabled = !value && Selected()?.Id == displayedSubmissionId && displayedSubmissionId is not null; + UseWaitCursor = value; + } + + private void ClearImages() + { + displayedSubmissionId = null; + foreach (var box in images) + { + var old = box.Image; + box.Image = null; + old?.Dispose(); + } + } + + private async Task ReloadAsync() + { + imageVersion++; + ClearImages(); + SetBusy(true); + error.Text = ""; + try + { + var submissions = await api.FaceSubmissionsAsync(); + if (IsDisposed) return; + filling = true; + grid.Rows.Clear(); + foreach (var item in submissions) + { + var note = item.SimilarTo is { } similar ? $"похоже на {similar} ({UiFormat.Percent(item.SimilarScore * 100)})" + : item.ReplacesExisting ? "заменит текущие образцы" : ""; + var index = grid.Rows.Add(item.StudentName, item.GroupName, item.CreatedAt.ToString("dd.MM HH:mm"), note); + grid.Rows[index].Tag = item; + if (item.SimilarTo is not null) grid.Rows[index].Cells[3].Style.ForeColor = Theme.Yellow; + } + name.Text = submissions.Count == 0 ? "Новых заявок нет" : ""; + if (grid.Rows.Count > 0) grid.CurrentCell = grid.Rows[0].Cells[0]; + filling = false; + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { filling = false; if (!IsDisposed) SetBusy(false); } + if (!IsDisposed && Selected() is not null) await LoadImagesAsync(); + } + + private async Task LoadImagesAsync() + { + var version = ++imageVersion; + ClearImages(); + var item = Selected(); + name.Text = item?.StudentName ?? "Новых заявок нет"; + details.Text = item is null ? "" : $"{item.GroupName} · {item.CreatedAt:dd.MM.yyyy HH:mm}" + + (item.ReplacesExisting ? " · заменит текущие образцы" : "") + + (item.SimilarTo is { } similar ? $" · похоже на {similar} ({UiFormat.Percent(item.SimilarScore * 100)})" : ""); + SetBusy(busy); + if (item is null) return; + try + { + var bytes = await Task.WhenAll(Enumerable.Range(0, 3).Select(index => api.FaceSubmissionImageAsync(item.Id, index))); + if (IsDisposed || version != imageVersion || Selected()?.Id != item.Id) return; + for (var i = 0; i < images.Length; i++) + { + using var stream = new MemoryStream(bytes[i]); + using var image = Image.FromStream(stream); + images[i].Image = new Bitmap(image); + } + displayedSubmissionId = item.Id; + SetBusy(busy); + } + catch (Exception ex) when (ex is ApiException or ArgumentException or System.Runtime.InteropServices.ExternalException) + { + if (!IsDisposed && version == imageVersion) { ClearImages(); error.Text = ex.Message; } + } + } + + private async Task DecideAsync(bool accepted) + { + if (busy || Selected() is not { } item || displayedSubmissionId != item.Id) return; + string? reason = null; + if (!accepted) + { + reason = Ui.Prompt(this, "Отклонить заявку", "Причина (необязательно)"); + if (reason is null) return; + } + if (Selected()?.Id != displayedSubmissionId || displayedSubmissionId != item.Id) return; + SetBusy(true); + error.Text = ""; + try + { + if (accepted) await api.ApproveSubmissionAsync(item.Id); + else await api.RejectSubmissionAsync(item.Id, reason); + if (IsDisposed) return; + Changed = true; + Ui.Toast(this, accepted ? "Регистрация подтверждена" : "Заявка отклонена"); + await ReloadAsync(); + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { if (!IsDisposed) SetBusy(false); } + } + + protected override void Dispose(bool disposing) + { + if (disposing) { imageVersion++; ClearImages(); } + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/GradeEditor.cs b/BiStudent/Pages/GradeEditor.cs new file mode 100644 index 0000000..7aa8639 --- /dev/null +++ b/BiStudent/Pages/GradeEditor.cs @@ -0,0 +1,190 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class GradeEditor : IDisposable +{ + private readonly DialogShell shell; + private readonly ApiClient api; + private readonly JournalColumnDto? column; + private readonly int groupId; + private readonly string? subject; + private readonly int studentId; + private readonly string studentName; + private readonly TextBox score = Ui.Input("Без балла"); + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly Label reasonLabel = Ui.Label("", 9, Theme.Muted); + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true }; + private readonly Dictionary statusButtons = []; + private ModernButton cancel = null!; + private AttendanceStatus? status; + private ExcuseReason? reason; + private string? comment; + private bool hasDocument; + private bool saving; + + public GradeEditor(ApiClient api, JournalColumnDto column, JournalCellDto cell, + int studentId, string studentName, bool scoreOnly = false, AttendanceRowDto? attendance = null, bool attendanceOnly = false) + { + this.api = api; + this.column = column; + this.studentId = studentId; + this.studentName = studentName; + status = cell.Status; + reason = attendance?.Reason ?? cell.Reason; + comment = attendance?.Comment ?? cell.Comment; + hasDocument = attendance?.HasDocument ?? false; + shell = new DialogShell(attendanceOnly ? "Посещаемость" : "Отметка и балл", 400, attendanceOnly ? 270 : column.Kind == JournalColumnKind.Lesson && !scoreOnly ? 330 : 220); + Build(cell.Score, column.Kind == JournalColumnKind.Lesson && !scoreOnly, !attendanceOnly); + } + + public GradeEditor(ApiClient api, int groupId, string subject, int studentId, string studentName, int? final) + { + this.api = api; + this.groupId = groupId; + this.subject = subject; + this.studentId = studentId; + this.studentName = studentName; + shell = new DialogShell("Итоговый балл", 400, 220); + Build(final, false); + } + + public event Action? Saved; + + private void Build(int? initialScore, bool attendance, bool scoreEnabled = true) + { + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, BackColor = Theme.Background }; + layout.Controls.Add(Ui.Label(studentName, 10, Theme.Muted)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); + if (attendance) + { + var buttons = Ui.Row(); + foreach (var (text, value) in new[] + { + ("П", AttendanceStatus.Present), ("Н", AttendanceStatus.Absent), ("Н — уважительная…", AttendanceStatus.Excused) + }) + { + var button = new ModernButton { Text = text, Width = value == AttendanceStatus.Excused ? 172 : 62, Height = 38, Selected = status == value }; + button.Click += async (_, _) => await SaveStatusAsync(value); + buttons.Controls.Add(button); + statusButtons[value] = button; + } + layout.Controls.Add(buttons); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 45)); + var clear = new ModernButton { Text = "Снять отметку", Width = 160, Height = 34 }; + clear.Click += async (_, _) => await SaveStatusAsync(null); + layout.Controls.Add(clear); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); + var reasonRow = Ui.Row(); + reasonLabel.Width = 230; + reasonLabel.Dock = DockStyle.None; + reasonRow.Controls.Add(reasonLabel); + var change = new LinkLabel { Text = "изменить", AutoSize = true, LinkColor = Theme.AccentHover, BackColor = Theme.Background }; + change.Click += async (_, _) => await SaveStatusAsync(AttendanceStatus.Excused); + reasonRow.Controls.Add(change); + layout.Controls.Add(reasonRow); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); + UpdateReason(); + } + if (scoreEnabled) + { + layout.Controls.Add(Ui.Label("Балл (1–100)", 9, Theme.Muted)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); + score.Text = initialScore?.ToString() ?? ""; + score.Dock = DockStyle.Fill; + score.TextChanged += (_, _) => { score.BackColor = Theme.SurfaceHover; error.Text = ""; }; + score.KeyDown += async (_, e) => + { + if (e.KeyCode != Keys.Enter) return; + e.SuppressKeyPress = true; + await SaveScoreAsync(); + }; + layout.Controls.Add(score); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + save.Click += async (_, _) => await SaveScoreAsync(); + shell.Buttons.Controls.Add(save); + shell.AcceptButton = save; + } + layout.Controls.Add(error); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); + shell.Content.Controls.Add(layout); + cancel = shell.AddButton(scoreEnabled ? "Отмена" : "Закрыть", DialogResult.Cancel); + shell.FormClosing += (_, e) => + { + if (!saving) return; + e.Cancel = true; + shell.DialogResult = DialogResult.None; + }; + } + + private void UpdateReason() + { + reasonLabel.Text = status == AttendanceStatus.Excused + ? UiFormat.ExcuseReasonText(reason ?? ExcuseReason.Other) + (hasDocument ? " · документ" : "") + : ""; + } + + private async Task SaveStatusAsync(AttendanceStatus? next) + { + if (saving || column is null) return; + if (next == AttendanceStatus.Excused) + { + using var dialog = new ExcuseDialog(studentName, reason, hasDocument, comment); + if (dialog.ShowDialog(shell) != DialogResult.OK) return; + (reason, hasDocument, comment) = dialog.Result; + } + saving = true; error.Text = ""; + save.Enabled = false; + cancel.Enabled = false; + try + { + await api.MarkAsync(column.Id, column.Date, studentId, next, + next == AttendanceStatus.Excused ? reason : null, + next == AttendanceStatus.Excused ? comment : null, + next == AttendanceStatus.Excused && hasDocument); + status = next; + foreach (var (value, button) in statusButtons) button.Selected = value == status; + UpdateReason(); + Saved?.Invoke(); + } + catch (ApiException ex) { error.Text = ex.Message; } + finally { saving = false; if (!shell.IsDisposed) save.Enabled = cancel.Enabled = true; } + } + + private async Task SaveScoreAsync() + { + if (saving) return; + var value = score.Text.Trim(); + if (value.Length > 0 && (!int.TryParse(value, out var number) || number is < 1 or > 100)) + { + score.BackColor = Theme.Mix(Theme.SurfaceHover, Theme.Red, .35f); + error.Text = "Укажите балл от 1 до 100."; + return; + } + int? grade = value.Length == 0 ? null : int.Parse(value); + saving = true; save.Enabled = false; error.Text = ""; + cancel.Enabled = false; + try + { + if (column?.Kind == JournalColumnKind.Lesson) await api.LessonGradeAsync(column.Id, column.Date, studentId, grade); + else if (column?.Kind == JournalColumnKind.Assessment) await api.AssessmentGradeAsync(column.Id, studentId, grade); + else await api.FinalGradeAsync(groupId, subject!, studentId, grade); + Saved?.Invoke(); + saving = false; + shell.Close(); + } + catch (ApiException ex) { error.Text = ex.Message; } + finally { saving = false; if (!shell.IsDisposed) save.Enabled = cancel.Enabled = true; } + } + + public DialogResult ShowDialog(IWin32Window? owner, Point near) + { + var screen = Screen.FromPoint(near).WorkingArea; + shell.StartPosition = FormStartPosition.Manual; + shell.Location = new Point(Math.Max(screen.Left, Math.Min(near.X, screen.Right - shell.Width)), + Math.Max(screen.Top, Math.Min(near.Y, screen.Bottom - shell.Height))); + return shell.ShowDialog(owner); + } + + public void Dispose() => shell.Dispose(); +} diff --git a/BiStudent/Pages/GroupsPage.cs b/BiStudent/Pages/GroupsPage.cs new file mode 100644 index 0000000..85226ba --- /dev/null +++ b/BiStudent/Pages/GroupsPage.cs @@ -0,0 +1,281 @@ +using System.Text; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class GroupsPage : Page +{ + private readonly FlowLayoutPanel groupCards = new() { Dock = DockStyle.Top, Height = 144, AutoScroll = true, WrapContents = false, BackColor = Theme.Background }; + private readonly CardPanel studentsCard = new("Студенты", "правый клик по строке — действия"); + private readonly DataGridView grid = Ui.Grid(); + private readonly Label status = Ui.Label("", 9, Theme.Muted); + private readonly Label recognitionNote = Ui.Label("", 9, Theme.Red); + private readonly ModernButton deleteGroup = new() { Text = "Удалить пустую группу", Danger = true, Width = 220, Visible = false }; + private readonly ModernButton submissionsButton = new() { Text = "Заявки на регистрацию", Width = 220 }; + private readonly ContextMenuStrip menu = new() { BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(10) }; + private readonly ToolStripMenuItem enrollItem = new("Зарегистрировать лицо с этой камеры"); + private readonly ToolStripMenuItem editItem = new("Изменить…"); + private readonly ToolStripMenuItem deleteItem = new("Удалить…"); + private List groups = []; + private List students = []; + private int? selectedId; + private bool recognitionAvailable; + private string recognitionMessage = ""; + + public GroupsPage(ApiClient api) : base(api, "Группы", "Списки групп и регистрация лиц студентов") + { + var import = new ModernButton { Text = "Импорт списков", Width = 140 }; + var code = new ModernButton { Text = "Код регистрации", Width = 185 }; + var addGroup = new ModernButton { Text = "+ Группа", Width = 130 }; + var addStudent = new ModernButton { Text = "+ Студент", Width = 140, Primary = true }; + Actions.Controls.Add(import); Actions.Controls.Add(code); Actions.Controls.Add(submissionsButton); + Actions.Controls.Add(addGroup); Actions.Controls.Add(addStudent); + import.Click += async (_, _) => await ImportAsync(); + code.Click += (_, _) => OpenCode(); + submissionsButton.Click += async (_, _) => await OpenSubmissionsAsync(); + addGroup.Click += async (_, _) => await AddGroupAsync(); + addStudent.Click += async (_, _) => await AddStudentAsync(); + + studentsCard.Dock = DockStyle.Fill; + studentsCard.Tools.Controls.Add(deleteGroup); + deleteGroup.Click += async (_, _) => await DeleteGroupAsync(); + + grid.AutoGenerateColumns = false; + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "№", FillWeight = 9 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "ФИО", FillWeight = 44 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Лицо", FillWeight = 22 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Посещаемость за 30 дней", FillWeight = 25 }); + grid.CellMouseDown += (_, e) => + { + if (e.Button == MouseButtons.Right && e.RowIndex >= 0) + grid.CurrentCell = grid.Rows[e.RowIndex].Cells[1]; + }; + grid.CellDoubleClick += async (_, e) => + { + if (e.RowIndex >= 0 && grid.Rows[e.RowIndex].Tag is StudentDto student) await EditStudentAsync(student); + }; + menu.Items.AddRange([enrollItem, editItem, deleteItem]); + menu.Opening += (_, e) => + { + var student = SelectedStudent(); + if (student is null) { e.Cancel = true; return; } + enrollItem.Enabled = recognitionAvailable; + }; + enrollItem.Click += async (_, _) => { if (SelectedStudent() is { } student) await EnrollAsync(student); }; + editItem.Click += async (_, _) => { if (SelectedStudent() is { } student) await EditStudentAsync(student); }; + deleteItem.Click += async (_, _) => { if (SelectedStudent() is { } student) await DeleteStudentAsync(student); }; + grid.ContextMenuStrip = menu; + + var info = new Panel { Dock = DockStyle.Top, Height = 32, BackColor = Theme.Surface }; + recognitionNote.Dock = DockStyle.Fill; + info.Controls.Add(recognitionNote); + status.Dock = DockStyle.Bottom; status.Height = 25; + studentsCard.Body.Controls.Add(grid); studentsCard.Body.Controls.Add(info); studentsCard.Body.Controls.Add(status); + Body.Controls.Add(studentsCard); Body.Controls.Add(groupCards); + } + + public override Task LoadAsync() => RunAsync(async () => + { + status.Text = "Загрузка групп…"; + try + { + var loadedGroups = await Api.GroupsAsync(); + var recognition = await Api.RecognitionStatusAsync(); + if (IsDisposed) return; + recognitionAvailable = recognition.Ok; + recognitionMessage = recognition.Message; + recognitionNote.Text = recognitionAvailable ? "" : $"Регистрация лиц недоступна: {recognitionMessage}"; + await ReloadAsync(loadedGroups); + } + finally { if (!IsDisposed) status.Text = ""; } + }); + + private async Task ReloadAsync(List? loaded = null) + { + groups = loaded ?? await Api.GroupsAsync(); + if (IsDisposed) return; + if (selectedId is null || groups.All(g => g.Id != selectedId)) + selectedId = groups.FirstOrDefault()?.Id; + RenderGroups(); + await LoadGroupAsync(); + if (IsDisposed) return; + var submissions = await Api.FaceSubmissionsAsync(); + if (!IsDisposed) + submissionsButton.Text = submissions.Count == 0 ? "Заявки на регистрацию" : $"Заявки на регистрацию ({submissions.Count})"; + } + + private void RenderGroups() + { + groupCards.SuspendLayout(); + foreach (Control control in groupCards.Controls.Cast().ToArray()) control.Dispose(); + groupCards.Controls.Clear(); + if (groups.Count == 0) + { + var empty = Ui.Label("Групп пока нет. Создайте группу или импортируйте список студентов.", 10, Theme.Muted); + empty.Dock = DockStyle.None; empty.AutoSize = true; + groupCards.Controls.Add(empty); + } + foreach (var group in groups) + { + var card = new SmoothPanel + { + Width = 230, Height = 118, Padding = new Padding(16, 10, 16, 10), + Margin = new Padding(0, 0, 12, 12), Cursor = Cursors.Hand, + BorderColor = group.Id == selectedId ? Theme.Accent : Theme.Line + }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 4, BackColor = Color.Transparent }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 27)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 23)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 25)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 14)); + var title = Ui.Label(group.Name, 12, Theme.Text, FontStyle.Bold); + var count = Ui.Label($"{group.Students} {UiFormat.Plural(group.Students, "студент", "студента", "студентов")}", 9, Theme.Muted); + var faces = Ui.Label($"лица: {group.WithFace} из {group.Students}", 9, Theme.Muted); + var progress = new ProgressLine { Dock = DockStyle.Fill, Value = group.Students == 0 ? 0 : 100d * group.WithFace / group.Students }; + layout.Controls.Add(title, 0, 0); layout.Controls.Add(count, 0, 1); + layout.Controls.Add(faces, 0, 2); layout.Controls.Add(progress, 0, 3); + card.Controls.Add(layout); + void Select(object? sender, EventArgs e) => _ = RunAsync(async () => + { + if (selectedId == group.Id) return; + selectedId = group.Id; + RenderGroups(); + await LoadGroupAsync(); + }); + card.Click += Select; layout.Click += Select; title.Click += Select; + count.Click += Select; faces.Click += Select; progress.Click += Select; + groupCards.Controls.Add(card); + } + groupCards.ResumeLayout(); + } + + private async Task LoadGroupAsync() + { + grid.Rows.Clear(); + deleteGroup.Visible = false; + if (selectedId is not { } id) + { + students = []; + studentsCard.Caption = "Студенты"; + return; + } + studentsCard.Caption = $"Студенты · {groups.First(g => g.Id == id).Name}"; + status.Text = "Загрузка студентов и посещаемости…"; + try + { + students = await Api.StudentsAsync(id); + var today = DateOnly.FromDateTime(DateTime.Today); + var journal = await Api.JournalAsync(id, today.AddDays(-29), today, null); + if (IsDisposed || selectedId != id) return; + var percentages = journal.Students.ToDictionary(s => s.StudentId, s => s.Percent); + for (var i = 0; i < students.Count; i++) + { + var student = students[i]; + var percent = 0d; + var hasLessons = journal.Columns.Count > 0 && percentages.TryGetValue(student.Id, out percent); + var face = student.FaceUnreadable ? "требуется повторная регистрация" + : student.FacePending ? "на проверке" + : student.HasFace ? "зарегистрировано" : "нет"; + var index = grid.Rows.Add(i + 1, student.FullName, face, + hasLessons ? UiFormat.Percent(percent) : "—"); + var row = grid.Rows[index]; + row.Tag = student; + row.Cells[2].Style.ForeColor = student.FaceUnreadable ? Theme.Red : student.FacePending ? Theme.Yellow + : student.HasFace ? Theme.Green : Theme.Muted; + row.Cells[3].Style.ForeColor = hasLessons ? UiFormat.PercentColor(percent) : Theme.Muted; + } + deleteGroup.Visible = students.Count == 0; + } + finally { if (!IsDisposed) status.Text = ""; } + } + + private StudentDto? SelectedStudent() => + grid.CurrentRow?.Tag as StudentDto; + + private void OpenCode() + { + if (selectedId is not { } id) + { + Ui.Toast(this, "Выберите группу"); + return; + } + using var dialog = new EnrollmentCodeDialog(Api, id, groups.First(group => group.Id == id).Name); + dialog.ShowDialog(FindForm()); + } + + private async Task OpenSubmissionsAsync() + { + using var dialog = new FaceSubmissionsDialog(Api); + dialog.ShowDialog(FindForm()); + if (dialog.Changed) await RunAsync(() => ReloadAsync()); + } + + private async Task ImportAsync() + { + using var dialog = new OpenFileDialog + { + Title = "Импорт списков групп", + Filter = "Журнал Excel или CSV (*.xlsx;*.csv)|*.xlsx;*.csv|Журнал Excel (*.xlsx)|*.xlsx|CSV «ФИО;Группа» (*.csv)|*.csv" + }; + if (dialog.ShowDialog(FindForm()) != DialogResult.OK) return; + await RunAsync(async () => + { + var file = await File.ReadAllBytesAsync(dialog.FileName); + var result = await Api.ImportStudentsAsync(file); + await ReloadAsync(); + Ui.Toast(this, $"Добавлено {result.Added}, пропущено дубликатов {result.Skipped}"); + if (result.Errors.Count > 0) Ui.Message(FindForm(), "Ошибки импорта", string.Join(Environment.NewLine, result.Errors)); + }); + } + + private async Task AddGroupAsync() + { + var name = Ui.Prompt(FindForm(), "Новая группа", "Название группы"); + if (string.IsNullOrWhiteSpace(name)) return; + await RunAsync(async () => + { + var group = await Api.CreateGroupAsync(new CreateGroupRequest(name)); + selectedId = group.Id; + await ReloadAsync(); + }, "Группа создана"); + } + + private async Task AddStudentAsync() + { + using var dialog = new StudentDialog(Api, groups, null, selectedId); + if (dialog.ShowDialog(FindForm()) == DialogResult.OK) await RunAsync(() => ReloadAsync(), "Студент добавлен"); + } + + private async Task EditStudentAsync(StudentDto student) + { + using var dialog = new StudentDialog(Api, groups, student, student.GroupId); + if (dialog.ShowDialog(FindForm()) == DialogResult.OK) await RunAsync(() => ReloadAsync(), "Данные студента обновлены"); + } + + private async Task EnrollAsync(StudentDto student) + { + if (!recognitionAvailable) return; + using var dialog = new EnrollmentDialog(Api, student); + if (dialog.ShowDialog(FindForm()) == DialogResult.OK) await RunAsync(() => ReloadAsync(), "Лицо зарегистрировано"); + } + + private async Task DeleteStudentAsync(StudentDto student) + { + if (!Ui.Confirm(FindForm(), "Удалить студента", $"Удалить {student.FullName}?", "Удалить", danger: true)) return; + await RunAsync(async () => { await Api.DeleteStudentAsync(student.Id); await ReloadAsync(); }, "Студент удалён"); + } + + private async Task DeleteGroupAsync() + { + if (selectedId is not { } id || students.Count != 0) return; + var name = groups.First(g => g.Id == id).Name; + if (!Ui.Confirm(FindForm(), "Удалить группу", $"Удалить пустую группу {name}?", "Удалить", danger: true)) return; + await RunAsync(async () => { await Api.DeleteGroupAsync(id); selectedId = null; await ReloadAsync(); }, "Группа удалена"); + } + + protected override void Dispose(bool disposing) + { + if (disposing) menu.Dispose(); + base.Dispose(disposing); + } +} diff --git a/BiStudent/Pages/JournalPage.cs b/BiStudent/Pages/JournalPage.cs new file mode 100644 index 0000000..c08fd54 --- /dev/null +++ b/BiStudent/Pages/JournalPage.cs @@ -0,0 +1,549 @@ +using System.Diagnostics; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class JournalPage : Page +{ + private readonly ModernComboBox groups = new() { Width = 160, DisplayMember = nameof(GroupDto.Name) }; + private readonly ModernComboBox subjects = new() { Width = 190 }; + private readonly DateTimePicker from = Picker(); + private readonly DateTimePicker to = Picker(); + private readonly ModernButton week = new() { Text = "Неделя", Width = 95 }; + private readonly ModernButton month = new() { Text = "Месяц", Width = 95 }; + private readonly ModernButton semester = new() { Text = "Семестр", Width = 105 }; + private readonly ModernButton addAssessment = new() { Text = "Добавить контрольную", Width = 215 }; + private readonly ModernButton fillFinal = new() { Text = "Заполнить итог", Width = 170 }; + private readonly ModernButton excel = new() { Text = "Скачать Excel", Primary = true, Width = 170 }; + private readonly DataGridView grid = Ui.Grid(); + private readonly CardPanel card = new("Журнал", "Клик по ячейке — изменить; двойной клик по заголовку — открыть"); + private readonly Label empty = Ui.Label("За выбранный период занятий и контрольных нет", 12, Theme.Muted); + private Dictionary<(int Id, DateOnly Date), Task>> attendance = new(); + private JournalDto? journal; + private JournalFilter? loadedFilter; + private bool busy; + private bool changing; + + public JournalPage(ApiClient api) : base(api, "Журнал", "Посещаемость и успеваемость группы по дисциплине") + { + addAssessment.Click += async (_, _) => await AddAssessmentAsync(); + fillFinal.Click += async (_, _) => await FillFinalAsync(); + excel.Click += async (_, _) => await ExportAsync(); + Actions.Controls.Add(addAssessment); Actions.Controls.Add(fillFinal); Actions.Controls.Add(excel); + + var filterCard = new SmoothPanel { Dock = DockStyle.Top, Height = 116, Padding = new Padding(16, 8, 8, 8) }; + var filters = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Color.Transparent }; + filters.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); filters.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + var fields = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Color.Transparent }; + fields.Controls.Add(FilterLabel("Группа")); fields.Controls.Add(groups); + fields.Controls.Add(FilterLabel("Дисциплина")); fields.Controls.Add(subjects); + fields.Controls.Add(FilterLabel("С")); fields.Controls.Add(from); + fields.Controls.Add(FilterLabel("По")); fields.Controls.Add(to); + var periods = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Color.Transparent }; + periods.Controls.Add(week); periods.Controls.Add(month); periods.Controls.Add(semester); + filters.Controls.Add(fields, 0, 0); filters.Controls.Add(periods, 0, 1); + filterCard.Controls.Add(filters); + groups.SelectedIndexChanged += async (_, _) => { if (!changing) await ExecuteAsync(() => RefreshAsync(true)); }; + subjects.SelectedIndexChanged += async (_, _) => { if (!changing) await ExecuteAsync(() => RefreshAsync(false)); }; + from.ValueChanged += async (_, _) => await DatesChangedAsync(true); + to.ValueChanged += async (_, _) => await DatesChangedAsync(false); + week.Click += async (_, _) => await PresetAsync(week, UiFormat.Monday(DateOnly.FromDateTime(DateTime.Today)).ToDateTime(TimeOnly.MinValue), UiFormat.Monday(DateOnly.FromDateTime(DateTime.Today)).AddDays(6).ToDateTime(TimeOnly.MinValue)); + month.Click += async (_, _) => await PresetAsync(month, new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1), new DateTime(DateTime.Today.Year, DateTime.Today.Month, DateTime.DaysInMonth(DateTime.Today.Year, DateTime.Today.Month))); + semester.Click += async (_, _) => await PresetAsync(semester, SemesterStart(DateTime.Today), DateTime.Today); + changing = true; + from.Value = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + to.Value = DateTime.Today; + month.Selected = true; + changing = false; + + grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None; + grid.SelectionMode = DataGridViewSelectionMode.CellSelect; + grid.ColumnHeadersHeight = 76; + grid.ColumnHeadersDefaultCellStyle.WrapMode = DataGridViewTriState.True; + grid.CellClick += async (_, e) => await EditCellAsync(e.RowIndex, e.ColumnIndex); + grid.CellPainting += PaintLesson; + grid.CellMouseEnter += async (_, e) => await LoadTooltipAsync(e.RowIndex, e.ColumnIndex); + grid.ColumnHeaderMouseDoubleClick += async (_, e) => await OpenColumnAsync(e.ColumnIndex); + empty.TextAlign = ContentAlignment.MiddleCenter; + card.Dock = DockStyle.Fill; + var legend = Ui.Label("П — присутствовал · Н — отсутствовал · Н жёлтым — по уважительной причине · баллы по 100-балльной шкале", 9, Theme.Muted); + legend.Dock = DockStyle.Bottom; legend.Height = 38; legend.Padding = new Padding(8, 0, 0, 0); + card.Body.Controls.Add(grid); card.Body.Controls.Add(empty); card.Body.Controls.Add(legend); + Body.Controls.Add(card); + Body.Controls.Add(new Panel { Dock = DockStyle.Top, Height = 16, BackColor = Theme.Background }); + Body.Controls.Add(filterCard); + SetBusy(false); + } + + private static DateTimePicker Picker() => new() + { + Format = DateTimePickerFormat.Custom, CustomFormat = "dd.MM.yyyy", Width = 115, + Font = Theme.Font(10), CalendarMonthBackground = Theme.Surface, CalendarForeColor = Theme.Text, + Margin = new Padding(0, 5, 10, 0) + }; + + private static Label FilterLabel(string text) => new() + { + Text = text, AutoSize = true, Font = Theme.Font(9), ForeColor = Theme.Muted, + BackColor = Color.Transparent, Margin = new Padding(0, 12, 7, 0) + }; + + private static DateTime SemesterStart(DateTime today) => today.Month switch + { + >= 9 => new DateTime(today.Year, 9, 1), + >= 2 => new DateTime(today.Year, 2, 1), + _ => new DateTime(today.Year - 1, 9, 1) + }; + + public override async Task LoadAsync() + { + await ExecuteAsync(async () => + { + var selectedId = (groups.SelectedItem as GroupDto)?.Id; + var items = await Api.GroupsAsync(); + if (IsDisposed) return; + changing = true; + groups.Items.Clear(); + foreach (var item in items) groups.Items.Add(item); + groups.SelectedItem = groups.Items.Cast().FirstOrDefault(x => x.Id == selectedId) ?? groups.Items.Cast().FirstOrDefault(); + changing = false; + await RefreshAsync(true); + }); + } + + private async Task DatesChangedAsync(bool start) + { + if (changing) return; + changing = true; + if (from.Value.Date > to.Value.Date) + { + if (start) to.Value = from.Value.Date; + else from.Value = to.Value.Date; + } + week.Selected = month.Selected = semester.Selected = false; + changing = false; + await ExecuteAsync(() => RefreshAsync(false)); + } + + private async Task PresetAsync(ModernButton chosen, DateTime start, DateTime end) + { + if (busy) return; + changing = true; + from.Value = start; to.Value = end; + week.Selected = chosen == week; month.Selected = chosen == month; semester.Selected = chosen == semester; + changing = false; + await ExecuteAsync(() => RefreshAsync(false)); + } + + private string? SelectedSubject => subjects.SelectedIndex > 0 ? subjects.SelectedItem as string : null; + private JournalFilter? CurrentFilter => groups.SelectedItem is GroupDto group + ? new(group.Id, DateOnly.FromDateTime(from.Value), DateOnly.FromDateTime(to.Value), SelectedSubject) : null; + + private async Task RefreshAsync(bool updateSubjects, bool preserve = false) + { + if (groups.SelectedItem is not GroupDto group) + { + ClearJournal("Нет групп со студентами"); + return; + } + var start = DateOnly.FromDateTime(from.Value); + var end = DateOnly.FromDateTime(to.Value); + try + { + if (updateSubjects) + { + var names = await Api.SubjectsAsync(group.Id); + if (IsDisposed) return; + SetSubjects(names); + } + var subject = SelectedSubject; + var data = await Api.JournalAsync(group.Id, start, end, subject); + if (IsDisposed) return; + ShowJournal(data, preserve); + loadedFilter = new(group.Id, start, end, subject); + } + catch (Exception ex) + { + if (!IsDisposed) ClearJournal(ex.Message); + throw; + } + } + + private void ClearJournal(string message) + { + journal = null; loadedFilter = null; attendance = new(); + grid.Rows.Clear(); grid.Columns.Clear(); grid.Visible = false; + empty.Text = message; empty.Visible = true; + } + + private void SetSubjects(List names) + { + var selected = SelectedSubject; + changing = true; + subjects.Items.Clear(); + subjects.Items.Add("Все дисциплины"); + foreach (var name in names.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.CurrentCultureIgnoreCase).OrderBy(x => x, StringComparer.CurrentCultureIgnoreCase)) + subjects.Items.Add(name); + subjects.SelectedItem = selected is not null && subjects.Items.Contains(selected) ? selected : subjects.Items.Count > 1 ? subjects.Items[1] : subjects.Items[0]; + changing = false; + } + + private static string ShortTitle(string title) => title.Length <= 13 ? title : title[..12] + "…"; + + private void ShowJournal(JournalDto data, bool preserve) + { + var firstRow = preserve && grid.Rows.Count > 0 ? grid.FirstDisplayedScrollingRowIndex : -1; + var horizontal = preserve ? grid.HorizontalScrollingOffset : 0; + (int StudentId, JournalColumnKind Kind, int Id, DateOnly Date)? selected = null; + if (preserve && grid.CurrentCell is { } current + && grid.Rows[current.RowIndex].Tag is JournalStudentDto selectedStudent + && grid.Columns[current.ColumnIndex].Tag is JournalEntry selectedEntry) + selected = (selectedStudent.StudentId, selectedEntry.Column.Kind, selectedEntry.Column.Id, selectedEntry.Column.Date); + journal = data; + attendance = new(); + grid.SuspendLayout(); + grid.Rows.Clear(); grid.Columns.Clear(); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "№", Width = 52, Frozen = true, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "ФИО студента", Width = 240, Frozen = true, + SortMode = DataGridViewColumnSortMode.NotSortable, + DefaultCellStyle = new DataGridViewCellStyle { Alignment = DataGridViewContentAlignment.MiddleLeft, Padding = new Padding(12, 0, 0, 0), Font = Theme.Font(10, FontStyle.Bold) } }); + var entries = data.Columns.Select((column, index) => new JournalEntry(column, index)) + .Where(x => SelectedSubject is not null || x.Column.Kind == JournalColumnKind.Lesson).ToList(); + foreach (var entry in entries) + { + var column = entry.Column; + var assessment = column.Kind == JournalColumnKind.Assessment; + var kind = UiFormat.AssessmentKindShort(column.AssessmentKind ?? AssessmentKind.Other); + var title = column.Title ?? ""; + var header = assessment ? $"{column.Date:dd.MM}\n{kind}\n{ShortTitle(title)}" + : $"{column.Date:dd.MM}\n{column.Start?.ToString("HH:mm") ?? "—"}"; + grid.Columns.Add(new DataGridViewTextBoxColumn + { + HeaderText = header, Width = assessment ? 112 : 90, Tag = entry, + ToolTipText = assessment ? $"{column.Date:dd.MM.yyyy} · {UiFormat.AssessmentKindText(column.AssessmentKind ?? AssessmentKind.Other)} · {title}" + : $"{column.Subject} · {column.Date:dd.MM.yyyy} {column.Start?.ToString("HH:mm") ?? "—"}", + SortMode = DataGridViewColumnSortMode.NotSortable, + HeaderCell = { Style = { BackColor = assessment ? Theme.SurfaceAlt : Theme.Surface, + SelectionBackColor = assessment ? Theme.SurfaceAlt : Theme.Surface } } + }); + } + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Пропущено, ч", Width = 118, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Посещ., %", Width = 105, SortMode = DataGridViewColumnSortMode.NotSortable }); + if (SelectedSubject is not null) + { + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Ср. балл", Width = 105, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Итог", Width = 90, Name = "final", SortMode = DataGridViewColumnSortMode.NotSortable }); + } + for (var rowIndex = 0; rowIndex < data.Students.Count; rowIndex++) + { + var student = data.Students[rowIndex]; + var row = grid.Rows[grid.Rows.Add()]; + row.Tag = student; + row.Cells[0].Value = rowIndex + 1; + row.Cells[1].Value = student.FullName; + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + var cell = entry.Index < student.Cells.Count ? student.Cells[entry.Index] : null; + var view = row.Cells[i + 2]; + if (cell is { InGroup: false }) + { + view.Value = "—"; + view.Style.ForeColor = Theme.Muted; + continue; + } + if (entry.Column.Kind == JournalColumnKind.Lesson) + view.ToolTipText = LessonTooltip(entry.Column, cell, null); + else + { + view.Value = cell?.Score?.ToString() ?? "·"; + view.Style.Font = Theme.Font(12, FontStyle.Bold); + view.Style.ForeColor = cell?.Score is null ? Theme.Muted : Theme.Text; + } + } + var summary = entries.Count + 2; + row.Cells[summary].Value = student.MissedHours; + row.Cells[summary].ToolTipText = $"Из них по уважительной: {student.ExcusedHours} ч"; + row.Cells[summary + 1].Value = UiFormat.Percent(student.Percent); + row.Cells[summary + 1].Style.ForeColor = student.Percent >= 85 ? Theme.Green : student.Percent >= 70 ? Theme.Yellow : Theme.Red; + row.Cells[summary + 1].Style.Font = Theme.Font(10, FontStyle.Bold); + if (SelectedSubject is not null) + { + row.Cells[summary + 2].Value = student.Average?.ToString("0.0") ?? "—"; + row.Cells[summary + 3].Value = student.Final?.ToString() ?? "—"; + row.Cells[summary + 3].Style.Font = Theme.Font(10, FontStyle.Bold); + } + } + grid.ResumeLayout(); + empty.Text = "За выбранный период занятий и контрольных нет"; + empty.Visible = entries.Count == 0; + grid.Visible = entries.Count > 0; + if (firstRow >= 0 && grid.Rows.Count > 0) grid.FirstDisplayedScrollingRowIndex = Math.Min(firstRow, grid.Rows.Count - 1); + if (horizontal > 0) grid.HorizontalScrollingOffset = horizontal; + if (selected is { } cellSelection) + { + var row = grid.Rows.Cast().FirstOrDefault(x => x.Tag is JournalStudentDto xStudent && xStudent.StudentId == cellSelection.StudentId); + var column = grid.Columns.Cast().FirstOrDefault(x => x.Tag is JournalEntry xEntry + && xEntry.Column.Kind == cellSelection.Kind && xEntry.Column.Id == cellSelection.Id && xEntry.Column.Date == cellSelection.Date); + if (row is not null && column is not null) grid.CurrentCell = row.Cells[column.Index]; + } + } + + private static string LessonTooltip(JournalColumnDto column, JournalCellDto? cell, AttendanceRowDto? row) + { + var lines = new List { $"{column.Subject} · {column.Date:dd.MM.yyyy} {column.Start?.ToString("HH:mm") ?? "—"}" }; + lines.Add(cell?.Status switch + { + AttendanceStatus.Present => "Присутствовал", + AttendanceStatus.Absent => "Отсутствовал", + AttendanceStatus.Excused => "Отсутствовал по уважительной причине", + _ => "Нет отметки" + }); + if (cell?.Status == AttendanceStatus.Excused) + { + if (cell.Reason is { } reason) lines.Add(UiFormat.ExcuseReasonText(reason)); + lines.Add(row is null ? "Сведения о документе загружаются" : + row.HasDocument ? "Документ предоставлен" : "Документ не предоставлен"); + } + if (!string.IsNullOrWhiteSpace(cell?.Comment)) lines.Add(cell.Comment); + if (cell?.Score is { } score) lines.Add($"Балл: {score}"); + return string.Join(Environment.NewLine, lines); + } + + private void PaintLesson(object? sender, DataGridViewCellPaintingEventArgs e) + { + if (e.RowIndex < 0 || e.ColumnIndex < 0 || grid.Columns[e.ColumnIndex].Tag is not JournalEntry entry + || entry.Column.Kind != JournalColumnKind.Lesson) return; + var student = grid.Rows[e.RowIndex].Tag as JournalStudentDto; + if (student is null) return; + var cell = entry.Index < student.Cells.Count ? student.Cells[entry.Index] : null; + if (cell is { InGroup: false }) + { + e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + var mutedBounds = new Rectangle(e.CellBounds.X + 10, e.CellBounds.Y, e.CellBounds.Width - 18, e.CellBounds.Height); + TextRenderer.DrawText(e.Graphics!, "—", Theme.Font(13, FontStyle.Bold), mutedBounds, Theme.Muted, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + e.Handled = true; + return; + } + var status = cell?.Status; + var color = status switch + { + AttendanceStatus.Present => Theme.Green, + AttendanceStatus.Absent => Theme.Red, + AttendanceStatus.Excused => Theme.Yellow, + _ => Theme.Muted + }; + e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + var bounds = new Rectangle(e.CellBounds.X + 10, e.CellBounds.Y, e.CellBounds.Width - 18, e.CellBounds.Height); + TextRenderer.DrawText(e.Graphics!, UiFormat.StatusSymbol(status), Theme.Font(13, FontStyle.Bold), bounds, color, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + if (SelectedSubject is not null && cell?.Score is { } score) + TextRenderer.DrawText(e.Graphics!, score.ToString(), Theme.Font(10, FontStyle.Bold), bounds, Theme.Text, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + if (status == AttendanceStatus.Excused) + { + var state = e.Graphics!.Save(); + e.Graphics.SetClip(e.CellBounds); + TextRenderer.DrawText(e.Graphics!, "у", Theme.Font(8, FontStyle.Bold), + new Rectangle(e.CellBounds.Right - 18, e.CellBounds.Top + 4, 14, 14), Theme.Yellow, + TextFormatFlags.Right | TextFormatFlags.Top | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix); + e.Graphics.Restore(state); + } + e.Handled = true; + } + + private async Task> AttendanceRowsAsync(JournalColumnDto column) + { + var cache = attendance; + var key = (column.Id, column.Date); + if (!cache.TryGetValue(key, out var task)) + { + task = Api.AttendanceAsync(column.Id, column.Date); + cache[key] = task; + } + try { return await task; } + catch { cache.Remove(key); throw; } + } + + private async Task LoadTooltipAsync(int rowIndex, int columnIndex) + { + if (rowIndex < 0 || columnIndex < 0 || journal is not { } data + || grid.Columns[columnIndex].Tag is not JournalEntry entry + || entry.Column.Kind != JournalColumnKind.Lesson + || grid.Rows[rowIndex].Tag is not JournalStudentDto student + || entry.Index >= student.Cells.Count + || student.Cells[entry.Index].Status != AttendanceStatus.Excused) return; + try + { + var rows = await AttendanceRowsAsync(entry.Column); + if (IsDisposed || !ReferenceEquals(journal, data) || columnIndex >= grid.Columns.Count + || grid.Columns[columnIndex].Tag is not JournalEntry current || current != entry) return; + foreach (DataGridViewRow view in grid.Rows) + { + if (view.Tag is not JournalStudentDto item || entry.Index >= item.Cells.Count + || item.Cells[entry.Index].Status != AttendanceStatus.Excused) continue; + var attendanceRow = rows.FirstOrDefault(x => x.StudentId == item.StudentId); + view.Cells[columnIndex].ToolTipText = LessonTooltip(entry.Column, item.Cells[entry.Index], attendanceRow); + } + } + catch { } + } + + private async Task EditCellAsync(int rowIndex, int columnIndex) + { + if (busy || loadedFilter != CurrentFilter || rowIndex < 0 || columnIndex < 0 + || grid.Rows[rowIndex].Tag is not JournalStudentDto student + || groups.SelectedItem is not GroupDto group + || FindForm() is not { } owner) return; + var selected = grid.Columns[columnIndex]; + if (selected.Tag is not JournalEntry && (selected.Name != "final" || SelectedSubject is null)) return; + if (selected.Tag is JournalEntry selectedEntry && selectedEntry.Index < student.Cells.Count + && !student.Cells[selectedEntry.Index].InGroup) return; + var bounds = grid.GetCellDisplayRectangle(columnIndex, rowIndex, false); + var near = grid.PointToScreen(new Point(bounds.Left, bounds.Bottom)); + await ExecuteAsync(async () => + { + GradeEditor editor; + if (selected.Tag is JournalEntry entry) + { + var cell = entry.Index < student.Cells.Count ? student.Cells[entry.Index] : new JournalCellDto(null, null, null, null); + AttendanceRowDto? row = null; + if (entry.Column.Kind == JournalColumnKind.Lesson) + row = (await AttendanceRowsAsync(entry.Column)).FirstOrDefault(x => x.StudentId == student.StudentId); + if (IsDisposed || owner.IsDisposed) return; + editor = new GradeEditor(Api, entry.Column, cell, student.StudentId, student.FullName, attendance: row, attendanceOnly: SelectedSubject is null); + } + else editor = new GradeEditor(Api, group.Id, SelectedSubject!, student.StudentId, student.FullName, student.Final); + using (editor) + { + var changed = false; + editor.Saved += () => changed = true; + editor.ShowDialog(owner, near); + if (changed && !IsDisposed) await RefreshAsync(false, true); + } + }); + } + + private async Task OpenColumnAsync(int columnIndex) + { + if (busy || loadedFilter != CurrentFilter || columnIndex < 0 + || grid.Columns[columnIndex].Tag is not JournalEntry entry + || groups.SelectedItem is not GroupDto group || FindForm() is not { } owner) return; + await ExecuteAsync(async () => + { + if (entry.Column.Kind == JournalColumnKind.Lesson) + { + var lessons = await Api.DayAsync(entry.Column.Date); + if (IsDisposed || owner.IsDisposed) return; + var lesson = lessons.FirstOrDefault(x => x.Slot.Id == entry.Column.Id); + if (lesson is null) { Ui.Toast(this, "Занятие не найдено в расписании.", true); return; } + using var dialog = new LessonDialog(Api, lesson); + dialog.ShowDialog(owner); + if (!IsDisposed) await RefreshAsync(false, true); + return; + } + if (SelectedSubject is not { } subject) return; + var assessments = await Api.AssessmentsAsync(group.Id, subject); + if (IsDisposed || owner.IsDisposed) return; + var assessment = assessments.FirstOrDefault(x => x.Id == entry.Column.Id); + if (assessment is null) { Ui.Toast(this, "Контрольная не найдена.", true); return; } + using var edit = new AssessmentDialog(Api, group.Id, group.Name, subject, assessments, assessment); + if (edit.ShowDialog(owner) == DialogResult.OK && !IsDisposed) await RefreshAsync(false, true); + }); + } + + private async Task AddAssessmentAsync() + { + if (busy || loadedFilter != CurrentFilter || groups.SelectedItem is not GroupDto group + || SelectedSubject is not { } subject || FindForm() is not { } owner) return; + await ExecuteAsync(async () => + { + var assessments = await Api.AssessmentsAsync(group.Id, subject); + if (IsDisposed || owner.IsDisposed) return; + using var dialog = new AssessmentDialog(Api, group.Id, group.Name, subject, assessments); + if (dialog.ShowDialog(owner) == DialogResult.OK && !IsDisposed) await RefreshAsync(false, true); + }); + } + + private async Task FillFinalAsync() + { + if (busy || loadedFilter != CurrentFilter || groups.SelectedItem is not GroupDto group + || SelectedSubject is not { } subject || FindForm() is not { } owner + || !Ui.Confirm(owner, "Заполнить итог?", + "Итоговый балл будет рассчитан как среднее всех баллов за семестр для студентов, у которых итог ещё не выставлен.", + "Заполнить")) return; + await ExecuteAsync(async () => + { + var result = await Api.FillFinalAsync(group.Id, subject); + if (IsDisposed) return; + Ui.Toast(this, $"Итог выставлен: {result.Count}"); + await RefreshAsync(false, true); + }); + } + + private async Task ExportAsync() + { + if (groups.SelectedItem is not GroupDto group) return; + await ExecuteAsync(async () => + { + var (content, name) = await Api.JournalFileAsync(group.Id, DateOnly.FromDateTime(from.Value), DateOnly.FromDateTime(to.Value), SelectedSubject, "xlsx"); + if (IsDisposed) return; + using var dialog = new SaveFileDialog { FileName = name, DefaultExt = "xlsx", AddExtension = true, Filter = "Excel (*.xlsx)|*.xlsx" }; + if (dialog.ShowDialog(FindForm()) != DialogResult.OK) return; + await File.WriteAllBytesAsync(dialog.FileName, content); + if (!IsDisposed) ShowSavedToast(dialog.FileName); + }); + } + + private void ShowSavedToast(string path) + { + var form = FindForm(); + if (form is null || form.IsDisposed) return; + var toast = new SmoothPanel { Width = 320, Height = 50, Radius = 12, Padding = new Padding(12, 4, 8, 4), BackColor = Theme.SurfaceAlt }; + var label = Ui.Label("Файл сохранён", 10, Theme.Text, FontStyle.Bold); + var open = new ModernButton { Text = "Открыть", Width = 100, Dock = DockStyle.Right, Margin = Padding.Empty }; + open.Click += (_, _) => + { + try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } + catch (Exception ex) when (ex is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) { Ui.Toast(this, $"Не удалось открыть файл: {ex.Message}", true); } + if (!toast.IsDisposed) toast.Dispose(); + }; + toast.Controls.Add(label); toast.Controls.Add(open); + form.Controls.Add(toast); + toast.Location = new Point(form.ClientSize.Width - toast.Width - 24, form.ClientSize.Height - toast.Height - 24); + toast.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + toast.BringToFront(); + var timer = new System.Windows.Forms.Timer { Interval = 8000 }; + timer.Tick += (_, _) => { timer.Dispose(); if (!toast.IsDisposed) toast.Dispose(); }; + toast.Disposed += (_, _) => timer.Dispose(); + timer.Start(); + } + + private async Task ExecuteAsync(Func action) + { + if (busy || IsDisposed) return; + busy = true; SetBusy(true); UseWaitCursor = true; + try { await action(); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + if (!IsDisposed) Ui.Toast(this, ex.Message, true); + } + finally { busy = false; if (!IsDisposed) { UseWaitCursor = false; SetBusy(false); } } + } + + private void SetBusy(bool state) + { + groups.Enabled = !state && groups.Items.Count > 0; + subjects.Enabled = !state && groups.SelectedItem is GroupDto; + from.Enabled = to.Enabled = week.Enabled = month.Enabled = semester.Enabled = !state && groups.SelectedItem is GroupDto; + addAssessment.Enabled = fillFinal.Enabled = !state && groups.SelectedItem is GroupDto && SelectedSubject is not null; + excel.Enabled = !state && groups.SelectedItem is GroupDto; + grid.Enabled = !state && journal is { Columns.Count: > 0 }; + card.Caption = state ? "Загрузка..." : "Журнал"; + } + + private readonly record struct JournalEntry(JournalColumnDto Column, int Index); + private readonly record struct JournalFilter(int GroupId, DateOnly From, DateOnly To, string? Subject); +} diff --git a/BiStudent/Pages/LessonDialog.cs b/BiStudent/Pages/LessonDialog.cs new file mode 100644 index 0000000..8343bd8 --- /dev/null +++ b/BiStudent/Pages/LessonDialog.cs @@ -0,0 +1,137 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class LessonDialog : DarkForm +{ + private readonly ApiClient api; + private readonly LessonDto lesson; + private readonly AttendanceList list = new(wideButtons: true) { Dock = DockStyle.Fill, EmptyText = "В группе нет студентов" }; + private readonly KpiTile present = new("Присутствуют", 20); + private readonly KpiTile absent = new("Отсутствуют", 20); + private readonly KpiTile camera = new("Отмечено камерой", 20) { Accent = true }; + private readonly System.Windows.Forms.Timer refresh = new() { Interval = 3000 }; + private bool loading; + private readonly SemaphoreSlim writes = new(1, 1); + private readonly List writeButtons = []; + + public LessonDialog(ApiClient api, LessonDto lesson) + { + this.api = api; + this.lesson = lesson; + Text = $"{lesson.Slot.Subject} · {UiFormat.Date(lesson.Date)}"; + ClientSize = new Size(960, 700); + MinimumSize = new Size(820, 560); + StartPosition = FormStartPosition.CenterParent; + ShowInTaskbar = false; + MinimizeBox = false; + Padding = new Padding(26, 18, 26, 20); + Build(); + list.StatusChosen += MarkAsync; + list.GradeChosen += GradeAsync; + refresh.Tick += async (_, _) => { if (writes.CurrentCount > 0) await LoadAsync(); }; + Shown += async (_, _) => { await LoadAsync(); if (!IsDisposed) refresh.Start(); }; + } + + private void Build() + { + var head = new TableLayoutPanel { Dock = DockStyle.Top, Height = 74, ColumnCount = 2, RowCount = 2, BackColor = Theme.Background }; + head.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); head.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + head.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); head.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + head.Controls.Add(Ui.Label(lesson.Slot.Subject, 19, Theme.Text, FontStyle.Bold), 0, 0); + var room = lesson.Slot.Room is { } r ? $" · ауд. {r}" : ""; + head.Controls.Add(Ui.Label($"{UiFormat.LongDate(lesson.Date)} · {UiFormat.Range(lesson.Slot)} · {lesson.Slot.GroupName}{room}{(lesson.Cancelled ? " · отменена" : "")}", 10, lesson.Cancelled ? Theme.Red : Theme.Muted), 0, 1); + var actions = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Theme.Background, Margin = new Padding(0, 8, 0, 0) }; + var all = new ModernButton { Text = "Все присутствуют", Width = 176 }; + all.Click += async (_, _) => await RunAsync(async () => { var r = await api.AllPresentAsync(lesson.Slot.Id, lesson.Date); Ui.Toast(this, $"Отмечено присутствующих: {r.Count}"); }); + var finish = new ModernButton { Text = "Завершить занятие", Primary = true, Width = 190, Margin = Padding.Empty }; + finish.Click += async (_, _) => + { + if (!Ui.Confirm(this, "Завершить занятие?", "Всем студентам без отметки будет поставлено «Н».", "Завершить")) return; + await RunAsync(async () => { var r = await api.FinishAsync(lesson.Slot.Id, lesson.Date); Ui.Toast(this, r.Count == 0 ? "Все уже были отмечены" : $"Отмечено отсутствующих: {r.Count}"); }); + }; + actions.Controls.Add(all); actions.Controls.Add(finish); + writeButtons.AddRange([all, finish]); + head.Controls.Add(actions, 1, 0); head.SetRowSpan(actions, 2); + + var kpis = new TableLayoutPanel { Dock = DockStyle.Top, Height = 112, ColumnCount = 3, BackColor = Theme.Background, Padding = new Padding(0, 10, 0, 12) }; + for (var i = 0; i < 3; i++) kpis.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 3)); + foreach (var tile in new[] { present, absent, camera }) { tile.Dock = DockStyle.Fill; kpis.Controls.Add(tile); } + camera.Margin = Padding.Empty; + + var card = new CardPanel("Студенты группы", "Клик по статусу — отметить, повторный клик — снять; балл — по 100-балльной шкале") { Dock = DockStyle.Fill }; + card.Body.Controls.Add(list); + Controls.Add(card); Controls.Add(kpis); Controls.Add(head); + } + + private async Task LoadAsync() + { + if (loading || IsDisposed) return; + loading = true; + try + { + var rows = await api.AttendanceAsync(lesson.Slot.Id, lesson.Date); + if (IsDisposed) return; + list.SetRows(rows); + present.Set(rows.Count(r => r.Status == AttendanceStatus.Present).ToString(), $"из {rows.Count}", Theme.Green); + absent.Set(rows.Count(r => r.Status is AttendanceStatus.Absent or AttendanceStatus.Excused).ToString(), $"из них по уважительной — {rows.Count(r => r.Status == AttendanceStatus.Excused)}", Theme.Red); + camera.Set(rows.Count(r => r.Method == MarkMethod.Camera).ToString()); + } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + finally { loading = false; } + } + + private async Task MarkAsync(AttendanceRowDto row, AttendanceStatus? status) + { + if (status != AttendanceStatus.Excused) + { + await RunAsync(() => api.MarkAsync(lesson.Slot.Id, lesson.Date, row.StudentId, status)); + return; + } + using var dialog = new ExcuseDialog(row.FullName, row.Reason, row.HasDocument, row.Comment); + if (dialog.ShowDialog(this) != DialogResult.OK) return; + var (reason, document, comment) = dialog.Result; + await RunAsync(() => api.MarkAsync(lesson.Slot.Id, lesson.Date, row.StudentId, status, reason, comment, document)); + } + + private async Task GradeAsync(AttendanceRowDto row) + { + var column = new JournalColumnDto(JournalColumnKind.Lesson, lesson.Slot.Id, lesson.Date, + lesson.Slot.Start, lesson.Slot.Subject); + var cell = new JournalCellDto(row.Status, row.Reason, row.Comment, row.Score); + using var editor = new GradeEditor(api, column, cell, row.StudentId, row.FullName, scoreOnly: true, attendance: row); + var changed = false; + editor.Saved += () => changed = true; + editor.ShowDialog(this, Cursor.Position); + if (changed) await LoadAsync(); + } + + private async Task RunAsync(Func action) + { + if (writes.CurrentCount == 0) return; + await writes.WaitAsync(); + if (IsDisposed) { writes.Release(); return; } + UseWaitCursor = true; + list.Enabled = false; + foreach (var button in writeButtons) button.Enabled = false; + try + { + await action(); + while (loading) await Task.Delay(50); + await LoadAsync(); + } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + finally + { + writes.Release(); + if (!IsDisposed) { UseWaitCursor = false; list.Enabled = true; foreach (var button in writeButtons) button.Enabled = true; } + } + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + refresh.Stop(); + refresh.Dispose(); + base.OnFormClosed(e); + } +} diff --git a/BiStudent/Pages/ReportPage.cs b/BiStudent/Pages/ReportPage.cs new file mode 100644 index 0000000..15d1a75 --- /dev/null +++ b/BiStudent/Pages/ReportPage.cs @@ -0,0 +1,439 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Printing; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class ReportPage : Page +{ + private readonly Panel scroll = new() { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Theme.Background }; + private readonly Panel content = new() { BackColor = Theme.Background }; + private readonly Banner banner = new(); + private readonly Label period = Ui.Label("", 9, Theme.Violet, FontStyle.Bold); + private readonly KpiTile saved = new("Сэкономлено преподавателем", 30) { Accent = true }; + private readonly KpiTile camera = new("Отмечено камерой", 30); + private readonly KpiTile exports = new("Выгрузок журнала", 30); + private readonly KpiTile attendance = new("Средняя посещаемость", 30); + private readonly TableLayoutPanel kpis = new() { ColumnCount = 4, RowCount = 1, BackColor = Theme.Background }; + private readonly CardPanel comparison = new("Было → стало"); + private readonly CardPanel trendCard = new("Посещаемость по дням"); + private readonly CardPanel groupsCard = new("По группам", "посещаемость и зона риска"); + private readonly CardPanel scaleCard = new("Масштаб колледжа"); + private readonly Label manualRollCall = Ui.Label("", 10, Theme.Text, FontStyle.Bold); + private readonly Label manualReport = Ui.Label("", 10, Theme.Text, FontStyle.Bold); + private readonly Label rollCallDetail = Detail(""); + private readonly Label formula = Ui.Label("", 8.5f, Theme.Muted); + private readonly Label scaleText = Ui.Label("", 10, Theme.Text); + private readonly Label scaleFormula = Ui.Label("", 8.5f, Theme.Muted); + private readonly NumericUpDown teachers = new() + { + Minimum = 1, Maximum = 1000, Value = 40, Width = 76, Height = 30, + BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle, + Font = Theme.Font(11, FontStyle.Bold), TextAlign = HorizontalAlignment.Center + }; + private readonly KpiTile perTeacher = new("На одного преподавателя", 22); + private readonly KpiTile allTeachers = new("На весь педсостав", 22); + private readonly KpiTile workDays = new("Рабочих дней по 8 часов", 22); + private readonly KpiTile schoolYear = new("За учебный год", 22); + private readonly TrendChart trend = new() { Dock = DockStyle.Fill }; + private readonly GroupChart groups = new() { Dock = DockStyle.Fill }; + private readonly ModernButton saveButton = new() { Text = "Сохранить PNG", Width = 164 }; + private readonly ModernButton printButton = new() { Text = "Печать…", Width = 126 }; + private ReportDto? report; + private bool loading; + private bool exporting; + private int chartHeight = 340; + + public ReportPage(ApiClient api) : base(api, "Отчёт", "Посещаемость, зона риска и эффект от внедрения за 30 дней") + { + AutoScaleMode = AutoScaleMode.Dpi; + Body.Controls.Add(scroll); + scroll.Controls.Add(content); + + banner.Height = 216; + period.Dock = DockStyle.None; + period.SetBounds(26, 20, 560, 28); + var heading = Ui.Label("Что автоматизация даёт педсоставу", 22, Theme.Text, FontStyle.Bold); + heading.Dock = DockStyle.None; + heading.SetBounds(26, 57, 790, 48); + var description = Ui.Label( + "BiStudent снимает с преподавателя перекличку, сведение журнала и контроль пропусков.\nНиже — фактические данные одного преподавателя и расчёт для всего колледжа.", + 10.5f, Theme.Text); + description.Dock = DockStyle.None; + description.SetBounds(28, 119, 830, 70); + saveButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + printButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + saveButton.Top = printButton.Top = 25; + banner.Controls.Add(period); + banner.Controls.Add(heading); + banner.Controls.Add(description); + banner.Controls.Add(saveButton); + banner.Controls.Add(printButton); + content.Controls.Add(banner); + + for (var i = 0; i < 4; i++) kpis.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + var tiles = new[] { saved, camera, exports, attendance }; + for (var i = 0; i < tiles.Length; i++) + { + tiles[i].Dock = DockStyle.Fill; + tiles[i].Margin = new Padding(0, 0, i == 3 ? 0 : 12, 0); + kpis.Controls.Add(tiles[i], i, 0); + } + content.Controls.Add(kpis); + + var before = ComparisonColumn("БЕЗ СИСТЕМЫ", Theme.Mix(Theme.Surface, Theme.Red, .13f), + [ + (manualRollCall, rollCallDetail), + (manualReport, Detail("Переписать отметки в таблицу и посчитать проценты")), + (Ui.Label("Расписание переписывается каждую неделю", 10, Theme.Text, FontStyle.Bold), Detail("Изменения приходится отслеживать отдельно")), + (Ui.Label("Пропуски ищут по бумажному журналу", 10, Theme.Text, FontStyle.Bold), Detail("Проблему замечают поздно")) + ]); + var after = ComparisonColumn("С BISTUDENT", Theme.Mix(Theme.Surface, Theme.Green, .12f), + [ + (Ui.Label("Без устной переклички", 10, Theme.Text, FontStyle.Bold), Detail("Камера отмечает присутствие во время занятия")), + (Ui.Label("Журнал готов в любой момент", 10, Theme.Text, FontStyle.Bold), Detail("Excel и CSV одной кнопкой")), + (Ui.Label("Шаблон на семестр", 10, Theme.Text, FontStyle.Bold), Detail("Чётность, разовые занятия и отмены")), + (Ui.Label("Автоматическая «зона риска»", 10, Theme.Text, FontStyle.Bold), Detail("Ниже 70% или три пропуска подряд")) + ]); + var compareColumns = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, BackColor = Theme.Surface }; + compareColumns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + compareColumns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + before.Dock = after.Dock = DockStyle.Fill; + before.Margin = new Padding(0, 0, 8, 0); + after.Margin = new Padding(8, 0, 0, 0); + compareColumns.Controls.Add(before, 0, 0); + compareColumns.Controls.Add(after, 1, 0); + var compareFooter = new Panel { Dock = DockStyle.Bottom, Height = 29, BackColor = Theme.Surface }; + formula.Dock = DockStyle.Fill; + compareFooter.Controls.Add(formula); + comparison.Body.Controls.Add(compareColumns); + comparison.Body.Controls.Add(compareFooter); + content.Controls.Add(comparison); + + trendCard.Body.Controls.Add(trend); + groupsCard.Body.Controls.Add(groups); + content.Controls.Add(trendCard); + content.Controls.Add(groupsCard); + + var scaleIntro = new TableLayoutPanel { Dock = DockStyle.Top, Height = 44, ColumnCount = 3, BackColor = Theme.Surface }; + scaleIntro.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + scaleIntro.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); + scaleIntro.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + var intro = Ui.Label("Если перейдут", 10, Theme.Text); + intro.AutoSize = true; + intro.Margin = new Padding(0, 0, 10, 0); + scaleText.AutoEllipsis = true; + scaleIntro.Controls.Add(intro, 0, 0); + scaleIntro.Controls.Add(teachers, 1, 0); + scaleIntro.Controls.Add(scaleText, 2, 0); + var scaleKpis = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 4, BackColor = Theme.Surface }; + for (var i = 0; i < 4; i++) scaleKpis.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + var scaleTiles = new[] { perTeacher, allTeachers, workDays, schoolYear }; + for (var i = 0; i < scaleTiles.Length; i++) + { + scaleTiles[i].Dock = DockStyle.Fill; + scaleTiles[i].Margin = new Padding(0, 0, i == 3 ? 0 : 10, 0); + scaleKpis.Controls.Add(scaleTiles[i], i, 0); + } + var scaleFooter = new Panel { Dock = DockStyle.Bottom, Height = 35, BackColor = Theme.Surface }; + scaleFormula.Dock = DockStyle.Fill; + scaleFooter.Controls.Add(scaleFormula); + scaleCard.Body.Controls.Add(scaleKpis); + scaleCard.Body.Controls.Add(scaleFooter); + scaleCard.Body.Controls.Add(scaleIntro); + content.Controls.Add(scaleCard); + + teachers.ValueChanged += (_, _) => UpdateScale(); + scroll.Resize += (_, _) => LayoutContent(); + saveButton.Click += (_, _) => Export(SavePng); + printButton.Click += (_, _) => Export(Print); + LayoutContent(); + } + + private static Label Detail(string text) => Ui.Label(text, 8.5f, Theme.Muted); + + private static SmoothPanel ComparisonColumn(string title, Color back, (Label Heading, Label Detail)[] items) + { + var panel = new SmoothPanel { BackColor = back, Padding = new Padding(16, 9, 16, 9) }; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 5, BackColor = Color.Transparent }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + for (var i = 0; i < 4; i++) layout.RowStyles.Add(new RowStyle(SizeType.Percent, 25)); + layout.Controls.Add(Ui.Label(title, 9, title == "БЕЗ СИСТЕМЫ" ? Theme.Red : Theme.Green, FontStyle.Bold), 0, 0); + for (var i = 0; i < items.Length; i++) + { + var row = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Color.Transparent }; + row.RowStyles.Add(new RowStyle(SizeType.Absolute, 24)); + row.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + items[i].Heading.AutoEllipsis = true; + row.Controls.Add(items[i].Heading, 0, 0); + row.Controls.Add(items[i].Detail, 0, 1); + layout.Controls.Add(row, 0, i + 1); + } + panel.Controls.Add(layout); + return panel; + } + + private void LayoutContent() + { + var width = Math.Max(920, scroll.ClientSize.Width - 20); + var y = 0; + banner.SetBounds(0, y, width, 216); + printButton.Left = width - printButton.Width - 22; + saveButton.Left = printButton.Left - saveButton.Width - 10; + y += 234; + kpis.SetBounds(0, y, width, 150); + y += 168; + comparison.SetBounds(0, y, width, 342); + y += 360; + var leftWidth = (width - 16) / 2; + trendCard.SetBounds(0, y, leftWidth, chartHeight); + groupsCard.SetBounds(leftWidth + 16, y, width - leftWidth - 16, chartHeight); + y += chartHeight + 18; + scaleCard.SetBounds(0, y, width, 270); + content.Size = new Size(width, y + 294); + } + + public override async Task LoadAsync() + { + if (loading) return; + loading = true; + UseWaitCursor = true; + Subtitle = "Загрузка отчёта…"; + try + { + var data = await Api.ReportAsync(); + if (IsDisposed) return; + report = data; + var today = DateOnly.FromDateTime(DateTime.Today); + period.Text = $"ОТЧЁТ ЗА 30 ДНЕЙ · {UiFormat.Date(today.AddDays(-29))} — {UiFormat.Date(today)}"; + saved.Set($"{UiFormat.Hours(data.Dashboard.SavedMinutes30Days)} ч", "за месяц", Theme.Text); + camera.Set(data.Dashboard.CameraMarks30Days.ToString("N0"), "камерой за 30 дней", Theme.Green); + exports.Set(data.ReportsExported30Days.ToString("N0"), "в Excel одной кнопкой", Theme.Violet); + attendance.Set(UiFormat.Percent(data.Dashboard.Attendance30Days), + $"{data.Dashboard.AtRisk.Count} в зоне риска", UiFormat.PercentColor(data.Dashboard.Attendance30Days)); + var rollCall = data.AverageGroupSize * data.SecondsPerManualMark / 60d; + manualRollCall.Text = $"Устная перекличка ≈ {rollCall:0.#} мин на занятие"; + rollCallDetail.Text = $"{data.AverageGroupSize} студентов × {data.SecondsPerManualMark} сек на фамилию и отметку"; + manualReport.Text = $"Сведение журнала ≈ {data.MinutesPerManualReport} мин на группу"; + formula.Text = $"Факт за месяц: {data.Dashboard.SavingsFormula}"; + trend.Points = data.Dashboard.Trend; + groups.Groups = data.Dashboard.Groups; + groups.Risk = data.Dashboard.AtRisk; + chartHeight = Math.Max(340, 135 + data.Dashboard.Groups.Count * 31 + Math.Min(5, data.Dashboard.AtRisk.Count) * 25); + scaleText.Text = $"преподавателей с нагрузкой как у этого ({data.LessonsPerWeek} занятий в неделю, {data.GroupCount} групп):"; + UpdateScale(); + LayoutContent(); + Subtitle = "Посещаемость, зона риска и эффект от внедрения за 30 дней"; + } + catch (ApiException ex) { Ui.Toast(this, ex.Message, true); Subtitle = "Не удалось загрузить отчёт"; } + finally + { + loading = false; + if (!IsDisposed) UseWaitCursor = false; + } + } + + private void UpdateScale() + { + if (report is null) return; + var rollCall = report.AverageGroupSize * report.SecondsPerManualMark / 60d; + var minutes = report.LessonsPerWeek * 4.3 * rollCall + report.GroupCount * report.MinutesPerManualReport; + var hours = minutes / 60; + var total = hours * (double)teachers.Value; + perTeacher.Set($"{hours:0.#} ч", "в месяц", Theme.AccentHover); + allTeachers.Set($"{total:0.#} ч", "в месяц", Theme.Green); + workDays.Set($"{total / 8:0.#}", "дней в месяц", Theme.Yellow); + schoolYear.Set($"{total * 9:0.#} ч", "за 9 месяцев", Theme.Violet); + scaleFormula.Text = $"Расчёт: {report.LessonsPerWeek} занятий × 4,3 недели × {rollCall:0.#} мин + {report.GroupCount} групп × {report.MinutesPerManualReport} мин. Один рабочий день — 8 часов."; + } + + private void Export(Action action) + { + if (exporting || loading || report is null) return; + exporting = true; + saveButton.Enabled = printButton.Enabled = false; + try { action(); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.ExternalException or InvalidOperationException) + { + Ui.Toast(this, $"Не удалось подготовить отчёт: {ex.Message}", true); + } + finally + { + exporting = false; + if (!IsDisposed) saveButton.Enabled = printButton.Enabled = true; + } + } + + private Bitmap RenderReport() + { + var bitmap = new Bitmap(content.Width, content.Height); + try + { + content.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size)); + return bitmap; + } + catch { bitmap.Dispose(); throw; } + } + + private void SavePng() + { + using var dialog = new SaveFileDialog { Title = "Сохранить отчёт", Filter = "Изображение PNG (*.png)|*.png", FileName = $"BiStudent-отчёт-{DateTime.Today:yyyy-MM-dd}.png" }; + if (dialog.ShowDialog(FindForm()) != DialogResult.OK) return; + using var image = RenderReport(); + image.Save(dialog.FileName, System.Drawing.Imaging.ImageFormat.Png); + Ui.Toast(this, "Отчёт сохранён в PNG"); + } + + private void Print() + { + using var image = RenderReport(); + using var document = new PrintDocument(); + document.DefaultPageSettings.Landscape = true; + document.DefaultPageSettings.PaperSize = new PaperSize("A4", 827, 1169); + document.DefaultPageSettings.Margins = new Margins(50, 50, 50, 50); + document.PrintPage += (_, e) => + { + if (e.Graphics is null) return; + e.Graphics.Clear(Color.White); + var area = e.MarginBounds; + var ratio = Math.Min((double)area.Width / image.Width, (double)area.Height / image.Height); + var width = (int)Math.Round(image.Width * ratio); + var height = (int)Math.Round(image.Height * ratio); + e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + e.Graphics.DrawImage(image, new Rectangle(area.X + (area.Width - width) / 2, area.Y + (area.Height - height) / 2, width, height)); + e.HasMorePages = false; + }; + using var preview = new PrintPreviewDialog { Document = document, Width = 1100, Height = 780 }; + preview.ShowDialog(FindForm()); + } + + private sealed class Banner : SmoothPanel + { + public Banner() { Padding = Padding.Empty; Radius = 18; } + + protected override void PaintSurface(Graphics g, GraphicsPath path) + { + using var gradient = new LinearGradientBrush(ClientRectangle, Color.FromArgb(25, 45, 83), Color.FromArgb(31, 42, 76), 0f); + g.FillPath(gradient, path); + var state = g.Save(); + g.SetClip(path); + using var glowPath = new GraphicsPath(); + glowPath.AddEllipse(Width - 360, -170, 500, 420); + using var glow = new PathGradientBrush(glowPath) + { + CenterColor = Color.FromArgb(100, Theme.Violet), + SurroundColors = [Color.FromArgb(0, Theme.Violet)] + }; + g.FillPath(glow, glowPath); + g.Restore(state); + } + } + + private sealed class TrendChart : Control + { + private List points = []; + public List Points { get => points; set { points = value; Invalidate(); } } + + public TrendChart() + { + DoubleBuffered = true; + BackColor = Theme.Surface; + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + var plot = new Rectangle(45, 14, Math.Max(1, Width - 64), Math.Max(1, Height - 62)); + for (var i = 0; i <= 2; i++) + { + var y = plot.Top + i * plot.Height / 2; + using var line = new Pen(Theme.Line); + g.DrawLine(line, plot.Left, y, plot.Right, y); + TextRenderer.DrawText(g, $"{100 - i * 50}%", Theme.Font(8), new Rectangle(0, y - 9, 40, 20), Theme.Muted, TextFormatFlags.Right); + } + if (points.Count == 0) + { + TextRenderer.DrawText(g, "Пока нет данных о посещаемости", Theme.Font(10), plot, Theme.Muted, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + return; + } + var linePoints = points.Select((point, i) => new PointF( + plot.Left + (points.Count == 1 ? plot.Width / 2f : i * plot.Width / (float)(points.Count - 1)), + plot.Bottom - (float)Math.Clamp(point.Percent, 0, 100) * plot.Height / 100)).ToArray(); + using var area = new GraphicsPath(); + area.AddLine(linePoints[0].X, plot.Bottom, linePoints[0].X, linePoints[0].Y); + if (linePoints.Length > 1) area.AddLines(linePoints); + area.AddLine(linePoints[^1].X, linePoints[^1].Y, linePoints[^1].X, plot.Bottom); + area.CloseFigure(); + using (var fill = new LinearGradientBrush(plot, Color.FromArgb(100, Theme.Accent), Color.FromArgb(5, Theme.Accent), 90f)) + g.FillPath(fill, area); + using (var pen = new Pen(Theme.AccentHover, 2.5f)) + if (linePoints.Length > 1) g.DrawLines(pen, linePoints); + using (var dot = new SolidBrush(Theme.AccentHover)) + foreach (var point in linePoints) g.FillEllipse(dot, point.X - 3, point.Y - 3, 6, 6); + var step = Math.Max(1, (int)Math.Ceiling(points.Count / 6d)); + for (var i = 0; i < points.Count; i += step) + TextRenderer.DrawText(g, points[i].Date.ToString("dd.MM"), Theme.Font(8), + new Rectangle((int)linePoints[i].X - 23, plot.Bottom + 11, 46, 20), Theme.Muted, TextFormatFlags.HorizontalCenter); + } + } + + private sealed class GroupChart : Control + { + private List groupData = []; + private List riskData = []; + public List Groups { get => groupData; set { groupData = value; Invalidate(); } } + public List Risk { get => riskData; set { riskData = value; Invalidate(); } } + + public GroupChart() + { + DoubleBuffered = true; + BackColor = Theme.Surface; + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + var y = 8; + if (groupData.Count == 0) + { + TextRenderer.DrawText(g, "Данных по группам пока нет", Theme.Font(9), new Rectangle(0, y, Width, 28), Theme.Muted); + y += 32; + } + foreach (var group in groupData) + { + TextRenderer.DrawText(g, group.GroupName, Theme.Font(9, FontStyle.Bold), new Rectangle(0, y, 115, 22), + Theme.Text, TextFormatFlags.EndEllipsis); + var track = new Rectangle(122, y + 5, Math.Max(1, Width - 185), 12); + Ui.FillRounded(g, track, 6, Theme.SurfaceAlt); + var filled = (int)Math.Round(track.Width * Math.Clamp(group.Percent, 0, 100) / 100); + if (filled > 0) + { + using var path = ModernButton.Rounded(new Rectangle(track.X, track.Y, filled, track.Height), 6); + using var brush = new LinearGradientBrush(track, Theme.Accent, Theme.Green, 0f); + g.FillPath(brush, path); + } + TextRenderer.DrawText(g, UiFormat.Percent(group.Percent), Theme.Font(9, FontStyle.Bold), + new Rectangle(Width - 57, y, 55, 22), UiFormat.PercentColor(group.Percent), TextFormatFlags.Right); + y += 31; + } + y += 13; + TextRenderer.DrawText(g, "ЗОНА РИСКА", Theme.Font(9, FontStyle.Bold), new Rectangle(0, y, Width, 25), Theme.Red); + y += 28; + if (riskData.Count == 0) + TextRenderer.DrawText(g, "Студентов в зоне риска нет", Theme.Font(9), new Rectangle(0, y, Width, 24), Theme.Muted); + foreach (var student in riskData.Take(5)) + { + TextRenderer.DrawText(g, $"{student.FullName} · {student.GroupName}", Theme.Font(9), + new Rectangle(0, y, Math.Max(1, Width - 65), 23), Theme.Text, TextFormatFlags.EndEllipsis); + TextRenderer.DrawText(g, UiFormat.Percent(student.Percent), Theme.Font(9, FontStyle.Bold), + new Rectangle(Width - 60, y, 58, 23), Theme.Red, TextFormatFlags.Right); + y += 25; + } + } + } +} diff --git a/BiStudent/Pages/ScheduleImportDialog.cs b/BiStudent/Pages/ScheduleImportDialog.cs new file mode 100644 index 0000000..9e3f28d --- /dev/null +++ b/BiStudent/Pages/ScheduleImportDialog.cs @@ -0,0 +1,123 @@ +using System.Text; +using BiStudent.Contracts; + +namespace BiStudent; + +/// Импорт расписания колледжа из Excel: выбор групп по результатам предпросмотра, затем импорт на сервере. +internal sealed class ScheduleImportDialog : DialogShell +{ + private readonly ApiClient api; + private readonly byte[] file; + private readonly List groups; + private readonly CheckedListBox list = new() + { + Dock = DockStyle.Fill, BackColor = Theme.Surface, ForeColor = Theme.Text, BorderStyle = BorderStyle.None, + CheckOnClick = true, Font = Theme.Font(10), IntegralHeight = false + }; + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton import; + private bool busy; + + public ScheduleImportResultDto? Result { get; private set; } + + public ScheduleImportDialog(ApiClient api, byte[] file, ScheduleImportPreviewDto preview) : base("Импорт расписания из Excel", 720, 640) + { + this.api = api; + this.file = file; + groups = preview.Groups; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5, BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 62)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, preview.Warnings.Count > 0 ? 44 : 0)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 26)); + layout.Controls.Add(Ui.Label($"Начало семестра: {preview.SemesterStart:dd.MM.yyyy}. Найдено групп: {groups.Count}.\n" + + "Отметьте группы для импорта. Уже существующие в системе группы отмечены заранее.", 10, Theme.Text), 0, 0); + foreach (var group in groups) + { + var text = $"{group.Group} · {group.Lessons} {UiFormat.Plural(group.Lessons, "занятие", "занятия", "занятий")} · {group.Sheet}" + + (group.Exists ? " · есть в системе" : ""); + list.Items.Add(text, group.Exists); + } + var listCard = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(12), BackColor = Theme.Surface, Margin = new Padding(0, 4, 0, 4) }; + listCard.Controls.Add(list); + layout.Controls.Add(listCard, 0, 1); + layout.Controls.Add(Ui.Label("Преподаватели: " + string.Join(", ", preview.Teachers) + + ".\nДля новых преподавателей будут созданы учётные записи с временными паролями.", 9, Theme.Muted), 0, 2); + if (preview.Warnings.Count > 0) + layout.Controls.Add(Ui.Label($"Не удалось разобрать записей: {preview.Warnings.Count} — они будут пропущены и показаны после импорта.", 9, Theme.Yellow), 0, 3); + error.TextAlign = ContentAlignment.MiddleLeft; + layout.Controls.Add(error, 0, 4); + Content.Controls.Add(layout); + + AddButton("Отмена", DialogResult.Cancel); + import = new ModernButton { Text = "Импортировать", Primary = true, Width = 170, Height = 40 }; + import.Click += async (_, _) => await ImportAsync(); + Buttons.Controls.Add(import); + AcceptButton = import; + FormClosing += (_, e) => { if (busy) e.Cancel = true; }; + } + + private async Task ImportAsync() + { + var selected = list.CheckedIndices.Cast().Select(i => groups[i].Group).ToList(); + if (selected.Count == 0) { error.Text = "Отметьте хотя бы одну группу."; return; } + busy = true; import.Enabled = false; UseWaitCursor = true; error.Text = ""; + try + { + Result = await api.ImportScheduleXlsxAsync(file, selected); + busy = false; + DialogResult = DialogResult.OK; + Close(); + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally + { + busy = false; + if (!IsDisposed) { import.Enabled = true; UseWaitCursor = false; } + } + } + + /// Итог импорта: созданные учётные записи с временными паролями (показываются один раз) и пропущенные записи. + public static void ShowResult(IWin32Window? owner, ScheduleImportResultDto result) + { + using var dialog = new DialogShell("Расписание импортировано", 760, 560); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, result.Warnings.Count > 0 ? 110 : 0)); + layout.Controls.Add(Ui.Label($"Добавлено занятий: {result.Added}, уже были: {result.Skipped}, новых групп: {result.GroupsCreated}.\n" + + "Передайте временные пароли преподавателям лично — при первом входе система попросит их сменить.", 10, Theme.Text), 0, 0); + var grid = Ui.Grid(); + grid.Dock = DockStyle.Fill; + grid.Columns.Add("name", "ФИО"); grid.Columns.Add("login", "Логин"); grid.Columns.Add("password", "Временный пароль"); grid.Columns.Add("state", "Статус"); + grid.Columns["password"]!.DefaultCellStyle.Font = new Font("Consolas", 11, FontStyle.Bold); + foreach (var teacher in result.Teachers) + grid.Rows.Add(teacher.FullName, teacher.Login, teacher.TemporaryPassword ?? "—", + !teacher.Created ? "уже была" : teacher.Active ? "создана" : "создана, отключена"); + layout.Controls.Add(grid, 0, 1); + if (result.Warnings.Count > 0) + { + var warnings = new TextBox + { + Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, BorderStyle = BorderStyle.None, + BackColor = Theme.Surface, ForeColor = Theme.Yellow, Font = Theme.Font(9), + Text = "Пропущено:\r\n" + string.Join("\r\n", result.Warnings) + }; + layout.Controls.Add(warnings, 0, 2); + } + dialog.Content.Controls.Add(layout); + dialog.AddButton("Закрыть", DialogResult.OK, true); + var copy = new ModernButton { Text = "Скопировать логины и пароли", Width = 260, Height = 40 }; + copy.Click += (_, _) => + { + var text = new StringBuilder("ФИО\tЛогин\tВременный пароль\r\n"); + foreach (var teacher in result.Teachers.Where(t => t.TemporaryPassword is not null)) + text.Append($"{teacher.FullName}\t{teacher.Login}\t{teacher.TemporaryPassword}\r\n"); + Clipboard.SetText(text.ToString()); + Ui.Toast(dialog, "Скопировано в буфер обмена"); + }; + dialog.Buttons.Controls.Add(copy); + dialog.ShowDialog(owner); + } +} diff --git a/BiStudent/Pages/SchedulePage.cs b/BiStudent/Pages/SchedulePage.cs new file mode 100644 index 0000000..0a44bbd --- /dev/null +++ b/BiStudent/Pages/SchedulePage.cs @@ -0,0 +1,243 @@ +using System.Text; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class SchedulePage : Page +{ + private DateOnly monday = UiFormat.Monday(DateOnly.FromDateTime(DateTime.Today)); + private readonly Label range = Ui.Label("", 15, Theme.Text, FontStyle.Bold); + private readonly Label parity = Ui.Label("", 9.5f, Theme.Muted); + private readonly TableLayoutPanel days = new() { Dock = DockStyle.Fill, ColumnCount = 6, BackColor = Theme.Background }; + private readonly ModernButton previous = new() { Text = "‹", Width = 42 }; + private readonly ModernButton next = new() { Text = "›", Width = 42 }; + private readonly ModernButton today = new() { Text = "Сегодня", Width = 104 }; + private readonly ModernButton import = new() { Text = "Импорт", Width = 120 }; + private readonly ModernButton add = new() { Text = "+ Пара", Primary = true, Width = 112 }; + private List lessons = []; + private bool busy; + + public SchedulePage(ApiClient api) : base(api, "Расписание", "Занятия на семестр: еженедельные, по чётным и нечётным неделям, разовые и отменённые") + { + Actions.Controls.Add(import); Actions.Controls.Add(add); + import.Click += async (_, _) => await ExecuteAsync(ImportAsync); + add.Click += async (_, _) => await OpenSlotAsync(null, monday); + previous.Click += async (_, _) => await MoveAsync(-7); + next.Click += async (_, _) => await MoveAsync(7); + today.Click += async (_, _) => { monday = UiFormat.Monday(DateOnly.FromDateTime(DateTime.Today)); await LoadAsync(); }; + var hint = new AutomationHint("расписание колледжа загружается из Excel («Импорт»), журнал строится по занятиям расписания"); + var navigation = new TableLayoutPanel { Dock = DockStyle.Top, Height = 70, ColumnCount = 5, BackColor = Theme.Background, Padding = new Padding(0, 14, 0, 12) }; + navigation.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 52)); navigation.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); navigation.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 52)); navigation.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 114)); navigation.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 170)); + range.TextAlign = ContentAlignment.MiddleCenter; parity.TextAlign = ContentAlignment.MiddleRight; + navigation.Controls.Add(previous, 0, 0); navigation.Controls.Add(range, 1, 0); navigation.Controls.Add(next, 2, 0); navigation.Controls.Add(today, 3, 0); navigation.Controls.Add(parity, 4, 0); + for (var i = 0; i < 6; i++) days.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 6)); + var legend = Ui.Label("● Каждая неделя ● Нечётная ● Чётная ● Разовое занятие ● Отменена", 9, Theme.Muted); + legend.Dock = DockStyle.Bottom; legend.Height = 42; legend.TextAlign = ContentAlignment.MiddleCenter; + Body.Controls.Add(days); Body.Controls.Add(legend); Body.Controls.Add(navigation); Body.Controls.Add(hint); + } + + public override Task LoadAsync() => ExecuteAsync(RefreshAsync); + + private async Task ExecuteAsync(Func action) + { + if (busy || IsDisposed) return; + busy = true; UseWaitCursor = true; + foreach (var button in new[] { previous, next, today, import, add }) button.Enabled = false; + try { await action(); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + catch (Exception ex) when (ex is IOException or InvalidOperationException) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + finally + { + busy = false; + if (!IsDisposed) { UseWaitCursor = false; foreach (var button in new[] { previous, next, today, import, add }) button.Enabled = true; } + } + } + + private async Task MoveAsync(int offset) + { + if (busy) return; + monday = monday.AddDays(offset); + await LoadAsync(); + } + + private async Task RefreshAsync() + { + var weekTask = Api.WeekAsync(monday); + var parityTask = Api.ParityAsync(monday); + await Task.WhenAll(weekTask, parityTask); + if (IsDisposed) return; + lessons = await weekTask; + var weekParity = await parityTask; + range.Text = $"{UiFormat.Date(monday)} — {UiFormat.Date(monday.AddDays(5))}"; + parity.Text = $"{UiFormat.Parity(weekParity)} неделя"; + BuildDays(); + } + + private void BuildDays() + { + days.SuspendLayout(); + foreach (Control control in days.Controls.Cast().ToList()) control.Dispose(); + days.Controls.Clear(); + for (var index = 0; index < 6; index++) + { + var date = monday.AddDays(index); + var column = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(index == 0 ? 0 : 4, 0, index == 5 ? 0 : 4, 0), Padding = new Padding(10), Radius = 12 }; + var header = new Panel { Dock = DockStyle.Top, Height = 54, BackColor = Theme.Surface }; + var title = Ui.Label($"{UiFormat.DayLong(date.DayOfWeek)}\n{date:dd.MM}", 9.5f, date == DateOnly.FromDateTime(DateTime.Today) ? Theme.AccentHover : Theme.Text, FontStyle.Bold); + title.TextAlign = ContentAlignment.MiddleCenter; + header.Controls.Add(title); + if (date == DateOnly.FromDateTime(DateTime.Today)) header.Controls.Add(new Panel { Dock = DockStyle.Bottom, Height = 2, BackColor = Theme.Accent }); + var list = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoScroll = true, FlowDirection = FlowDirection.TopDown, WrapContents = false, BackColor = Theme.Surface, Padding = new Padding(0, 8, 0, 0) }; + var entries = lessons.Where(x => x.Date == date).OrderBy(x => x.Slot.Start).ToList(); + if (entries.Count == 0) { var empty = Ui.Label("Нет пар", 9.5f, Theme.Muted); empty.Dock = DockStyle.Top; empty.Height = 40; empty.TextAlign = ContentAlignment.MiddleCenter; list.Controls.Add(empty); } + foreach (var lesson in entries) + { + var card = new LessonCard(lesson) { Width = Math.Max(120, list.ClientSize.Width - 6) }; + card.Click += (_, _) => ShowMenu(card, lesson); + list.Controls.Add(card); + } + list.Resize += (_, _) => { foreach (var card in list.Controls.OfType()) card.Width = Math.Max(120, list.ClientSize.Width - 6); }; + column.Controls.Add(list); column.Controls.Add(header); days.Controls.Add(column, index, 0); + } + days.ResumeLayout(); + } + + private void ShowMenu(LessonCard card, LessonDto lesson) + { + if (busy) return; + var menu = new ContextMenuStrip { Renderer = new DarkMenuRenderer(), BackColor = Theme.Surface, ForeColor = Theme.Text, Font = Theme.Font(10), ShowImageMargin = false }; + var marks = menu.Items.Add("Отметки…"); + marks.Enabled = !lesson.Cancelled; + marks.Click += async (_, _) => { if (Shell is { } shell) await shell.OpenLessonAsync(lesson); }; + menu.Items.Add("Изменить…").Click += async (_, _) => await OpenSlotAsync(lesson.Slot, lesson.Date); + menu.Items.Add(lesson.Cancelled ? "Вернуть пару" : "Отменить на эту дату…").Click += async (_, _) => + { + if (lesson.Cancelled) await ExecuteAsync(async () => { await Api.RestoreLessonAsync(lesson.Slot.Id, lesson.Date); await RefreshAsync(); }); + else + { + var reason = Ui.Prompt(FindForm(), "Отменить пару", "Причина отмены (можно оставить пустой)"); + if (reason is not null) await ExecuteAsync(async () => { await Api.CancelLessonAsync(lesson.Slot.Id, lesson.Date, reason); await RefreshAsync(); }); + } + }; + menu.Items.Add("Удалить из расписания…").Click += async (_, _) => + { + if (!Ui.Confirm(FindForm(), "Удалить пару", "Шаблон пары и все её отметки будут удалены. Чтобы убрать одно занятие, лучше отмените его на дату.", "Удалить", true)) return; + await ExecuteAsync(async () => { await Api.DeleteSlotAsync(lesson.Slot.Id); await RefreshAsync(); }); + }; + menu.Closed += (_, _) => menu.Dispose(); + menu.Show(card, new Point(8, card.Height - 4)); + } + + private async Task OpenSlotAsync(SlotDto? slot, DateOnly date) + { + if (busy) return; + using var dialog = new SlotDialog(Api, slot, date, lessons.Select(x => x.Slot.Subject).Distinct(StringComparer.CurrentCultureIgnoreCase)); + if (dialog.ShowDialog(FindForm()) == DialogResult.OK) await LoadAsync(); + } + + private async Task ImportAsync() + { + using var guide = new DialogShell("Импорт расписания", 680, 330); + guide.Content.Controls.Add(Ui.Label( + "Excel — расписание колледжа на семестр (блоки «день · звонок · пара · аудитория · группа»). " + + "Вы выберете группы, а учётные записи преподавателей создадутся автоматически (нужны права администратора).\n\n" + + "CSV: День;Начало;Конец;Предмет;Группа;Аудитория;Неделя — одна строка на шаблон пары.", 10, Theme.Text)); + var template = new ModernButton { Text = "Сохранить шаблон…", Width = 180, Height = 40 }; + template.Click += async (_, _) => + { + using var save = new SaveFileDialog { Filter = "CSV (*.csv)|*.csv", FileName = "raspisanie-template.csv" }; + if (save.ShowDialog(guide) != DialogResult.OK) return; + try + { + const string csv = "День;Начало;Конец;Предмет;Группа;Аудитория;Неделя\r\nПн;08:30;09:50;Математика;П-21;101;Каждая\r\nВт;10:00;11:20;Физика;П-21;202;Нечётная\r\n"; + await File.WriteAllTextAsync(save.FileName, csv, new UTF8Encoding(true)); + if (!guide.IsDisposed) Ui.Toast(guide, "Шаблон сохранён"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (!guide.IsDisposed) Ui.Toast(guide, $"Не удалось сохранить шаблон: {ex.Message}", true); + } + }; + guide.Buttons.Controls.Add(template); + guide.AddButton("Отмена", DialogResult.Cancel); + guide.AddButton("Выбрать CSV…", DialogResult.OK); + guide.AddButton("Расписание Excel…", DialogResult.Yes, true); + var choice = guide.ShowDialog(FindForm()); + if (choice == DialogResult.Yes) { await ImportExcelAsync(); return; } + if (choice != DialogResult.OK) return; + using var open = new OpenFileDialog { Filter = "CSV (*.csv)|*.csv", CheckFileExists = true }; + if (open.ShowDialog(FindForm()) != DialogResult.OK) return; + string content; + try { content = await File.ReadAllTextAsync(open.FileName, Encoding.UTF8); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (!IsDisposed) Ui.Toast(this, $"Не удалось прочитать CSV: {ex.Message}", true); + return; + } + var result = await Api.ImportScheduleAsync(content); + if (result.Errors.Count > 0) Ui.Message(FindForm(), "Ошибки импорта", string.Join(Environment.NewLine, result.Errors)); + else Ui.Toast(this, $"Добавлено {result.Added} {UiFormat.Plural(result.Added, "пара", "пары", "пар")}"); + await RefreshAsync(); + } + + private async Task ImportExcelAsync() + { + using var open = new OpenFileDialog { Title = "Расписание колледжа", Filter = "Excel (*.xlsx)|*.xlsx", CheckFileExists = true }; + if (open.ShowDialog(FindForm()) != DialogResult.OK) return; + byte[] file; + try { file = await File.ReadAllBytesAsync(open.FileName); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (!IsDisposed) Ui.Toast(this, $"Не удалось прочитать файл: {ex.Message}", true); + return; + } + var preview = await Api.PreviewScheduleXlsxAsync(file); + if (IsDisposed) return; + if (preview.Groups.Count == 0) { Ui.Message(FindForm(), "Импорт расписания", "В файле не найдено групп в формате расписания колледжа."); return; } + using var dialog = new ScheduleImportDialog(Api, file, preview); + if (dialog.ShowDialog(FindForm()) != DialogResult.OK || dialog.Result is not { } result) return; + ScheduleImportDialog.ShowResult(FindForm(), result); + await RefreshAsync(); + } + + private sealed class LessonCard : SmoothPanel + { + private readonly LessonDto lesson; + public LessonCard(LessonDto lesson) + { + this.lesson = lesson; Height = 94; Padding = Padding.Empty; Margin = new Padding(0, 0, 0, 8); Cursor = Cursors.Hand; Radius = 10; + BackColor = Theme.SurfaceHover; + } + protected override void OnMouseEnter(EventArgs e) { BackColor = Theme.SurfaceAlt; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { BackColor = Theme.SurfaceHover; Invalidate(); base.OnMouseLeave(e); } + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var slot = lesson.Slot; + var color = lesson.Cancelled ? Theme.Red : slot.OneOffDate.HasValue ? Theme.Violet : slot.Parity == WeekParity.Odd ? Theme.Yellow : slot.Parity == WeekParity.Even ? Theme.Green : Theme.Accent; + using (var brush = new SolidBrush(color)) e.Graphics.FillRectangle(brush, 0, 10, 4, Height - 20); + var foreground = lesson.Cancelled ? Theme.Mix(Theme.Text, BackColor, .55f) : Theme.Text; + var font = Theme.Font(9.5f, FontStyle.Bold | (lesson.Cancelled ? FontStyle.Strikeout : FontStyle.Regular)); + TextRenderer.DrawText(e.Graphics, UiFormat.Range(slot), Theme.Font(8.5f, FontStyle.Bold), new Rectangle(13, 8, Width - 20, 20), lesson.Cancelled ? Theme.Red : color, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + TextRenderer.DrawText(e.Graphics, slot.Subject, font, new Rectangle(13, 32, Width - 20, 25), foreground, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + var details = slot.GroupName + (string.IsNullOrWhiteSpace(slot.Room) ? "" : $" · ауд. {slot.Room}"); + TextRenderer.DrawText(e.Graphics, details, Theme.Font(8.5f), new Rectangle(13, 63, Width - 20, 20), lesson.Cancelled ? Theme.Mix(Theme.Muted, BackColor, .5f) : Theme.Muted, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } + } + + private sealed class DarkMenuRenderer : ToolStripProfessionalRenderer + { + public DarkMenuRenderer() : base(new DarkColors()) { } + protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e) { e.TextColor = e.Item.Enabled ? Theme.Text : Theme.Muted; base.OnRenderItemText(e); } + private sealed class DarkColors : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => Theme.Surface; + public override Color ImageMarginGradientBegin => Theme.Surface; + public override Color ImageMarginGradientMiddle => Theme.Surface; + public override Color ImageMarginGradientEnd => Theme.Surface; + public override Color MenuItemSelected => Theme.SurfaceAlt; + public override Color MenuItemBorder => Theme.Line; + public override Color MenuBorder => Theme.Line; + } + } +} diff --git a/BiStudent/Pages/SlotDialog.cs b/BiStudent/Pages/SlotDialog.cs new file mode 100644 index 0000000..5aaaaeb --- /dev/null +++ b/BiStudent/Pages/SlotDialog.cs @@ -0,0 +1,181 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class SlotDialog : DarkForm +{ + private readonly ApiClient api; + private readonly SlotDto? slot; + private readonly ModernComboBox group = new(); + private readonly TextBox newGroup = Ui.Input("Название новой группы"); + private readonly TextBox subject = Ui.Input("Предмет"); + private readonly TextBox room = Ui.Input("Аудитория"); + private readonly ModernComboBox day = new(); + private readonly ModernComboBox parity = new(); + private readonly DateTimePicker date = new() { Format = DateTimePickerFormat.Short }; + private readonly DateTimePicker start = TimePicker(); + private readonly DateTimePicker end = TimePicker(); + private readonly CheckBox oneOff = new() { Text = "Разовое занятие", ForeColor = Theme.Text, BackColor = Theme.Background, AutoSize = true }; + private readonly Label error = Ui.Label("", 9.5f, Theme.Red); + private readonly Label dateLabel = Ui.Label("День недели", 9.5f, Theme.Muted); + private readonly Label newGroupLabel = Ui.Label("Новая группа", 9.5f, Theme.Muted); + private readonly TableLayoutPanel fields = new() { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 11, BackColor = Theme.Background, Padding = new Padding(24, 6, 24, 4) }; + private readonly FlowLayoutPanel presets = new() { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = true, BackColor = Theme.Background }; + private readonly List chips = []; + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true, Width = 140 }; + private bool loading; + + public SlotDialog(ApiClient api, SlotDto? slot, DateOnly selectedDate, IEnumerable subjects) + { + this.api = api; this.slot = slot; + Text = slot is null ? "Добавить пару" : "Изменить пару"; + ClientSize = new Size(680, 680); FormBorderStyle = FormBorderStyle.FixedDialog; StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + var heading = Ui.Label(Text, 15, Theme.Text, FontStyle.Bold); heading.Dock = DockStyle.Top; heading.Height = 52; heading.Padding = new Padding(24, 8, 0, 0); + var footer = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 62, FlowDirection = FlowDirection.RightToLeft, BackColor = Theme.Background, Padding = new Padding(14, 8, 14, 8) }; + var cancel = new ModernButton { Text = "Отмена", Width = 120 }; + cancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; + save.Click += async (_, _) => await SaveAsync(); + footer.Controls.Add(save); footer.Controls.Add(cancel); AcceptButton = save; CancelButton = cancel; + fields.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); fields.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + foreach (var height in new[] { 46, 0, 46, 46, 90, 46, 46, 42, 46, 46, 50 }) fields.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + Add(0, "Группа", group); fields.Controls.Add(newGroupLabel, 0, 1); newGroup.Dock = DockStyle.Fill; fields.Controls.Add(newGroup, 1, 1); Add(2, "Предмет", subject); Add(3, "Аудитория", room); + Add(4, "Пресеты звонков", presets); Add(5, "Начало", start); Add(6, "Конец", end); Add(7, "", oneOff); + fields.Controls.Add(dateLabel, 0, 8); fields.Controls.Add(day, 1, 8); + Add(9, "Неделя", parity); + fields.Controls.Add(error, 0, 10); fields.SetColumnSpan(error, 2); + Controls.Add(fields); Controls.Add(footer); Controls.Add(heading); + foreach (var name in new[] { "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота" }) day.Items.Add(name); + foreach (var name in new[] { "Каждая", "Нечётная", "Чётная" }) parity.Items.Add(name); + day.SelectedIndex = Math.Clamp((int)(slot?.DayOfWeek ?? selectedDate.DayOfWeek) - 1, 0, 5); + parity.SelectedIndex = slot?.Parity switch { WeekParity.Odd => 1, WeekParity.Even => 2, _ => 0 }; + date.Value = (slot?.OneOffDate ?? selectedDate).ToDateTime(TimeOnly.MinValue); + subject.Text = slot?.Subject ?? ""; room.Text = slot?.Room ?? ""; + start.Value = DateTime.Today.Add((slot?.Start ?? new TimeOnly(8, 30)).ToTimeSpan()); + end.Value = DateTime.Today.Add((slot?.End ?? new TimeOnly(9, 50)).ToTimeSpan()); + var values = new[] { (8, 30, 9, 50), (10, 0, 11, 20), (11, 30, 12, 50), (13, 20, 14, 40), (14, 50, 16, 10), (16, 20, 17, 40) }; + for (var i = 0; i < values.Length; i++) + { + var (sh, sm, eh, em) = values[i]; + var chip = new ModernButton { Text = $"{i + 1} пара {sh:00}:{sm:00}–{eh:00}:{em:00}", Width = 150, Height = 34, Font = Theme.Font(8.5f), Margin = new Padding(0, 0, 7, 6) }; + chip.Click += (_, _) => { start.Value = DateTime.Today.AddHours(sh).AddMinutes(sm); end.Value = DateTime.Today.AddHours(eh).AddMinutes(em); UpdateChips(); }; + chips.Add(chip); presets.Controls.Add(chip); + } + start.ValueChanged += (_, _) => UpdateChips(); end.ValueChanged += (_, _) => UpdateChips(); + StepByFive(start); StepByFive(end); UpdateChips(); + var suggestions = new AutoCompleteStringCollection(); + suggestions.AddRange(subjects.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.CurrentCultureIgnoreCase).ToArray()); + subject.AutoCompleteMode = AutoCompleteMode.SuggestAppend; subject.AutoCompleteSource = AutoCompleteSource.CustomSource; subject.AutoCompleteCustomSource = suggestions; + group.SelectedIndexChanged += (_, _) => ShowNewGroup(); + oneOff.Checked = slot?.OneOffDate is not null; + oneOff.CheckedChanged += (_, _) => ShowDate(); + ShowDate(); + Shown += async (_, _) => await LoadGroupsAsync(); + } + + private void Add(int row, string caption, Control control) + { + fields.Controls.Add(Ui.Label(caption, 9.5f, Theme.Muted), 0, row); + control.Dock = DockStyle.Fill; fields.Controls.Add(control, 1, row); + } + + private static DateTimePicker TimePicker() => new() { Format = DateTimePickerFormat.Custom, CustomFormat = "HH:mm", ShowUpDown = true, Width = 140 }; + + private static void StepByFive(DateTimePicker picker) + { + var previous = picker.Value; + var adjusting = false; + picker.ValueChanged += (_, _) => + { + if (adjusting) return; + var value = picker.Value; + var remainder = value.Minute % 5; + if (remainder != 0) + { + adjusting = true; + try { picker.Value = value.AddMinutes(value > previous ? 5 - remainder : -remainder); } + finally { adjusting = false; } + } + previous = picker.Value; + }; + picker.KeyDown += (_, e) => + { + if (e.KeyCode is not (Keys.Up or Keys.Down)) return; + picker.Value = picker.Value.AddMinutes(e.KeyCode == Keys.Up ? 5 : -5); + e.SuppressKeyPress = true; + }; + } + + private void UpdateChips() + { + var values = new[] { (new TimeOnly(8, 30), new TimeOnly(9, 50)), (new TimeOnly(10, 0), new TimeOnly(11, 20)), (new TimeOnly(11, 30), new TimeOnly(12, 50)), (new TimeOnly(13, 20), new TimeOnly(14, 40)), (new TimeOnly(14, 50), new TimeOnly(16, 10)), (new TimeOnly(16, 20), new TimeOnly(17, 40)) }; + for (var i = 0; i < chips.Count; i++) chips[i].Selected = values[i] == (TimeOnly.FromDateTime(start.Value), TimeOnly.FromDateTime(end.Value)); + } + + private void ShowNewGroup() + { + var visible = group.SelectedItem is GroupChoice { Id: 0 }; + fields.RowStyles[1].Height = visible ? 46 : 0; + newGroup.Visible = visible; newGroupLabel.Visible = visible; + } + + private void ShowDate() + { + var single = oneOff.Checked; + fields.Controls.Remove(single ? day : date); + fields.Controls.Add(single ? date : day, 1, 8); + dateLabel.Text = single ? "Дата занятия" : "День недели"; + parity.Enabled = !single; + parity.Visible = !single; + fields.RowStyles[9].Height = single ? 0 : 46; + } + + private async Task LoadGroupsAsync() + { + if (loading || IsDisposed) return; + loading = true; save.Enabled = false; error.Text = "Загрузка групп…"; + try + { + var groups = await api.GroupsAsync(); + if (IsDisposed) return; + group.Items.Clear(); + foreach (var item in groups) group.Items.Add(new GroupChoice(item.Id, item.Name)); + group.Items.Add(new GroupChoice(0, "+ Новая группа…")); + var index = slot is null ? (groups.Count > 0 ? 0 : group.Items.Count - 1) : groups.FindIndex(x => x.Id == slot.GroupId); + group.SelectedIndex = index >= 0 ? index : group.Items.Count - 1; + error.Text = ""; save.Enabled = true; + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { loading = false; } + } + + private async Task SaveAsync() + { + if (loading || IsDisposed) return; + error.Text = ""; + if (group.SelectedItem is not GroupChoice choice) { error.Text = "Выберите группу."; return; } + if (choice.Id == 0 && string.IsNullOrWhiteSpace(newGroup.Text)) { error.Text = "Введите название новой группы."; return; } + if (string.IsNullOrWhiteSpace(subject.Text)) { error.Text = "Введите предмет."; return; } + var from = TimeOnly.FromDateTime(start.Value); var to = TimeOnly.FromDateTime(end.Value); + if (to <= from) { error.Text = "Конец пары должен быть позже начала."; return; } + loading = true; save.Enabled = false; UseWaitCursor = true; + try + { + var selectedDay = (DayOfWeek)(day.SelectedIndex + 1); + var request = new SaveSlotRequest(choice.Id, choice.Id == 0 ? newGroup.Text.Trim() : null, subject.Text.Trim(), + string.IsNullOrWhiteSpace(room.Text) ? null : room.Text.Trim(), oneOff.Checked ? date.Value.DayOfWeek : selectedDay, + from, to, oneOff.Checked ? WeekParity.Every : parity.SelectedIndex switch { 1 => WeekParity.Odd, 2 => WeekParity.Even, _ => WeekParity.Every }, + oneOff.Checked ? DateOnly.FromDateTime(date.Value) : null); + await api.SaveSlotAsync(slot?.Id, request); + if (IsDisposed) return; + DialogResult = DialogResult.OK; Close(); + } + catch (ApiException ex) { if (!IsDisposed) error.Text = ex.Message; } + finally { loading = false; if (!IsDisposed) { save.Enabled = true; UseWaitCursor = false; } } + } + + private sealed record GroupChoice(int Id, string Name) + { + public override string ToString() => Name; + } +} diff --git a/BiStudent/Pages/StudentDialog.cs b/BiStudent/Pages/StudentDialog.cs new file mode 100644 index 0000000..f27dfef --- /dev/null +++ b/BiStudent/Pages/StudentDialog.cs @@ -0,0 +1,89 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class StudentDialog : DarkForm +{ + private readonly ApiClient api; + private readonly StudentDto? student; + private readonly IReadOnlyList groups; + private readonly TextBox name = Ui.Input("Фамилия Имя Отчество"); + private readonly ModernComboBox group = new() { Dock = DockStyle.Fill }; + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true }; + private readonly ModernButton cancel = new() { Text = "Отмена" }; + private bool saving; + + public StudentDialog(ApiClient api, IReadOnlyList groups, StudentDto? student, int? selectedGroupId) + { + this.api = api; + this.groups = groups; + this.student = student; + Text = student is null ? "Новый студент" : "Изменить студента"; + ClientSize = new Size(480, 310); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 6, + Padding = new Padding(24, 18, 24, 0), BackColor = Theme.Background + }; + foreach (var height in new[] { 31, 45, 31, 45, 39, 40 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + name.Dock = DockStyle.Fill; + name.Text = student?.FullName ?? ""; + foreach (var item in groups) group.Items.Add(item.Name); + var selected = selectedGroupId ?? student?.GroupId; + group.SelectedIndex = selected is { } id ? groups.ToList().FindIndex(g => g.Id == id) : -1; + if (group.SelectedIndex < 0 && group.Items.Count > 0) group.SelectedIndex = 0; + error.TextAlign = ContentAlignment.MiddleLeft; + layout.Controls.Add(Ui.Label("ФИО", 9.5f, Theme.Muted), 0, 0); + layout.Controls.Add(name, 0, 1); + layout.Controls.Add(Ui.Label("Группа", 9.5f, Theme.Muted), 0, 2); + layout.Controls.Add(group, 0, 3); + layout.Controls.Add(error, 0, 4); + + var actions = Ui.Row(); + actions.FlowDirection = FlowDirection.RightToLeft; + actions.Controls.Add(save); actions.Controls.Add(cancel); + layout.Controls.Add(actions, 0, 5); + Controls.Add(layout); + AcceptButton = save; + CancelButton = cancel; + save.Click += async (_, _) => await SaveAsync(); + cancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; + Shown += (_, _) => { name.Focus(); name.SelectAll(); }; + } + + private async Task SaveAsync() + { + if (saving) return; + var fullName = name.Text.Trim(); + if (fullName.Length == 0 || group.SelectedIndex < 0) + { + error.Text = "Укажите ФИО и группу."; + return; + } + saving = true; + save.Enabled = false; + cancel.Enabled = false; + UseWaitCursor = true; + error.Text = "Сохранение…"; + try + { + await api.SaveStudentAsync(student?.Id, new SaveStudentRequest(fullName, groups[group.SelectedIndex].Id)); + if (IsDisposed) return; + DialogResult = DialogResult.OK; + Close(); + } + catch (ApiException ex) { error.Text = ex.Message; } + catch (Exception ex) when (ex is IOException or InvalidOperationException) { error.Text = ex.Message; } + finally + { + saving = false; + if (!IsDisposed) { save.Enabled = true; cancel.Enabled = true; UseWaitCursor = false; } + } + } +} diff --git a/BiStudent/Pages/TeacherDialog.cs b/BiStudent/Pages/TeacherDialog.cs new file mode 100644 index 0000000..0c25074 --- /dev/null +++ b/BiStudent/Pages/TeacherDialog.cs @@ -0,0 +1,92 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class TeacherDialog : IDisposable +{ + private readonly ApiClient api; + private readonly TeacherAccountDto? teacher; + private readonly DialogShell dialog; + private readonly TextBox name = Ui.Input("Фамилия Имя Отчество"); + private readonly TextBox login = Ui.Input("Логин"); + private readonly CheckBox admin = new() { Text = "Администратор", ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Dock = DockStyle.Fill }; + private readonly CheckBox active = new() { Text = "Учётная запись активна", ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat, Dock = DockStyle.Fill }; + private readonly Label error = Ui.Label("", 9, Theme.Red); + private readonly ModernButton save = new() { Text = "Сохранить", Primary = true }; + private readonly ModernButton cancel; + private bool saving; + + public TemporaryPasswordDto? TemporaryPassword { get; private set; } + + public TeacherDialog(ApiClient api, TeacherAccountDto? teacher) + { + this.api = api; + this.teacher = teacher; + dialog = new DialogShell(teacher is null ? "Новый преподаватель" : "Изменить преподавателя", 520, 410); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 7, BackColor = Theme.Background }; + foreach (var height in new[] { 28, 42, 28, 42, 42, 42, 40 }) + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + name.Text = teacher?.FullName ?? ""; + login.Text = teacher?.Login ?? ""; + admin.Checked = teacher?.IsAdmin ?? false; + active.Checked = teacher?.IsActive ?? true; + name.Dock = DockStyle.Fill; + login.Dock = DockStyle.Fill; + layout.Controls.Add(Ui.Label("ФИО", 9.5f, Theme.Muted), 0, 0); + layout.Controls.Add(name, 0, 1); + layout.Controls.Add(Ui.Label("Логин", 9.5f, Theme.Muted), 0, 2); + layout.Controls.Add(login, 0, 3); + layout.Controls.Add(admin, 0, 4); + layout.Controls.Add(active, 0, 5); + layout.Controls.Add(error, 0, 6); + dialog.Content.Controls.Add(layout); + cancel = dialog.AddButton("Отмена", DialogResult.Cancel); + dialog.Buttons.Controls.Add(save); + dialog.AcceptButton = save; + save.Click += async (_, _) => await SaveAsync(); + dialog.Shown += (_, _) => { name.Focus(); name.SelectAll(); }; + dialog.FormClosing += (_, e) => + { + if (!saving) return; + e.Cancel = true; + dialog.DialogResult = DialogResult.None; + }; + } + + public DialogResult ShowDialog(IWin32Window? owner = null) => dialog.ShowDialog(owner); + + private async Task SaveAsync() + { + if (saving) return; + var fullName = name.Text.Trim(); + var userLogin = login.Text.Trim(); + if (fullName.Length == 0 || userLogin.Length == 0) + { + error.Text = "Укажите ФИО и логин."; + return; + } + saving = true; + save.Enabled = false; + cancel.Enabled = false; + dialog.UseWaitCursor = true; + error.Text = ""; + try + { + var request = new SaveTeacherRequest(userLogin, fullName, admin.Checked, active.Checked); + if (teacher is null) TemporaryPassword = await api.CreateTeacherAsync(request); + else await api.UpdateTeacherAsync(teacher.Id, request); + if (dialog.IsDisposed) return; + saving = false; + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + } + catch (ApiException ex) { error.Text = ex.Message; } + finally + { + saving = false; + if (!dialog.IsDisposed) { save.Enabled = cancel.Enabled = true; dialog.UseWaitCursor = false; } + } + } + + public void Dispose() => dialog.Dispose(); +} diff --git a/BiStudent/Pages/TodayPage.cs b/BiStudent/Pages/TodayPage.cs new file mode 100644 index 0000000..4d589b2 --- /dev/null +++ b/BiStudent/Pages/TodayPage.cs @@ -0,0 +1,264 @@ +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class TodayPage : Page +{ + private readonly KpiTile attendance = new("Посещаемость за 7 дней"); + private readonly KpiTile automatic = new("Отмечено камерой за неделю"); + private readonly KpiTile score = new("Средний балл за 30 дней") { Accent = true }; + private readonly KpiTile risk = new("В зоне риска"); + private readonly CardPanel nowCard = new("Сейчас"); + private readonly CardPanel lessonsCard = new("Занятия сегодня", "двойной клик — открыть занятие"); + private readonly CardPanel riskCard = new("Зона риска", "по данным за 30 дней"); + private readonly Panel pending = new() { Dock = DockStyle.Top, Height = 62, BackColor = Theme.Surface, Visible = false }; + private readonly Label pendingText = Ui.Label("", 11, Theme.Yellow, FontStyle.Bold); + private readonly ListView lessons = DarkList(); + private readonly FlowLayoutPanel riskList = new() { Dock = DockStyle.Fill, AutoScroll = true, FlowDirection = FlowDirection.LeftToRight, BackColor = Theme.Surface }; + private readonly System.Windows.Forms.Timer refresh = new() { Interval = 60000 }; + private List today = []; + private bool loading; + private bool left; + + public TodayPage(ApiClient api) : base(api, "Сегодня", UiFormat.LongDate(DateOnly.FromDateTime(DateTime.Now))) + { + var kpis = new TableLayoutPanel { Dock = DockStyle.Top, Height = 124, ColumnCount = 4, BackColor = Theme.Background, Padding = new Padding(0, 0, 0, 16) }; + for (var i = 0; i < 4; i++) kpis.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + foreach (var tile in new[] { attendance, automatic, score, risk }) { tile.Dock = DockStyle.Fill; kpis.Controls.Add(tile); } + risk.Margin = Padding.Empty; + + var middle = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, BackColor = Theme.Background }; + middle.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 52)); middle.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 48)); + nowCard.Dock = DockStyle.Fill; nowCard.Margin = new Padding(0, 0, 14, 0); + lessonsCard.Dock = DockStyle.Fill; lessonsCard.Margin = Padding.Empty; + lessons.Columns.Add("Время", 80); lessons.Columns.Add("Занятие", 300); lessons.Columns.Add("Статус", 110); + lessons.Resize += (_, _) => lessons.Columns[1].Width = Math.Max(160, lessons.ClientSize.Width - 200); + lessons.DoubleClick += async (_, _) => + { + if (lessons.SelectedItems.Count == 1 && lessons.SelectedItems[0].Tag is LessonDto lesson && Shell is { } shell) await shell.OpenLessonAsync(lesson); + }; + lessonsCard.Body.Controls.Add(lessons); + middle.Controls.Add(nowCard, 0, 0); middle.Controls.Add(lessonsCard, 1, 0); + + riskCard.Dock = DockStyle.Bottom; riskCard.Height = 210; riskCard.Margin = Padding.Empty; + riskCard.Body.Controls.Add(riskList); + var spacer = new Panel { Dock = DockStyle.Bottom, Height = 16, BackColor = Theme.Background }; + + var check = new ModernButton { Text = "Проверить", Width = 140, Dock = DockStyle.Right }; + check.Click += async (_, _) => + { + using var dialog = new FaceSubmissionsDialog(Api); + dialog.ShowDialog(FindForm()); + if (dialog.Changed && !IsDisposed) + { + try { await LoadAsync(); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + } + }; + pending.Padding = new Padding(18, 10, 18, 10); + pendingText.Dock = DockStyle.Fill; + pending.Controls.Add(pendingText); + pending.Controls.Add(check); + Body.Controls.Add(middle); Body.Controls.Add(spacer); Body.Controls.Add(riskCard); + Body.Controls.Add(pending); Body.Controls.Add(kpis); + refresh.Tick += async (_, _) => + { + try { await LoadAsync(); } + catch (ApiException ex) { if (!IsDisposed) Ui.Toast(this, ex.Message, true); } + }; + } + + private static ListView DarkList() + { + var list = new ListView + { + Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, HeaderStyle = ColumnHeaderStyle.None, + BorderStyle = BorderStyle.None, BackColor = Theme.Surface, ForeColor = Theme.Text, Font = Theme.Font(10.5f), + MultiSelect = false, OwnerDraw = true + }; + list.SmallImageList = new ImageList { ImageSize = new Size(1, 44) }; + list.DrawItem += (_, e) => e.DrawDefault = false; + list.DrawSubItem += (_, e) => + { + if (e.Item is null || e.SubItem is null) return; + var selected = e.Item.Selected; + using (var back = new SolidBrush(selected ? Theme.SurfaceHover : Theme.Surface)) e.Graphics.FillRectangle(back, e.Bounds); + var color = e.ColumnIndex == 2 && e.SubItem.Tag is Color c ? c : e.ColumnIndex == 0 ? Theme.Muted : Theme.Text; + var font = e.ColumnIndex == 1 ? Theme.Font(10.5f, FontStyle.Bold) : Theme.Font(10); + var lines = e.SubItem.Text.Split('\n'); + if (lines.Length == 2) + { + TextRenderer.DrawText(e.Graphics, lines[0], font, new Rectangle(e.Bounds.X + 6, e.Bounds.Y + 3, e.Bounds.Width - 8, 20), color, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + TextRenderer.DrawText(e.Graphics, lines[1], Theme.Font(9), new Rectangle(e.Bounds.X + 6, e.Bounds.Y + 22, e.Bounds.Width - 8, 18), Theme.Muted, TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } + else TextRenderer.DrawText(e.Graphics, e.SubItem.Text, font, Rectangle.Inflate(e.Bounds, -6, 0), color, TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + using var line = new Pen(Theme.Line); + e.Graphics.DrawLine(line, e.Bounds.Left, e.Bounds.Bottom - 1, e.Bounds.Right, e.Bounds.Bottom - 1); + }; + return list; + } + + private static string Greeting(DateTime now) => now.Hour switch { < 5 => "Доброй ночи", < 12 => "Доброе утро", < 18 => "Добрый день", _ => "Добрый вечер" }; + + private static string ShortName(string fullName) + { + var clean = System.Text.RegularExpressions.Regex.Replace(fullName, @"\s*\(.*?\)", "").Trim(); + var parts = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 3 ? $"{parts[1]} {parts[2]}" : clean; + } + + public override async Task LoadAsync() + { + if (loading) return; + loading = true; + try + { + var now = DateTime.Now; + var name = ShortName(Api.Teacher?.FullName ?? ""); + Title = string.IsNullOrEmpty(name) ? Greeting(now) : $"{Greeting(now)}, {name}"; + Subtitle = UiFormat.LongDate(DateOnly.FromDateTime(now)); + var data = await Api.DashboardAsync(); + if (Gone) return; + today = data.Today; + var percents = new Dictionary(); + foreach (var lesson in today.Where(l => !l.Cancelled && l.EndsAt <= now)) + { + var rows = await Api.AttendanceAsync(lesson.Slot.Id, lesson.Date); + if (Gone) return; + percents[lesson.Slot.Id] = rows.Count == 0 ? 0 : 100d * rows.Count(r => r.Status == AttendanceStatus.Present) / rows.Count; + } + List? currentRows = null; + if (data.Current is { } live) + { + currentRows = await Api.AttendanceAsync(live.Slot.Id, live.Date); + if (Gone) return; + } + attendance.Set(UiFormat.Percent(data.Attendance7Days), $"за 30 дней — {UiFormat.Percent(data.Attendance30Days)}", UiFormat.PercentColor(data.Attendance7Days)); + automatic.Set(data.CameraMarks7Days.ToString(), $"за месяц — {data.CameraMarks30Days}"); + score.Set(data.AverageScore30Days is { } average ? average.ToString("0.0") : "—", "по 100-балльной шкале"); + risk.Set(data.AtRisk.Count.ToString(), "студентов требуют внимания", data.AtRisk.Count > 0 ? Theme.Red : Theme.Green); + pending.Visible = data.PendingFaceSubmissions > 0; + pendingText.Text = $"Заявки на регистрацию лица: {data.PendingFaceSubmissions}"; + BuildNow(data, now, currentRows); + BuildLessons(now, percents); + BuildRisk(data.AtRisk); + if (!left) refresh.Start(); + } + finally { loading = false; } + } + + private bool Gone => left || IsDisposed; + + private void BuildNow(DashboardDto data, DateTime now, List? rows) + { + foreach (var old in nowCard.Body.Controls.Cast().ToList()) old.Dispose(); + nowCard.Body.Controls.Clear(); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 6, BackColor = Theme.Surface }; + foreach (var height in new[] { 30, 46, 28, 26, 40, 60 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + var lesson = data.Current ?? data.Next; + if (lesson is null) + { + nowCard.Caption = "Сегодня"; + layout.Controls.Add(Ui.Label("Занятий сегодня больше нет", 18, Theme.Text, FontStyle.Bold), 0, 1); + layout.Controls.Add(Ui.Label("Отметки прошедших занятий доступны в журнале.", 10, Theme.Muted), 0, 2); + var journal = new ModernButton { Text = "Открыть журнал", Width = 180 }; + journal.Click += async (_, _) => { if (Shell is { } shell) await shell.ShowPageAsync("journal"); }; + var row = Ui.Row(); row.Controls.Add(journal); layout.Controls.Add(row, 0, 5); + nowCard.Body.Controls.Add(layout); + return; + } + var live = data.Current is not null; + nowCard.Caption = live ? "● Идёт сейчас" : "Следующее занятие"; + layout.Controls.Add(Ui.Label(lesson.Slot.Subject, 20, Theme.Text, FontStyle.Bold), 0, 1); + layout.Controls.Add(Ui.Label($"{lesson.Slot.GroupName} · {UiFormat.Range(lesson.Slot)}{(lesson.Slot.Room is { } room ? $" · ауд. {room}" : "")}", 10.5f, Theme.Muted), 0, 2); + if (live) + { + var progress = new ProgressLine { Dock = DockStyle.Fill, Margin = new Padding(0, 8, 0, 8), Value = (now - lesson.StartsAt).TotalMinutes / (lesson.EndsAt - lesson.StartsAt).TotalMinutes * 100 }; + layout.Controls.Add(progress, 0, 3); + rows ??= []; + var marked = rows.Count(r => r.Status is not null); + layout.Controls.Add(Ui.Label($"{marked} из {rows.Count} отмечено", 13, Theme.Text, FontStyle.Bold), 0, 4); + } + else + { + layout.Controls.Add(Ui.Label($"Начнётся через {UiFormat.Duration((lesson.StartsAt - now).TotalMinutes)}", 10.5f, Theme.AccentHover), 0, 3); + layout.Controls.Add(Ui.Label("Камеру можно запустить заранее — отметка начнётся со звонком.", 9.5f, Theme.Muted), 0, 4); + } + var actions = Ui.Row(); + var camera = new ModernButton { Text = live ? "Начать отметку" : "Подготовить камеру", Icon = "◉", Primary = true, Width = 250 }; + camera.Click += async (_, _) => { if (Shell is { } shell) await shell.OpenCamera(lesson); }; + var manual = new ModernButton { Text = "Открыть занятие", Width = 190 }; + manual.Click += async (_, _) => { if (Shell is { } shell) await shell.OpenLessonAsync(lesson); }; + actions.Controls.Add(camera); actions.Controls.Add(manual); + layout.Controls.Add(actions, 0, 5); + nowCard.Body.Controls.Add(layout); + } + + private void BuildLessons(DateTime now, Dictionary percents) + { + lessons.BeginUpdate(); + try + { + lessons.Items.Clear(); + foreach (var lesson in today) + { + string state; + Color color; + if (lesson.Cancelled) { state = "отменена"; color = Theme.Red; } + else if (lesson.EndsAt <= now) + { + var percent = percents.GetValueOrDefault(lesson.Slot.Id); + state = UiFormat.Percent(percent); color = UiFormat.PercentColor(percent); + } + else if (lesson.StartsAt <= now) { state = "● идёт"; color = Theme.Green; } + else { state = "впереди"; color = Theme.Muted; } + var item = new ListViewItem(UiFormat.Time(lesson.Slot.Start)) { Tag = lesson }; + item.SubItems.Add($"{lesson.Slot.Subject}\n{lesson.Slot.GroupName}{(lesson.Slot.Room is { } room ? $" · ауд. {room}" : "")}"); + item.SubItems.Add(new ListViewItem.ListViewSubItem { Text = state, Tag = color }); + lessons.Items.Add(item); + } + if (today.Count == 0) lessons.Items.Add(new ListViewItem(["", "По расписанию сегодня занятий нет", ""])); + } + finally { lessons.EndUpdate(); } + } + + private void BuildRisk(List students) + { + riskList.SuspendLayout(); + foreach (Control control in riskList.Controls.Cast().ToList()) control.Dispose(); + riskList.Controls.Clear(); + if (students.Count == 0) + { + var ok = Ui.Label("Все студенты посещают занятия стабильно.", 10, Theme.Muted); + ok.Dock = DockStyle.None; ok.AutoSize = true; + riskList.Controls.Add(ok); + } + foreach (var student in students.Take(8)) + { + var item = new SmoothPanel { Width = 330, Height = 64, BackColor = Theme.SurfaceHover, Radius = 12, Padding = new Padding(14, 6, 14, 6), Margin = new Padding(0, 0, 12, 12) }; + var grid = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 2, BackColor = Color.Transparent }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); + var name = Ui.Label(student.FullName, 10, Theme.Text, FontStyle.Bold); name.AutoEllipsis = true; + var reason = Ui.Label($"{student.GroupName} · {student.Reason}", 8.5f, Theme.Muted); reason.AutoEllipsis = true; + var percent = Ui.Label(UiFormat.Percent(student.Percent), 13, UiFormat.PercentColor(student.Percent), FontStyle.Bold); + percent.TextAlign = ContentAlignment.MiddleRight; + grid.Controls.Add(name, 0, 0); grid.Controls.Add(reason, 0, 1); grid.Controls.Add(percent, 1, 0); grid.SetRowSpan(percent, 2); + item.Controls.Add(grid); + riskList.Controls.Add(item); + } + riskList.ResumeLayout(); + } + + public override Task OnLeaveAsync() + { + left = true; + refresh.Stop(); + return Task.CompletedTask; + } + + protected override void Dispose(bool disposing) + { + if (disposing) refresh.Dispose(); + base.Dispose(disposing); + } +} diff --git a/BiStudent/Program.cs b/BiStudent/Program.cs index d71241b..8d71157 100644 --- a/BiStudent/Program.cs +++ b/BiStudent/Program.cs @@ -1,354 +1,161 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Forms; +using System.Globalization; -namespace BiStudent +namespace BiStudent; + +internal static class Program { - internal static class Program + private static readonly string[] PageKeys = ["today", "camera", "schedule", "journal", "groups", "report", "admin"]; + + [STAThread] + private static int Main(string[] args) { - /// - /// Главная точка входа для приложения. - /// - [STAThread] - static int Main(string[] args) + var culture = CultureInfo.GetCultureInfo("ru-RU"); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + CrashGuard.Install(); + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + if (args.Contains("--self-test")) return SelfTest(); + var screenshots = Array.IndexOf(args, "--screenshots"); + if (screenshots >= 0 && args.Length >= screenshots + 4) + return Screenshots(args[screenshots + 1], args[screenshots + 2], args[screenshots + 3]); + + while (true) { - if (args.Contains("--distance-check")) + var settings = ClientSettings.Load(); + ApiClient? resumed = null; + string? loginError = null; + if (settings.RememberMe && settings.LoadRefreshToken() is { } token) { try { - var samples = new AttendanceStore().Samples(); - if (samples.Count == 0) throw new InvalidOperationException("Нет локальных образцов для проверки."); - using var engine = new FaceEngine(); - engine.Train(samples); - var lines = new List(); - var detectedSmall = 0; var recognizedSmall = 0; - foreach (var sample in samples) + resumed = new ApiClient(settings); + resumed.RefreshTokenChanged += value => { settings.StoreRefreshToken(value); settings.Save(); }; + if (!Task.Run(() => resumed.TryResumeAsync(token)).GetAwaiter().GetResult()) { - using var image = OpenCvSharp.Cv2.ImDecode(sample.Image, OpenCvSharp.ImreadModes.Color); - foreach (var size in new[] { 96, 64, 48, 36 }) - { - using var frame = new OpenCvSharp.Mat(720,1280,OpenCvSharp.MatType.CV_8UC3,OpenCvSharp.Scalar.All(60)); - using var resized = new OpenCvSharp.Mat(); - OpenCvSharp.Cv2.Resize(image,resized,new OpenCvSharp.Size(size,size),0,0,OpenCvSharp.InterpolationFlags.Area); - using var target = new OpenCvSharp.Mat(frame,new OpenCvSharp.Rect(600,300,size,size)); resized.CopyTo(target); - var watch = System.Diagnostics.Stopwatch.StartNew(); var matches = engine.Scan(frame); - lines.Add($"sample student={sample.StudentId}; image={size}px; detected={matches.Count}; correct={matches.Any(x=>x.StudentId==sample.StudentId)}; elapsed={watch.ElapsedMilliseconds}ms; " + string.Join("; ",matches.Select(x=>$"bounds={x.Bounds.Width}x{x.Bounds.Height}, similarity={x.Similarity:F3}, id={x.StudentId}"))); - if (size == 64) { detectedSmall += matches.Count; recognizedSmall += matches.Count(x=>x.StudentId==sample.StudentId); } - if (matches.Any(x=>x.StudentId is int id && id!=sample.StudentId)) throw new InvalidOperationException("Уменьшенный образец принят за другого студента."); - } + resumed.Dispose(); + resumed = null; + settings.StoreRefreshToken(null); + settings.Save(); } - using var blank = new OpenCvSharp.Mat(720,1280,OpenCvSharp.MatType.CV_8UC3,OpenCvSharp.Scalar.All(60)); - if (engine.Scan(blank).Count != 0) throw new InvalidOperationException("Лицо найдено в пустом кадре."); - File.WriteAllLines(Path.Combine(AppContext.BaseDirectory,"distance-result.txt"),lines); - if (detectedSmall!=samples.Count || recognizedSmall!=samples.Count) throw new InvalidOperationException("Не все уменьшенные лица распознаны."); - return 0; } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"distance-error.txt"),ex.ToString()); return 1; } - } - if (args.Length==2 && args[0]=="--enrollment-check") - { - string? code=null; CloudFirestore? cloud=null; - try { - cloud=CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); - code=cloud.CreateFaceInviteAsync(new CloudStudent("_diagnostic_registration",0,"Проверка регистрации")).GetAwaiter().GetResult(); - var settings=FirebaseSettings.Load(); var root=$"https://firestore.googleapis.com/v1/projects/{settings.ProjectId}/databases/(default)/documents/groups/{settings.GroupId}"; - using var http=new System.Net.Http.HttpClient(); - using var invite=http.GetAsync(root+"/faceInvites/"+code).GetAwaiter().GetResult(); if (!invite.IsSuccessStatusCode) throw new Exception("Code cannot be read anonymously."); - var body=System.Text.Json.JsonSerializer.Serialize(new { fields=new { studentId=new { stringValue="_diagnostic_registration" },payload=new { stringValue="{\"samples\":[]}" } } }); - using var submitted=http.PatchAsync(root+"/faceSubmissions/"+code+"?currentDocument.exists=false",new System.Net.Http.StringContent(body,System.Text.Encoding.UTF8,"application/json")).GetAwaiter().GetResult(); - if (!submitted.IsSuccessStatusCode) throw new Exception("Anonymous code submission denied."); - using var duplicate=http.PatchAsync(root+"/faceSubmissions/"+code+"?currentDocument.exists=false",new System.Net.Http.StringContent(body,System.Text.Encoding.UTF8,"application/json")).GetAwaiter().GetResult(); - if (duplicate.IsSuccessStatusCode) throw new Exception("Code accepted twice."); - using var privateRead=http.GetAsync(root+"/faceSubmissions/"+code).GetAwaiter().GetResult(); if (privateRead.StatusCode!=System.Net.HttpStatusCode.Forbidden) throw new Exception("Raw face submission publicly readable."); - using var listFaces=http.GetAsync(root+"/faces").GetAwaiter().GetResult(); if (listFaces.StatusCode!=System.Net.HttpStatusCode.Forbidden) throw new Exception("Cloud faces publicly readable."); - var snapshot=cloud.LoadAsync().GetAwaiter().GetResult(); if (!snapshot.FaceSubmissions.Any(x=>x.SubmissionCode==code)) throw new Exception("Teacher cannot see submission."); - cloud.RevokeFaceInviteAsync(code).GetAwaiter().GetResult(); code=null; - File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"enrollment-result.txt"),"OK: anonymous code lookup; single submission; repeat rejected; private faces/submissions denied; teacher receives submission; diagnostic invite revoked and submission removed"); return 0; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"enrollment-error.txt"),ex.ToString()); return 1; } - finally { if (cloud is not null && code is not null) { try { cloud.RevokeFaceInviteAsync(code).GetAwaiter().GetResult(); } catch { } } cloud?.Dispose(); } - } - if (args.Length==2 && args[0]=="--sync-faces") - { - try { - using var cloud=CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); var snapshot=cloud.LoadAsync().GetAwaiter().GetResult(); - var settings=FirebaseSettings.Load(); var store=new AttendanceStore(); store.MigrateTeacher(settings.PreviousTeacherUid,settings.TeacherUid,settings.TeacherName); store.ApplyCloudRoster(snapshot,settings.GroupId); - var sync=new FaceSync(store,cloud,snapshot.GroupName); sync.ImportAsync(snapshot).GetAwaiter().GetResult(); sync.SaveAsync().GetAwaiter().GetResult(); - var fresh=cloud.LoadAsync().GetAwaiter().GetResult(); - var copy=new AttendanceStore(Path.Combine(AppContext.BaseDirectory,"diagnostics","faces-real-fresh-"+Guid.NewGuid().ToString("N"))); copy.ApplyCloudRoster(fresh,settings.GroupId); - var restored=new FaceSync(copy,cloud,fresh.GroupName); restored.ImportAsync(fresh).GetAwaiter().GetResult(); - if (copy.Samples().Count!=fresh.Faces.Count*3 || restored.HasChanges) throw new Exception("Fresh device did not restore cloud faces."); - new PublicStatsPublisher(cloud,store).PublishAsync(fresh).GetAwaiter().GetResult(); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"faces-result.txt"),$"OK: cloud faces={fresh.Faces.Count}; fresh device samples={copy.Samples().Count}; no pending={(!sync.HasChanges)}; import error={sync.Error ?? "none"}"); return 0; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"faces-error.txt"),ex.ToString()); return 1; } - } - if (args.Length==2 && args[0]=="--remember-check") - { - try { - var settings=FirebaseSettings.Load(); using var json=System.Text.Json.JsonDocument.Parse(File.ReadAllText(args[1])); using var http=new System.Net.Http.HttpClient(); var auth=new FirebaseAuth(settings,http); - var session=auth.SignInAsync(json.RootElement.GetProperty("password").GetString()!).GetAwaiter().GetResult(); RememberedSession.Save(settings,session); - var restored=auth.RestoreAsync(RememberedSession.Load(settings)!).GetAwaiter().GetResult(); RememberedSession.Save(settings,restored); - if (restored.Uid!=settings.TeacherUid) throw new Exception("Remembered account mismatch."); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"remember-result.txt"),"OK: encrypted device token saved; fresh process can restore Firebase login; password not stored"); return 0; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"remember-error.txt"),ex.ToString()); return 1; } - } - if (args.Length == 2 && args[0] == "--sync-attendance") - { - try { - using var cloud = CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); - var snapshot = cloud.LoadAsync().GetAwaiter().GetResult(); - var store = new AttendanceStore(); store.ApplyCloudRoster(snapshot, FirebaseSettings.Load().GroupId); - var sync = new AttendanceSync(store,cloud,snapshot); sync.SaveAsync().GetAwaiter().GetResult(); - var other = cloud.LoadAsync().GetAwaiter().GetResult(); - var copy = new AttendanceStore(Path.Combine(AppContext.BaseDirectory,"diagnostics","attendance-real-fresh-" + Guid.NewGuid().ToString("N"))); - copy.ApplyCloudRoster(other,FirebaseSettings.Load().GroupId); var restored = new AttendanceSync(copy,cloud,other); - if (restored.HasChanges || copy.ExportAttendance(snapshot.GroupName).Count != other.Attendance.Count(x => x.Present)) throw new Exception("Fresh device restore failed."); - new PublicStatsPublisher(cloud,store).PublishAsync(snapshot).GetAwaiter().GetResult(); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"attendance-result.txt"),$"OK: Firebase attendance records={other.Attendance.Count}; present={other.Attendance.Count(x => x.Present)}; fresh device restored; local pending={sync.HasChanges}"); return 0; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"attendance-error.txt"),ex.ToString()); return 1; } - } - if (args.Contains("--camera-check")) - { - try { - var results = new List(); - for (var i=0;i<2;i++) { - var watch = System.Diagnostics.Stopwatch.StartNew(); - using var camera = CameraDevice.Open(CancellationToken.None); using var frame = new OpenCvSharp.Mat(); - var deadline = DateTime.UtcNow.AddSeconds(10); - while ((!camera.Read(frame) || frame.Empty()) && DateTime.UtcNow < deadline) Thread.Sleep(20); - if (frame.Empty()) throw new Exception("Камера не вернула кадр."); - results.Add($"Run {i+1}: first frame={watch.ElapsedMilliseconds}ms; backend={camera.GetBackendName()}; frame={frame.Width}x{frame.Height}"); - } - File.WriteAllLines(Path.Combine(AppContext.BaseDirectory,"camera-result.txt"),results); return 0; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"camera-error.txt"),ex.ToString()); return 1; } - } - if (args.Contains("--camera-ui-check")) - { - try { - Application.EnableVisualStyles(); - var directory = Path.Combine(AppContext.BaseDirectory,"diagnostics"); Directory.CreateDirectory(directory); - var store = new AttendanceStore(Path.Combine(directory,"camera-ui-" + Guid.NewGuid().ToString("N"))); - using var form = new EnrollmentForm(store,new Student(1,"Тест интерфейса","Т-1",0)); - form.Show(); Application.DoEvents(); - using (var initial = new System.Drawing.Bitmap(form.Width,form.Height)) { form.DrawToBitmap(initial,new System.Drawing.Rectangle(0,0,form.Width,form.Height)); initial.Save(Path.Combine(directory,"enrollment-loader.png")); } - var preview = (PictureBox)typeof(EnrollmentForm).GetField("preview",System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(form)!; - var deadline = DateTime.UtcNow.AddSeconds(20); - while (preview.Image is null && DateTime.UtcNow < deadline) { Application.DoEvents(); Thread.Sleep(20); } - var ready = preview.Image is not null; - using (var image = new System.Drawing.Bitmap(form.Width,form.Height)) { form.DrawToBitmap(image,new System.Drawing.Rectangle(0,0,form.Width,form.Height)); image.Save(Path.Combine(directory,"enrollment-ready.png")); } - form.Close(); while (!form.IsDisposed) { Application.DoEvents(); Thread.Sleep(20); } - using var lesson = new LessonForm(store,new CloudSnapshot("Т-1",[],[new("csharp","Программирование C#"),new("economics","Основы экономики и предпринимательской деятельности")],[])); - lesson.Show(); Application.DoEvents(); using var lessonImage = new System.Drawing.Bitmap(lesson.Width,lesson.Height); - lesson.DrawToBitmap(lessonImage,new System.Drawing.Rectangle(0,0,lesson.Width,lesson.Height)); lessonImage.Save(Path.Combine(directory,"lesson-choices.png")); lesson.Close(); - File.WriteAllText(Path.Combine(directory,"camera-ui-check.txt"),"Camera ready=" + ready + "; asynchronous close completed"); return ready ? 0 : 1; - } catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"camera-ui-error.txt"),ex.ToString()); return 1; } - } - if (args.Length == 2 && args[0] == "--publish-stats") - { - try - { - using var cloud = CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); - var snapshot = cloud.LoadAsync().GetAwaiter().GetResult(); - new PublicStatsPublisher(cloud, new AttendanceStore()).PublishAsync(snapshot, true).GetAwaiter().GetResult(); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "publish-result.txt"), $"OK: public snapshot published; students={snapshot.Students.Count}; grades={snapshot.Grades.Count(x => x.Score is not null)}"); return 0; - } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "publish-error.txt"), ex.ToString()); return 1; } - } - if (args.Length == 2 && args[0] == "--rename-teacher") - { - try + catch (Exception ex) { - using var cloud = CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); - var count = cloud.RenameTeacherAsync().GetAwaiter().GetResult(); - var settings = FirebaseSettings.Load(); - var local = new AttendanceStore().RenameTeacher(settings.TeacherUid, settings.TeacherName); - File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "rename-result.txt"), $"OK: Firebase profile updated; cloud records={count}; local records={local}; name={settings.TeacherName}"); return 0; + resumed?.Dispose(); + resumed = null; + loginError = ex.Message; } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "rename-error.txt"), ex.ToString()); return 1; } } - if (args.Length == 2 && args[0] == "--cache-cloud-check") + ApiClient api; + if (resumed is not null) api = resumed; + else { - try { CloudDiagnostics.CheckCacheCloudAsync(args[1]).GetAwaiter().GetResult(); return 0; } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "cloud-error.txt"), ex.ToString()); return 1; } + using var login = new LoginForm(loginError); + if (login.ShowDialog() != DialogResult.OK || login.Api is null) return 0; + api = login.Api; } - if (args.Length == 2 && args[0] == "--journal-check") + using (api) { - try { CloudDiagnostics.CheckJournalAsync(args[1]).GetAwaiter().GetResult(); return 0; } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "cloud-error.txt"), ex.ToString()); return 1; } - } - if (args.Length == 2 && args[0] == "--cloud-check") - { - try { CloudDiagnostics.CheckAsync(args[1]).GetAwaiter().GetResult(); return 0; } - catch (Exception ex) { File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "cloud-error.txt"), ex.ToString()); return 1; } - } - if (args.Length == 2 && args[0] == "--cloud-preview") - { - try + if (api.Teacher?.MustChangePassword == true) { - using var cloud = CloudDiagnostics.ConnectAsync(args[1]).GetAwaiter().GetResult(); - var snapshot = cloud.LoadAsync().GetAwaiter().GetResult(); - Application.EnableVisualStyles(); - var workspace = new CloudWorkspace(() => Task.FromResult(snapshot), cloud.SaveChangesAsync, Path.Combine(AppContext.BaseDirectory, "diagnostics", "preview-draft.json")); - workspace.LoadAsync().GetAwaiter().GetResult(); - using var form = new CloudGradesForm(workspace, snapshot.Students[0].Id, snapshot.Students[0].Name, snapshot.Subjects); - form.Show(); - var deadline = DateTime.UtcNow.AddSeconds(30); - while (DateTime.UtcNow < deadline && !form.Controls.OfType().SelectMany(x => x.Controls.OfType