init 1.0.0
This commit is contained in:
@@ -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
|
||||||
+77
@@ -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/
|
||||||
Vendored
+52
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+57
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||||
|
<NoWarn>CS8618</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Avalonia 12.0.4 – aktuell stabil (April 2026) -->
|
||||||
|
<PackageVersion Include="Avalonia" Version="12.0.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="12.0.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||||
|
|
||||||
|
<!-- MVVM & DI – .NET 10 kompatibel -->
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
|
||||||
|
<!-- Datenbank -->
|
||||||
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
|
|
||||||
|
<!-- API -->
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
|
||||||
|
|
||||||
|
<!-- Tests -->
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<SyncEvent> 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<PlainSyncEvent> 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);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using LiteDB;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only Event-Log pro User. Server versteht Payload nicht.
|
||||||
|
/// </summary>
|
||||||
|
public class EventStore(string dataPath) : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
public PushResponse Push(string userId, List<SyncEvent> events)
|
||||||
|
{
|
||||||
|
var col = GetCol(userId);
|
||||||
|
var seq = LastSeq(col);
|
||||||
|
var rejects = new List<Guid>();
|
||||||
|
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<ServerEvent> 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<ServerEvent>("events");
|
||||||
|
col.EnsureIndex(x => x.ServerSeq);
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long LastSeq(ILiteCollection<ServerEvent> 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; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
<PackageReference Include="LiteDB" />
|
||||||
|
<PackageReference Include="System.Text.Json" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Api;
|
||||||
|
|
||||||
|
public class PlainEventStore(EventStore eventStore)
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> Allowed =
|
||||||
|
["Grade", "ExamResult", "WorkTask", "Lesson"];
|
||||||
|
|
||||||
|
public PlainPushResponse Push(string userId, List<PlainSyncEvent> 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<int>("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<EventStore>(_ => new EventStore(data));
|
||||||
|
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||||
|
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||||
|
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||||
|
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapAuthEndpoints(secret);
|
||||||
|
app.MapSyncEndpoints();
|
||||||
|
app.MapSnapshotEndpoints();
|
||||||
|
app.MapReadableSnapshotEndpoints();
|
||||||
|
app.MapPlainSyncEndpoints();
|
||||||
|
app.Run();
|
||||||
@@ -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<LearningGroup> Groups { get; set; } = [];
|
||||||
|
public List<Student> Students { get; set; } = [];
|
||||||
|
public List<Enrollment> Enrollments { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReadableSnapshotMeta
|
||||||
|
{
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int GroupCount { get; set; }
|
||||||
|
}
|
||||||
@@ -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<ReadableSnapshot>(File.ReadAllText(p), _opts) : null;
|
||||||
|
}
|
||||||
|
private string FilePath(string userId) =>
|
||||||
|
Path.Combine(_path, $"{string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'))}.json");
|
||||||
|
}
|
||||||
@@ -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<SnapshotEntry> Col => _db.GetCollection<SnapshotEntry>("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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
public interface IStudentRepository
|
||||||
|
{
|
||||||
|
Student? GetById(Guid id);
|
||||||
|
List<Student> GetAll(bool includeInactive = false);
|
||||||
|
List<Student> GetByGroup(Guid groupId, string schoolYear);
|
||||||
|
void Save(Student student);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IGroupRepository
|
||||||
|
{
|
||||||
|
LearningGroup? GetById(Guid id);
|
||||||
|
List<LearningGroup> GetAll();
|
||||||
|
List<LearningGroup> GetBySchoolYear(string schoolYear);
|
||||||
|
void Save(LearningGroup group);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IEnrollmentRepository
|
||||||
|
{
|
||||||
|
List<Enrollment> GetByStudent(Guid studentId);
|
||||||
|
List<Enrollment> GetByGroup(Guid groupId);
|
||||||
|
List<Enrollment> GetByGroupAndYear(Guid groupId, string schoolYear);
|
||||||
|
void Save(Enrollment enrollment);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IExamRepository
|
||||||
|
{
|
||||||
|
Exam? GetById(Guid id);
|
||||||
|
List<Exam> GetByGroup(Guid groupId);
|
||||||
|
void Save(Exam exam);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IExamResultRepository
|
||||||
|
{
|
||||||
|
List<ExamResult> GetByExam(Guid examId);
|
||||||
|
List<ExamResult> GetByStudent(Guid studentId);
|
||||||
|
ExamResult? GetByExamAndStudent(Guid examId, Guid studentId);
|
||||||
|
void Save(ExamResult result);
|
||||||
|
void SaveMany(List<ExamResult> results);
|
||||||
|
}
|
||||||
|
public interface IGradeRepository
|
||||||
|
{
|
||||||
|
List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId);
|
||||||
|
List<Grade> GetByGroup(Guid groupId);
|
||||||
|
void Save(Grade grade);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IUnitRepository
|
||||||
|
{
|
||||||
|
Unit? GetById(Guid id);
|
||||||
|
List<Unit> GetByGroup(Guid groupId);
|
||||||
|
void Save(Unit unit);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface ILessonRepository
|
||||||
|
{
|
||||||
|
List<Lesson> GetByUnit(Guid unitId);
|
||||||
|
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
|
||||||
|
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
|
||||||
|
void Save(Lesson lesson);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IDocumentationRepository
|
||||||
|
{
|
||||||
|
List<Documentation> GetByStudent(Guid studentId);
|
||||||
|
List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type);
|
||||||
|
void Save(Documentation doc);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface IWorkTaskRepository
|
||||||
|
{
|
||||||
|
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||||
|
List<WorkTask> GetAll();
|
||||||
|
void Save(WorkTask task);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
|
public interface ITimeEntryRepository
|
||||||
|
{
|
||||||
|
List<TimeEntry> GetByDate(DateOnly date);
|
||||||
|
List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to);
|
||||||
|
List<TimeEntry> GetByTask(Guid taskId);
|
||||||
|
void Save(TimeEntry entry);
|
||||||
|
void Delete(Guid id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<ExamTask> Tasks { get; set; } = [];
|
||||||
|
public List<GradingKeyEntry> 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<double> 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 }
|
||||||
@@ -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 }
|
||||||
@@ -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<string> 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<string> Methods { get; set; } = [];
|
||||||
|
public List<string> 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 }
|
||||||
@@ -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<Contact> 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 }
|
||||||
@@ -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<string> 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<string> 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 }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
public class GradingService
|
||||||
|
{
|
||||||
|
public string CalculateGrade(double achieved, double maximum, List<GradingKeyEntry> 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<GradingKeyEntry> 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<GradingKeyEntry> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LiteDB" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using LiteDB;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||||||
|
/// </summary>
|
||||||
|
public class LiteDbContext : IDisposable
|
||||||
|
{
|
||||||
|
private readonly LiteDatabase _db;
|
||||||
|
|
||||||
|
public LiteDbContext(string databasePath)
|
||||||
|
{
|
||||||
|
_db = new LiteDatabase(new ConnectionString(databasePath)
|
||||||
|
{
|
||||||
|
Connection = ConnectionType.Shared,
|
||||||
|
});
|
||||||
|
EnsureIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
|
||||||
|
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
|
||||||
|
public ILiteCollection<Enrollment> Enrollments => _db.GetCollection<Enrollment>("enrollments");
|
||||||
|
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
|
||||||
|
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
|
||||||
|
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
|
||||||
|
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||||
|
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||||
|
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||||||
|
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||||
|
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("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();
|
||||||
|
}
|
||||||
@@ -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<Student> GetAll(bool includeInactive = false) =>
|
||||||
|
(includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive))
|
||||||
|
.OrderBy(s => s.LastName).ToList();
|
||||||
|
public List<Student> 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<LearningGroup> GetAll() =>
|
||||||
|
db.Groups.FindAll().OrderBy(g => g.SchoolYear).ThenBy(g => g.Name).ToList();
|
||||||
|
public List<LearningGroup> 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<Enrollment> GetByStudent(Guid id) =>
|
||||||
|
db.Enrollments.Find(e => e.StudentId == id).ToList();
|
||||||
|
public List<Enrollment> GetByGroup(Guid id) =>
|
||||||
|
db.Enrollments.Find(e => e.GroupId == id).ToList();
|
||||||
|
public List<Enrollment> 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<Exam> 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<ExamResult> GetByExam(Guid id) =>
|
||||||
|
db.ExamResults.Find(r => r.ExamId == id).ToList();
|
||||||
|
public List<ExamResult> 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<ExamResult> 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<Grade> GetByStudentAndGroup(Guid sid, Guid gid) =>
|
||||||
|
db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList();
|
||||||
|
public List<Grade> 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<Unit> 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<Lesson> GetByUnit(Guid id) =>
|
||||||
|
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ToList();
|
||||||
|
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
|
||||||
|
db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList();
|
||||||
|
public List<Lesson> 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<Documentation> GetByStudent(Guid id) =>
|
||||||
|
db.Documentation.Find(d => d.StudentId == id).OrderByDescending(d => d.Date).ToList();
|
||||||
|
public List<Documentation> 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<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||||
|
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
|
||||||
|
public List<WorkTask> 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<TimeEntry> GetByDate(DateOnly date) =>
|
||||||
|
db.TimeEntries.Find(e => e.Date == date).OrderBy(e => e.StartTime).ToList();
|
||||||
|
public List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to) =>
|
||||||
|
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
|
||||||
|
public List<TimeEntry> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LehrerApp.Desktop.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -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<MainWindowViewModel>();
|
||||||
|
WireCallbacks(mainVm);
|
||||||
|
desktop.MainWindow = new MainWindow { DataContext = mainVm };
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WireCallbacks(MainWindowViewModel main)
|
||||||
|
{
|
||||||
|
// GroupList → GroupDetail
|
||||||
|
var gl = Services.GetRequiredService<GroupListViewModel>();
|
||||||
|
gl.OnNavigateToDetail = id => main.NavigateToGroupDetail(id);
|
||||||
|
gl.OnAddGroup = () => ShowAddGroupDialog(gl);
|
||||||
|
|
||||||
|
// Dashboard → GroupDetail (Chips)
|
||||||
|
var dash = Services.GetRequiredService<DashboardViewModel>();
|
||||||
|
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
|
||||||
|
|
||||||
|
// StudentList → StudentDetail
|
||||||
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||||
|
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async void ShowAddGroupDialog(GroupListViewModel groupList)
|
||||||
|
{
|
||||||
|
var vm = new ViewModels.Groups.AddGroupDialogViewModel(
|
||||||
|
Services.GetRequiredService<Core.Interfaces.IGroupRepository>(),
|
||||||
|
Services.GetRequiredService<Core.Services.SchoolYearService>());
|
||||||
|
var dialog = new Views.Groups.AddGroupDialog { DataContext = vm };
|
||||||
|
|
||||||
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
|
{
|
||||||
|
var ok = await dialog.ShowDialog<bool>(owner);
|
||||||
|
if (ok) groupList.LoadGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IStudentRepository, StudentRepository>();
|
||||||
|
services.AddSingleton<IGroupRepository, GroupRepository>();
|
||||||
|
services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>();
|
||||||
|
services.AddSingleton<IExamRepository, ExamRepository>();
|
||||||
|
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
|
||||||
|
services.AddSingleton<IGradeRepository, GradeRepository>();
|
||||||
|
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||||
|
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||||
|
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||||
|
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||||
|
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||||
|
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
|
services.AddSingleton<GradingService>();
|
||||||
|
services.AddSingleton<SchoolYearService>();
|
||||||
|
|
||||||
|
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||||
|
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||||
|
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||||
|
services.AddSingleton<byte[]>(_ =>
|
||||||
|
{
|
||||||
|
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<SyncEngine>(sp => new SyncEngine(
|
||||||
|
sp.GetRequiredService<EventQueue>(),
|
||||||
|
sp.GetRequiredService<ConflictResolver>(),
|
||||||
|
BuildHttp(serverUrl, appData),
|
||||||
|
new SyncConfig
|
||||||
|
{
|
||||||
|
ServerUrl = serverUrl,
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceType = DeviceType.Desktop,
|
||||||
|
AutoSyncIntervalMinutes = 5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
|
||||||
|
BuildHttp(serverUrl, appData),
|
||||||
|
sp.GetRequiredService<LiteDbContext>(),
|
||||||
|
sp.GetRequiredService<byte[]>(),
|
||||||
|
DeviceType.Desktop, DbPath, keyPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ViewModels ────────────────────────────────────────────────────────
|
||||||
|
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||||
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
|
services.AddSingleton<DashboardViewModel>();
|
||||||
|
services.AddSingleton<SyncStatusViewModel>();
|
||||||
|
services.AddSingleton<GroupListViewModel>();
|
||||||
|
services.AddSingleton<StudentListViewModel>();
|
||||||
|
|
||||||
|
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||||
|
services.AddTransient<GroupDetailViewModel>();
|
||||||
|
services.AddTransient<StudentDetailViewModel>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
LehrerApp Assets – Icons und Bilder hier ablegen.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
@@ -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<LessonItem> TodaysLessons { get; } = [];
|
||||||
|
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
|
||||||
|
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
|
||||||
|
|
||||||
|
// Navigation-Callback – wird von App.axaml.cs verdrahtet
|
||||||
|
public Action<Guid>? 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; } = ""; }
|
||||||
@@ -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<Guid>? OnNavigateToDetail { get; set; }
|
||||||
|
public Action? OnAddGroup { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _selectedSchoolYear = "";
|
||||||
|
[ObservableProperty] private string _searchText = "";
|
||||||
|
[ObservableProperty] private GroupListItem? _selectedGroup;
|
||||||
|
|
||||||
|
public ObservableCollection<string> SchoolYears { get; } = [];
|
||||||
|
public ObservableCollection<GroupListItem> 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<StudentSummary> Students { get; } = [];
|
||||||
|
public ObservableCollection<ExamSummary> 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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DashboardViewModel>(),
|
||||||
|
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||||
|
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
|
||||||
|
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<GroupDetailViewModel>();
|
||||||
|
vm.LoadGroup(groupId);
|
||||||
|
CurrentPage = vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NavigateToStudent(Guid studentId)
|
||||||
|
{
|
||||||
|
ActiveNavItem = NavItem.Students;
|
||||||
|
var vm = _services.GetRequiredService<StudentDetailViewModel>();
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
@@ -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<Guid>? OnNavigateToDetail { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _searchText = "";
|
||||||
|
[ObservableProperty] private bool _showInactive;
|
||||||
|
[ObservableProperty] private StudentListItem? _selectedStudent;
|
||||||
|
|
||||||
|
public ObservableCollection<StudentListItem> 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<EnrollmentEntry> Enrollments { get; } = [];
|
||||||
|
public ObservableCollection<DocEntry> 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; } }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView"
|
||||||
|
x:DataType="vm:DashboardViewModel">
|
||||||
|
|
||||||
|
<ScrollViewer Padding="24">
|
||||||
|
<StackPanel Spacing="20">
|
||||||
|
|
||||||
|
<!-- Begrüßung -->
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
|
||||||
|
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
|
||||||
|
|
||||||
|
<!-- Heutige Stunden -->
|
||||||
|
<Border Grid.Column="0" Grid.Row="0" Margin="0,0,8,8"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
|
||||||
|
Opacity="0.5" Margin="0,0,0,10"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding TodaysLessons}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:LessonItem">
|
||||||
|
<Grid ColumnDefinitions="4,*" Margin="0,4">
|
||||||
|
<Border Grid.Column="0" Width="4" CornerRadius="2"
|
||||||
|
Background="{DynamicResource SystemAccentColor}"
|
||||||
|
Margin="0,0,10,0"/>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="{Binding GroupName}" FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
<TextBlock Text="{Binding Topic}" FontSize="12" Opacity="0.7"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Keine Stunden heute" Opacity="0.4" FontSize="13"
|
||||||
|
IsVisible="{Binding !TodaysLessons.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Offene Aufgaben -->
|
||||||
|
<Border Grid.Column="1" Grid.Row="0" Margin="8,0,0,8"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
|
||||||
|
Opacity="0.5" Margin="0,0,0,10"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding OpenTasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:TaskItem">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||||
|
FontSize="13" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
|
||||||
|
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Keine offenen Aufgaben" Opacity="0.4" FontSize="13"
|
||||||
|
IsVisible="{Binding !OpenTasks.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Meine Lerngruppen -->
|
||||||
|
<Border Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
|
||||||
|
Opacity="0.5" Margin="0,0,0,10"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding CurrentGroups}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:GroupChip">
|
||||||
|
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Background="{DynamicResource SystemAccentColorLight2}"
|
||||||
|
CornerRadius="6" Padding="12,6" Margin="0,0,8,8">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
|
||||||
|
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Noch keine Lerngruppen." Opacity="0.4" FontSize="13"
|
||||||
|
IsVisible="{Binding !CurrentGroups.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views.Dashboard;
|
||||||
|
public partial class DashboardView : UserControl { public DashboardView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.AddGroupDialog"
|
||||||
|
x:DataType="vm:AddGroupDialogViewModel"
|
||||||
|
Title="Neue Lerngruppe"
|
||||||
|
Width="420" SizeToContent="Height"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="14">
|
||||||
|
<TextBlock Text="Neue Lerngruppe anlegen" FontSize="18" FontWeight="SemiBold"/>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding Name}" Watermark="z.B. 10E, Q1 Chemie, 5a"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Fach" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding Subject}" Watermark="z.B. Chemie, Mathematik"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
|
||||||
|
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Schuljahr" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding SchoolYears}"
|
||||||
|
SelectedItem="{Binding SelectedSchoolYear}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
||||||
|
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Anlegen" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
public partial class AddGroupDialog : Window
|
||||||
|
{
|
||||||
|
public AddGroupDialog() => InitializeComponent();
|
||||||
|
private void OnSave(object? s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is AddGroupDialogViewModel vm && vm.SaveCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
if (vm.Result is not null) Close(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
|
||||||
|
x:DataType="vm:GroupDetailViewModel">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="{Binding GroupTitle}" FontSize="22" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding GroupSubtitle}" FontSize="12" Opacity="0.5"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13">
|
||||||
|
<Run Text="{Binding StudentCount}"/>
|
||||||
|
<Run Text=" Schüler"/>
|
||||||
|
</TextBlock>
|
||||||
|
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}"/>
|
||||||
|
<Button Content="+ Klausur" Command="{Binding AddExamCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Avalonia 12: TabbedPage ersetzt manuelles Tab-System.
|
||||||
|
Kein ActiveTab-Property, kein IsVisible-Binding, keine eigenen Tab-Buttons.
|
||||||
|
TabPlacement="Top" → Tabs oben (wie Browser-Tabs).
|
||||||
|
-->
|
||||||
|
<TabbedPage Grid.Row="1" TabPlacement="Top">
|
||||||
|
|
||||||
|
<!-- Tab: Übersicht -->
|
||||||
|
<ContentPage Header="Übersicht">
|
||||||
|
<ScrollViewer Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="{Binding GroupSubtitle}" Opacity="0.7" FontSize="14"/>
|
||||||
|
<TextBlock Text="Hier erscheint später eine Zusammenfassung der Lerngruppe."
|
||||||
|
Opacity="0.4"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Schüler -->
|
||||||
|
<ContentPage Header="Schüler">
|
||||||
|
<DataGrid ItemsSource="{Binding Students}"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
IsReadOnly="True"
|
||||||
|
GridLinesVisibility="Horizontal"
|
||||||
|
CanUserReorderColumns="False"
|
||||||
|
CanUserResizeColumns="True"
|
||||||
|
Margin="0">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Name"
|
||||||
|
Binding="{Binding FullName}"
|
||||||
|
Width="*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Klausuren -->
|
||||||
|
<ContentPage Header="Klausuren">
|
||||||
|
<DataGrid ItemsSource="{Binding Exams}"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
IsReadOnly="True"
|
||||||
|
GridLinesVisibility="Horizontal"
|
||||||
|
CanUserReorderColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Datum" Binding="{Binding Date}" Width="110"/>
|
||||||
|
<DataGridTextColumn Header="Titel" Binding="{Binding Title}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Status" Binding="{Binding StatusLabel}" Width="130"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Noten -->
|
||||||
|
<ContentPage Header="Noten">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Planung -->
|
||||||
|
<ContentPage Header="Planung">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Unterrichtseinheiten" FontSize="16" Opacity="0.4"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Dokumentation -->
|
||||||
|
<ContentPage Header="Dokumentation">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Schülerdokumentation" FontSize="16" Opacity="0.4"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
</TabbedPage>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
public partial class GroupDetailView : UserControl { public GroupDetailView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
|
||||||
|
x:DataType="vm:GroupListViewModel">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="Lerngruppen" FontSize="22" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="12" Opacity="0.5">
|
||||||
|
<Run Text="{Binding Groups.Count}"/>
|
||||||
|
<Run Text=" Gruppen · "/>
|
||||||
|
<Run Text="{Binding SelectedSchoolYear}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}"
|
||||||
|
SelectedItem="{Binding SelectedSchoolYear}"
|
||||||
|
Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2" Content="+ Neue Gruppe"
|
||||||
|
Command="{Binding AddGroupCommand}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="260,*">
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,1,0">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
|
||||||
|
PlaceholderText="Suchen…" Margin="12,8"/>
|
||||||
|
<ListBox ItemsSource="{Binding Groups}"
|
||||||
|
SelectedItem="{Binding SelectedGroup}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:GroupListItem">
|
||||||
|
<Grid ColumnDefinitions="4,*" Margin="2,4">
|
||||||
|
<Border Grid.Column="0" Width="4" CornerRadius="2"
|
||||||
|
Background="{DynamicResource SystemAccentColor}"
|
||||||
|
Margin="0,0,10,0"/>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Name}"
|
||||||
|
FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding TypeLabel}"
|
||||||
|
FontSize="11" Opacity="0.5"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
|
||||||
|
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" Spacing="8"
|
||||||
|
IsVisible="{Binding !SelectedGroup}">
|
||||||
|
<TextBlock Text="Gruppe auswählen" FontSize="16" Opacity="0.4"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="oder + Neue Gruppe anlegen" FontSize="12" Opacity="0.3"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
public partial class GroupListView : UserControl { public GroupListView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
|
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:vms="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
xmlns:views="clr-namespace:LehrerApp.Desktop.Views"
|
||||||
|
xmlns:vd="clr-namespace:LehrerApp.Desktop.Views.Dashboard"
|
||||||
|
xmlns:vg="clr-namespace:LehrerApp.Desktop.Views.Groups"
|
||||||
|
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Title="LehrerApp"
|
||||||
|
Width="1280" Height="800"
|
||||||
|
MinWidth="900" MinHeight="600">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Avalonia 12: DrawerPage ersetzt die selbstgebaute Sidebar.
|
||||||
|
DrawerBreakpointWidth="900" → Sidebar ab 900px dauerhaft sichtbar,
|
||||||
|
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
|
||||||
|
Kein eigener Code nötig.
|
||||||
|
-->
|
||||||
|
<DrawerPage DrawerLength="220"
|
||||||
|
DrawerBehavior="Auto">
|
||||||
|
|
||||||
|
<DrawerPage.Drawer>
|
||||||
|
<DockPanel>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Top" Padding="16,20,16,14"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="LehrerApp" FontSize="20" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding CurrentSchoolYear}"
|
||||||
|
FontSize="12" Opacity="0.55" Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Bottom" Padding="12,8"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,1,0,0">
|
||||||
|
<views:SyncStatusBar/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="8,12,8,0" Spacing="2">
|
||||||
|
<Button Content="📊 Dashboard" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Dashboard}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="UNTERRICHT" FontSize="10" FontWeight="Bold"
|
||||||
|
Opacity="0.4" Margin="10,14,0,4"/>
|
||||||
|
|
||||||
|
<Button Content="🏫 Lerngruppen" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Groups}"/>
|
||||||
|
<Button Content="👤 Schüler" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Students}"/>
|
||||||
|
<Button Content="📝 Klausuren" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Exams}"/>
|
||||||
|
<Button Content="📅 Planung" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Planner}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="VERWALTUNG" FontSize="10" FontWeight="Bold"
|
||||||
|
Opacity="0.4" Margin="10,14,0,4"/>
|
||||||
|
|
||||||
|
<Button Content="⏱ Arbeitszeit" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Workload}"/>
|
||||||
|
<Button Content="⚙️ Einstellungen" HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left" Background="Transparent"
|
||||||
|
Padding="10,8" CornerRadius="6"
|
||||||
|
Command="{Binding NavigateToCommand}"
|
||||||
|
CommandParameter="{x:Static vm:NavItem.Settings}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</DrawerPage.Drawer>
|
||||||
|
|
||||||
|
<!-- DataTemplates verbinden ViewModels mit ihren Views -->
|
||||||
|
<ContentControl Content="{Binding CurrentPage}">
|
||||||
|
<ContentControl.DataTemplates>
|
||||||
|
<DataTemplate DataType="vm:DashboardViewModel">
|
||||||
|
<vd:DashboardView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vmg:GroupListViewModel">
|
||||||
|
<vg:GroupListView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vmg:GroupDetailViewModel">
|
||||||
|
<vg:GroupDetailView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vms:StudentListViewModel">
|
||||||
|
<vs:StudentListView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vms:StudentDetailViewModel">
|
||||||
|
<vs:StudentDetailView/>
|
||||||
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||||
|
<views:PlaceholderView/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ContentControl.DataTemplates>
|
||||||
|
</ContentControl>
|
||||||
|
|
||||||
|
</DrawerPage>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.PlaceholderView"
|
||||||
|
x:DataType="vm:PlaceholderViewModel">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Icon}" FontSize="48" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="24" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Wird in einer späteren Version implementiert."
|
||||||
|
Opacity="0.5" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
public partial class PlaceholderView : UserControl { public PlaceholderView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView"
|
||||||
|
x:DataType="vm:StudentDetailViewModel">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" IsVisible="{Binding !IsEditing}">
|
||||||
|
<TextBlock Text="{Binding StudentTitle}" FontSize="22" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}">
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
|
||||||
|
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||||
|
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||||
|
IsVisible="{Binding !IsEditing}"/>
|
||||||
|
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
|
||||||
|
IsVisible="{Binding IsEditing}"/>
|
||||||
|
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
|
||||||
|
IsVisible="{Binding IsEditing}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Avalonia 12: TabbedPage für Schüler-Tabs.
|
||||||
|
Kein manuelles Tab-Management nötig.
|
||||||
|
-->
|
||||||
|
<TabbedPage Grid.Row="1" TabPlacement="Top">
|
||||||
|
|
||||||
|
<ContentPage Header="Übersicht">
|
||||||
|
<ScrollViewer Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Enrollments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:EnrollmentEntry">
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="70,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding SchoolYear}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding GroupName}" Margin="8,0"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Subject}" Opacity="0.5" FontSize="12"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Keine Einschreibungen vorhanden." Opacity="0.4"
|
||||||
|
IsVisible="{Binding !Enrollments.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<ContentPage Header="Noten">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
<ContentPage Header="Dokumentation">
|
||||||
|
<ScrollViewer Padding="20">
|
||||||
|
<StackPanel>
|
||||||
|
<ItemsControl ItemsSource="{Binding Documentation}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:DocEntry">
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="80,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Date}" Opacity="0.5" FontSize="12"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="8,0">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Text="🔒" FontSize="14"
|
||||||
|
IsVisible="{Binding IsConfidential}"
|
||||||
|
ToolTip.Tip="Vertraulich"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Keine Dokumentation vorhanden." Opacity="0.4"
|
||||||
|
IsVisible="{Binding !Documentation.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
|
</TabbedPage>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views.Students;
|
||||||
|
public partial class StudentDetailView : UserControl { public StudentDetailView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Students.StudentListView"
|
||||||
|
x:DataType="vm:StudentListViewModel">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="Schüler" FontSize="22" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="12" Opacity="0.5">
|
||||||
|
<Run Text="{Binding Students.Count}"/>
|
||||||
|
<Run Text=" Schüler gesamt"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Grid.Column="1" Content="Inaktive anzeigen"
|
||||||
|
IsChecked="{Binding ShowInactive}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<Button Grid.Column="2" Content="+ Neuer Schüler"
|
||||||
|
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<DockPanel Grid.Row="1">
|
||||||
|
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
|
||||||
|
PlaceholderText="Name suchen…" Margin="16,10,16,4"/>
|
||||||
|
<DataGrid ItemsSource="{Binding Students}"
|
||||||
|
SelectedItem="{Binding SelectedStudent}"
|
||||||
|
AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
|
GridLinesVisibility="Horizontal"
|
||||||
|
CanUserReorderColumns="False" Margin="16,4">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
namespace LehrerApp.Desktop.Views.Students;
|
||||||
|
public partial class StudentListView : UserControl { public StudentListView() => InitializeComponent(); }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
|
||||||
|
x:DataType="vm:SyncStatusViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="↻" FontSize="14"
|
||||||
|
Command="{Binding SyncNowCommand}"
|
||||||
|
IsVisible="{Binding IsServerConfigured}"
|
||||||
|
Background="Transparent" Padding="6,4"
|
||||||
|
ToolTip.Tip="Jetzt synchronisieren"/>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
public partial class SyncStatusBar : UserControl
|
||||||
|
{
|
||||||
|
public SyncStatusBar()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is null)
|
||||||
|
DataContext = App.Services.GetRequiredService<SyncStatusViewModel>();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="LehrerApp.Desktop"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Desktop gewinnt gegen Companion.
|
||||||
|
/// Bei Gleichstand: späterer Timestamp gewinnt.
|
||||||
|
/// </summary>
|
||||||
|
public class ConflictResolver(EventQueue queue)
|
||||||
|
{
|
||||||
|
public ConflictEntry? TryResolve(SyncEvent remote, string localDeviceId)
|
||||||
|
{
|
||||||
|
var local = queue.GetPending()
|
||||||
|
.FirstOrDefault(e => e.EntityType == remote.EntityType
|
||||||
|
&& e.EntityId == remote.EntityId
|
||||||
|
&& e.DeviceId != remote.DeviceId);
|
||||||
|
if (local is null) return null;
|
||||||
|
|
||||||
|
var winner = (local.DeviceType, remote.DeviceType) switch
|
||||||
|
{
|
||||||
|
(DeviceType.Desktop, DeviceType.Companion) => local,
|
||||||
|
(DeviceType.Companion, DeviceType.Desktop) => remote,
|
||||||
|
_ => local.Timestamp >= remote.Timestamp ? local : remote,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (winner == remote) queue.Acknowledge([local.EventId]);
|
||||||
|
|
||||||
|
return new ConflictEntry
|
||||||
|
{
|
||||||
|
LocalEvent = local,
|
||||||
|
RemoteEvent = remote,
|
||||||
|
Resolution = winner == local ? "LocalWon" : "RemoteWon",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LehrerApp.Sync.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AES-256-GCM Verschlüsselung + PBKDF2 Schlüsselableitung.
|
||||||
|
///
|
||||||
|
/// Pairing-Flow:
|
||||||
|
/// Sender: EncryptedSyncKey = AES(syncKey, PBKDF2(code))
|
||||||
|
/// Empfänger: syncKey = AES_Decrypt(EncryptedSyncKey, PBKDF2(code))
|
||||||
|
/// → Schlüssel verlässt nie den Server, nur der Code wird geteilt.
|
||||||
|
/// </summary>
|
||||||
|
public static class SyncCrypto
|
||||||
|
{
|
||||||
|
private const int KeySize = 32; // 256 bit
|
||||||
|
private const int NonceSize = 12; // 96 bit – GCM Standard
|
||||||
|
private const int TagSize = 16; // 128 bit Auth-Tag
|
||||||
|
private const int Pbkdf2Iter = 100_000;
|
||||||
|
|
||||||
|
// ── Schlüssel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static byte[] GenerateKey()
|
||||||
|
{
|
||||||
|
var k = new byte[KeySize];
|
||||||
|
RandomNumberGenerator.Fill(k);
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
public static string KeyToBase64(byte[] key) => Convert.ToBase64String(key);
|
||||||
|
public static byte[] KeyFromBase64(string b64) => Convert.FromBase64String(b64);
|
||||||
|
|
||||||
|
/// <summary>Leitet Schlüssel aus Einmal-Code ab. PBKDF2 erschwert Brute-Force.</summary>
|
||||||
|
public static byte[] DeriveKeyFromCode(string code)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(code.Trim().ToUpperInvariant());
|
||||||
|
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
|
||||||
|
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EncryptKeyWithCode(byte[] syncKey, string code) =>
|
||||||
|
Convert.ToBase64String(Encrypt(syncKey, DeriveKeyFromCode(code)));
|
||||||
|
|
||||||
|
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
|
||||||
|
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
|
||||||
|
|
||||||
|
// ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
|
||||||
|
public static byte[] Encrypt(byte[] plaintext, byte[] key)
|
||||||
|
{
|
||||||
|
var nonce = new byte[NonceSize];
|
||||||
|
RandomNumberGenerator.Fill(nonce);
|
||||||
|
var ciphertext = new byte[plaintext.Length];
|
||||||
|
var tag = new byte[TagSize];
|
||||||
|
using var aes = new AesGcm(key, TagSize);
|
||||||
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||||
|
var result = new byte[NonceSize + ciphertext.Length + TagSize];
|
||||||
|
nonce.CopyTo(result, 0);
|
||||||
|
ciphertext.CopyTo(result, NonceSize);
|
||||||
|
tag.CopyTo(result, NonceSize + ciphertext.Length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Decrypt(byte[] encrypted, byte[] key)
|
||||||
|
{
|
||||||
|
if (encrypted.Length < NonceSize + TagSize)
|
||||||
|
throw new CryptographicException("Ungültiges Datenformat.");
|
||||||
|
var nonce = encrypted[..NonceSize];
|
||||||
|
var tag = encrypted[^TagSize..];
|
||||||
|
var ciphertext = encrypted[NonceSize..^TagSize];
|
||||||
|
var plaintext = new byte[ciphertext.Length];
|
||||||
|
using var aes = new AesGcm(key, TagSize);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON-Komfort ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static string EncryptObject<T>(T obj, byte[] key) =>
|
||||||
|
Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(obj), key));
|
||||||
|
public static T? DecryptObject<T>(string b64, byte[] key) =>
|
||||||
|
JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(b64), key));
|
||||||
|
|
||||||
|
// ── Schlüsselspeicherung ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static void SaveKey(byte[] key, string path)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, KeyToBase64(key));
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
|
}
|
||||||
|
public static byte[]? LoadKey(string path) =>
|
||||||
|
File.Exists(path) ? KeyFromBase64(File.ReadAllText(path).Trim()) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using LiteDB;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
|
||||||
|
/// erfolgreich zum Server gepusht wurden.
|
||||||
|
/// </summary>
|
||||||
|
public class EventQueue : IDisposable
|
||||||
|
{
|
||||||
|
private readonly LiteDatabase _db;
|
||||||
|
private readonly ILiteCollection<SyncEvent> _queue;
|
||||||
|
private readonly ILiteCollection<SyncMeta> _meta;
|
||||||
|
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||||
|
private long _currentSeq;
|
||||||
|
|
||||||
|
public EventQueue(string path)
|
||||||
|
{
|
||||||
|
_db = new LiteDatabase(path);
|
||||||
|
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||||
|
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||||
|
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||||
|
_queue.EnsureIndex(x => x.SequenceNr);
|
||||||
|
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
|
||||||
|
string entityType, string entityId, string operation, string payload)
|
||||||
|
{
|
||||||
|
var evt = new SyncEvent
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceType = deviceType,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
SequenceNr = ++_currentSeq,
|
||||||
|
EntityType = entityType,
|
||||||
|
EntityId = entityId,
|
||||||
|
Operation = operation,
|
||||||
|
Payload = payload,
|
||||||
|
};
|
||||||
|
_queue.Insert(evt);
|
||||||
|
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SyncEvent> GetPending(int max = 200) =>
|
||||||
|
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
|
||||||
|
public int PendingCount() => _queue.Count();
|
||||||
|
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
||||||
|
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
||||||
|
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
|
||||||
|
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
|
||||||
|
public void SetLastSyncAt(DateTime dt) =>
|
||||||
|
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
|
||||||
|
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||||
|
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||||
|
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConflictEntry
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
|
||||||
|
public SyncEvent LocalEvent { get; init; } = null!;
|
||||||
|
public SyncEvent RemoteEvent { get; init; } = null!;
|
||||||
|
public string Resolution { get; init; } = "";
|
||||||
|
public bool Reviewed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SyncMeta
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public long Value { get; set; }
|
||||||
|
public DateTime? Timestamp { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LiteDB" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
namespace LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Desktop-Event: Payload ist AES-256-GCM verschlüsselt.</summary>
|
||||||
|
public class SyncEvent
|
||||||
|
{
|
||||||
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||||
|
public string DeviceId { get; init; } = "";
|
||||||
|
public DeviceType DeviceType { get; init; }
|
||||||
|
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||||
|
public long SequenceNr { get; init; }
|
||||||
|
public string EntityType { get; init; } = "";
|
||||||
|
public string EntityId { get; init; } = "";
|
||||||
|
public string Operation { get; init; } = "";
|
||||||
|
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
|
||||||
|
public string Payload { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
|
||||||
|
public class PlainSyncEvent
|
||||||
|
{
|
||||||
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||||
|
public string DeviceId { get; init; } = "";
|
||||||
|
public DeviceType DeviceType { get; init; } = DeviceType.Companion;
|
||||||
|
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||||
|
public string EntityType { get; init; } = "";
|
||||||
|
public string EntityId { get; init; } = "";
|
||||||
|
public string Operation { get; init; } = "";
|
||||||
|
public string Payload { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync API Responses ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class PushResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public long ServerSequenceNr { get; init; }
|
||||||
|
public List<Guid> ConflictingEventIds { get; init; } = [];
|
||||||
|
}
|
||||||
|
public class PullResponse
|
||||||
|
{
|
||||||
|
public List<SyncEvent> Events { get; init; } = [];
|
||||||
|
public long ServerSequenceNr { get; init; }
|
||||||
|
}
|
||||||
|
public class PlainPushResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public long ServerSequenceNr { get; init; }
|
||||||
|
public List<Guid> RejectedEventIds { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshot Modelle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload-Request für Device-Pairing.
|
||||||
|
/// EncryptedPayload: LiteDB verschlüsselt mit Sync-Key.
|
||||||
|
/// EncryptedSyncKey: Sync-Key verschlüsselt mit PBKDF2(Code).
|
||||||
|
/// → Empfänger braucht nur den Code um beides zu entschlüsseln.
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotUploadRequest
|
||||||
|
{
|
||||||
|
public string EncryptedPayload { get; init; } = "";
|
||||||
|
public string EncryptedSyncKey { get; init; } = "";
|
||||||
|
public DeviceType DeviceType { get; init; }
|
||||||
|
}
|
||||||
|
public class SnapshotUploadResponse
|
||||||
|
{
|
||||||
|
/// <summary>Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU". 24h gültig, einmalig.</summary>
|
||||||
|
public string Code { get; init; } = "";
|
||||||
|
public DateTime ExpiresAt { get; init; }
|
||||||
|
}
|
||||||
|
public class SnapshotDownloadResponse
|
||||||
|
{
|
||||||
|
public string EncryptedPayload { get; init; } = "";
|
||||||
|
public string EncryptedSyncKey { get; init; } = "";
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public DeviceType SourceDeviceType { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SyncStatus
|
||||||
|
{
|
||||||
|
public SyncState State { get; set; } = SyncState.Idle;
|
||||||
|
public DateTime? LastSyncAt { get; set; }
|
||||||
|
public int PendingEvents { get; set; }
|
||||||
|
public int ConflictCount { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public enum DeviceType { Desktop, Companion }
|
||||||
|
public enum SyncState { Idle, Syncing, Error, Offline }
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Device-Pairing via verschlüsseltem Snapshot + Einmal-Code.
|
||||||
|
///
|
||||||
|
/// Sender: CreateAndUploadAsync() → zeigt Code dem Nutzer
|
||||||
|
/// Empfänger: RestoreFromCodeAsync(code) → entschlüsselt Schlüssel + DB
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotService(
|
||||||
|
HttpClient http, LiteDbContext db, byte[] syncKey,
|
||||||
|
DeviceType deviceType, string dbPath, string keyPath)
|
||||||
|
{
|
||||||
|
public event Action<SnapshotProgress>? ProgressChanged;
|
||||||
|
|
||||||
|
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
|
||||||
|
db.Checkpoint();
|
||||||
|
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
|
||||||
|
var dbBytes = await File.ReadAllBytesAsync(dbPath, ct);
|
||||||
|
Report(SnapshotStep.Encrypting, "Verschlüsselung läuft…");
|
||||||
|
var encPayload = Convert.ToBase64String(SyncCrypto.Encrypt(dbBytes, syncKey));
|
||||||
|
|
||||||
|
// Schritt 1: Upload ohne Key → Code erhalten
|
||||||
|
Report(SnapshotStep.Uploading, "Code wird angefordert…");
|
||||||
|
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
||||||
|
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
|
||||||
|
r1.EnsureSuccessStatusCode();
|
||||||
|
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
|
||||||
|
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
|
||||||
|
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
|
||||||
|
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
|
||||||
|
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
||||||
|
new SnapshotUploadRequest { EncryptedPayload = encPayload,
|
||||||
|
EncryptedSyncKey = encKey, DeviceType = deviceType }, ct);
|
||||||
|
r2.EnsureSuccessStatusCode();
|
||||||
|
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
Report(SnapshotStep.Done, $"Bereit – Code: {result.Code}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> RestoreFromCodeAsync(string code, string targetDbPath,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sanitized = code.Trim().ToUpperInvariant();
|
||||||
|
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
|
||||||
|
var resp = await http.GetAsync($"/api/snapshot/{sanitized}", ct);
|
||||||
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||||
|
|
||||||
|
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
|
||||||
|
byte[] restoredKey;
|
||||||
|
try { restoredKey = SyncCrypto.DecryptKeyWithCode(dl.EncryptedSyncKey, sanitized); }
|
||||||
|
catch (System.Security.Cryptography.CryptographicException)
|
||||||
|
{ throw new InvalidOperationException("Schlüssel-Entschlüsselung fehlgeschlagen – Code korrekt?"); }
|
||||||
|
|
||||||
|
SyncCrypto.SaveKey(restoredKey, keyPath);
|
||||||
|
|
||||||
|
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
|
||||||
|
var dbBytes = SyncCrypto.Decrypt(Convert.FromBase64String(dl.EncryptedPayload), restoredKey);
|
||||||
|
|
||||||
|
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetDbPath)!);
|
||||||
|
if (File.Exists(targetDbPath))
|
||||||
|
File.Move(targetDbPath, $"{targetDbPath}.backup-{DateTime.Now:yyyyMMdd-HHmmss}");
|
||||||
|
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
|
||||||
|
Report(SnapshotStep.Done, "Abgeschlossen – bitte App neu starten.");
|
||||||
|
return restoredKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Report(SnapshotStep step, string msg) =>
|
||||||
|
ProgressChanged?.Invoke(new(step, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SnapshotProgress(SnapshotStep Step, string Message)
|
||||||
|
{
|
||||||
|
public int Percent => Step switch
|
||||||
|
{
|
||||||
|
SnapshotStep.Checkpointing => 10, SnapshotStep.Reading => 20,
|
||||||
|
SnapshotStep.Encrypting => 35, SnapshotStep.Uploading => 60,
|
||||||
|
SnapshotStep.Downloading => 35, SnapshotStep.Decrypting => 65,
|
||||||
|
SnapshotStep.Writing => 85, SnapshotStep.Done => 100,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
public bool IsComplete => Step == SnapshotStep.Done;
|
||||||
|
}
|
||||||
|
public enum SnapshotStep { Idle, Checkpointing, Reading, Encrypting,
|
||||||
|
Uploading, Downloading, Decrypting, Writing, Done }
|
||||||
|
public class SnapshotNotFoundException(string msg) : Exception(msg);
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Push/Pull Orchestrierung.
|
||||||
|
/// Automatisch alle N Minuten + manuell per SyncNowAsync().
|
||||||
|
/// </summary>
|
||||||
|
public class SyncEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EventQueue _queue;
|
||||||
|
private readonly ConflictResolver _resolver;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly SyncConfig _config;
|
||||||
|
private readonly Timer _timer;
|
||||||
|
|
||||||
|
public SyncStatus Status { get; private set; } = new();
|
||||||
|
public event Action<SyncStatus>? StatusChanged;
|
||||||
|
|
||||||
|
public SyncEngine(EventQueue queue, ConflictResolver resolver,
|
||||||
|
HttpClient http, SyncConfig config)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_resolver = resolver;
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_timer = new Timer(
|
||||||
|
async _ => await SyncNowAsync(true), null,
|
||||||
|
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||||
|
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
||||||
|
UpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||||||
|
{
|
||||||
|
if (Status.State == SyncState.Syncing)
|
||||||
|
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
|
||||||
|
SetState(SyncState.Syncing);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (pushed, _) = await PushAsync();
|
||||||
|
var (pulled, conflicts) = await PullAsync();
|
||||||
|
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||||
|
SetState(SyncState.Idle);
|
||||||
|
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
|
||||||
|
}
|
||||||
|
catch (HttpRequestException) { SetState(SyncState.Offline); return new() { Reason = "Server nicht erreichbar" }; }
|
||||||
|
catch (Exception ex) { SetState(SyncState.Error, ex.Message); return new() { Reason = ex.Message }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||||
|
{
|
||||||
|
var pending = _queue.GetPending();
|
||||||
|
if (pending.Count == 0) return (0, 0);
|
||||||
|
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
||||||
|
if (result is null) return (0, 0);
|
||||||
|
_queue.Acknowledge(pending
|
||||||
|
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||||
|
.Select(e => e.EventId));
|
||||||
|
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
||||||
|
return (pending.Count - result.ConflictingEventIds.Count,
|
||||||
|
result.ConflictingEventIds.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
||||||
|
{
|
||||||
|
var since = _queue.GetLastServerSeq();
|
||||||
|
var resp = await _http.GetFromJsonAsync<PullResponse>(
|
||||||
|
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
||||||
|
if (resp is null || resp.Events.Count == 0) return (0, 0);
|
||||||
|
var conflicts = 0;
|
||||||
|
foreach (var evt in resp.Events)
|
||||||
|
{
|
||||||
|
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||||
|
if (c is not null) { _queue.AddConflict(c); conflicts++; }
|
||||||
|
}
|
||||||
|
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||||
|
return (resp.Events.Count, conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(SyncState state, string? error = null)
|
||||||
|
{
|
||||||
|
Status = new SyncStatus
|
||||||
|
{
|
||||||
|
State = state,
|
||||||
|
LastSyncAt = _queue.GetLastSyncAt(),
|
||||||
|
PendingEvents = _queue.PendingCount(),
|
||||||
|
ConflictCount = _queue.ConflictCount(),
|
||||||
|
ErrorMessage = error,
|
||||||
|
};
|
||||||
|
StatusChanged?.Invoke(Status);
|
||||||
|
}
|
||||||
|
private void UpdateStatus() => SetState(Status.State);
|
||||||
|
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SyncConfig
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; set; } = "";
|
||||||
|
public string DeviceId { get; set; } = "";
|
||||||
|
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
|
||||||
|
public int AutoSyncIntervalMinutes { get; set; } = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SyncResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public bool Skipped { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
public int EventsPushed { get; set; }
|
||||||
|
public int EventsPulled { get; set; }
|
||||||
|
public int Conflicts { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Core", "LehrerApp.Core\LehrerApp.Core.csproj", "{A1000001-0000-0000-0000-000000000001}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp.Data\LehrerApp.Data.csproj", "{A1000002-0000-0000-0000-000000000002}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["LehrerApp.Api/LehrerApp.Api.csproj", "LehrerApp.Api/"]
|
||||||
|
COPY ["LehrerApp.Sync/LehrerApp.Sync.csproj", "LehrerApp.Sync/"]
|
||||||
|
COPY ["LehrerApp.Core/LehrerApp.Core.csproj", "LehrerApp.Core/"]
|
||||||
|
COPY ["LehrerApp.Data/LehrerApp.Data.csproj", "LehrerApp.Data/"]
|
||||||
|
COPY ["Directory.Build.props", "."]
|
||||||
|
COPY ["Directory.Packages.props", "."]
|
||||||
|
RUN dotnet restore "LehrerApp.Api/LehrerApp.Api.csproj"
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish "LehrerApp.Api/LehrerApp.Api.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENTRYPOINT ["dotnet", "LehrerApp.Api.dll"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.api
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.301",
|
||||||
|
"rollForward": "latestPatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user