From 5ca960746b8d25976c9a0d288a669106b38e9258 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 19 Jun 2026 00:42:00 +0200 Subject: [PATCH] init 1.0.0 --- .env.example | 6 + .gitignore | 77 +++++++ .vscode/launch.json | 52 +++++ .vscode/tasks.json | 57 +++++ Directory.Build.props | 11 + Directory.Packages.props | 29 +++ LehrerApp.Api/Endpoints/Endpoints.cs | 133 ++++++++++++ LehrerApp.Api/EventStore.cs | 84 ++++++++ LehrerApp.Api/LehrerApp.Api.csproj | 14 ++ LehrerApp.Api/PlainEventStore.cs | 34 +++ LehrerApp.Api/Program.cs | 42 ++++ LehrerApp.Api/ReadableSnapshot.cs | 18 ++ LehrerApp.Api/ReadableSnapshotStore.cs | 23 ++ LehrerApp.Api/SnapshotStore.cs | 64 ++++++ LehrerApp.Core/Interfaces/IRepositories.cs | 87 ++++++++ LehrerApp.Core/LehrerApp.Core.csproj | 5 + LehrerApp.Core/Models/Exam.cs | 42 ++++ LehrerApp.Core/Models/LearningGroup.cs | 26 +++ LehrerApp.Core/Models/Planning.cs | 50 +++++ LehrerApp.Core/Models/Student.cs | 24 +++ LehrerApp.Core/Models/Workload.cs | 61 ++++++ LehrerApp.Core/Services/GradingService.cs | 52 +++++ LehrerApp.Core/Services/SchoolYearService.cs | 22 ++ LehrerApp.Data/LehrerApp.Data.csproj | 9 + LehrerApp.Data/LiteDbContext.cs | 60 ++++++ .../Repositories/AllRepositories.cs | 135 ++++++++++++ LehrerApp.Desktop/App.axaml | 9 + LehrerApp.Desktop/App.axaml.cs | 61 ++++++ LehrerApp.Desktop/AppBootstrapper.cs | 138 ++++++++++++ LehrerApp.Desktop/Assets/README.txt | 1 + LehrerApp.Desktop/LehrerApp.Desktop.csproj | 25 +++ LehrerApp.Desktop/Program.cs | 16 ++ .../ViewModels/DashboardViewModel.cs | 72 +++++++ .../ViewModels/Groups/GroupViewModels.cs | 196 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 66 ++++++ .../ViewModels/Students/StudentViewModels.cs | 131 ++++++++++++ .../ViewModels/SyncStatusViewModel.cs | 43 ++++ .../Views/Dashboard/DashboardView.axaml | 106 ++++++++++ .../Views/Dashboard/DashboardView.axaml.cs | 3 + .../Views/Groups/AddGroupDialog.axaml | 41 ++++ .../Views/Groups/AddGroupDialog.axaml.cs | 17 ++ .../Views/Groups/GroupDetailView.axaml | 111 ++++++++++ .../Views/Groups/GroupDetailView.axaml.cs | 3 + .../Views/Groups/GroupListView.axaml | 71 +++++++ .../Views/Groups/GroupListView.axaml.cs | 3 + LehrerApp.Desktop/Views/MainWindow.axaml | 119 +++++++++++ LehrerApp.Desktop/Views/MainWindow.axaml.cs | 8 + .../Views/Shared/PlaceholderView.axaml | 13 ++ .../Views/Shared/PlaceholderView.axaml.cs | 3 + .../Views/Students/StudentDetailView.axaml | 100 +++++++++ .../Views/Students/StudentDetailView.axaml.cs | 3 + .../Views/Students/StudentListView.axaml | 40 ++++ .../Views/Students/StudentListView.axaml.cs | 3 + LehrerApp.Desktop/Views/SyncStatusBar.axaml | 18 ++ .../Views/SyncStatusBar.axaml.cs | 16 ++ LehrerApp.Desktop/app.manifest | 11 + LehrerApp.Sync/ConflictResolver.cs | 35 ++++ LehrerApp.Sync/Crypto/SyncCrypto.cs | 96 +++++++++ LehrerApp.Sync/EventQueue.cs | 77 +++++++ LehrerApp.Sync/LehrerApp.Sync.csproj | 10 + LehrerApp.Sync/Models/SyncModels.cs | 95 +++++++++ LehrerApp.Sync/SnapshotService.cs | 100 +++++++++ LehrerApp.Sync/SyncEngine.cs | 116 +++++++++++ LehrerApp.sln | 30 +++ docker/Dockerfile.api | 19 ++ docker/docker-compose.yml | 13 ++ global.json | 6 + 67 files changed, 3261 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 LehrerApp.Api/Endpoints/Endpoints.cs create mode 100644 LehrerApp.Api/EventStore.cs create mode 100644 LehrerApp.Api/LehrerApp.Api.csproj create mode 100644 LehrerApp.Api/PlainEventStore.cs create mode 100644 LehrerApp.Api/Program.cs create mode 100644 LehrerApp.Api/ReadableSnapshot.cs create mode 100644 LehrerApp.Api/ReadableSnapshotStore.cs create mode 100644 LehrerApp.Api/SnapshotStore.cs create mode 100644 LehrerApp.Core/Interfaces/IRepositories.cs create mode 100644 LehrerApp.Core/LehrerApp.Core.csproj create mode 100644 LehrerApp.Core/Models/Exam.cs create mode 100644 LehrerApp.Core/Models/LearningGroup.cs create mode 100644 LehrerApp.Core/Models/Planning.cs create mode 100644 LehrerApp.Core/Models/Student.cs create mode 100644 LehrerApp.Core/Models/Workload.cs create mode 100644 LehrerApp.Core/Services/GradingService.cs create mode 100644 LehrerApp.Core/Services/SchoolYearService.cs create mode 100644 LehrerApp.Data/LehrerApp.Data.csproj create mode 100644 LehrerApp.Data/LiteDbContext.cs create mode 100644 LehrerApp.Data/Repositories/AllRepositories.cs create mode 100644 LehrerApp.Desktop/App.axaml create mode 100644 LehrerApp.Desktop/App.axaml.cs create mode 100644 LehrerApp.Desktop/AppBootstrapper.cs create mode 100644 LehrerApp.Desktop/Assets/README.txt create mode 100644 LehrerApp.Desktop/LehrerApp.Desktop.csproj create mode 100644 LehrerApp.Desktop/Program.cs create mode 100644 LehrerApp.Desktop/ViewModels/DashboardViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs create mode 100644 LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs create mode 100644 LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml create mode 100644 LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/GroupListView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/GroupListView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/MainWindow.axaml create mode 100644 LehrerApp.Desktop/Views/MainWindow.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Shared/PlaceholderView.axaml create mode 100644 LehrerApp.Desktop/Views/Shared/PlaceholderView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Students/StudentDetailView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Students/StudentListView.axaml create mode 100644 LehrerApp.Desktop/Views/Students/StudentListView.axaml.cs create mode 100644 LehrerApp.Desktop/Views/SyncStatusBar.axaml create mode 100644 LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs create mode 100644 LehrerApp.Desktop/app.manifest create mode 100644 LehrerApp.Sync/ConflictResolver.cs create mode 100644 LehrerApp.Sync/Crypto/SyncCrypto.cs create mode 100644 LehrerApp.Sync/EventQueue.cs create mode 100644 LehrerApp.Sync/LehrerApp.Sync.csproj create mode 100644 LehrerApp.Sync/Models/SyncModels.cs create mode 100644 LehrerApp.Sync/SnapshotService.cs create mode 100644 LehrerApp.Sync/SyncEngine.cs create mode 100644 LehrerApp.sln create mode 100644 docker/Dockerfile.api create mode 100644 docker/docker-compose.yml create mode 100644 global.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7449ac --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Vorlage – als .env kopieren und Werte eintragen. +# Die echte .env kommt NICHT ins Repo. + +# Mindestens 32 zufällige Zeichen – z.B. generiert mit: +# openssl rand -base64 32 +JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1df22ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# ── Build-Ausgaben ──────────────────────────────────────────────────────────── +bin/ +obj/ +out/ + +# ── .NET ────────────────────────────────────────────────────────────────────── +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ +*.rsuser + +# NuGet +*.nupkg +*.snupkg +project.lock.json +project.fragment.lock.json +artifacts/ +packages/ + +# Publish-Ausgaben +publish/ +**/Properties/PublishProfiles/ + +# ── Sensible Laufzeit-Daten (NIEMALS ins Repo!) ─────────────────────────────── +# Sync-Schlüssel und Geräte-IDs +*.key +device.id +auth.token +server.txt + +# Lokale Datenbanken +*.db +*.db-journal +*.db-wal +*.db-shm + +# Backups der lokalen DB +*.db.backup-* + +# ── API / Docker ────────────────────────────────────────────────────────────── +# Lokale Datenhaltung des Servers +LehrerApp.Api/data/ +docker/data/ + +# Umgebungsvariablen – .env.example ins Repo, .env nicht +.env +!.env.example + +# ── VS Code ─────────────────────────────────────────────────────────────────── +.vscode/settings.json +# launch.json und tasks.json BLEIBEN im Repo (für alle Entwickler) +!.vscode/launch.json +!.vscode/tasks.json + +# ── macOS ───────────────────────────────────────────────────────────────────── +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# ── Windows ─────────────────────────────────────────────────────────────────── +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# ── Rider / JetBrains ───────────────────────────────────────────────────────── +.idea/ +*.sln.iml + +# ── Logs ────────────────────────────────────────────────────────────────────── +*.log +logs/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0e12390 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,52 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Desktop App starten", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Desktop bauen", + "program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll", + "args": [], + "cwd": "${workspaceFolder}/LehrerApp.Desktop", + "stopAtEntry": false, + "console": "internalConsole", + "env": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + { + "name": "API Server starten", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "API bauen", + "program": "${workspaceFolder}/LehrerApp.Api/bin/Debug/net10.0/LehrerApp.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/LehrerApp.Api", + "stopAtEntry": false, + "console": "internalConsole", + "env": { + "DOTNET_ENVIRONMENT": "Development", + "JWT_SECRET": "dev-secret-mindestens-32-zeichen-lang!!", + "Api__Port": "5000", + "Api__DataPath": "./data" + } + }, + { + "name": "Desktop + API (gleichzeitig)", + "configurations": [ + "Desktop App starten", + "API Server starten" + ] + } + ], + "compounds": [ + { + "name": "Desktop + API (gleichzeitig)", + "configurations": [ + "Desktop App starten", + "API Server starten" + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..21aeba4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,57 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Desktop bauen", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/LehrerApp.Desktop/LehrerApp.Desktop.csproj", + "--configuration", "Debug", + "--verbosity", "minimal" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "API bauen", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/LehrerApp.Api/LehrerApp.Api.csproj", + "--configuration", "Debug", + "--verbosity", "minimal" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "Solution bauen", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/LehrerApp.sln", + "--configuration", "Debug", + "--verbosity", "minimal" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "NuGet restore", + "command": "dotnet", + "type": "process", + "args": [ + "restore", + "${workspaceFolder}/LehrerApp.sln" + ], + "problemMatcher": [] + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0618bd7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + latest + false + + CS8618 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..44e9fdd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs new file mode 100644 index 0000000..63306a2 --- /dev/null +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -0,0 +1,133 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Sync.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace LehrerApp.Api; + +public static class Endpoints +{ + // ── Auth ────────────────────────────────────────────────────────────────── + + public static void MapAuthEndpoints(this WebApplication app, string secret) + { + app.MapPost("/api/auth/login", (LoginRequest req) => + { + if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) + return Results.Unauthorized(); + // TODO: Passwort gegen DB prüfen + return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); + }); + + app.MapPost("/api/auth/register", (RegisterRequest req) => + { + if (req.Password.Length < 12) + return Results.BadRequest("Passwort mind. 12 Zeichen."); + // TODO: User anlegen, Passwort hashen (BCrypt) + return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); + }); + } + + // ── Sync ────────────────────────────────────────────────────────────────── + + public static void MapSyncEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/sync").RequireAuthorization(); + g.MapPost("/push", ([FromBody] List events, + ClaimsPrincipal user, EventStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events)); + }); + g.MapGet("/pull", ([FromQuery] long since, [FromQuery] string deviceId, + ClaimsPrincipal user, EventStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(store.Pull(uid, since, deviceId)); + }); + g.MapGet("/status", (ClaimsPrincipal user) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow }); + }); + } + + // ── Snapshot (Device-Pairing) ───────────────────────────────────────────── + + public static void MapSnapshotEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/snapshot").RequireAuthorization(); + g.MapPost("/upload", ([FromBody] SnapshotUploadRequest req, + ClaimsPrincipal user, SnapshotStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(store.Store(uid, req)); + }); + g.MapGet("/{code}", (string code, ClaimsPrincipal user, SnapshotStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var result = store.Retrieve(uid, code); + return result is null + ? Results.NotFound("Snapshot nicht gefunden, abgelaufen oder bereits verwendet.") + : Results.Ok(result); + }); + } + + // ── Readable Snapshot (WebApp) ──────────────────────────────────────────── + + public static void MapReadableSnapshotEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/snapshot/readable").RequireAuthorization(); + g.MapPost("/", ([FromBody] ReadableSnapshot snap, + ClaimsPrincipal user, ReadableSnapshotStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + snap.ExportedAt = DateTime.UtcNow; + store.Store(uid, snap); + return Results.Ok(new { exportedAt = snap.ExportedAt, + studentCount = snap.Meta.StudentCount }); + }); + g.MapGet("/", (ClaimsPrincipal user, ReadableSnapshotStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var snap = store.Load(uid); + return snap is null ? Results.NotFound("Kein Snapshot vorhanden.") : Results.Ok(snap); + }); + } + + // ── Plain Sync (WebApp schreibt Events) ─────────────────────────────────── + + public static void MapPlainSyncEndpoints(this WebApplication app) + { + var g = app.MapGroup("/api/sync/plain").RequireAuthorization(); + g.MapPost("/push", ([FromBody] List events, + ClaimsPrincipal user, PlainEventStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events)); + }); + } + + // ── JWT ─────────────────────────────────────────────────────────────────── + + private static string Jwt(string userId, string secret) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + claims: [new(ClaimTypes.NameIdentifier, userId), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())], + expires: DateTime.UtcNow.AddDays(30), + signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + +public record LoginRequest(string Username, string Password); +public record RegisterRequest(string Username, string Password, string DisplayName); diff --git a/LehrerApp.Api/EventStore.cs b/LehrerApp.Api/EventStore.cs new file mode 100644 index 0000000..82e4999 --- /dev/null +++ b/LehrerApp.Api/EventStore.cs @@ -0,0 +1,84 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +/// +/// Append-only Event-Log pro User. Server versteht Payload nicht. +/// +public class EventStore(string dataPath) : IDisposable +{ + private readonly Dictionary _dbs = new(); + private readonly Lock _lock = new(); + + public PushResponse Push(string userId, List events) + { + var col = GetCol(userId); + var seq = LastSeq(col); + var rejects = new List(); + foreach (var e in events.OrderBy(e => e.Timestamp)) + { + var recent = col.FindOne(x => + x.EntityType == e.EntityType && x.EntityId == e.EntityId && + x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30)); + if (recent is not null) { rejects.Add(e.EventId); continue; } + col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId, + DeviceType = e.DeviceType, Timestamp = e.Timestamp, + ClientSeq = e.SequenceNr, ServerSeq = ++seq, + EntityType = e.EntityType, EntityId = e.EntityId, + Operation = e.Operation, Payload = e.Payload }); + } + return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects }; + } + + public PullResponse Pull(string userId, long since, string requestingDeviceId) + { + var col = GetCol(userId); + var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId) + .OrderBy(e => e.ServerSeq).Take(500) + .Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId, + DeviceType = e.DeviceType, Timestamp = e.Timestamp, + SequenceNr = e.ServerSeq, EntityType = e.EntityType, + EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload }) + .ToList(); + return new() { Events = events, ServerSequenceNr = LastSeq(col) }; + } + + private ILiteCollection GetCol(string userId) + { + lock (_lock) + { + if (!_dbs.TryGetValue(userId, out var db)) + { + var safe = string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-')); + db = new LiteDatabase(Path.Combine(dataPath, $"{safe}.db")); + _dbs[userId] = db; + } + var col = db.GetCollection("events"); + col.EnsureIndex(x => x.ServerSeq); + return col; + } + } + + private static long LastSeq(ILiteCollection col) + { + var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSeq), Query.Descending)); + return last?.ServerSeq ?? 0; + } + + public void Dispose() { foreach (var db in _dbs.Values) db.Dispose(); } +} + +internal class ServerEvent +{ + public Guid EventId { get; set; } + public string DeviceId { get; set; } = ""; + public DeviceType DeviceType { get; set; } + public DateTime Timestamp { get; set; } + public long ClientSeq { get; set; } + public long ServerSeq { get; set; } + public string EntityType { get; set; } = ""; + public string EntityId { get; set; } = ""; + public string Operation { get; set; } = ""; + public string Payload { get; set; } = ""; +} diff --git a/LehrerApp.Api/LehrerApp.Api.csproj b/LehrerApp.Api/LehrerApp.Api.csproj new file mode 100644 index 0000000..7bce219 --- /dev/null +++ b/LehrerApp.Api/LehrerApp.Api.csproj @@ -0,0 +1,14 @@ + + + net10.0 + Linux + + + + + + + + + + diff --git a/LehrerApp.Api/PlainEventStore.cs b/LehrerApp.Api/PlainEventStore.cs new file mode 100644 index 0000000..12f035b --- /dev/null +++ b/LehrerApp.Api/PlainEventStore.cs @@ -0,0 +1,34 @@ +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +public class PlainEventStore(EventStore eventStore) +{ + private static readonly HashSet Allowed = + ["Grade", "ExamResult", "WorkTask", "Lesson"]; + + public PlainPushResponse Push(string userId, List events) + { + var permitted = events.Where(e => Allowed.Contains(e.EntityType)).ToList(); + var rejected = events.Where(e => !Allowed.Contains(e.EntityType)) + .Select(e => e.EventId).ToList(); + if (permitted.Count == 0) return new() { Success = true, RejectedEventIds = rejected }; + + var syncEvents = permitted.Select(e => new SyncEvent + { + EventId = e.EventId, DeviceId = e.DeviceId, + DeviceType = DeviceType.Companion, + Timestamp = e.Timestamp, SequenceNr = 0, + EntityType = e.EntityType, EntityId = e.EntityId, + Operation = e.Operation, Payload = e.Payload, + }).ToList(); + + var result = eventStore.Push(userId, syncEvents); + return new() + { + Success = result.Success, + ServerSequenceNr = result.ServerSequenceNr, + RejectedEventIds = [.. result.ConflictingEventIds, .. rejected], + }; + } +} diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs new file mode 100644 index 0000000..0d47454 --- /dev/null +++ b/LehrerApp.Api/Program.cs @@ -0,0 +1,42 @@ +using System.Text; +using LehrerApp.Api; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseKestrel(o => +{ + var port = builder.Configuration.GetValue("Api:Port", 5000); + o.ListenAnyIP(port); +}); + +var secret = builder.Configuration["JWT_SECRET"] + ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => o.TokenValidationParameters = new() + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), + ValidateIssuer = false, ValidateAudience = false, + ClockSkew = TimeSpan.FromMinutes(5), + }); +builder.Services.AddAuthorization(); + +var data = builder.Configuration["Api:DataPath"] ?? "./data"; +builder.Services.AddSingleton(_ => new EventStore(data)); +builder.Services.AddSingleton(_ => new SnapshotStore(data)); +builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data)); +builder.Services.AddSingleton(sp => + new PlainEventStore(sp.GetRequiredService())); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapAuthEndpoints(secret); +app.MapSyncEndpoints(); +app.MapSnapshotEndpoints(); +app.MapReadableSnapshotEndpoints(); +app.MapPlainSyncEndpoints(); +app.Run(); diff --git a/LehrerApp.Api/ReadableSnapshot.cs b/LehrerApp.Api/ReadableSnapshot.cs new file mode 100644 index 0000000..b767af7 --- /dev/null +++ b/LehrerApp.Api/ReadableSnapshot.cs @@ -0,0 +1,18 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Api; + +public class ReadableSnapshot +{ + public DateTime ExportedAt { get; set; } + public ReadableSnapshotMeta Meta { get; set; } = new(); + public List Groups { get; set; } = []; + public List Students { get; set; } = []; + public List Enrollments { get; set; } = []; +} + +public class ReadableSnapshotMeta +{ + public int StudentCount { get; set; } + public int GroupCount { get; set; } +} diff --git a/LehrerApp.Api/ReadableSnapshotStore.cs b/LehrerApp.Api/ReadableSnapshotStore.cs new file mode 100644 index 0000000..68de7a8 --- /dev/null +++ b/LehrerApp.Api/ReadableSnapshotStore.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using LehrerApp.Core.Models; + +namespace LehrerApp.Api; + +public class ReadableSnapshotStore(string dataPath) +{ + private readonly string _path = Path.Combine(dataPath, "readable"); + private static readonly JsonSerializerOptions _opts = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public void Store(string userId, ReadableSnapshot snap) + { + Directory.CreateDirectory(_path); + File.WriteAllText(FilePath(userId), JsonSerializer.Serialize(snap, _opts)); + } + public ReadableSnapshot? Load(string userId) + { + var p = FilePath(userId); + return File.Exists(p) ? JsonSerializer.Deserialize(File.ReadAllText(p), _opts) : null; + } + private string FilePath(string userId) => + Path.Combine(_path, $"{string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'))}.json"); +} diff --git a/LehrerApp.Api/SnapshotStore.cs b/LehrerApp.Api/SnapshotStore.cs new file mode 100644 index 0000000..7d2a4e9 --- /dev/null +++ b/LehrerApp.Api/SnapshotStore.cs @@ -0,0 +1,64 @@ +using LiteDB; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Api; + +public class SnapshotStore(string dataPath) : IDisposable +{ + private readonly LiteDatabase _db = + new(Path.Combine(dataPath, "snapshots.db")); + private readonly Timer _cleanup; + + public SnapshotStore(string dataPath, bool unused = false) : this(dataPath) + { + Directory.CreateDirectory(dataPath); + _cleanup = new(_ => Clean(), null, TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + } + + private ILiteCollection Col => _db.GetCollection("snap"); + + public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req) + { + Col.DeleteMany(e => e.UserId == userId); + var code = NewCode(); + var entry = new SnapshotEntry { Code = code, UserId = userId, + EncryptedPayload = req.EncryptedPayload, + EncryptedSyncKey = req.EncryptedSyncKey, + SourceDeviceType = req.DeviceType, + CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(24) }; + Col.Insert(entry); + return new() { Code = code, ExpiresAt = entry.ExpiresAt }; + } + + public SnapshotDownloadResponse? Retrieve(string userId, string code) + { + var e = Col.FindOne(x => x.UserId == userId && x.Code == code.ToUpperInvariant()); + if (e is null || e.ExpiresAt < DateTime.UtcNow) { if (e is not null) Col.Delete(e.Id); return null; } + Col.Delete(e.Id); + return new() { EncryptedPayload = e.EncryptedPayload, EncryptedSyncKey = e.EncryptedSyncKey, + CreatedAt = e.CreatedAt, SourceDeviceType = e.SourceDeviceType }; + } + + private void Clean() => Col.DeleteMany(e => e.ExpiresAt < DateTime.UtcNow); + + private static string NewCode() + { + string[] animals = ["TIGER","ADLER","DACHS","LUCHS","FALKE","IGEL","ELCH","FUCHS","RABE","WOLF","BISON","LAMM","EULE","BIBER","STORCH"]; + string[] colors = ["BLAU","GRUEN","ROT","GOLD","GRAU","CYAN","ROSA","LILA","SAND","MINT"]; + return $"{animals[Random.Shared.Next(animals.Length)]}-{Random.Shared.Next(10,99)}-{colors[Random.Shared.Next(colors.Length)]}"; + } + + public void Dispose() { _cleanup?.Dispose(); _db.Dispose(); } +} + +internal class SnapshotEntry +{ + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string Code { get; set; } = ""; + public string UserId { get; set; } = ""; + public string EncryptedPayload { get; set; } = ""; + public string EncryptedSyncKey { get; set; } = ""; + public DeviceType SourceDeviceType { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime ExpiresAt { get; set; } +} diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs new file mode 100644 index 0000000..9005d82 --- /dev/null +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -0,0 +1,87 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Interfaces; + +public interface IStudentRepository +{ + Student? GetById(Guid id); + List GetAll(bool includeInactive = false); + List GetByGroup(Guid groupId, string schoolYear); + void Save(Student student); + void Delete(Guid id); +} +public interface IGroupRepository +{ + LearningGroup? GetById(Guid id); + List GetAll(); + List GetBySchoolYear(string schoolYear); + void Save(LearningGroup group); + void Delete(Guid id); +} +public interface IEnrollmentRepository +{ + List GetByStudent(Guid studentId); + List GetByGroup(Guid groupId); + List GetByGroupAndYear(Guid groupId, string schoolYear); + void Save(Enrollment enrollment); + void Delete(Guid id); +} +public interface IExamRepository +{ + Exam? GetById(Guid id); + List GetByGroup(Guid groupId); + void Save(Exam exam); + void Delete(Guid id); +} +public interface IExamResultRepository +{ + List GetByExam(Guid examId); + List GetByStudent(Guid studentId); + ExamResult? GetByExamAndStudent(Guid examId, Guid studentId); + void Save(ExamResult result); + void SaveMany(List results); +} +public interface IGradeRepository +{ + List GetByStudentAndGroup(Guid studentId, Guid groupId); + List GetByGroup(Guid groupId); + void Save(Grade grade); + void Delete(Guid id); +} +public interface IUnitRepository +{ + Unit? GetById(Guid id); + List GetByGroup(Guid groupId); + void Save(Unit unit); + void Delete(Guid id); +} +public interface ILessonRepository +{ + List GetByUnit(Guid unitId); + List GetByGroupAndDate(Guid groupId, DateOnly date); + List GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to); + void Save(Lesson lesson); + void Delete(Guid id); +} +public interface IDocumentationRepository +{ + List GetByStudent(Guid studentId); + List GetByStudentAndType(Guid studentId, DocumentationType type); + void Save(Documentation doc); + void Delete(Guid id); +} +public interface IWorkTaskRepository +{ + List GetByStatus(WorkTaskStatus status); + List GetAll(); + void Save(WorkTask task); + void Delete(Guid id); +} +public interface ITimeEntryRepository +{ + List GetByDate(DateOnly date); + List GetByDateRange(DateOnly from, DateOnly to); + List GetByTask(Guid taskId); + void Save(TimeEntry entry); + void Delete(Guid id); +} diff --git a/LehrerApp.Core/LehrerApp.Core.csproj b/LehrerApp.Core/LehrerApp.Core.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/LehrerApp.Core/LehrerApp.Core.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs new file mode 100644 index 0000000..36cff29 --- /dev/null +++ b/LehrerApp.Core/Models/Exam.cs @@ -0,0 +1,42 @@ +namespace LehrerApp.Core.Models; + +public class Exam +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Title { get; set; } = ""; + public DateOnly Date { get; set; } + public string Subject { get; set; } = ""; + public int? ExamNumber { get; set; } + public List Tasks { get; set; } = []; + public List GradingKey { get; set; } = []; + public ExamStatus Status { get; set; } = ExamStatus.Planned; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class ExamTask +{ + public int Nr { get; set; } + public string? Title { get; set; } + public double MaxPoints { get; set; } + public double Weight { get; set; } = 1.0; +} +public class GradingKeyEntry +{ + public string Grade { get; set; } = ""; + public double MinPercent { get; set; } +} +public class ExamResult +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ExamId { get; set; } + public Guid StudentId { get; set; } + public List Points { get; set; } = []; + public double TotalPoints { get; set; } + public string? Grade { get; set; } + public bool Absent { get; set; } + public string? Comment { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public enum ExamStatus { Planned, Conducted, Graded, Returned } diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs new file mode 100644 index 0000000..8e77cb6 --- /dev/null +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -0,0 +1,26 @@ +namespace LehrerApp.Core.Models; + +public class LearningGroup +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + public GroupType Type { get; set; } + public string? Subject { get; set; } + public string SchoolYear { get; set; } = ""; + public int GradeLevel { get; set; } + public GradingSystem GradingSystem { get; set; } + public int? HoursPerWeek { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class Enrollment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + public string SchoolYear { get; set; } = ""; + public DateOnly EnrolledAt { get; set; } = DateOnly.FromDateTime(DateTime.Today); + public DateOnly? LeftAt { get; set; } +} +public enum GroupType { Class, Course } +public enum GradingSystem { Grades1To6, Points0To15 } diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs new file mode 100644 index 0000000..1b24ca5 --- /dev/null +++ b/LehrerApp.Core/Models/Planning.cs @@ -0,0 +1,50 @@ +namespace LehrerApp.Core.Models; + +public class Grade +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + public string SchoolYear { get; set; } = ""; + public GradeCategory Category { get; set; } + public string Value { get; set; } = ""; + public DateOnly Date { get; set; } + public double Weight { get; set; } = 1.0; + public string? Note { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} +public enum GradeCategory { Oral, Homework, Participation, Project, Other } + +public class Unit +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Title { get; set; } = ""; + public string Subject { get; set; } = ""; + public string SchoolYear { get; set; } = ""; + public DateOnly? StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public List Competencies { get; set; } = []; + public UnitStatus Status { get; set; } = UnitStatus.Planned; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class Lesson +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UnitId { get; set; } + public Guid GroupId { get; set; } + public DateOnly Date { get; set; } + public int? LessonNumber { get; set; } + public string Topic { get; set; } = ""; + public string? Phase { get; set; } + public List Methods { get; set; } = []; + public List Materials { get; set; } = []; + public string? Homework { get; set; } + public string? Reflection { get; set; } + public LessonStatus Status { get; set; } = LessonStatus.Planned; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public enum UnitStatus { Planned, Active, Completed } +public enum LessonStatus { Planned, Conducted } diff --git a/LehrerApp.Core/Models/Student.cs b/LehrerApp.Core/Models/Student.cs new file mode 100644 index 0000000..4780030 --- /dev/null +++ b/LehrerApp.Core/Models/Student.cs @@ -0,0 +1,24 @@ +namespace LehrerApp.Core.Models; + +public class Student +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string FirstName { get; set; } = ""; + public string LastName { get; set; } = ""; + public string FullName => $"{LastName}, {FirstName}"; + public DateOnly? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + public List Contacts { get; set; } = []; + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class Contact +{ + public string Name { get; set; } = ""; + public string Relation { get; set; } = ""; + public string? Phone { get; set; } + public string? Email { get; set; } +} +public enum Gender { M, W, D } diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs new file mode 100644 index 0000000..9e8acf8 --- /dev/null +++ b/LehrerApp.Core/Models/Workload.cs @@ -0,0 +1,61 @@ +namespace LehrerApp.Core.Models; + +public class Documentation +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid? GroupId { get; set; } + public DocumentationType Type { get; set; } + public DateOnly Date { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public List Participants { get; set; } = []; + public AbsenceData? AbsenceData { get; set; } + public SupportData? SupportData { get; set; } + public bool IsConfidential { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class AbsenceData +{ + public int LessonCount { get; set; } + public bool Excused { get; set; } + public string? Reason { get; set; } +} +public class SupportData +{ + public List Measures { get; set; } = []; + public DateOnly? ReviewDate { get; set; } + public SupportStatus Status { get; set; } = SupportStatus.Active; +} +public enum DocumentationType { Conversation, Incident, SupportPlan, Absence } +public enum SupportStatus { Active, Completed, Paused } + +public class WorkTask +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = ""; + public TaskCategory Category { get; set; } + public Guid? GroupId { get; set; } + public DateOnly? DueDate { get; set; } + public int? EstimatedMinutes { get; set; } + public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open; + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} +public class TimeEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? TaskId { get; set; } + public string Category { get; set; } = ""; + public Guid? GroupId { get; set; } + public DateOnly Date { get; set; } + public TimeOnly? StartTime { get; set; } + public TimeOnly? EndTime { get; set; } + public int DurationMinutes { get; set; } + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} +public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other } +public enum WorkTaskStatus { Open, InProgress, Done } diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs new file mode 100644 index 0000000..1b8377f --- /dev/null +++ b/LehrerApp.Core/Services/GradingService.cs @@ -0,0 +1,52 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public class GradingService +{ + public string CalculateGrade(double achieved, double maximum, List key) + { + if (maximum <= 0) return "-"; + var percent = achieved / maximum * 100.0; + return key.OrderByDescending(k => k.MinPercent) + .FirstOrDefault(k => percent >= k.MinPercent) + ?.Grade ?? key.OrderBy(k => k.MinPercent).First().Grade; + } + public static List DefaultKey1To6() => + [ + new() { Grade = "1", MinPercent = 87.5 }, + new() { Grade = "2", MinPercent = 75.0 }, + new() { Grade = "3", MinPercent = 62.5 }, + new() { Grade = "4", MinPercent = 50.0 }, + new() { Grade = "5", MinPercent = 25.0 }, + new() { Grade = "6", MinPercent = 0.0 }, + ]; + public static List DefaultKey0To15() => + [ + new() { Grade = "15", MinPercent = 95.0 }, + new() { Grade = "14", MinPercent = 90.0 }, + new() { Grade = "13", MinPercent = 85.0 }, + new() { Grade = "12", MinPercent = 80.0 }, + new() { Grade = "11", MinPercent = 75.0 }, + new() { Grade = "10", MinPercent = 70.0 }, + new() { Grade = "9", MinPercent = 65.0 }, + new() { Grade = "8", MinPercent = 60.0 }, + new() { Grade = "7", MinPercent = 55.0 }, + new() { Grade = "6", MinPercent = 50.0 }, + new() { Grade = "5", MinPercent = 45.0 }, + new() { Grade = "4", MinPercent = 40.0 }, + new() { Grade = "3", MinPercent = 33.0 }, + new() { Grade = "2", MinPercent = 27.0 }, + new() { Grade = "1", MinPercent = 20.0 }, + new() { Grade = "0", MinPercent = 0.0 }, + ]; + public double WeightedAverage(List<(string Grade, double Weight)> grades) + { + var numeric = grades + .Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight)) + .Where(g => g.Value.HasValue).ToList(); + if (numeric.Count == 0) return 0; + var total = numeric.Sum(g => g.Weight); + return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; + } +} diff --git a/LehrerApp.Core/Services/SchoolYearService.cs b/LehrerApp.Core/Services/SchoolYearService.cs new file mode 100644 index 0000000..ecbb295 --- /dev/null +++ b/LehrerApp.Core/Services/SchoolYearService.cs @@ -0,0 +1,22 @@ +namespace LehrerApp.Core.Services; + +public class SchoolYearService +{ + public string CurrentSchoolYear() + { + var now = DateTime.Today; + return FormatSchoolYear(now.Month >= 8 ? now.Year : now.Year - 1); + } + public string FormatSchoolYear(int startYear) => + $"{startYear}/{(startYear + 1) % 100:D2}"; + public DateOnly SchoolYearStart(string sy) => + new(int.Parse(sy.Split('/')[0]), 8, 1); + public DateOnly SchoolYearEnd(string sy) => + new(int.Parse(sy.Split('/')[0]) + 1, 7, 31); + public List RecentSchoolYears(int count = 5) + { + var now = DateTime.Today; + var cur = now.Month >= 8 ? now.Year : now.Year - 1; + return Enumerable.Range(0, count).Select(i => FormatSchoolYear(cur - i)).ToList(); + } +} diff --git a/LehrerApp.Data/LehrerApp.Data.csproj b/LehrerApp.Data/LehrerApp.Data.csproj new file mode 100644 index 0000000..93f7e26 --- /dev/null +++ b/LehrerApp.Data/LehrerApp.Data.csproj @@ -0,0 +1,9 @@ + + net10.0 + + + + + + + diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs new file mode 100644 index 0000000..3946c2b --- /dev/null +++ b/LehrerApp.Data/LiteDbContext.cs @@ -0,0 +1,60 @@ +using LiteDB; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data; + +/// +/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer. +/// +public class LiteDbContext : IDisposable +{ + private readonly LiteDatabase _db; + + public LiteDbContext(string databasePath) + { + _db = new LiteDatabase(new ConnectionString(databasePath) + { + Connection = ConnectionType.Shared, + }); + EnsureIndexes(); + } + + public ILiteCollection Students => _db.GetCollection("students"); + public ILiteCollection Groups => _db.GetCollection("groups"); + public ILiteCollection Enrollments => _db.GetCollection("enrollments"); + public ILiteCollection Exams => _db.GetCollection("exams"); + public ILiteCollection ExamResults => _db.GetCollection("exam_results"); + public ILiteCollection Grades => _db.GetCollection("grades"); + public ILiteCollection Units => _db.GetCollection("units"); + public ILiteCollection Lessons => _db.GetCollection("lessons"); + public ILiteCollection Documentation => _db.GetCollection("documentation"); + public ILiteCollection Tasks => _db.GetCollection("tasks"); + public ILiteCollection TimeEntries => _db.GetCollection("time_entries"); + + public void Checkpoint() => _db.Checkpoint(); + + private void EnsureIndexes() + { + Students.EnsureIndex(x => x.LastName); + Students.EnsureIndex(x => x.IsActive); + Groups.EnsureIndex(x => x.SchoolYear); + Enrollments.EnsureIndex(x => x.StudentId); + Enrollments.EnsureIndex(x => x.GroupId); + Enrollments.EnsureIndex(x => x.SchoolYear); + Exams.EnsureIndex(x => x.GroupId); + Exams.EnsureIndex(x => x.Status); + ExamResults.EnsureIndex(x => x.ExamId); + ExamResults.EnsureIndex(x => x.StudentId); + Grades.EnsureIndex(x => x.StudentId); + Grades.EnsureIndex(x => x.GroupId); + Units.EnsureIndex(x => x.GroupId); + Lessons.EnsureIndex(x => x.UnitId); + Lessons.EnsureIndex(x => x.GroupId); + Lessons.EnsureIndex(x => x.Date); + Documentation.EnsureIndex(x => x.StudentId); + Tasks.EnsureIndex(x => x.Status); + TimeEntries.EnsureIndex(x => x.Date); + } + + public void Dispose() => _db.Dispose(); +} diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs new file mode 100644 index 0000000..afe2f7a --- /dev/null +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -0,0 +1,135 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Data.Repositories; + +public class StudentRepository(LiteDbContext db) : IStudentRepository +{ + public Student? GetById(Guid id) => db.Students.FindById(id); + public List GetAll(bool includeInactive = false) => + (includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive)) + .OrderBy(s => s.LastName).ToList(); + public List GetByGroup(Guid groupId, string schoolYear) + { + var ids = db.Enrollments + .Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear) + .Select(e => e.StudentId).ToHashSet(); + return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); + } + public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); } + public void Delete(Guid id) => db.Students.Delete(id); +} + +public class GroupRepository(LiteDbContext db) : IGroupRepository +{ + public LearningGroup? GetById(Guid id) => db.Groups.FindById(id); + public List GetAll() => + db.Groups.FindAll().OrderBy(g => g.SchoolYear).ThenBy(g => g.Name).ToList(); + public List GetBySchoolYear(string schoolYear) => + db.Groups.Find(g => g.SchoolYear == schoolYear).OrderBy(g => g.Name).ToList(); + public void Save(LearningGroup g) { g.UpdatedAt = DateTime.UtcNow; db.Groups.Upsert(g); } + public void Delete(Guid id) => db.Groups.Delete(id); +} + +public class EnrollmentRepository(LiteDbContext db) : IEnrollmentRepository +{ + public List GetByStudent(Guid id) => + db.Enrollments.Find(e => e.StudentId == id).ToList(); + public List GetByGroup(Guid id) => + db.Enrollments.Find(e => e.GroupId == id).ToList(); + public List GetByGroupAndYear(Guid groupId, string sy) => + db.Enrollments.Find(e => e.GroupId == groupId && e.SchoolYear == sy).ToList(); + public void Save(Enrollment e) => db.Enrollments.Upsert(e); + public void Delete(Guid id) => db.Enrollments.Delete(id); +} + +public class ExamRepository(LiteDbContext db) : IExamRepository +{ + public Exam? GetById(Guid id) => db.Exams.FindById(id); + public List GetByGroup(Guid groupId) => + db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList(); + public void Save(Exam e) { e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); } + public void Delete(Guid id) => db.Exams.Delete(id); +} + +public class ExamResultRepository(LiteDbContext db) : IExamResultRepository +{ + public List GetByExam(Guid id) => + db.ExamResults.Find(r => r.ExamId == id).ToList(); + public List GetByStudent(Guid id) => + db.ExamResults.Find(r => r.StudentId == id).ToList(); + public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) => + db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId); + public void Save(ExamResult r) { r.UpdatedAt = DateTime.UtcNow; db.ExamResults.Upsert(r); } + public void SaveMany(List results) + { + var now = DateTime.UtcNow; + foreach (var r in results) r.UpdatedAt = now; + db.ExamResults.Upsert(results); + } +} + +public class GradeRepository(LiteDbContext db) : IGradeRepository +{ + public List GetByStudentAndGroup(Guid sid, Guid gid) => + db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList(); + public List GetByGroup(Guid id) => + db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList(); + public void Save(Grade g) => db.Grades.Upsert(g); + public void Delete(Guid id) => db.Grades.Delete(id); +} + +public class UnitRepository(LiteDbContext db) : IUnitRepository +{ + public Unit? GetById(Guid id) => db.Units.FindById(id); + public List GetByGroup(Guid id) => + db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList(); + public void Save(Unit u) { u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); } + public void Delete(Guid id) => db.Units.Delete(id); +} + +public class LessonRepository(LiteDbContext db) : ILessonRepository +{ + public List GetByUnit(Guid id) => + db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ToList(); + public List GetByGroupAndDate(Guid gid, DateOnly date) => + db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList(); + public List GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => + db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to) + .OrderBy(l => l.Date).ToList(); + public void Save(Lesson l) { l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); } + public void Delete(Guid id) => db.Lessons.Delete(id); +} + +public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository +{ + public List GetByStudent(Guid id) => + db.Documentation.Find(d => d.StudentId == id).OrderByDescending(d => d.Date).ToList(); + public List GetByStudentAndType(Guid sid, DocumentationType type) => + db.Documentation.Find(d => d.StudentId == sid && d.Type == type) + .OrderByDescending(d => d.Date).ToList(); + public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); } + public void Delete(Guid id) => db.Documentation.Delete(id); +} + +public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository +{ + public List GetByStatus(WorkTaskStatus s) => + db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); + public List GetAll() => + db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); + public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); } + public void Delete(Guid id) => db.Tasks.Delete(id); +} + +public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository +{ + public List GetByDate(DateOnly date) => + db.TimeEntries.Find(e => e.Date == date).OrderBy(e => e.StartTime).ToList(); + public List GetByDateRange(DateOnly from, DateOnly to) => + db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList(); + public List GetByTask(Guid id) => + db.TimeEntries.Find(e => e.TaskId == id).ToList(); + public void Save(TimeEntry e) => db.TimeEntries.Upsert(e); + public void Delete(Guid id) => db.TimeEntries.Delete(id); +} diff --git a/LehrerApp.Desktop/App.axaml b/LehrerApp.Desktop/App.axaml new file mode 100644 index 0000000..03585de --- /dev/null +++ b/LehrerApp.Desktop/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs new file mode 100644 index 0000000..5ea7ee9 --- /dev/null +++ b/LehrerApp.Desktop/App.axaml.cs @@ -0,0 +1,61 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop; + +public class App : Application +{ + public static IServiceProvider Services { get; private set; } = null!; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + Services = AppBootstrapper.BuildServices(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainVm = Services.GetRequiredService(); + WireCallbacks(mainVm); + desktop.MainWindow = new MainWindow { DataContext = mainVm }; + } + + base.OnFrameworkInitializationCompleted(); + } + + private static void WireCallbacks(MainWindowViewModel main) + { + // GroupList → GroupDetail + var gl = Services.GetRequiredService(); + gl.OnNavigateToDetail = id => main.NavigateToGroupDetail(id); + gl.OnAddGroup = () => ShowAddGroupDialog(gl); + + // Dashboard → GroupDetail (Chips) + var dash = Services.GetRequiredService(); + dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id); + + // StudentList → StudentDetail + var sl = Services.GetRequiredService(); + sl.OnNavigateToDetail = id => main.NavigateToStudent(id); + } + + private static async void ShowAddGroupDialog(GroupListViewModel groupList) + { + var vm = new ViewModels.Groups.AddGroupDialogViewModel( + Services.GetRequiredService(), + Services.GetRequiredService()); + var dialog = new Views.Groups.AddGroupDialog { DataContext = vm }; + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var ok = await dialog.ShowDialog(owner); + if (ok) groupList.LoadGroups(); + } + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs new file mode 100644 index 0000000..7e32192 --- /dev/null +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -0,0 +1,138 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Services; +using LehrerApp.Data; +using LehrerApp.Data.Repositories; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Sync; +using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop; + +/// +/// Konfiguriert den DI-Container für den Desktop-Client. +/// +/// WICHTIG: Alle Repositories sind Singleton – eine LiteDB-Datei pro Nutzer. +/// Sync-Services werden nur registriert wenn eine Server-URL konfiguriert ist. +/// +public static class AppBootstrapper +{ + public static string DbPath { get; private set; } = ""; + public static string AppDataPath { get; private set; } = ""; + + public static IServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + // ── Pfade ───────────────────────────────────────────────────────────── + var appData = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "LehrerApp"); + Directory.CreateDirectory(appData); + AppDataPath = appData; + DbPath = Path.Combine(appData, "lehrerapp.db"); + var queuePath = Path.Combine(appData, "syncqueue.db"); + var keyPath = Path.Combine(appData, "sync.key"); + + // ── Datenbank ───────────────────────────────────────────────────────── + services.AddSingleton(_ => new LiteDbContext(DbPath)); + + // ── Repositories ────────────────────────────────────────────────────── + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ── Services ────────────────────────────────────────────────────────── + services.AddSingleton(); + services.AddSingleton(); + + // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── + services.AddSingleton(_ => new EventQueue(queuePath)); + services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService())); + services.AddSingleton(_ => + { + var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, keyPath); + return key; + }); + + var serverUrl = LoadServerUrl(appData); + var deviceId = LoadOrCreateDeviceId(appData); + + if (!string.IsNullOrEmpty(serverUrl)) + { + services.AddSingleton(sp => new SyncEngine( + sp.GetRequiredService(), + sp.GetRequiredService(), + BuildHttp(serverUrl, appData), + new SyncConfig + { + ServerUrl = serverUrl, + DeviceId = deviceId, + DeviceType = DeviceType.Desktop, + AutoSyncIntervalMinutes = 5, + })); + + services.AddSingleton(sp => new SnapshotService( + BuildHttp(serverUrl, appData), + sp.GetRequiredService(), + sp.GetRequiredService(), + DeviceType.Desktop, DbPath, keyPath)); + } + + // ── ViewModels ──────────────────────────────────────────────────────── + // Singleton: einmal erstellt, überall dieselbe Instanz + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Transient: neue Instanz pro Navigation (für Detailseiten) + services.AddTransient(); + services.AddTransient(); + + return services.BuildServiceProvider(); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static HttpClient BuildHttp(string url, string appData) + { + var http = new HttpClient { BaseAddress = new Uri(url) }; + var tokenPath = Path.Combine(appData, "auth.token"); + if (File.Exists(tokenPath)) + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", File.ReadAllText(tokenPath).Trim()); + return http; + } + + public static string LoadServerUrl(string? path = null) => + File.Exists(Path.Combine(path ?? AppDataPath, "server.txt")) + ? File.ReadAllText(Path.Combine(path ?? AppDataPath, "server.txt")).Trim() + : ""; + + public static void SaveServerUrl(string url) => + File.WriteAllText(Path.Combine(AppDataPath, "server.txt"), url); + + private static string LoadOrCreateDeviceId(string appData) + { + var p = Path.Combine(appData, "device.id"); + if (File.Exists(p)) return File.ReadAllText(p).Trim(); + var id = Guid.NewGuid().ToString(); + File.WriteAllText(p, id); + return id; + } +} diff --git a/LehrerApp.Desktop/Assets/README.txt b/LehrerApp.Desktop/Assets/README.txt new file mode 100644 index 0000000..813a9bc --- /dev/null +++ b/LehrerApp.Desktop/Assets/README.txt @@ -0,0 +1 @@ +LehrerApp Assets – Icons und Bilder hier ablegen. diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj new file mode 100644 index 0000000..63f5a40 --- /dev/null +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -0,0 +1,25 @@ + + + WinExe + net10.0 + true + app.manifest + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Program.cs b/LehrerApp.Desktop/Program.cs new file mode 100644 index 0000000..acd39a9 --- /dev/null +++ b/LehrerApp.Desktop/Program.cs @@ -0,0 +1,16 @@ +using Avalonia; + +namespace LehrerApp.Desktop; + +class Program +{ + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..c4f4f14 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -0,0 +1,72 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class DashboardViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly ILessonRepository _lessons; + private readonly IWorkTaskRepository _tasks; + private readonly SchoolYearService _sy; + + [ObservableProperty] private string _greeting = ""; + [ObservableProperty] private string _currentDate = ""; + [ObservableProperty] private string _currentSchoolYear = ""; + + public ObservableCollection TodaysLessons { get; } = []; + public ObservableCollection OpenTasks { get; } = []; + public ObservableCollection CurrentGroups { get; } = []; + + // Navigation-Callback – wird von App.axaml.cs verdrahtet + public Action? OnNavigateToGroup { get; set; } + + public DashboardViewModel(IGroupRepository groups, ILessonRepository lessons, + IWorkTaskRepository tasks, SchoolYearService sy) + { + _groups = groups; _lessons = lessons; _tasks = tasks; _sy = sy; + Load(); + } + + private void Load() + { + var now = DateTime.Now; + var today = DateOnly.FromDateTime(now); + CurrentDate = now.ToString("dddd, d. MMMM yyyy", new CultureInfo("de-DE")); + CurrentSchoolYear = _sy.CurrentSchoolYear(); + Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend"; + + TodaysLessons.Clear(); + var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id); + foreach (var l in groups.Keys.SelectMany(gid => _lessons.GetByGroupAndDate(gid, today)) + .OrderBy(l => l.LessonNumber)) + { + if (groups.TryGetValue(l.GroupId, out var g)) + TodaysLessons.Add(new() { GroupName = g.Name, Topic = l.Topic }); + } + + OpenTasks.Clear(); + foreach (var t in _tasks.GetByStatus(WorkTaskStatus.Open) + .Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress)) + .OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5)) + OpenTasks.Add(new() { Title = t.Title, + DueDate = t.DueDate?.ToString("dd.MM.") ?? "", + IsOverdue = t.DueDate.HasValue && t.DueDate < today }); + + CurrentGroups.Clear(); + foreach (var g in groups.Values.OrderBy(g => g.Name)) + CurrentGroups.Add(new() { GroupId = g.Id, Name = g.Name, Subject = g.Subject ?? "" }); + } + + [RelayCommand] private void OpenGroup(GroupChip? c) { if (c is not null) OnNavigateToGroup?.Invoke(c.GroupId); } + [RelayCommand] private void Refresh() => Load(); +} + +public class LessonItem { public string GroupName { get; set; } = ""; public string Topic { get; set; } = ""; } +public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } } +public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs new file mode 100644 index 0000000..6ed2cbe --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -0,0 +1,196 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Gruppenliste ────────────────────────────────────────────────────────────── + +public partial class GroupListViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly SchoolYearService _sy; + + public Action? OnNavigateToDetail { get; set; } + public Action? OnAddGroup { get; set; } + + [ObservableProperty] private string _selectedSchoolYear = ""; + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private GroupListItem? _selectedGroup; + + public ObservableCollection SchoolYears { get; } = []; + public ObservableCollection Groups { get; } = []; + + public GroupListViewModel(IGroupRepository groups, SchoolYearService sy) + { + _groups = groups; _sy = sy; + foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); + SelectedSchoolYear = sy.CurrentSchoolYear(); + } + + partial void OnSelectedSchoolYearChanged(string value) => LoadGroups(); + partial void OnSearchTextChanged(string value) => LoadGroups(); + partial void OnSelectedGroupChanged(GroupListItem? value) + { + if (value is not null) OnNavigateToDetail?.Invoke(value.Id); + } + + public void LoadGroups() + { + Groups.Clear(); + var all = _groups.GetBySchoolYear(SelectedSchoolYear); + var filtered = string.IsNullOrWhiteSpace(SearchText) ? all + : all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false)); + foreach (var g in filtered.OrderBy(g => g.Name)) + Groups.Add(new GroupListItem(g)); + } + + [RelayCommand] private void AddGroup() => OnAddGroup?.Invoke(); + [RelayCommand] private void Refresh() => LoadGroups(); +} + +public class GroupListItem +{ + public Guid Id { get; } + public string Name { get; } + public string Subject { get; } + public string DisplayName { get; } + public string TypeLabel { get; } + public string GradingLabel { get; } + + public GroupListItem(LearningGroup g) + { + Id = g.Id; + Name = g.Name; + Subject = g.Subject ?? ""; + TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs"; + GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15"; + DisplayName = string.IsNullOrEmpty(Subject) ? Name : $"{Name} · {Subject}"; + } +} + +// ── Gruppendetail ───────────────────────────────────────────────────────────── + +public partial class GroupDetailViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly IStudentRepository _students; + private readonly IExamRepository _exams; + private readonly IGradeRepository _grades; + + [ObservableProperty] private LearningGroup? _group; + [ObservableProperty] private string _groupTitle = ""; + [ObservableProperty] private string _groupSubtitle = ""; + [ObservableProperty] private int _studentCount; + + public ObservableCollection Students { get; } = []; + public ObservableCollection Exams { get; } = []; + + public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, + IExamRepository exams, IGradeRepository grades) + { + _groups = groups; _students = students; _exams = exams; _grades = grades; + } + + public void LoadGroup(Guid id) + { + Group = _groups.GetById(id); + if (Group is null) return; + GroupTitle = Group.Name; + GroupSubtitle = $"{Group.SchoolYear} · " + + $"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " + + $"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}"; + + Students.Clear(); + var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear); + StudentCount = enrolled.Count; + foreach (var s in enrolled) Students.Add(new StudentSummary(s)); + + Exams.Clear(); + foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e)); + } + + [RelayCommand] private void AddStudent() { /* TODO */ } + [RelayCommand] private void AddExam() { /* TODO */ } + [RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); } +} + +public class StudentSummary +{ + public Guid Id { get; } + public string FullName { get; } + public StudentSummary(Core.Models.Student s) { Id = s.Id; FullName = s.FullName; } +} + +public class ExamSummary +{ + public Guid Id { get; } + public string Title { get; } + public string Date { get; } + public string StatusLabel { get; } + public ExamSummary(Core.Models.Exam e) + { + Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); + StatusLabel = e.Status switch + { + ExamStatus.Planned => "Geplant", + ExamStatus.Conducted => "Durchgeführt", + ExamStatus.Graded => "Korrigiert", + ExamStatus.Returned => "Zurückgegeben", + _ => "", + }; + } +} + +// ── Dialog: Neue Lerngruppe anlegen ────────────────────────────────────────── + +public partial class AddGroupDialogViewModel : ObservableObject +{ + private readonly IGroupRepository _groups; + private readonly SchoolYearService _sy; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _subject = ""; + [ObservableProperty] private int _gradeLevel = 10; + [ObservableProperty] private GradingSystem _gradingSystem = GradingSystem.Grades1To6; + [ObservableProperty] private string _selectedSchoolYear = ""; + [ObservableProperty] private string _validationMessage = ""; + + public List SchoolYears { get; } + public LearningGroup? Result { get; private set; } + + public AddGroupDialogViewModel(IGroupRepository groups, SchoolYearService sy) + { + _groups = groups; _sy = sy; + SchoolYears = sy.RecentSchoolYears(3); + SelectedSchoolYear = sy.CurrentSchoolYear(); + // Notensystem automatisch nach Klassenstufe + PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(GradeLevel)) + GradingSystem = GradeLevel >= 11 ? GradingSystem.Points0To15 + : GradingSystem.Grades1To6; + }; + } + + [RelayCommand] + private void Save() + { + if (string.IsNullOrWhiteSpace(Name)) { ValidationMessage = "Name erforderlich."; return; } + if (GradeLevel is < 1 or > 13) { ValidationMessage = "Klassenstufe 1–13."; return; } + Result = new LearningGroup + { + Name = Name.Trim(), + Subject = string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim(), + Type = GroupType.Course, + GradeLevel = GradeLevel, + GradingSystem = GradingSystem, + SchoolYear = SelectedSchoolYear, + }; + _groups.Save(Result); + } +} diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..fb1c19d --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,66 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using LehrerApp.Desktop.ViewModels.Students; +using Microsoft.Extensions.DependencyInjection; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + private readonly IServiceProvider _services; + + [ObservableProperty] private ObservableObject? _currentPage; + [ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard; + [ObservableProperty] private string _currentSchoolYear = ""; + + public MainWindowViewModel(IServiceProvider services, + DashboardViewModel dashboard, SchoolYearService sy) + { + _services = services; + CurrentSchoolYear = sy.CurrentSchoolYear(); + CurrentPage = dashboard; + } + + [RelayCommand] + private void NavigateTo(NavItem item) + { + ActiveNavItem = item; + CurrentPage = item switch + { + NavItem.Dashboard => _services.GetRequiredService(), + NavItem.Groups => _services.GetRequiredService(), + NavItem.Students => _services.GetRequiredService(), + NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, + NavItem.Planner => new PlaceholderViewModel { Title = "Unterrichtsplanung", Icon = "📅" }, + NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" }, + NavItem.Settings => new PlaceholderViewModel { Title = "Einstellungen", Icon = "⚙️" }, + _ => CurrentPage, + }; + } + + public void NavigateToGroupDetail(Guid groupId) + { + ActiveNavItem = NavItem.Groups; + var vm = _services.GetRequiredService(); + vm.LoadGroup(groupId); + CurrentPage = vm; + } + + public void NavigateToStudent(Guid studentId) + { + ActiveNavItem = NavItem.Students; + var vm = _services.GetRequiredService(); + vm.LoadStudent(studentId); + CurrentPage = vm; + } +} + +public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings } + +public partial class PlaceholderViewModel : ObservableObject +{ + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _icon = ""; +} diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs new file mode 100644 index 0000000..fab019b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -0,0 +1,131 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Students; + +public partial class StudentListViewModel : ObservableObject +{ + private readonly IStudentRepository _students; + public Action? OnNavigateToDetail { get; set; } + + [ObservableProperty] private string _searchText = ""; + [ObservableProperty] private bool _showInactive; + [ObservableProperty] private StudentListItem? _selectedStudent; + + public ObservableCollection Students { get; } = []; + + public StudentListViewModel(IStudentRepository students) + { + _students = students; + LoadStudents(); + } + + partial void OnSearchTextChanged(string value) => LoadStudents(); + partial void OnShowInactiveChanged(bool value) => LoadStudents(); + partial void OnSelectedStudentChanged(StudentListItem? value) + { + if (value is not null) OnNavigateToDetail?.Invoke(value.Id); + } + + public void LoadStudents() + { + Students.Clear(); + var all = _students.GetAll(ShowInactive); + var f = string.IsNullOrWhiteSpace(SearchText) ? all + : all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + foreach (var s in f) Students.Add(new StudentListItem(s)); + } + + [RelayCommand] private void AddStudent() { /* TODO */ } + [RelayCommand] private void Refresh() => LoadStudents(); +} + +public class StudentListItem +{ + public Guid Id { get; } + public string FullName { get; } + public string DateOfBirth { get; } + public StudentListItem(Student s) + { + Id = s.Id; FullName = s.FullName; + DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? ""; + } +} + +public partial class StudentDetailViewModel : ObservableObject +{ + private readonly IStudentRepository _students; + private readonly IEnrollmentRepository _enrollments; + private readonly IGroupRepository _groups; + private readonly IDocumentationRepository _docs; + + [ObservableProperty] private Student? _student; + [ObservableProperty] private string _studentTitle = ""; + [ObservableProperty] private bool _isEditing; + [ObservableProperty] private string _editFirstName = ""; + [ObservableProperty] private string _editLastName = ""; + + public ObservableCollection Enrollments { get; } = []; + public ObservableCollection Documentation { get; } = []; + + public StudentDetailViewModel(IStudentRepository students, + IEnrollmentRepository enrollments, IGroupRepository groups, + IDocumentationRepository docs) + { + _students = students; _enrollments = enrollments; + _groups = groups; _docs = docs; + } + + public void LoadStudent(Guid id) + { + Student = _students.GetById(id); + if (Student is null) return; + StudentTitle = Student.FullName; + EditFirstName = Student.FirstName; + EditLastName = Student.LastName; + + Enrollments.Clear(); + foreach (var e in _enrollments.GetByStudent(Student.Id)) + { + var g = _groups.GetById(e.GroupId); + if (g is null) continue; + Enrollments.Add(new() { SchoolYear = e.SchoolYear, GroupName = g.Name, Subject = g.Subject ?? "" }); + } + + Documentation.Clear(); + foreach (var d in _docs.GetByStudent(Student.Id)) + Documentation.Add(new() { Date = d.Date.ToString("dd.MM.yyyy"), Title = d.Title, + TypeLabel = d.Type switch + { + DocumentationType.Conversation => "Gespräch", + DocumentationType.Incident => "Vorkommnis", + DocumentationType.SupportPlan => "Förderplan", + DocumentationType.Absence => "Fehlzeit", + _ => "", + }, + IsConfidential = d.IsConfidential }); + } + + [RelayCommand] private void StartEdit() => IsEditing = true; + [RelayCommand] private void CancelEdit() + { + if (Student is null) return; + EditFirstName = Student.FirstName; EditLastName = Student.LastName; + IsEditing = false; + } + [RelayCommand] private void SaveEdit() + { + if (Student is null) return; + Student.FirstName = EditFirstName; Student.LastName = EditLastName; + _students.Save(Student); + StudentTitle = Student.FullName; + IsEditing = false; + } +} + +public class EnrollmentEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; } +public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } } diff --git a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs new file mode 100644 index 0000000..ab6b663 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs @@ -0,0 +1,43 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Sync; +using LehrerApp.Sync.Models; + +namespace LehrerApp.Desktop.ViewModels; + +public partial class SyncStatusViewModel : ObservableObject +{ + private readonly SyncEngine? _engine; + + [ObservableProperty] private string _statusText = "Kein Server konfiguriert"; + [ObservableProperty] private string _lastSyncText = ""; + [ObservableProperty] private bool _isSyncing; + [ObservableProperty] private bool _isServerConfigured; + [ObservableProperty] private int _pendingCount; + + public SyncStatusViewModel(SyncEngine? engine) + { + _engine = engine; + IsServerConfigured = engine is not null; + if (_engine is not null) _engine.StatusChanged += OnStatus; + } + + private void OnStatus(SyncStatus s) + { + IsSyncing = s.State == SyncState.Syncing; + PendingCount = s.PendingEvents; + StatusText = s.State switch + { + SyncState.Idle => PendingCount > 0 ? $"{PendingCount} ausstehend" : "Synchronisiert", + SyncState.Syncing => "Synchronisiere…", + SyncState.Offline => "Offline", + SyncState.Error => $"Fehler: {s.ErrorMessage}", + _ => "", + }; + LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie"; + } + + [RelayCommand(CanExecute = nameof(CanSync))] + private async Task SyncNow() { if (_engine is not null) await _engine.SyncNowAsync(); } + private bool CanSync() => _engine is not null && !IsSyncing; +} diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml new file mode 100644 index 0000000..bb69f40 --- /dev/null +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml.cs b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml.cs new file mode 100644 index 0000000..7fcb46e --- /dev/null +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml.cs @@ -0,0 +1,3 @@ +using Avalonia.Controls; +namespace LehrerApp.Desktop.Views.Dashboard; +public partial class DashboardView : UserControl { public DashboardView() => InitializeComponent(); } diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml new file mode 100644 index 0000000..b742df6 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +