Initial
This commit is contained in:
25
Directory.Build.props
Normal file
25
Directory.Build.props
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project>
|
||||
<!--
|
||||
Gemeinsame Eigenschaften für alle Projekte in der Solution.
|
||||
Wird automatisch von MSBuild in jedem Projekt-Build eingebunden.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
|
||||
<!-- Unterdrückt den .NET 9 STS-Ablauf-Hinweis im Build-Output -->
|
||||
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Gemeinsame Analysator-Einstellungen.
|
||||
CS8618: Non-nullable field – in MVVM oft falsch-positiv durch
|
||||
[ObservableProperty] Source Generator.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<NoWarn>CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
34
Directory.Packages.props
Normal file
34
Directory.Packages.props
Normal file
@@ -0,0 +1,34 @@
|
||||
<Project>
|
||||
<!--
|
||||
Central Package Management (CPM) – alle NuGet-Versionen an einem Ort.
|
||||
In den .csproj-Dateien werden Versionen weggelassen:
|
||||
<PackageReference Include="LiteDB" /> ← kein Version-Attribut nötig
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Avalonia -->
|
||||
<PackageVersion Include="Avalonia" Version="11.3.12" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
|
||||
<!-- MVVM & DI -->
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm"
|
||||
Version="8.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection"
|
||||
Version="9.0.3" />
|
||||
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- ASP.NET Core / Web -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer"
|
||||
Version="9.0.3" />
|
||||
|
||||
<!-- System -->
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
20
Dockerfile.api
Normal file
20
Dockerfile.api
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.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/"]
|
||||
RUN dotnet restore "LehrerApp.Api/LehrerApp.Api.csproj"
|
||||
COPY . .
|
||||
RUN dotnet publish "LehrerApp.Api/LehrerApp.Api.csproj" \
|
||||
-c Release -o /app/publish --no-restore
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/data
|
||||
ENTRYPOINT ["dotnet", "LehrerApp.Api.dll"]
|
||||
147
LehrerApp.Api/Endpoints/Endpoints.cs
Normal file
147
LehrerApp.Api/Endpoints/Endpoints.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
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 jwtSecret)
|
||||
{
|
||||
app.MapPost("/api/auth/login", (LoginRequest req) =>
|
||||
{
|
||||
// TODO: Passwort-Hash gegen DB prüfen
|
||||
if (string.IsNullOrWhiteSpace(req.Username) ||
|
||||
string.IsNullOrWhiteSpace(req.Password))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var token = GenerateJwt(req.Username, jwtSecret);
|
||||
return Results.Ok(new { token, userId = req.Username });
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/register", (RegisterRequest req) =>
|
||||
{
|
||||
// TODO: User anlegen, Passwort hashen (BCrypt)
|
||||
if (string.IsNullOrWhiteSpace(req.Username) ||
|
||||
req.Password.Length < 12)
|
||||
return Results.BadRequest(
|
||||
"Passwort muss mindestens 12 Zeichen haben.");
|
||||
|
||||
var token = GenerateJwt(req.Username, jwtSecret);
|
||||
return Results.Ok(new { token, userId = req.Username });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sync ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public static void MapSyncEndpoints(this WebApplication app)
|
||||
{
|
||||
var sync = app.MapGroup("/api/sync").RequireAuthorization();
|
||||
|
||||
sync.MapPost("/push", (
|
||||
[FromBody] List<SyncEvent> events,
|
||||
ClaimsPrincipal user,
|
||||
EventStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
var result = store.Push(userId, events);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
sync.MapGet("/pull", (
|
||||
[FromQuery] long since,
|
||||
[FromQuery] string deviceId,
|
||||
ClaimsPrincipal user,
|
||||
EventStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
var result = store.Pull(userId, since, deviceId);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
sync.MapGet("/status", (ClaimsPrincipal user) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
return Results.Ok(new { userId, timestamp = DateTime.UtcNow });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static void MapSnapshotEndpoints(this WebApplication app)
|
||||
{
|
||||
var snap = app.MapGroup("/api/snapshot").RequireAuthorization();
|
||||
|
||||
// Sender: Snapshot hochladen → Einmal-Code erhalten
|
||||
snap.MapPost("/upload", (
|
||||
[FromBody] SnapshotUploadRequest request,
|
||||
ClaimsPrincipal user,
|
||||
SnapshotStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EncryptedPayload))
|
||||
return Results.BadRequest("Kein Payload.");
|
||||
|
||||
var result = store.Store(userId, request);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
// Empfänger: Snapshot per Code abrufen (Einmal-Verwendung)
|
||||
snap.MapGet("/{code}", (
|
||||
string code,
|
||||
ClaimsPrincipal user,
|
||||
SnapshotStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
var result = store.Retrieve(userId, code);
|
||||
if (result is null)
|
||||
return Results.NotFound(
|
||||
"Snapshot nicht gefunden, abgelaufen oder bereits verwendet.");
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
}
|
||||
|
||||
// ── JWT ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GenerateJwt(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 Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(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);
|
||||
|
||||
// Ergänzung – wird unten in der bestehenden Datei angefügt
|
||||
// In Program.cs: app.MapReadableSnapshotEndpoints(); und app.MapPlainSyncEndpoints();
|
||||
115
LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs
Normal file
115
LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Security.Claims;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public static class ReadableSnapshotEndpoints
|
||||
{
|
||||
// ── Readable Snapshot ─────────────────────────────────────────────────────
|
||||
|
||||
public static void MapReadableSnapshotEndpoints(this WebApplication app)
|
||||
{
|
||||
var snap = app.MapGroup("/api/snapshot/readable")
|
||||
.RequireAuthorization();
|
||||
|
||||
// Desktop → Server: aktuellen Snapshot hochladen
|
||||
snap.MapPost("/", (
|
||||
[FromBody] ReadableSnapshot snapshot,
|
||||
ClaimsPrincipal user,
|
||||
ReadableSnapshotStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
snapshot.ExportedAt = DateTime.UtcNow;
|
||||
store.Store(userId, snapshot);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
exportedAt = snapshot.ExportedAt,
|
||||
studentCount = snapshot.Meta.StudentCount,
|
||||
groupCount = snapshot.Meta.GroupCount,
|
||||
});
|
||||
});
|
||||
|
||||
// WebApp → Server: Snapshot laden
|
||||
snap.MapGet("/", (
|
||||
ClaimsPrincipal user,
|
||||
ReadableSnapshotStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
var snapshot = store.Load(userId);
|
||||
if (snapshot is null)
|
||||
return Results.NotFound(
|
||||
"Kein Snapshot vorhanden. " +
|
||||
"Bitte zuerst den Desktop-Client mit dem Server verbinden.");
|
||||
|
||||
return Results.Ok(snapshot);
|
||||
});
|
||||
|
||||
// Nur Metadaten – für Freshness-Check der WebApp
|
||||
snap.MapGet("/meta", (
|
||||
ClaimsPrincipal user,
|
||||
ReadableSnapshotStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
var meta = store.LoadMeta(userId);
|
||||
if (meta is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(meta);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Plain Sync (WebApp → EventStore) ─────────────────────────────────────
|
||||
|
||||
public static void MapPlainSyncEndpoints(this WebApplication app)
|
||||
{
|
||||
var sync = app.MapGroup("/api/sync/plain")
|
||||
.RequireAuthorization();
|
||||
|
||||
// WebApp schreibt Events (Klartext) – werden beim Desktop-Pull abgeholt
|
||||
sync.MapPost("/push", (
|
||||
[FromBody] List<PlainSyncEvent> events,
|
||||
ClaimsPrincipal user,
|
||||
PlainEventStore store) =>
|
||||
{
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return Results.Unauthorized();
|
||||
|
||||
// Nur erlaubte EntityTypes für WebApp-Schreibzugriff
|
||||
var allowed = new HashSet<string>
|
||||
{
|
||||
"Grade", "ExamResult", "WorkTask", "Lesson"
|
||||
};
|
||||
|
||||
var rejected = events
|
||||
.Where(e => !allowed.Contains(e.EntityType))
|
||||
.Select(e => e.EventId)
|
||||
.ToList();
|
||||
|
||||
var permitted = events
|
||||
.Where(e => allowed.Contains(e.EntityType))
|
||||
.ToList();
|
||||
|
||||
PlainPushResponse result;
|
||||
if (permitted.Count > 0)
|
||||
result = store.Push(userId, permitted);
|
||||
else
|
||||
result = new PlainPushResponse { Success = true };
|
||||
|
||||
// Abgelehnte Events mit Grund zurückmelden
|
||||
result = result with
|
||||
{
|
||||
RejectedEventIds = [.. result.RejectedEventIds, .. rejected],
|
||||
};
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
144
LehrerApp.Api/EventStore.cs
Normal file
144
LehrerApp.Api/EventStore.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Server-seitiger Event-Speicher.
|
||||
/// Speichert verschlüsselte Events pro User – versteht den Payload nicht.
|
||||
/// Eine LiteDB-Datei pro User in dataPath/{userId}.db
|
||||
/// </summary>
|
||||
public class EventStore
|
||||
{
|
||||
private readonly string _dataPath;
|
||||
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public EventStore(string dataPath)
|
||||
{
|
||||
_dataPath = dataPath;
|
||||
Directory.CreateDirectory(dataPath);
|
||||
}
|
||||
|
||||
// ── Push: Events vom Client entgegennehmen ────────────────────────────────
|
||||
|
||||
public PushResponse Push(string userId, List<SyncEvent> events)
|
||||
{
|
||||
var db = GetDb(userId);
|
||||
var col = db.GetCollection<ServerEvent>("events");
|
||||
col.EnsureIndex(x => x.ServerSequenceNr);
|
||||
|
||||
var conflictIds = new List<Guid>();
|
||||
long serverSeq = GetLastSequenceNr(col);
|
||||
|
||||
foreach (var evt in events.OrderBy(e => e.Timestamp))
|
||||
{
|
||||
// Konflikt: anderes Gerät hat dieselbe Entity kürzlich geändert
|
||||
var recent = col.FindOne(e =>
|
||||
e.EntityType == evt.EntityType &&
|
||||
e.EntityId == evt.EntityId &&
|
||||
e.DeviceId != evt.DeviceId &&
|
||||
e.Timestamp > evt.Timestamp.AddSeconds(-30));
|
||||
|
||||
if (recent is not null)
|
||||
{
|
||||
conflictIds.Add(evt.EventId);
|
||||
continue;
|
||||
}
|
||||
|
||||
col.Insert(new ServerEvent
|
||||
{
|
||||
EventId = evt.EventId,
|
||||
DeviceId = evt.DeviceId,
|
||||
DeviceType = evt.DeviceType,
|
||||
Timestamp = evt.Timestamp,
|
||||
ClientSequenceNr = evt.SequenceNr,
|
||||
ServerSequenceNr = ++serverSeq,
|
||||
EntityType = evt.EntityType,
|
||||
EntityId = evt.EntityId,
|
||||
Operation = evt.Operation,
|
||||
Payload = evt.Payload, // verschlüsselt – Server liest nicht
|
||||
});
|
||||
}
|
||||
|
||||
return new PushResponse
|
||||
{
|
||||
Success = true,
|
||||
ServerSequenceNr = serverSeq,
|
||||
ConflictingEventIds = conflictIds,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Pull: neue Events für Client bereitstellen ────────────────────────────
|
||||
|
||||
public PullResponse Pull(string userId, long since, string requestingDeviceId)
|
||||
{
|
||||
var db = GetDb(userId);
|
||||
var col = db.GetCollection<ServerEvent>("events");
|
||||
|
||||
// Nur Events anderer Geräte zurückgeben
|
||||
var events = col
|
||||
.Find(e => e.ServerSequenceNr > since && e.DeviceId != requestingDeviceId)
|
||||
.OrderBy(e => e.ServerSequenceNr)
|
||||
.Take(500)
|
||||
.Select(e => new SyncEvent
|
||||
{
|
||||
EventId = e.EventId,
|
||||
DeviceId = e.DeviceId,
|
||||
DeviceType = e.DeviceType,
|
||||
Timestamp = e.Timestamp,
|
||||
SequenceNr = e.ServerSequenceNr,
|
||||
EntityType = e.EntityType,
|
||||
EntityId = e.EntityId,
|
||||
Operation = e.Operation,
|
||||
Payload = e.Payload,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PullResponse
|
||||
{
|
||||
Events = events,
|
||||
ServerSequenceNr = GetLastSequenceNr(col),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private LiteDatabase GetDb(string userId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_dbs.TryGetValue(userId, out var db))
|
||||
{
|
||||
// Sanitize userId für Dateinamen
|
||||
var safeName = string.Concat(userId
|
||||
.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
|
||||
var path = Path.Combine(_dataPath, $"{safeName}.db");
|
||||
db = new LiteDatabase(path);
|
||||
_dbs[userId] = db;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetLastSequenceNr(ILiteCollection<ServerEvent> col)
|
||||
{
|
||||
var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSequenceNr), Query.Descending));
|
||||
return last?.ServerSequenceNr ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Internes Dokument im Server-EventStore
|
||||
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 ClientSequenceNr { get; set; }
|
||||
public long ServerSequenceNr { get; set; }
|
||||
public string EntityType { get; set; } = "";
|
||||
public string EntityId { get; set; } = "";
|
||||
public string Operation { get; set; } = "";
|
||||
public string Payload { get; set; } = ""; // AES-256 – Server liest nicht
|
||||
}
|
||||
19
LehrerApp.Api/LehrerApp.Api.csproj
Normal file
19
LehrerApp.Api/LehrerApp.Api.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<!-- Für Docker: kein self-contained nötig, Runtime im Container -->
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Alle Microsoft-Pakete auf 9.0.x pinnen -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="LiteDB" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
50
LehrerApp.Api/PlainEventStore.cs
Normal file
50
LehrerApp.Api/PlainEventStore.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Speichert Klartext-Events der WebApp/Companion im EventStore.
|
||||
/// Diese werden vom Desktop-Client beim nächsten Pull abgeholt
|
||||
/// und wie normale Events angewendet – nur ohne Entschlüsselung.
|
||||
///
|
||||
/// Technisch gesehen ist das nur eine dünne Schicht über dem
|
||||
/// bestehenden EventStore – Plain-Events werden einfach mit
|
||||
/// DeviceType.Companion markiert und landen im selben Stream.
|
||||
/// </summary>
|
||||
public class PlainEventStore
|
||||
{
|
||||
private readonly EventStore _eventStore;
|
||||
|
||||
public PlainEventStore(EventStore eventStore)
|
||||
{
|
||||
_eventStore = eventStore;
|
||||
}
|
||||
|
||||
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
|
||||
{
|
||||
// Plain-Events in normale SyncEvents umwandeln
|
||||
// DeviceType.Companion → Desktop-Client entschlüsselt nicht
|
||||
var syncEvents = events.Select(e => new SyncEvent
|
||||
{
|
||||
EventId = e.EventId,
|
||||
DeviceId = e.DeviceId,
|
||||
DeviceType = DeviceType.Companion, // ← Signal: kein Decrypt nötig
|
||||
Timestamp = e.Timestamp,
|
||||
SequenceNr = 0, // vom EventStore vergeben
|
||||
EntityType = e.EntityType,
|
||||
EntityId = e.EntityId,
|
||||
Operation = e.Operation,
|
||||
Payload = e.Payload, // Klartext JSON
|
||||
}).ToList();
|
||||
|
||||
var result = _eventStore.Push(userId, syncEvents);
|
||||
|
||||
return new PlainPushResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
ServerSequenceNr = result.ServerSequenceNr,
|
||||
RejectedEventIds = result.ConflictingEventIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
57
LehrerApp.Api/Program.cs
Normal file
57
LehrerApp.Api/Program.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Text;
|
||||
using LehrerApp.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── Kestrel ───────────────────────────────────────────────────────────────────
|
||||
builder.WebHost.UseKestrel(options =>
|
||||
{
|
||||
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
|
||||
options.ListenAnyIP(port);
|
||||
});
|
||||
|
||||
// ── JWT Auth ──────────────────────────────────────────────────────────────────
|
||||
var jwtSecret = builder.Configuration["JWT_SECRET"]
|
||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(jwtSecret)),
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.FromMinutes(5),
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────────────
|
||||
var dataPath = builder.Configuration["Api:DataPath"] ?? "./data";
|
||||
|
||||
builder.Services.AddSingleton<EventStore>(_ => new EventStore(dataPath));
|
||||
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(dataPath));
|
||||
builder.Services.AddSingleton<ReadableSnapshotStore>(
|
||||
_ => new ReadableSnapshotStore(dataPath));
|
||||
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
app.MapAuthEndpoints(jwtSecret);
|
||||
app.MapSyncEndpoints();
|
||||
app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
|
||||
app.Run();
|
||||
72
LehrerApp.Api/ReadableSnapshotStore.cs
Normal file
72
LehrerApp.Api/ReadableSnapshotStore.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Server-seitiger Speicher für lesbare Snapshots.
|
||||
/// Eine JSON-Datei pro User – wird bei jedem Export überschrieben.
|
||||
/// Kein Ablauf-Datum – der letzte Stand bleibt bis zum nächsten Export.
|
||||
/// </summary>
|
||||
public class ReadableSnapshotStore
|
||||
{
|
||||
private readonly string _dataPath;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public ReadableSnapshotStore(string dataPath)
|
||||
{
|
||||
_dataPath = Path.Combine(dataPath, "readable");
|
||||
Directory.CreateDirectory(_dataPath);
|
||||
}
|
||||
|
||||
// ── Speichern ─────────────────────────────────────────────────────────────
|
||||
|
||||
public void Store(string userId, ReadableSnapshot snapshot)
|
||||
{
|
||||
var path = SnapshotPath(userId);
|
||||
var json = JsonSerializer.Serialize(snapshot, JsonOptions);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
// ── Laden ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public ReadableSnapshot? Load(string userId)
|
||||
{
|
||||
var path = SnapshotPath(userId);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<ReadableSnapshot>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt nur die Metadaten zurück – für schnelle Freshness-Prüfung.
|
||||
/// </summary>
|
||||
public SnapshotMeta? LoadMeta(string userId)
|
||||
{
|
||||
var snapshot = Load(userId);
|
||||
return snapshot?.Meta;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private string SnapshotPath(string userId)
|
||||
{
|
||||
// Sanitize userId
|
||||
var safe = string.Concat(
|
||||
userId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
|
||||
return Path.Combine(_dataPath, $"{safe}.json");
|
||||
}
|
||||
}
|
||||
155
LehrerApp.Api/SnapshotStore.cs
Normal file
155
LehrerApp.Api/SnapshotStore.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Server-seitiger Snapshot-Speicher.
|
||||
/// Speichert:
|
||||
/// - EncryptedPayload: mit Sync-Schlüssel verschlüsselte LiteDB
|
||||
/// - EncryptedSyncKey: mit Code-Key (PBKDF2) verschlüsselter Sync-Schlüssel
|
||||
///
|
||||
/// Der Server versteht beides nicht – er reicht es nur weiter.
|
||||
/// TTL: 24h, Einmal-Verwendung.
|
||||
/// </summary>
|
||||
public class SnapshotStore : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<SnapshotEntry> _col;
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
private static readonly string[] Animals =
|
||||
[
|
||||
"TIGER", "ADLER", "DACHS", "LUCHS", "FALKE",
|
||||
"IGEL", "ELCH", "FUCHS", "RABE", "WOLF",
|
||||
"BISON", "LAMM", "EULE", "BIBER", "STORCH",
|
||||
"HIRSCH","OTTER", "MARDER","KRANICH","LACHS",
|
||||
];
|
||||
|
||||
private static readonly string[] Colors =
|
||||
[
|
||||
"BLAU", "GRUEN", "ROT", "GOLD", "GRAU",
|
||||
"CYAN", "ROSA", "LILA", "SAND", "MINT",
|
||||
"SMARAGD","KORALLE","INDIGO","AMBER","JADE",
|
||||
];
|
||||
|
||||
public SnapshotStore(string dataPath)
|
||||
{
|
||||
Directory.CreateDirectory(dataPath);
|
||||
_db = new LiteDatabase(Path.Combine(dataPath, "snapshots.db"));
|
||||
_col = _db.GetCollection<SnapshotEntry>("snapshots");
|
||||
_col.EnsureIndex(x => x.Code);
|
||||
_col.EnsureIndex(x => x.ExpiresAt);
|
||||
|
||||
_cleanupTimer = new Timer(
|
||||
_ => Cleanup(),
|
||||
null,
|
||||
TimeSpan.FromHours(1),
|
||||
TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Speichert Snapshot und verschlüsselten Sync-Schlüssel.
|
||||
/// Wenn für denselben User bereits ein Snapshot existiert, wird er ersetzt.
|
||||
/// </summary>
|
||||
public SnapshotUploadResponse Store(
|
||||
string userId,
|
||||
SnapshotUploadRequest request)
|
||||
{
|
||||
// Alten Snapshot desselben Users überschreiben
|
||||
_col.DeleteMany(e => e.UserId == userId);
|
||||
|
||||
var code = GenerateCode();
|
||||
var entry = new SnapshotEntry
|
||||
{
|
||||
Code = code,
|
||||
UserId = userId,
|
||||
EncryptedPayload = request.EncryptedPayload,
|
||||
EncryptedSyncKey = request.EncryptedSyncKey,
|
||||
SourceDeviceType = request.DeviceType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
|
||||
_col.Insert(entry);
|
||||
|
||||
return new SnapshotUploadResponse
|
||||
{
|
||||
Code = code,
|
||||
ExpiresAt = entry.ExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Download (Einmal-Verwendung) ──────────────────────────────────────────
|
||||
|
||||
public SnapshotDownloadResponse? Retrieve(string userId, string code)
|
||||
{
|
||||
var entry = _col.FindOne(e =>
|
||||
e.UserId == userId &&
|
||||
e.Code == code.ToUpperInvariant());
|
||||
|
||||
if (entry is null) return null;
|
||||
|
||||
if (entry.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
_col.Delete(entry.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Einmal-Verwendung: sofort löschen
|
||||
_col.Delete(entry.Id);
|
||||
|
||||
return new SnapshotDownloadResponse
|
||||
{
|
||||
EncryptedPayload = entry.EncryptedPayload,
|
||||
EncryptedSyncKey = entry.EncryptedSyncKey,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
SourceDeviceType = entry.SourceDeviceType,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bereinigung ───────────────────────────────────────────────────────────
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_col.DeleteMany(e => e.ExpiresAt < now);
|
||||
}
|
||||
|
||||
// ── Code-Generierung ──────────────────────────────────────────────────────
|
||||
|
||||
private static string GenerateCode()
|
||||
{
|
||||
var rng = Random.Shared;
|
||||
var animal = Animals[rng.Next(Animals.Length)];
|
||||
var number = rng.Next(10, 99);
|
||||
var color = Colors[rng.Next(Colors.Length)];
|
||||
return $"{animal}-{number}-{color}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer.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; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sync-Schlüssel verschlüsselt mit PBKDF2(Code).
|
||||
/// Leer wenn noch nicht gesetzt (zweistufiger Upload).
|
||||
/// </summary>
|
||||
public string EncryptedSyncKey { get; set; } = "";
|
||||
|
||||
public DeviceType SourceDeviceType { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
12
LehrerApp.Api/appsettings.json
Normal file
12
LehrerApp.Api/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Api": {
|
||||
"Port": 5000,
|
||||
"DataPath": "./data"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
97
LehrerApp.Core/Interfaces/IRepositories.cs
Normal file
97
LehrerApp.Core/Interfaces/IRepositories.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
}
|
||||
8
LehrerApp.Core/LehrerApp.Core.csproj
Normal file
8
LehrerApp.Core/LehrerApp.Core.csproj
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
47
LehrerApp.Core/Models/Exam.cs
Normal file
47
LehrerApp.Core/Models/Exam.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
public class Exam
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid GroupId { get; set; }
|
||||
public string Title { get; set; } = ""; // "Arbeit Nr. 2 – Redox"
|
||||
public DateOnly Date { get; set; }
|
||||
public string Subject { get; set; } = "";
|
||||
public int? ExamNumber { get; set; }
|
||||
public List<ExamTask> Tasks { get; set; } = []; // nested – kein JOIN
|
||||
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; } = ""; // "1","2"... oder "15","14"...
|
||||
public double MinPercent { get; set; }
|
||||
}
|
||||
|
||||
// Ergebnisse separat – für Notenspiegel ohne Aufgabentext laden
|
||||
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; } = []; // Index = Aufgabe Nr - 1
|
||||
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 }
|
||||
33
LehrerApp.Core/Models/LearningGroup.cs
Normal file
33
LehrerApp.Core/Models/LearningGroup.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
public class LearningGroup
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = ""; // "10E", "Q1 Chemie"
|
||||
public GroupType Type { get; set; }
|
||||
public string? Subject { get; set; } // "Chemie", "Mathematik"
|
||||
public string SchoolYear { get; set; } = ""; // "2024/25"
|
||||
public int GradeLevel { get; set; } // 5, 10, 11, 12 ...
|
||||
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; } // bei Wechsel mid-year
|
||||
}
|
||||
|
||||
public enum GroupType { Class, Course }
|
||||
|
||||
public enum GradingSystem
|
||||
{
|
||||
Grades1To6, // Noten 1–6 (Sek I)
|
||||
Points0To15, // Punkte 0–15 (Oberstufe)
|
||||
}
|
||||
57
LehrerApp.Core/Models/Planning.cs
Normal file
57
LehrerApp.Core/Models/Planning.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
// ── Sonstige Noten ────────────────────────────────────────────────────────────
|
||||
|
||||
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; } = ""; // "2", "11", "+" je nach System
|
||||
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 }
|
||||
|
||||
// ── Unterrichtsplanung ────────────────────────────────────────────────────────
|
||||
|
||||
public class Unit // Unterrichtseinheit / Reihe
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid GroupId { get; set; }
|
||||
public string Title { get; set; } = ""; // "Ionenbindung"
|
||||
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 // Einzelstunde
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UnitId { get; set; }
|
||||
public Guid GroupId { get; set; } // Redundanz für schnelle Abfragen
|
||||
public DateOnly Date { get; set; }
|
||||
public int? LessonNumber { get; set; }
|
||||
public string Topic { get; set; } = "";
|
||||
public string? Phase { get; set; } // "Einstieg", "Erarbeitung" ...
|
||||
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 }
|
||||
52
LehrerApp.Core/Models/ReadableSnapshot.cs
Normal file
52
LehrerApp.Core/Models/ReadableSnapshot.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lesbarer Snapshot der lokalen Datenbank für die WebApp.
|
||||
/// Wird periodisch vom Desktop exportiert und auf dem Server hinterlegt.
|
||||
///
|
||||
/// Bewusst NICHT enthalten:
|
||||
/// - Documentation (vertraulich, bleibt lokal)
|
||||
/// - Vollständige Notizen (nur Noten-Übersichten)
|
||||
/// - Sync-interne Daten
|
||||
/// </summary>
|
||||
public class ReadableSnapshot
|
||||
{
|
||||
public DateTime ExportedAt { get; set; } = DateTime.UtcNow;
|
||||
public string SchoolYear { get; set; } = "";
|
||||
|
||||
public List<Student> Students { get; set; } = [];
|
||||
public List<LearningGroup> Groups { get; set; } = [];
|
||||
public List<Enrollment> Enrollments { get; set; } = [];
|
||||
|
||||
// Klausuren mit Aufgaben aber ohne Ergebnisse
|
||||
public List<Exam> Exams { get; set; } = [];
|
||||
|
||||
// Ergebnisse separat – kann bei Bedarf weggelassen werden
|
||||
public List<ExamResult> ExamResults { get; set; } = [];
|
||||
|
||||
// Sonstige Noten
|
||||
public List<Grade> Grades { get; set; } = [];
|
||||
|
||||
// Unterrichtsplanung
|
||||
public List<Unit> Units { get; set; } = [];
|
||||
public List<Lesson> Lessons { get; set; } = [];
|
||||
|
||||
// Aufgaben (kein Zeiterfassungs-Detail)
|
||||
public List<WorkTask> Tasks { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Metadaten für die WebApp – wie alt ist der Snapshot?
|
||||
/// </summary>
|
||||
public SnapshotMeta Meta { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SnapshotMeta
|
||||
{
|
||||
public int StudentCount { get; set; }
|
||||
public int GroupCount { get; set; }
|
||||
public int ExamCount { get; set; }
|
||||
public DateTime OldestData { get; set; }
|
||||
public string ExportedByDevice { get; set; } = "";
|
||||
}
|
||||
26
LehrerApp.Core/Models/Student.cs
Normal file
26
LehrerApp.Core/Models/Student.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
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; } = ""; // "Mutter", "Vater", "Vormund"
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public enum Gender { M, W, D }
|
||||
70
LehrerApp.Core/Models/Workload.cs
Normal file
70
LehrerApp.Core/Models/Workload.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
// ── Schülerdokumentation ──────────────────────────────────────────────────────
|
||||
|
||||
public class Documentation
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid StudentId { get; set; }
|
||||
public Guid? GroupId { get; set; } // optional – nicht immer kursbezogen
|
||||
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; } = []; // für Gesprächsnotizen
|
||||
public AbsenceData? AbsenceData { get; set; }
|
||||
public SupportData? SupportData { get; set; }
|
||||
public bool IsConfidential { get; set; } // DSGVO: besonders schützenswert
|
||||
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 }
|
||||
|
||||
// ── Arbeitszeit & Aufgaben ────────────────────────────────────────────────────
|
||||
|
||||
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 }
|
||||
90
LehrerApp.Core/Services/GradingService.cs
Normal file
90
LehrerApp.Core/Services/GradingService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public class GradingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Berechnet die Note aus Punkten anhand des Notenschlüssels der Klausur.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardnotenschlüssel 1–6 nach NRW-Schema (anpassbar).
|
||||
/// </summary>
|
||||
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 },
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Standardnotenschlüssel 0–15 Punkte (Oberstufe).
|
||||
/// </summary>
|
||||
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 },
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet den Notendurchschnitt aus einer Liste von Notenwerten (1-6 System).
|
||||
/// </summary>
|
||||
public double AverageGrade1To6(List<string> grades)
|
||||
{
|
||||
var numeric = grades
|
||||
.Select(g => int.TryParse(g, out var n) ? (int?)n : null)
|
||||
.Where(n => n.HasValue)
|
||||
.Select(n => n!.Value)
|
||||
.ToList();
|
||||
|
||||
return numeric.Count == 0 ? 0 : numeric.Average();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Berechnet den Notendurchschnitt aus gewichteten Noten.
|
||||
/// </summary>
|
||||
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 totalWeight = numeric.Sum(g => g.Weight);
|
||||
var weightedSum = numeric.Sum(g => g.Value!.Value * g.Weight);
|
||||
|
||||
return totalWeight == 0 ? 0 : weightedSum / totalWeight;
|
||||
}
|
||||
}
|
||||
48
LehrerApp.Core/Services/SchoolYearService.cs
Normal file
48
LehrerApp.Core/Services/SchoolYearService.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public class SchoolYearService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gibt das aktuelle Schuljahr zurück, z.B. "2024/25".
|
||||
/// Schuljahr beginnt am 1. August.
|
||||
/// </summary>
|
||||
public string CurrentSchoolYear()
|
||||
{
|
||||
var now = DateTime.Today;
|
||||
var startYear = now.Month >= 8 ? now.Year : now.Year - 1;
|
||||
return FormatSchoolYear(startYear);
|
||||
}
|
||||
|
||||
public string FormatSchoolYear(int startYear) =>
|
||||
$"{startYear}/{(startYear + 1) % 100:D2}";
|
||||
|
||||
/// <summary>
|
||||
/// Gibt den Beginn des Schuljahres zurück (1. August).
|
||||
/// </summary>
|
||||
public DateOnly SchoolYearStart(string schoolYear)
|
||||
{
|
||||
var year = int.Parse(schoolYear.Split('/')[0]);
|
||||
return new DateOnly(year, 8, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt das Ende des Schuljahres zurück (31. Juli des Folgejahres).
|
||||
/// </summary>
|
||||
public DateOnly SchoolYearEnd(string schoolYear)
|
||||
{
|
||||
var year = int.Parse(schoolYear.Split('/')[0]) + 1;
|
||||
return new DateOnly(year, 7, 31);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt die letzten N Schuljahre zurück (für Dropdowns).
|
||||
/// </summary>
|
||||
public List<string> RecentSchoolYears(int count = 5)
|
||||
{
|
||||
var now = DateTime.Today;
|
||||
var currentStart = now.Month >= 8 ? now.Year : now.Year - 1;
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(i => FormatSchoolYear(currentStart - i))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
15
LehrerApp.Data/LehrerApp.Data.csproj
Normal file
15
LehrerApp.Data/LehrerApp.Data.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- LiteDB 5.x ist die aktuelle stabile Version für .NET 9 -->
|
||||
<PackageReference Include="LiteDB" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
117
LehrerApp.Data/LiteDbContext.cs
Normal file
117
LehrerApp.Data/LiteDbContext.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Datenbankverbindung. Einmalig als Singleton registrieren.
|
||||
/// Eine Instanz = eine .db Datei = ein Nutzer.
|
||||
/// </summary>
|
||||
public class LiteDbContext : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
|
||||
public LiteDbContext(string databasePath)
|
||||
{
|
||||
// WAL-Modus für bessere Performance bei Concurrent Reads
|
||||
var connectionString = new ConnectionString(databasePath)
|
||||
{
|
||||
Connection = ConnectionType.Shared,
|
||||
};
|
||||
|
||||
_db = new LiteDatabase(connectionString);
|
||||
EnsureIndexes();
|
||||
}
|
||||
|
||||
// ── Collections ──────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
|
||||
// ── Transaktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
public T Transaction<T>(Func<T> action) => _db.BeginTrans() ? action() : action();
|
||||
|
||||
public void Checkpoint() => _db.Checkpoint();
|
||||
|
||||
// ── Backup ───────────────────────────────────────────────────────────────
|
||||
|
||||
public void BackupTo(string targetPath)
|
||||
{
|
||||
_db.Checkpoint();
|
||||
File.Copy(_db.UserVersion.ToString(), targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
// ── Indizes ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void EnsureIndexes()
|
||||
{
|
||||
Students.EnsureIndex(x => x.LastName);
|
||||
Students.EnsureIndex(x => x.IsActive);
|
||||
|
||||
Groups.EnsureIndex(x => x.SchoolYear);
|
||||
Groups.EnsureIndex(x => x.Type);
|
||||
|
||||
Enrollments.EnsureIndex(x => x.StudentId);
|
||||
Enrollments.EnsureIndex(x => x.GroupId);
|
||||
Enrollments.EnsureIndex(x => x.SchoolYear);
|
||||
|
||||
Exams.EnsureIndex(x => x.GroupId);
|
||||
Exams.EnsureIndex(x => x.Date);
|
||||
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);
|
||||
Units.EnsureIndex(x => x.Status);
|
||||
|
||||
Lessons.EnsureIndex(x => x.UnitId);
|
||||
Lessons.EnsureIndex(x => x.GroupId);
|
||||
Lessons.EnsureIndex(x => x.Date);
|
||||
|
||||
Documentation.EnsureIndex(x => x.StudentId);
|
||||
Documentation.EnsureIndex(x => x.Type);
|
||||
|
||||
Tasks.EnsureIndex(x => x.Status);
|
||||
Tasks.EnsureIndex(x => x.DueDate);
|
||||
|
||||
TimeEntries.EnsureIndex(x => x.Date);
|
||||
TimeEntries.EnsureIndex(x => x.TaskId);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
95
LehrerApp.Data/Repositories/GroupExamRepositories.cs
Normal file
95
LehrerApp.Data/Repositories/GroupExamRepositories.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Data.Repositories;
|
||||
|
||||
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 group)
|
||||
{
|
||||
group.UpdatedAt = DateTime.UtcNow;
|
||||
db.Groups.Upsert(group);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Groups.Delete(id);
|
||||
}
|
||||
|
||||
public class EnrollmentRepository(LiteDbContext db) : IEnrollmentRepository
|
||||
{
|
||||
public List<Enrollment> GetByStudent(Guid studentId) =>
|
||||
db.Enrollments.Find(e => e.StudentId == studentId).ToList();
|
||||
|
||||
public List<Enrollment> GetByGroup(Guid groupId) =>
|
||||
db.Enrollments.Find(e => e.GroupId == groupId).ToList();
|
||||
|
||||
public List<Enrollment> GetByGroupAndYear(Guid groupId, string schoolYear) =>
|
||||
db.Enrollments
|
||||
.Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear)
|
||||
.ToList();
|
||||
|
||||
public void Save(Enrollment enrollment) =>
|
||||
db.Enrollments.Upsert(enrollment);
|
||||
|
||||
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 exam)
|
||||
{
|
||||
exam.UpdatedAt = DateTime.UtcNow;
|
||||
db.Exams.Upsert(exam);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Exams.Delete(id);
|
||||
}
|
||||
|
||||
public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
||||
{
|
||||
public List<ExamResult> GetByExam(Guid examId) =>
|
||||
db.ExamResults.Find(r => r.ExamId == examId).ToList();
|
||||
|
||||
public List<ExamResult> GetByStudent(Guid studentId) =>
|
||||
db.ExamResults.Find(r => r.StudentId == studentId).ToList();
|
||||
|
||||
public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
|
||||
db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId);
|
||||
|
||||
public void Save(ExamResult result)
|
||||
{
|
||||
result.UpdatedAt = DateTime.UtcNow;
|
||||
db.ExamResults.Upsert(result);
|
||||
}
|
||||
|
||||
public void SaveMany(List<ExamResult> results)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var r in results) r.UpdatedAt = now;
|
||||
db.ExamResults.Upsert(results);
|
||||
}
|
||||
}
|
||||
140
LehrerApp.Data/Repositories/OtherRepositories.cs
Normal file
140
LehrerApp.Data/Repositories/OtherRepositories.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Data.Repositories;
|
||||
|
||||
public class GradeRepository(LiteDbContext db) : IGradeRepository
|
||||
{
|
||||
public List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId) =>
|
||||
db.Grades
|
||||
.Find(g => g.StudentId == studentId && g.GroupId == groupId)
|
||||
.OrderBy(g => g.Date)
|
||||
.ToList();
|
||||
|
||||
public List<Grade> GetByGroup(Guid groupId) =>
|
||||
db.Grades.Find(g => g.GroupId == groupId)
|
||||
.OrderBy(g => g.Date)
|
||||
.ToList();
|
||||
|
||||
public void Save(Grade grade) =>
|
||||
db.Grades.Upsert(grade);
|
||||
|
||||
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 groupId) =>
|
||||
db.Units.Find(u => u.GroupId == groupId)
|
||||
.OrderBy(u => u.StartDate)
|
||||
.ToList();
|
||||
|
||||
public void Save(Unit unit)
|
||||
{
|
||||
unit.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(unit);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Units.Delete(id);
|
||||
}
|
||||
|
||||
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
{
|
||||
public List<Lesson> GetByUnit(Guid unitId) =>
|
||||
db.Lessons.Find(l => l.UnitId == unitId)
|
||||
.OrderBy(l => l.Date)
|
||||
.ToList();
|
||||
|
||||
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
|
||||
db.Lessons.Find(l => l.GroupId == groupId && l.Date == date).ToList();
|
||||
|
||||
public List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to) =>
|
||||
db.Lessons
|
||||
.Find(l => l.GroupId == groupId && l.Date >= from && l.Date <= to)
|
||||
.OrderBy(l => l.Date)
|
||||
.ToList();
|
||||
|
||||
public void Save(Lesson lesson)
|
||||
{
|
||||
lesson.UpdatedAt = DateTime.UtcNow;
|
||||
db.Lessons.Upsert(lesson);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Lessons.Delete(id);
|
||||
}
|
||||
|
||||
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
|
||||
{
|
||||
public List<Documentation> GetByStudent(Guid studentId) =>
|
||||
db.Documentation.Find(d => d.StudentId == studentId)
|
||||
.OrderByDescending(d => d.Date)
|
||||
.ToList();
|
||||
|
||||
public List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type) =>
|
||||
db.Documentation
|
||||
.Find(d => d.StudentId == studentId && d.Type == type)
|
||||
.OrderByDescending(d => d.Date)
|
||||
.ToList();
|
||||
|
||||
public void Save(Documentation doc)
|
||||
{
|
||||
doc.UpdatedAt = DateTime.UtcNow;
|
||||
db.Documentation.Upsert(doc);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Documentation.Delete(id);
|
||||
}
|
||||
|
||||
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
{
|
||||
public List<WorkTask> GetByStatus(WorkTaskStatus status) =>
|
||||
db.Tasks.Find(t => t.Status == status)
|
||||
.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 task)
|
||||
{
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
db.Tasks.Upsert(task);
|
||||
}
|
||||
|
||||
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)
|
||||
.ThenBy(e => e.StartTime)
|
||||
.ToList();
|
||||
|
||||
public List<TimeEntry> GetByTask(Guid taskId) =>
|
||||
db.TimeEntries.Find(e => e.TaskId == taskId).ToList();
|
||||
|
||||
public void Save(TimeEntry entry) =>
|
||||
db.TimeEntries.Upsert(entry);
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.TimeEntries.Delete(id);
|
||||
}
|
||||
38
LehrerApp.Data/Repositories/StudentRepository.cs
Normal file
38
LehrerApp.Data/Repositories/StudentRepository.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
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().OrderBy(s => s.LastName).ToList()
|
||||
: db.Students.Find(s => s.IsActive).OrderBy(s => s.LastName).ToList();
|
||||
|
||||
public List<Student> GetByGroup(Guid groupId, string schoolYear)
|
||||
{
|
||||
// Enrollment als Bindeglied – Schüler-IDs der Gruppe ermitteln
|
||||
var studentIds = db.Enrollments
|
||||
.Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear)
|
||||
.Select(e => e.StudentId)
|
||||
.ToHashSet();
|
||||
|
||||
return db.Students
|
||||
.Find(s => studentIds.Contains(s.Id))
|
||||
.OrderBy(s => s.LastName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void Save(Student student)
|
||||
{
|
||||
student.UpdatedAt = DateTime.UtcNow;
|
||||
db.Students.Upsert(student);
|
||||
}
|
||||
|
||||
public void Delete(Guid id) =>
|
||||
db.Students.Delete(id);
|
||||
}
|
||||
18
LehrerApp.Desktop/App.axaml
Normal file
18
LehrerApp.Desktop/App.axaml
Normal file
@@ -0,0 +1,18 @@
|
||||
<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 />
|
||||
</Application.Styles>
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Views/DataTemplates.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
</Application>
|
||||
33
LehrerApp.Desktop/App.axaml.cs
Normal file
33
LehrerApp.Desktop/App.axaml.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
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)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = Services.GetRequiredService<MainWindowViewModel>(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
181
LehrerApp.Desktop/AppBootstrapper.cs
Normal file
181
LehrerApp.Desktop/AppBootstrapper.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
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;
|
||||
|
||||
public static class AppBootstrapper
|
||||
{
|
||||
public static IServiceProvider BuildServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// ── Pfade ─────────────────────────────────────────────────────────────
|
||||
var appData = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"LehrerApp");
|
||||
Directory.CreateDirectory(appData);
|
||||
|
||||
var 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>();
|
||||
|
||||
// ── Core Services ─────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
services.AddSingleton<SchoolYearService>();
|
||||
|
||||
// ── Sync ──────────────────────────────────────────────────────────────
|
||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||
services.AddSingleton(sp =>
|
||||
new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||
|
||||
// Schlüssel laden oder neu generieren
|
||||
services.AddSingleton<byte[]>(_ =>
|
||||
{
|
||||
var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, keyPath);
|
||||
return key;
|
||||
});
|
||||
|
||||
// EventApplier – wendet gezogene Events auf LiteDB an
|
||||
services.AddSingleton<EventApplier>(sp => new EventApplier(
|
||||
key: sp.GetRequiredService<byte[]>(),
|
||||
students: sp.GetRequiredService<IStudentRepository>(),
|
||||
groups: sp.GetRequiredService<IGroupRepository>(),
|
||||
enrollments: sp.GetRequiredService<IEnrollmentRepository>(),
|
||||
exams: sp.GetRequiredService<IExamRepository>(),
|
||||
examResults: sp.GetRequiredService<IExamResultRepository>(),
|
||||
grades: sp.GetRequiredService<IGradeRepository>(),
|
||||
units: sp.GetRequiredService<IUnitRepository>(),
|
||||
lessons: sp.GetRequiredService<ILessonRepository>(),
|
||||
docs: sp.GetRequiredService<IDocumentationRepository>(),
|
||||
tasks: sp.GetRequiredService<IWorkTaskRepository>(),
|
||||
timeEntries: sp.GetRequiredService<ITimeEntryRepository>()));
|
||||
|
||||
// SyncEngine + ReadableSnapshotService nur wenn Server konfiguriert
|
||||
var serverUrl = LoadServerUrl(appData);
|
||||
var deviceId = LoadOrCreateDeviceId(appData);
|
||||
|
||||
services.AddSingleton<SyncEngine?>(sp =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(serverUrl)) return null;
|
||||
|
||||
var http = BuildHttpClient(serverUrl, appData);
|
||||
var config = new SyncConfig
|
||||
{
|
||||
ServerUrl = serverUrl,
|
||||
DeviceId = deviceId,
|
||||
DeviceType = DeviceType.Desktop,
|
||||
AutoSyncIntervalMinutes = 5,
|
||||
};
|
||||
return new SyncEngine(
|
||||
sp.GetRequiredService<EventQueue>(),
|
||||
sp.GetRequiredService<ConflictResolver>(),
|
||||
http,
|
||||
config);
|
||||
});
|
||||
|
||||
services.AddSingleton<ReadableSnapshotService?>(sp =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(serverUrl)) return null;
|
||||
|
||||
return new ReadableSnapshotService(
|
||||
http: BuildHttpClient(serverUrl, appData),
|
||||
students: sp.GetRequiredService<IStudentRepository>(),
|
||||
groups: sp.GetRequiredService<IGroupRepository>(),
|
||||
enrollments: sp.GetRequiredService<IEnrollmentRepository>(),
|
||||
exams: sp.GetRequiredService<IExamRepository>(),
|
||||
examResults: sp.GetRequiredService<IExamResultRepository>(),
|
||||
grades: sp.GetRequiredService<IGradeRepository>(),
|
||||
units: sp.GetRequiredService<IUnitRepository>(),
|
||||
lessons: sp.GetRequiredService<ILessonRepository>(),
|
||||
tasks: sp.GetRequiredService<IWorkTaskRepository>(),
|
||||
schoolYear: sp.GetRequiredService<SchoolYearService>(),
|
||||
deviceId: deviceId);
|
||||
});
|
||||
|
||||
services.AddSingleton<SnapshotService?>(sp =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(serverUrl)) return null;
|
||||
|
||||
return new SnapshotService(
|
||||
http: BuildHttpClient(serverUrl, appData),
|
||||
db: sp.GetRequiredService<LiteDbContext>(),
|
||||
syncKey: sp.GetRequiredService<byte[]>(),
|
||||
deviceType: DeviceType.Desktop,
|
||||
dbPath: dbPath,
|
||||
keyPath: keyPath);
|
||||
});
|
||||
|
||||
// ── ViewModels ────────────────────────────────────────────────────────
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddTransient<GroupListViewModel>();
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
services.AddTransient<StudentListViewModel>();
|
||||
services.AddTransient<StudentDetailViewModel>();
|
||||
services.AddSingleton<SyncStatusViewModel>();
|
||||
services.AddTransient<DevicePairingViewModel>(sp =>
|
||||
new DevicePairingViewModel(
|
||||
sp.GetRequiredService<SnapshotService?>()
|
||||
?? throw new InvalidOperationException(
|
||||
"Kein Server konfiguriert.")));
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private static HttpClient BuildHttpClient(string serverUrl, string appData)
|
||||
{
|
||||
var http = new HttpClient { BaseAddress = new Uri(serverUrl) };
|
||||
var tokenPath = Path.Combine(appData, "auth.token");
|
||||
if (File.Exists(tokenPath))
|
||||
{
|
||||
var token = File.ReadAllText(tokenPath).Trim();
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", token);
|
||||
}
|
||||
return http;
|
||||
}
|
||||
|
||||
private static string LoadServerUrl(string appData)
|
||||
{
|
||||
var path = Path.Combine(appData, "server.txt");
|
||||
return File.Exists(path) ? File.ReadAllText(path).Trim() : "";
|
||||
}
|
||||
|
||||
private static string LoadOrCreateDeviceId(string appData)
|
||||
{
|
||||
var path = Path.Combine(appData, "device.id");
|
||||
if (File.Exists(path)) return File.ReadAllText(path).Trim();
|
||||
var id = Guid.NewGuid().ToString();
|
||||
File.WriteAllText(path, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
32
LehrerApp.Desktop/LehrerApp.Desktop.csproj
Normal file
32
LehrerApp.Desktop/LehrerApp.Desktop.csproj
Normal file
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<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>
|
||||
<!-- Avalonia 11.3.x – aktuell stabiler Branch -->
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<!-- ReactiveUI aus Avalonia 11.3 entfernt – CommunityToolkit.Mvvm reicht -->
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
16
LehrerApp.Desktop/Program.cs
Normal file
16
LehrerApp.Desktop/Program.cs
Normal file
@@ -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();
|
||||
}
|
||||
136
LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
Normal file
136
LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
|
||||
public partial class DashboardViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly SchoolYearService _schoolYearService;
|
||||
|
||||
[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; } = [];
|
||||
|
||||
public DashboardViewModel(
|
||||
IGroupRepository groups,
|
||||
ILessonRepository lessons,
|
||||
IWorkTaskRepository tasks,
|
||||
SchoolYearService schoolYearService)
|
||||
{
|
||||
_groups = groups;
|
||||
_lessons = lessons;
|
||||
_tasks = tasks;
|
||||
_schoolYearService = schoolYearService;
|
||||
|
||||
Load();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
CurrentDate = now.ToString("dddd, d. MMMM yyyy",
|
||||
new System.Globalization.CultureInfo("de-DE"));
|
||||
CurrentSchoolYear = _schoolYearService.CurrentSchoolYear();
|
||||
Greeting = now.Hour < 12 ? "Guten Morgen" :
|
||||
now.Hour < 18 ? "Guten Tag" : "Guten Abend";
|
||||
|
||||
// Heutige Stunden
|
||||
TodaysLessons.Clear();
|
||||
var schoolYear = _schoolYearService.CurrentSchoolYear();
|
||||
var allGroups = _groups.GetBySchoolYear(schoolYear)
|
||||
.ToDictionary(g => g.Id);
|
||||
|
||||
var todayLessons = allGroups.Keys
|
||||
.SelectMany(gid => _lessons.GetByGroupAndDate(gid, today))
|
||||
.OrderBy(l => l.LessonNumber)
|
||||
.ToList();
|
||||
|
||||
foreach (var lesson in todayLessons)
|
||||
{
|
||||
if (!allGroups.TryGetValue(lesson.GroupId, out var group)) continue;
|
||||
TodaysLessons.Add(new LessonItem
|
||||
{
|
||||
GroupName = group.Name,
|
||||
Topic = lesson.Topic,
|
||||
Status = lesson.Status,
|
||||
});
|
||||
}
|
||||
|
||||
// Offene Aufgaben (mit Fälligkeit)
|
||||
OpenTasks.Clear();
|
||||
var openTasks = _tasks.GetByStatus(WorkTaskStatus.Open)
|
||||
.Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress))
|
||||
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue)
|
||||
.Take(5);
|
||||
|
||||
foreach (var task in openTasks)
|
||||
{
|
||||
var isOverdue = task.DueDate.HasValue && task.DueDate < today;
|
||||
var isDueSoon = task.DueDate.HasValue &&
|
||||
task.DueDate >= today &&
|
||||
task.DueDate <= today.AddDays(3);
|
||||
|
||||
OpenTasks.Add(new TaskItem
|
||||
{
|
||||
Title = task.Title,
|
||||
DueDate = task.DueDate?.ToString("dd.MM.") ?? "",
|
||||
IsOverdue = isOverdue,
|
||||
IsDueSoon = isDueSoon,
|
||||
Status = task.Status,
|
||||
});
|
||||
}
|
||||
|
||||
// Aktuelle Lerngruppen als Chips
|
||||
CurrentGroups.Clear();
|
||||
foreach (var group in _groups.GetBySchoolYear(schoolYear)
|
||||
.OrderBy(g => g.Name))
|
||||
{
|
||||
CurrentGroups.Add(new GroupChip
|
||||
{
|
||||
GroupId = group.Id,
|
||||
Name = group.Name,
|
||||
Subject = group.Subject ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Refresh() => Load();
|
||||
}
|
||||
|
||||
// Anzeigemodelle für Dashboard-Widgets
|
||||
public class LessonItem
|
||||
{
|
||||
public string GroupName { get; set; } = "";
|
||||
public string Topic { get; set; } = "";
|
||||
public LessonStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class TaskItem
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string DueDate { get; set; } = "";
|
||||
public bool IsOverdue { get; set; }
|
||||
public bool IsDueSoon { get; set; }
|
||||
public WorkTaskStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class GroupChip
|
||||
{
|
||||
public Guid GroupId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
}
|
||||
158
LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs
Normal file
158
LehrerApp.Desktop/ViewModels/DevicePairingViewModel.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Steuert den Dialog zum Einrichten eines neuen Geräts.
|
||||
///
|
||||
/// Zwei Modi:
|
||||
/// Sender → "Neues Gerät hinzufügen" – erzeugt Snapshot und zeigt Code
|
||||
/// Empfänger → "Mit bestehendem Account verbinden" – Code eingeben, Restore
|
||||
/// </summary>
|
||||
public partial class DevicePairingViewModel : ObservableObject
|
||||
{
|
||||
private readonly SnapshotService _snapshotService;
|
||||
|
||||
// ── Gemeinsamer State ─────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private PairingMode _mode = PairingMode.SelectMode;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private int _progressPercent;
|
||||
[ObservableProperty] private bool _isError;
|
||||
[ObservableProperty] private bool _isComplete;
|
||||
|
||||
// ── Sender-State ─────────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _generatedCode = "";
|
||||
[ObservableProperty] private DateTime _codeExpiresAt;
|
||||
[ObservableProperty] private string _codeExpiresText = "";
|
||||
|
||||
// ── Empfänger-State ───────────────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _inputCode = "";
|
||||
|
||||
public DevicePairingViewModel(SnapshotService snapshotService)
|
||||
{
|
||||
_snapshotService = snapshotService;
|
||||
_snapshotService.ProgressChanged += OnProgress;
|
||||
}
|
||||
|
||||
// ── Navigation zwischen Modi ──────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectSender() => Mode = PairingMode.Sender;
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectReceiver() => Mode = PairingMode.Receiver;
|
||||
|
||||
[RelayCommand]
|
||||
private void Back()
|
||||
{
|
||||
Mode = PairingMode.SelectMode;
|
||||
Reset();
|
||||
}
|
||||
|
||||
// ── Sender: Snapshot erstellen ────────────────────────────────────────────
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCreateSnapshot))]
|
||||
private async Task CreateSnapshotAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
IsError = false;
|
||||
IsComplete = false;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _snapshotService.CreateAndUploadAsync();
|
||||
|
||||
GeneratedCode = result.Code;
|
||||
CodeExpiresAt = result.ExpiresAt;
|
||||
CodeExpiresText = $"Gültig bis {result.ExpiresAt:HH:mm} Uhr " +
|
||||
$"({result.ExpiresAt:dd.MM.yyyy})";
|
||||
Mode = PairingMode.SenderShowCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsError = true;
|
||||
StatusMessage = $"Fehler: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCreateSnapshot() => !IsBusy;
|
||||
|
||||
// ── Empfänger: Snapshot wiederherstellen ──────────────────────────────────
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRestore))]
|
||||
private async Task RestoreAsync(string targetDbPath)
|
||||
{
|
||||
IsBusy = true;
|
||||
IsError = false;
|
||||
IsComplete = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Schlüssel wird aus dem Code extrahiert und lokal gespeichert
|
||||
// → App-Neustart lädt ihn automatisch
|
||||
await _snapshotService.RestoreFromCodeAsync(
|
||||
InputCode.Trim(), targetDbPath);
|
||||
|
||||
IsComplete = true;
|
||||
StatusMessage = "Erfolgreich! Bitte die App neu starten.";
|
||||
Mode = PairingMode.ReceiverDone;
|
||||
}
|
||||
catch (SnapshotNotFoundException ex)
|
||||
{
|
||||
IsError = true;
|
||||
StatusMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsError = true;
|
||||
StatusMessage = $"Fehler beim Wiederherstellen: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanRestore() =>
|
||||
!IsBusy && InputCode.Trim().Length >= 5;
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnProgress(SnapshotProgress progress)
|
||||
{
|
||||
StatusMessage = progress.Message;
|
||||
ProgressPercent = progress.PercentComplete;
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
StatusMessage = "";
|
||||
ProgressPercent = 0;
|
||||
IsError = false;
|
||||
IsComplete = false;
|
||||
GeneratedCode = "";
|
||||
InputCode = "";
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
public enum PairingMode
|
||||
{
|
||||
SelectMode, // Startbildschirm: Sender oder Empfänger wählen
|
||||
Sender, // Sender bereit, Snapshot erstellen
|
||||
SenderShowCode, // Code wird angezeigt
|
||||
Receiver, // Code-Eingabe
|
||||
ReceiverDone, // Erfolgreich wiederhergestellt
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
public partial class AddGroupDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly SchoolYearService _schoolYearService;
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _subject = "";
|
||||
[ObservableProperty] private GroupType _type = GroupType.Course;
|
||||
[ObservableProperty] private int _gradeLevel = 10;
|
||||
[ObservableProperty] private GradingSystem _gradingSystem = GradingSystem.Grades1To6;
|
||||
[ObservableProperty] private int? _hoursPerWeek;
|
||||
[ObservableProperty] private string _selectedSchoolYear = "";
|
||||
[ObservableProperty] private string _validationMessage = "";
|
||||
|
||||
public List<string> SchoolYears { get; }
|
||||
public List<GroupTypeItem> GroupTypes { get; } =
|
||||
[
|
||||
new(GroupType.Course, "Fachkurs"),
|
||||
new(GroupType.Class, "Klassenverband"),
|
||||
];
|
||||
public List<GradingSystemItem> GradingSystems { get; } =
|
||||
[
|
||||
new(GradingSystem.Grades1To6, "Noten 1–6 (Sek I)"),
|
||||
new(GradingSystem.Points0To15, "Punkte 0–15 (Oberstufe)"),
|
||||
];
|
||||
|
||||
// Wird vom Dialog aufgerufen wenn OK geklickt wurde
|
||||
public LearningGroup? Result { get; private set; }
|
||||
|
||||
public AddGroupDialogViewModel(
|
||||
IGroupRepository groups,
|
||||
SchoolYearService schoolYearService)
|
||||
{
|
||||
_groups = groups;
|
||||
_schoolYearService = schoolYearService;
|
||||
SchoolYears = schoolYearService.RecentSchoolYears(3);
|
||||
SelectedSchoolYear = schoolYearService.CurrentSchoolYear();
|
||||
|
||||
// Automatisch Notensystem vorschlagen wenn Klassenstufe sich ändert
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(GradeLevel))
|
||||
GradingSystem = GradeLevel >= 11
|
||||
? GradingSystem.Points0To15
|
||||
: GradingSystem.Grades1To6;
|
||||
};
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private bool Save()
|
||||
{
|
||||
// Validierung
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
ValidationMessage = "Bitte einen Namen eingeben.";
|
||||
return false;
|
||||
}
|
||||
if (GradeLevel is < 1 or > 13)
|
||||
{
|
||||
ValidationMessage = "Klassenstufe muss zwischen 1 und 13 liegen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
Result = new LearningGroup
|
||||
{
|
||||
Name = Name.Trim(),
|
||||
Subject = string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim(),
|
||||
Type = Type,
|
||||
GradeLevel = GradeLevel,
|
||||
GradingSystem = GradingSystem,
|
||||
SchoolYear = SelectedSchoolYear,
|
||||
HoursPerWeek = HoursPerWeek,
|
||||
};
|
||||
|
||||
_groups.Save(Result);
|
||||
ValidationMessage = "";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public record GroupTypeItem(GroupType Value, string Label);
|
||||
public record GradingSystemItem(GradingSystem Value, string Label);
|
||||
208
LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
Normal file
208
LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
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 _schoolYearService;
|
||||
|
||||
[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 schoolYearService)
|
||||
{
|
||||
_groups = groups;
|
||||
_schoolYearService = schoolYearService;
|
||||
|
||||
foreach (var y in schoolYearService.RecentSchoolYears())
|
||||
SchoolYears.Add(y);
|
||||
|
||||
SelectedSchoolYear = schoolYearService.CurrentSchoolYear();
|
||||
LoadGroups();
|
||||
}
|
||||
|
||||
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
|
||||
partial void OnSearchTextChanged(string value) => LoadGroups();
|
||||
|
||||
private 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)
|
||||
Groups.Add(new GroupListItem(g));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddGroup()
|
||||
{
|
||||
// TODO: Dialog öffnen
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Refresh() => LoadGroups();
|
||||
}
|
||||
|
||||
public class GroupListItem
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string Subject { get; }
|
||||
public string SchoolYear { get; }
|
||||
public int GradeLevel { get; }
|
||||
public string TypeLabel { get; }
|
||||
public string GradingLabel { get; }
|
||||
|
||||
public GroupListItem(LearningGroup g)
|
||||
{
|
||||
Id = g.Id;
|
||||
Name = g.Name;
|
||||
Subject = g.Subject ?? "";
|
||||
SchoolYear = g.SchoolYear;
|
||||
GradeLevel = g.GradeLevel;
|
||||
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
|
||||
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6
|
||||
? "Noten 1–6"
|
||||
: "Punkte 0–15";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gruppendetail (Tab-Navigation) ────────────────────────────────────────────
|
||||
|
||||
public partial class GroupDetailViewModel : ObservableObject
|
||||
{
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly SchoolYearService _schoolYearService;
|
||||
|
||||
[ObservableProperty] private LearningGroup? _group;
|
||||
[ObservableProperty] private string _groupTitle = "";
|
||||
[ObservableProperty] private int _studentCount;
|
||||
[ObservableProperty] private GroupTab _activeTab = GroupTab.Overview;
|
||||
|
||||
// Sub-ViewModels für Tabs – lazy geladen
|
||||
public ObservableCollection<StudentSummary> Students { get; } = [];
|
||||
public ObservableCollection<ExamSummary> Exams { get; } = [];
|
||||
|
||||
public GroupDetailViewModel(
|
||||
IGroupRepository groups,
|
||||
IStudentRepository students,
|
||||
IEnrollmentRepository enrollments,
|
||||
IExamRepository exams,
|
||||
SchoolYearService schoolYearService)
|
||||
{
|
||||
_groups = groups;
|
||||
_students = students;
|
||||
_enrollments = enrollments;
|
||||
_exams = exams;
|
||||
_schoolYearService = schoolYearService;
|
||||
}
|
||||
|
||||
public void LoadGroup(Guid groupId)
|
||||
{
|
||||
Group = _groups.GetById(groupId);
|
||||
if (Group is null) return;
|
||||
|
||||
GroupTitle = $"{Group.Name} · {Group.SchoolYear}";
|
||||
LoadStudents();
|
||||
LoadExams();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SwitchTab(GroupTab tab)
|
||||
{
|
||||
ActiveTab = tab;
|
||||
}
|
||||
|
||||
private void LoadStudents()
|
||||
{
|
||||
if (Group is null) return;
|
||||
Students.Clear();
|
||||
|
||||
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear);
|
||||
StudentCount = enrolled.Count;
|
||||
|
||||
foreach (var s in enrolled)
|
||||
Students.Add(new StudentSummary(s));
|
||||
}
|
||||
|
||||
private void LoadExams()
|
||||
{
|
||||
if (Group is null) return;
|
||||
Exams.Clear();
|
||||
|
||||
foreach (var e in _exams.GetByGroup(Group.Id))
|
||||
Exams.Add(new ExamSummary(e));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddStudent()
|
||||
{
|
||||
// TODO: Schüler-Auswahl-Dialog
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddExam()
|
||||
{
|
||||
// TODO: Klausur-Erstellen-Dialog
|
||||
}
|
||||
}
|
||||
|
||||
public enum GroupTab { Overview, Students, Exams, Grades, Planner, Documentation }
|
||||
|
||||
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 Status { get; }
|
||||
|
||||
public ExamSummary(Core.Models.Exam e)
|
||||
{
|
||||
Id = e.Id;
|
||||
Title = e.Title;
|
||||
Date = e.Date.ToString("dd.MM.yyyy");
|
||||
Status = e.Status switch
|
||||
{
|
||||
ExamStatus.Planned => "Geplant",
|
||||
ExamStatus.Conducted => "Durchgeführt",
|
||||
ExamStatus.Graded => "Korrigiert",
|
||||
ExamStatus.Returned => "Zurückgegeben",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
}
|
||||
87
LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
87
LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
|
||||
// ── Fehlende Property die im MainWindow.axaml referenziert wird ───────────
|
||||
[ObservableProperty]
|
||||
private string _currentSchoolYear = "";
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceProvider services,
|
||||
DashboardViewModel dashboard,
|
||||
SchoolYearService schoolYearService)
|
||||
{
|
||||
_services = services;
|
||||
CurrentPage = dashboard;
|
||||
CurrentSchoolYear = schoolYearService.CurrentSchoolYear();
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
[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 => CreatePlaceholder("Klausuren"),
|
||||
NavItem.Planner => CreatePlaceholder("Unterrichtsplanung"),
|
||||
NavItem.Workload => CreatePlaceholder("Arbeitszeit"),
|
||||
NavItem.Settings => CreatePlaceholder("Einstellungen"),
|
||||
_ => CurrentPage,
|
||||
};
|
||||
}
|
||||
|
||||
public void NavigateToGroup(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;
|
||||
}
|
||||
|
||||
private static PlaceholderViewModel CreatePlaceholder(string title) =>
|
||||
new() { Title = title };
|
||||
}
|
||||
|
||||
public enum NavItem
|
||||
{
|
||||
Dashboard,
|
||||
Groups,
|
||||
Students,
|
||||
Exams,
|
||||
Planner,
|
||||
Workload,
|
||||
Settings,
|
||||
}
|
||||
|
||||
public partial class PlaceholderViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _title = "";
|
||||
}
|
||||
223
LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
Normal file
223
LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
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;
|
||||
|
||||
// ── Schülerliste ──────────────────────────────────────────────────────────────
|
||||
|
||||
public partial class StudentListViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStudentRepository _students;
|
||||
|
||||
[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();
|
||||
|
||||
private void LoadStudents()
|
||||
{
|
||||
Students.Clear();
|
||||
var all = _students.GetAll(includeInactive: ShowInactive);
|
||||
|
||||
var filtered = string.IsNullOrWhiteSpace(SearchText)
|
||||
? all
|
||||
: all.Where(s =>
|
||||
s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var s in filtered)
|
||||
Students.Add(new StudentListItem(s));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddStudent()
|
||||
{
|
||||
// TODO: Neuer-Schüler-Dialog
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Refresh() => LoadStudents();
|
||||
}
|
||||
|
||||
public class StudentListItem
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public string FullName { get; }
|
||||
public string DateOfBirth { get; }
|
||||
public bool IsActive { get; }
|
||||
|
||||
public StudentListItem(Student s)
|
||||
{
|
||||
Id = s.Id;
|
||||
FullName = s.FullName;
|
||||
DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? "";
|
||||
IsActive = s.IsActive;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schülerdetail ─────────────────────────────────────────────────────────────
|
||||
|
||||
public partial class StudentDetailViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IDocumentationRepository _docs;
|
||||
|
||||
[ObservableProperty] private Student? _student;
|
||||
[ObservableProperty] private string _studentTitle = "";
|
||||
[ObservableProperty] private StudentTab _activeTab = StudentTab.Overview;
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
|
||||
// Bearbeitbare Felder
|
||||
[ObservableProperty] private string _editFirstName = "";
|
||||
[ObservableProperty] private string _editLastName = "";
|
||||
[ObservableProperty] private string _editNotes = "";
|
||||
|
||||
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = [];
|
||||
public ObservableCollection<GradeEntry> Grades { get; } = [];
|
||||
public ObservableCollection<DocEntry> Documentation { get; } = [];
|
||||
|
||||
public StudentDetailViewModel(
|
||||
IStudentRepository students,
|
||||
IEnrollmentRepository enrollments,
|
||||
IGroupRepository groups,
|
||||
IGradeRepository grades,
|
||||
IDocumentationRepository docs)
|
||||
{
|
||||
_students = students;
|
||||
_enrollments = enrollments;
|
||||
_groups = groups;
|
||||
_grades = grades;
|
||||
_docs = docs;
|
||||
}
|
||||
|
||||
public void LoadStudent(Guid studentId)
|
||||
{
|
||||
Student = _students.GetById(studentId);
|
||||
if (Student is null) return;
|
||||
|
||||
StudentTitle = Student.FullName;
|
||||
EditFirstName = Student.FirstName;
|
||||
EditLastName = Student.LastName;
|
||||
EditNotes = Student.Notes ?? "";
|
||||
|
||||
LoadEnrollments();
|
||||
LoadDocs();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SwitchTab(StudentTab tab) => ActiveTab = tab;
|
||||
|
||||
[RelayCommand]
|
||||
private void StartEdit() => IsEditing = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelEdit()
|
||||
{
|
||||
if (Student is null) return;
|
||||
EditFirstName = Student.FirstName;
|
||||
EditLastName = Student.LastName;
|
||||
EditNotes = Student.Notes ?? "";
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveEdit()
|
||||
{
|
||||
if (Student is null) return;
|
||||
Student.FirstName = EditFirstName;
|
||||
Student.LastName = EditLastName;
|
||||
Student.Notes = string.IsNullOrWhiteSpace(EditNotes) ? null : EditNotes;
|
||||
_students.Save(Student);
|
||||
StudentTitle = Student.FullName;
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
private void LoadEnrollments()
|
||||
{
|
||||
if (Student is null) return;
|
||||
Enrollments.Clear();
|
||||
|
||||
var enrollments = _enrollments.GetByStudent(Student.Id);
|
||||
var groupIds = enrollments.Select(e => e.GroupId).Distinct();
|
||||
var groupMap = groupIds
|
||||
.Select(id => _groups.GetById(id))
|
||||
.Where(g => g is not null)
|
||||
.ToDictionary(g => g!.Id);
|
||||
|
||||
foreach (var e in enrollments.OrderByDescending(e => e.SchoolYear))
|
||||
{
|
||||
if (!groupMap.TryGetValue(e.GroupId, out var group)) continue;
|
||||
Enrollments.Add(new EnrollmentEntry
|
||||
{
|
||||
SchoolYear = e.SchoolYear,
|
||||
GroupName = group.Name,
|
||||
Subject = group.Subject ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDocs()
|
||||
{
|
||||
if (Student is null) return;
|
||||
Documentation.Clear();
|
||||
|
||||
foreach (var doc in _docs.GetByStudent(Student.Id))
|
||||
{
|
||||
Documentation.Add(new DocEntry
|
||||
{
|
||||
Date = doc.Date.ToString("dd.MM.yyyy"),
|
||||
Title = doc.Title,
|
||||
TypeLabel = doc.Type switch
|
||||
{
|
||||
DocumentationType.Conversation => "Gespräch",
|
||||
DocumentationType.Incident => "Vorkommnis",
|
||||
DocumentationType.SupportPlan => "Förderplan",
|
||||
DocumentationType.Absence => "Fehlzeit",
|
||||
_ => "",
|
||||
},
|
||||
IsConfidential = doc.IsConfidential,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum StudentTab { Overview, Grades, Documentation }
|
||||
|
||||
public class EnrollmentEntry
|
||||
{
|
||||
public string SchoolYear { get; set; } = "";
|
||||
public string GroupName { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
}
|
||||
|
||||
public class GradeEntry
|
||||
{
|
||||
public string Date { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string GroupName { 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; }
|
||||
}
|
||||
63
LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs
Normal file
63
LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
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 int _pendingCount;
|
||||
[ObservableProperty] private int _conflictCount;
|
||||
[ObservableProperty] private bool _isSyncing;
|
||||
[ObservableProperty] private bool _hasConflicts;
|
||||
[ObservableProperty] private bool _isServerConfigured;
|
||||
|
||||
public SyncStatusViewModel(SyncEngine? engine)
|
||||
{
|
||||
_engine = engine;
|
||||
IsServerConfigured = engine is not null;
|
||||
|
||||
if (_engine is not null)
|
||||
{
|
||||
_engine.StatusChanged += OnStatusChanged;
|
||||
OnStatusChanged(_engine.Status);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStatusChanged(SyncStatus status)
|
||||
{
|
||||
IsSyncing = status.State == SyncState.Syncing;
|
||||
HasConflicts = status.ConflictCount > 0;
|
||||
PendingCount = status.PendingEvents;
|
||||
ConflictCount = status.ConflictCount;
|
||||
|
||||
StatusText = status.State switch
|
||||
{
|
||||
SyncState.Idle => PendingCount > 0
|
||||
? $"{PendingCount} ausstehend"
|
||||
: "Synchronisiert",
|
||||
SyncState.Syncing => "Synchronisiere...",
|
||||
SyncState.Offline => "Offline",
|
||||
SyncState.Error => $"Fehler: {status.ErrorMessage}",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
LastSyncText = status.LastSyncAt.HasValue
|
||||
? $"Zuletzt: {status.LastSyncAt:HH:mm}"
|
||||
: "Noch nie synchronisiert";
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSyncNow))]
|
||||
private async Task SyncNow()
|
||||
{
|
||||
if (_engine is null) return;
|
||||
await _engine.SyncNowAsync(isAutomatic: false);
|
||||
}
|
||||
|
||||
private bool CanSyncNow() => _engine is not null && !IsSyncing;
|
||||
}
|
||||
115
LehrerApp.Desktop/Views/DashboardView.axaml
Normal file
115
LehrerApp.Desktop/Views/DashboardView.axaml
Normal file
@@ -0,0 +1,115 @@
|
||||
<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.DashboardView"
|
||||
x:DataType="vm:DashboardViewModel">
|
||||
|
||||
<ScrollViewer Padding="24">
|
||||
<StackPanel Spacing="20">
|
||||
|
||||
<!-- Header -->
|
||||
<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"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16" Margin="0,0,8,8">
|
||||
<StackPanel>
|
||||
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
|
||||
Opacity="0.5" Margin="0,0,0,12"/>
|
||||
<ItemsControl ItemsSource="{Binding TodaysLessons}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:LessonItem">
|
||||
<Border Padding="0,6" BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<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>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Keine Stunden heute" FontSize="13" Opacity="0.5"
|
||||
IsVisible="{Binding !TodaysLessons.Count}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Offene Aufgaben -->
|
||||
<Border Grid.Column="1" Grid.Row="0"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="8" Padding="16" Margin="8,0,0,8">
|
||||
<StackPanel>
|
||||
<TextBlock Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
|
||||
Opacity="0.5" Margin="0,0,0,12"/>
|
||||
<ItemsControl ItemsSource="{Binding OpenTasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:TaskItem">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="13" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<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" FontSize="13" Opacity="0.5"
|
||||
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,12"/>
|
||||
<ItemsControl ItemsSource="{Binding CurrentGroups}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GroupChip">
|
||||
<Border 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>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Noch keine Lerngruppen für dieses Schuljahr"
|
||||
FontSize="13" Opacity="0.5"
|
||||
IsVisible="{Binding !CurrentGroups.Count}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
38
LehrerApp.Desktop/Views/DataTemplates.axaml
Normal file
38
LehrerApp.Desktop/Views/DataTemplates.axaml
Normal file
@@ -0,0 +1,38 @@
|
||||
<ResourceDictionary 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:vg="clr-namespace:LehrerApp.Desktop.Views.Groups"
|
||||
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
|
||||
xmlns:v="clr-namespace:LehrerApp.Desktop.Views">
|
||||
|
||||
<!-- Dashboard -->
|
||||
<DataTemplate DataType="vm:DashboardViewModel">
|
||||
<v:DashboardView/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Platzhalter -->
|
||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||
<v:PlaceholderView/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Gruppen -->
|
||||
<DataTemplate DataType="vmg:GroupListViewModel">
|
||||
<vg:GroupListView/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="vmg:GroupDetailViewModel">
|
||||
<vg:GroupDetailView/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Schüler -->
|
||||
<DataTemplate DataType="vms:StudentListViewModel">
|
||||
<vs:StudentListView/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="vms:StudentDetailViewModel">
|
||||
<vs:StudentDetailView/>
|
||||
</DataTemplate>
|
||||
|
||||
</ResourceDictionary>
|
||||
185
LehrerApp.Desktop/Views/DevicePairingDialog.axaml
Normal file
185
LehrerApp.Desktop/Views/DevicePairingDialog.axaml
Normal file
@@ -0,0 +1,185 @@
|
||||
<Window 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.DevicePairingDialog"
|
||||
x:DataType="vm:DevicePairingViewModel"
|
||||
Title="Gerät einrichten"
|
||||
Width="480" Height="420"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="28">
|
||||
|
||||
<!-- ── Modus: Auswahl ──────────────────────────────────────────────────── -->
|
||||
<StackPanel Spacing="16"
|
||||
IsVisible="{Binding Mode,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:PairingMode.SelectMode}}">
|
||||
|
||||
<TextBlock Text="Gerät einrichten"
|
||||
FontSize="20" FontWeight="SemiBold"/>
|
||||
<TextBlock TextWrapping="Wrap" Opacity="0.7">
|
||||
Möchtest du ein neues Gerät mit deinem bestehenden Datenbestand
|
||||
verbinden, oder dieses Gerät als erstes Gerät einrichten?
|
||||
</TextBlock>
|
||||
|
||||
<Button Content="Daten auf dieses Gerät übertragen"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding SelectReceiverCommand}"
|
||||
Padding="12,10"/>
|
||||
|
||||
<Button Content="Von diesem Gerät Daten übertragen"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding SelectSenderCommand}"
|
||||
Padding="12,10"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Modus: Sender ───────────────────────────────────────────────────── -->
|
||||
<StackPanel Spacing="16"
|
||||
IsVisible="{Binding Mode,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:PairingMode.Sender}}">
|
||||
|
||||
<TextBlock Text="Daten übertragen"
|
||||
FontSize="20" FontWeight="SemiBold"/>
|
||||
<TextBlock TextWrapping="Wrap" Opacity="0.7">
|
||||
Es wird ein verschlüsselter Snapshot deiner lokalen Datenbank erstellt
|
||||
und kurzzeitig auf dem Server hinterlegt. Du erhältst einen Einmal-Code
|
||||
der 24 Stunden gültig ist.
|
||||
</TextBlock>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<ProgressBar Value="{Binding ProgressPercent}"
|
||||
Maximum="100" Height="6" CornerRadius="3"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
FontSize="12" Opacity="0.7"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
|
||||
<!-- Fehler -->
|
||||
<Border Background="#22FF0000" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsError}">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap" Foreground="Red"/>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,Auto" Margin="0,8,0,0">
|
||||
<Button Grid.Column="0"
|
||||
Content="← Zurück"
|
||||
Command="{Binding BackCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
<Button Grid.Column="2"
|
||||
Content="Snapshot erstellen"
|
||||
Command="{Binding CreateSnapshotCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Padding="16,8"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Modus: Sender zeigt Code ────────────────────────────────────────── -->
|
||||
<StackPanel Spacing="16"
|
||||
IsVisible="{Binding Mode,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:PairingMode.SenderShowCode}}">
|
||||
|
||||
<TextBlock Text="Code bereit" FontSize="20" FontWeight="SemiBold"/>
|
||||
<TextBlock TextWrapping="Wrap" Opacity="0.7">
|
||||
Gib diesen Code auf dem Zielgerät ein. Der Code ist einmalig verwendbar.
|
||||
</TextBlock>
|
||||
|
||||
<!-- Der Code prominent anzeigen -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
CornerRadius="10" Padding="24,20">
|
||||
<StackPanel HorizontalAlignment="Center" Spacing="8">
|
||||
<TextBlock Text="{Binding GeneratedCode}"
|
||||
FontSize="32" FontWeight="Bold"
|
||||
FontFamily="Cascadia Code, Consolas, monospace"
|
||||
HorizontalAlignment="Center"
|
||||
LetterSpacing="4"/>
|
||||
<TextBlock Text="{Binding CodeExpiresText}"
|
||||
FontSize="12" Opacity="0.5"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.6">
|
||||
Der Code wird nach dem ersten Abruf automatisch ungültig.
|
||||
Der Server speichert nur verschlüsselte Daten – kein Klartext.
|
||||
</TextBlock>
|
||||
|
||||
<Button Content="Fertig" HorizontalAlignment="Right"
|
||||
Click="OnClose" Padding="16,8"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Modus: Empfänger – Code eingeben ────────────────────────────────── -->
|
||||
<StackPanel Spacing="16"
|
||||
IsVisible="{Binding Mode,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:PairingMode.Receiver}}">
|
||||
|
||||
<TextBlock Text="Code eingeben"
|
||||
FontSize="20" FontWeight="SemiBold"/>
|
||||
<TextBlock TextWrapping="Wrap" Opacity="0.7">
|
||||
Gib den Code ein der auf dem Quellgerät angezeigt wird.
|
||||
Format: WORT-ZZ-WORT (z.B. TIGER-42-BLAU)
|
||||
</TextBlock>
|
||||
|
||||
<TextBox Text="{Binding InputCode}"
|
||||
Watermark="TIGER-42-BLAU"
|
||||
FontSize="20"
|
||||
FontFamily="Cascadia Code, Consolas, monospace"
|
||||
TextAlignment="Center"
|
||||
CharacterCasing="Upper"/>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<ProgressBar Value="{Binding ProgressPercent}"
|
||||
Maximum="100" Height="6" CornerRadius="3"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
FontSize="12" Opacity="0.7"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
|
||||
<!-- Fehler -->
|
||||
<Border Background="#22FF0000" CornerRadius="6" Padding="12"
|
||||
IsVisible="{Binding IsError}">
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap" Foreground="Red"/>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,Auto">
|
||||
<Button Grid.Column="0" Content="← Zurück"
|
||||
Command="{Binding BackCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
<Button Grid.Column="2"
|
||||
Content="Wiederherstellen"
|
||||
Click="OnRestore"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Padding="16,8"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Modus: Empfänger – Fertig ───────────────────────────────────────── -->
|
||||
<StackPanel Spacing="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Mode,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:PairingMode.ReceiverDone}}">
|
||||
|
||||
<TextBlock Text="✓" FontSize="48" HorizontalAlignment="Center"
|
||||
Foreground="Green"/>
|
||||
<TextBlock Text="Erfolgreich übertragen!"
|
||||
FontSize="20" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Opacity="0.7" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
<Button Content="App neu starten"
|
||||
HorizontalAlignment="Center"
|
||||
Click="OnRestartRequested"
|
||||
Padding="16,8" Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
34
LehrerApp.Desktop/Views/DevicePairingDialog.axaml.cs
Normal file
34
LehrerApp.Desktop/Views/DevicePairingDialog.axaml.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public partial class DevicePairingDialog : Window
|
||||
{
|
||||
// Wird von außen gesetzt – Pfad zur lokalen DB
|
||||
public string TargetDbPath { get; set; } = "";
|
||||
|
||||
// Signal: App-Neustart nötig
|
||||
public bool RestartRequested { get; private set; }
|
||||
|
||||
public DevicePairingDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) =>
|
||||
Close();
|
||||
|
||||
private async void OnRestore(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is DevicePairingViewModel vm)
|
||||
await vm.RestoreCommand.ExecuteAsync(TargetDbPath);
|
||||
}
|
||||
|
||||
private void OnRestartRequested(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
RestartRequested = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
109
LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
Normal file
109
LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml
Normal file
@@ -0,0 +1,109 @@
|
||||
<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" Height="480"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
|
||||
<StackPanel Grid.Row="0" Spacing="16">
|
||||
|
||||
<TextBlock Text="Neue Lerngruppe anlegen"
|
||||
FontSize="18" FontWeight="SemiBold"/>
|
||||
|
||||
<!-- Name -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Name}"
|
||||
Watermark="z.B. 10E, Q1 Chemie, 5a"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Fach -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Fach" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Subject}"
|
||||
Watermark="z.B. Chemie, Mathematik"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Typ und Klassenstufe nebeneinander -->
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Typ" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding GroupTypes}"
|
||||
SelectedItem="{Binding Type}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GroupTypeItem">
|
||||
<TextBlock Text="{Binding Label}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding GradeLevel}"
|
||||
Minimum="1" Maximum="13"
|
||||
FormatString="0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Notensystem und Schuljahr -->
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Notensystem" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding GradingSystems}"
|
||||
SelectedItem="{Binding GradingSystem}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GradingSystemItem">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</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>
|
||||
|
||||
<!-- Wochenstunden -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Wochenstunden (optional)" FontSize="12" Opacity="0.7"/>
|
||||
<NumericUpDown Value="{Binding HoursPerWeek}"
|
||||
Minimum="1" Maximum="20"
|
||||
FormatString="0"
|
||||
AllowSpin="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Validierungsfehler -->
|
||||
<TextBlock Text="{Binding ValidationMessage}"
|
||||
Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding ValidationMessage,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Buttons ──────────────────────────────────────────────────────── -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,16,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>
|
||||
33
LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml.cs
Normal file
33
LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class AddGroupDialog : Window
|
||||
{
|
||||
public bool Saved { get; private set; }
|
||||
|
||||
public AddGroupDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.Groups.AddGroupDialogViewModel vm)
|
||||
{
|
||||
if (vm.SaveCommand.CanExecute(null))
|
||||
{
|
||||
vm.SaveCommand.Execute(null);
|
||||
if (vm.Result is not null)
|
||||
{
|
||||
Saved = true;
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) =>
|
||||
Close(false);
|
||||
}
|
||||
129
LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
Normal file
129
LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
Normal file
@@ -0,0 +1,129 @@
|
||||
<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,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 FontSize="12" Opacity="0.5">
|
||||
<Run Text="{Binding StudentCount}"/>
|
||||
<Run Text="Schüler"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="+ Schüler"
|
||||
Command="{Binding AddStudentCommand}"/>
|
||||
<Button Content="+ Klausur"
|
||||
Command="{Binding AddExamCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Tab-Leiste ──────────────────────────────────────────────────── -->
|
||||
<Border Grid.Row="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="16,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Content="Übersicht"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Overview}"
|
||||
Classes.active="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Overview}}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Schüler"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Students}"
|
||||
Classes.active="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Students}}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Klausuren"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Exams}"
|
||||
Classes.active="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Exams}}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Noten"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Grades}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Planung"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Planner}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Dokumentation"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:GroupTab.Documentation}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Tab-Inhalt ──────────────────────────────────────────────────── -->
|
||||
<ScrollViewer Grid.Row="2" Padding="20">
|
||||
|
||||
<!-- Schülerliste -->
|
||||
<DataGrid ItemsSource="{Binding Students}"
|
||||
IsVisible="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Students}}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserReorderColumns="False"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name"
|
||||
Binding="{Binding FullName}"
|
||||
Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- Klausuren -->
|
||||
<DataGrid ItemsSource="{Binding Exams}"
|
||||
IsVisible="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Exams}}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserReorderColumns="False">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datum"
|
||||
Binding="{Binding Date}"
|
||||
Width="100"/>
|
||||
<DataGridTextColumn Header="Titel"
|
||||
Binding="{Binding Title}"
|
||||
Width="*"/>
|
||||
<DataGridTextColumn Header="Status"
|
||||
Binding="{Binding Status}"
|
||||
Width="120"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- Übersicht (Platzhalter) -->
|
||||
<StackPanel IsVisible="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:GroupTab.Overview}}"
|
||||
Spacing="12">
|
||||
<TextBlock Text="Übersicht" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Hier erscheint eine Zusammenfassung der Lerngruppe."
|
||||
Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
100
LehrerApp.Desktop/Views/Groups/GroupListView.axaml
Normal file
100
LehrerApp.Desktop/Views/Groups/GroupListView.axaml
Normal file
@@ -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.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
|
||||
x:DataType="vm:GroupListViewModel">
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ── Toolbar ─────────────────────────────────────────────────────── -->
|
||||
<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>
|
||||
|
||||
<!-- Schuljahr-Auswahl -->
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding SchoolYears}"
|
||||
SelectedItem="{Binding SelectedSchoolYear}"
|
||||
Width="100" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Neue Gruppe -->
|
||||
<Button Grid.Column="2"
|
||||
Content="+ Neue Gruppe"
|
||||
Command="{Binding AddGroupCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Inhalt ──────────────────────────────────────────────────────── -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="260,*">
|
||||
|
||||
<!-- Suchliste links -->
|
||||
<Border Grid.Column="0"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,1,0">
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Top"
|
||||
Text="{Binding SearchText}"
|
||||
Watermark="Suchen…"
|
||||
Margin="12,8"/>
|
||||
<ListBox ItemsSource="{Binding Groups}"
|
||||
SelectedItem="{Binding SelectedGroup}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GroupListItem">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Detail rechts – Platzhalter bis Gruppe gewählt -->
|
||||
<Border Grid.Column="1">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding !SelectedGroup}">
|
||||
<TextBlock Text="Keine Gruppe ausgewählt"
|
||||
FontSize="16" Opacity="0.4"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Wähle eine Gruppe aus der Liste oder lege eine neue an."
|
||||
FontSize="12" Opacity="0.3"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
13
LehrerApp.Desktop/Views/Groups/GroupViews.cs
Normal file
13
LehrerApp.Desktop/Views/Groups/GroupViews.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class GroupListView : UserControl
|
||||
{
|
||||
public GroupListView() => InitializeComponent();
|
||||
}
|
||||
|
||||
public partial class GroupDetailView : UserControl
|
||||
{
|
||||
public GroupDetailView() => InitializeComponent();
|
||||
}
|
||||
108
LehrerApp.Desktop/Views/MainWindow.axaml
Normal file
108
LehrerApp.Desktop/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,108 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||
xmlns:views="clr-namespace:LehrerApp.Desktop.Views"
|
||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="LehrerApp"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="900" MinHeight="600">
|
||||
|
||||
<Grid ColumnDefinitions="220,*">
|
||||
|
||||
<!-- ── Sidebar ───────────────────────────────────────────────────────── -->
|
||||
<Border Grid.Column="0"
|
||||
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,1,0">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Padding="16,20,16,12"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel>
|
||||
<TextBlock Text="LehrerApp"
|
||||
FontSize="20" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding CurrentSchoolYear, FallbackValue=''}"
|
||||
FontSize="12" Opacity="0.6" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Sync-Status unten -->
|
||||
<Border DockPanel.Dock="Bottom" Padding="12,8"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<views:SyncStatusBar />
|
||||
</Border>
|
||||
|
||||
<!-- Nav-Items -->
|
||||
<StackPanel Margin="8,12">
|
||||
<views:NavButton Icon="" Label="Dashboard"
|
||||
Item="Dashboard"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Dashboard}" />
|
||||
|
||||
<TextBlock Text="UNTERRICHT" FontSize="10" FontWeight="Bold"
|
||||
Opacity="0.4" Margin="8,16,0,4" />
|
||||
|
||||
<views:NavButton Icon="" Label="Lerngruppen"
|
||||
Item="Groups"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Groups}" />
|
||||
|
||||
<views:NavButton Icon="" Label="Schüler"
|
||||
Item="Students"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Students}" />
|
||||
|
||||
<views:NavButton Icon="" Label="Klausuren"
|
||||
Item="Exams"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Exams}" />
|
||||
|
||||
<views:NavButton Icon="" Label="Planung"
|
||||
Item="Planner"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Planner}" />
|
||||
|
||||
<TextBlock Text="VERWALTUNG" FontSize="10" FontWeight="Bold"
|
||||
Opacity="0.4" Margin="8,16,0,4" />
|
||||
|
||||
<views:NavButton Icon="" Label="Arbeitszeit"
|
||||
Item="Workload"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Workload}" />
|
||||
|
||||
<views:NavButton Icon="" Label="Einstellungen"
|
||||
Item="Settings"
|
||||
ActiveItem="{Binding ActiveNavItem}"
|
||||
Command="{Binding NavigateToCommand}"
|
||||
CommandParameter="{x:Static vm:NavItem.Settings}" />
|
||||
</StackPanel>
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Hauptinhalt ───────────────────────────────────────────────────── -->
|
||||
<ContentControl Grid.Column="1"
|
||||
Content="{Binding CurrentPage}">
|
||||
<ContentControl.DataTemplates>
|
||||
<DataTemplate DataType="vm:DashboardViewModel">
|
||||
<views:DashboardView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:PlaceholderViewModel}">
|
||||
<views:PlaceholderView />
|
||||
</DataTemplate>
|
||||
</ContentControl.DataTemplates>
|
||||
</ContentControl>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
11
LehrerApp.Desktop/Views/MainWindow.axaml.cs
Normal file
11
LehrerApp.Desktop/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
38
LehrerApp.Desktop/Views/NavButton.axaml
Normal file
38
LehrerApp.Desktop/Views/NavButton.axaml
Normal file
@@ -0,0 +1,38 @@
|
||||
<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.NavButton">
|
||||
|
||||
<Button Command="{Binding Command, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding CommandParameter, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Padding="10,8"
|
||||
CornerRadius="6"
|
||||
Background="Transparent">
|
||||
|
||||
<Grid ColumnDefinitions="24,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Icon, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
FontFamily="Segoe MDL2 Assets"
|
||||
FontSize="14"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Opacity="0.7"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Label, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<Button.Styles>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background"
|
||||
Value="{DynamicResource SystemControlBackgroundListLowBrush}"/>
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
|
||||
</Button>
|
||||
|
||||
</UserControl>
|
||||
69
LehrerApp.Desktop/Views/NavButton.axaml.cs
Normal file
69
LehrerApp.Desktop/Views/NavButton.axaml.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using System.Windows.Input;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public partial class NavButton : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<string> IconProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Icon), "");
|
||||
|
||||
public static readonly StyledProperty<string> LabelProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Label), "");
|
||||
|
||||
public static readonly StyledProperty<NavItem> ItemProperty =
|
||||
AvaloniaProperty.Register<NavButton, NavItem>(nameof(Item));
|
||||
|
||||
public static readonly StyledProperty<NavItem> ActiveItemProperty =
|
||||
AvaloniaProperty.Register<NavButton, NavItem>(nameof(ActiveItem));
|
||||
|
||||
public static readonly StyledProperty<ICommand?> CommandProperty =
|
||||
AvaloniaProperty.Register<NavButton, ICommand?>(nameof(Command));
|
||||
|
||||
public static readonly StyledProperty<object?> CommandParameterProperty =
|
||||
AvaloniaProperty.Register<NavButton, object?>(nameof(CommandParameter));
|
||||
|
||||
public string Icon
|
||||
{
|
||||
get => GetValue(IconProperty);
|
||||
set => SetValue(IconProperty, value);
|
||||
}
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => GetValue(LabelProperty);
|
||||
set => SetValue(LabelProperty, value);
|
||||
}
|
||||
|
||||
public NavItem Item
|
||||
{
|
||||
get => GetValue(ItemProperty);
|
||||
set => SetValue(ItemProperty, value);
|
||||
}
|
||||
|
||||
public NavItem ActiveItem
|
||||
{
|
||||
get => GetValue(ActiveItemProperty);
|
||||
set => SetValue(ActiveItemProperty, value);
|
||||
}
|
||||
|
||||
public ICommand? Command
|
||||
{
|
||||
get => GetValue(CommandProperty);
|
||||
set => SetValue(CommandProperty, value);
|
||||
}
|
||||
|
||||
public object? CommandParameter
|
||||
{
|
||||
get => GetValue(CommandParameterProperty);
|
||||
set => SetValue(CommandParameterProperty, value);
|
||||
}
|
||||
|
||||
public NavButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
13
LehrerApp.Desktop/Views/PlaceholderView.axaml
Normal file
13
LehrerApp.Desktop/Views/PlaceholderView.axaml
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- PlaceholderView.axaml -->
|
||||
<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="8">
|
||||
<TextBlock Text="{Binding Title}" FontSize="24" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Wird noch implementiert…" Opacity="0.5"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
148
LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
Normal file
148
LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
Normal file
@@ -0,0 +1,148 @@
|
||||
<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,Auto,*">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────── -->
|
||||
<Border Grid.Row="0" Padding="20,16"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<!-- Anzeigemodus -->
|
||||
<StackPanel Grid.Column="0"
|
||||
IsVisible="{Binding !IsEditing}">
|
||||
<TextBlock Text="{Binding StudentTitle}"
|
||||
FontSize="22" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Student.DateOfBirth,
|
||||
StringFormat='Geb. {0:dd.MM.yyyy}',
|
||||
FallbackValue=''}"
|
||||
FontSize="12" Opacity="0.5"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Bearbeitungsmodus -->
|
||||
<StackPanel Grid.Column="0" Spacing="6"
|
||||
IsVisible="{Binding IsEditing}">
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding EditFirstName}"
|
||||
Watermark="Vorname"/>
|
||||
<TextBox Grid.Column="2"
|
||||
Text="{Binding EditLastName}"
|
||||
Watermark="Nachname"/>
|
||||
</Grid>
|
||||
<TextBox Text="{Binding EditNotes}"
|
||||
Watermark="Interne Notiz (optional)"
|
||||
AcceptsReturn="True" MaxHeight="80"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Aktions-Buttons -->
|
||||
<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>
|
||||
|
||||
<!-- ── Tab-Leiste ──────────────────────────────────────────────────── -->
|
||||
<Border Grid.Row="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="16,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Content="Übersicht"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:StudentTab.Overview}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Noten"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:StudentTab.Grades}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
<Button Content="Dokumentation"
|
||||
Command="{Binding SwitchTabCommand}"
|
||||
CommandParameter="{x:Static vm:StudentTab.Documentation}"
|
||||
Background="Transparent" Padding="12,8"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Tab-Inhalt ──────────────────────────────────────────────────── -->
|
||||
<ScrollViewer Grid.Row="2" Padding="20">
|
||||
|
||||
<!-- Übersicht: Lerngruppen-Verlauf -->
|
||||
<StackPanel IsVisible="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:StudentTab.Overview}}"
|
||||
Spacing="16">
|
||||
|
||||
<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="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding SchoolYear}"
|
||||
FontWeight="SemiBold" Width="70"/>
|
||||
<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="{Binding Student.Notes}"
|
||||
IsVisible="{Binding Student.Notes,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Opacity="0.7" FontSize="13"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Dokumentation -->
|
||||
<ItemsControl IsVisible="{Binding ActiveTab,
|
||||
Converter={x:Static ObjectConverters.Equal},
|
||||
ConverterParameter={x:Static vm:StudentTab.Documentation}}"
|
||||
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="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Date}"
|
||||
Opacity="0.5" FontSize="12" Width="80"/>
|
||||
<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>
|
||||
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
63
LehrerApp.Desktop/Views/Students/StudentListView.axaml
Normal file
63
LehrerApp.Desktop/Views/Students/StudentListView.axaml
Normal file
@@ -0,0 +1,63 @@
|
||||
<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,*">
|
||||
|
||||
<!-- ── Toolbar ─────────────────────────────────────────────────────── -->
|
||||
<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>
|
||||
|
||||
<!-- ── Liste + Suche ───────────────────────────────────────────────── -->
|
||||
<DockPanel Grid.Row="1">
|
||||
<TextBox DockPanel.Dock="Top"
|
||||
Text="{Binding SearchText}"
|
||||
Watermark="Name suchen…"
|
||||
Margin="16,10,16,4"/>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Students}"
|
||||
SelectedItem="{Binding SelectedStudent}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserReorderColumns="False"
|
||||
CanUserResizeColumns="True"
|
||||
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>
|
||||
13
LehrerApp.Desktop/Views/Students/StudentViews.cs
Normal file
13
LehrerApp.Desktop/Views/Students/StudentViews.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Students;
|
||||
|
||||
public partial class StudentListView : UserControl
|
||||
{
|
||||
public StudentListView() => InitializeComponent();
|
||||
}
|
||||
|
||||
public partial class StudentDetailView : UserControl
|
||||
{
|
||||
public StudentDetailView() => InitializeComponent();
|
||||
}
|
||||
24
LehrerApp.Desktop/Views/SyncStatusBar.axaml
Normal file
24
LehrerApp.Desktop/Views/SyncStatusBar.axaml
Normal file
@@ -0,0 +1,24 @@
|
||||
<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"
|
||||
Command="{Binding SyncNowCommand}"
|
||||
IsVisible="{Binding IsServerConfigured}"
|
||||
ToolTip.Tip="Jetzt synchronisieren"
|
||||
Padding="6,4">
|
||||
<TextBlock Text="↻" FontSize="14"
|
||||
IsVisible="{Binding !IsSyncing}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
20
LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs
Normal file
20
LehrerApp.Desktop/Views/SyncStatusBar.axaml.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
public partial class SyncStatusBar : UserControl
|
||||
{
|
||||
public SyncStatusBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// DataContext aus DI holen sobald die View geladen ist
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is null)
|
||||
DataContext = App.Services.GetRequiredService<SyncStatusViewModel>();
|
||||
};
|
||||
}
|
||||
}
|
||||
17
LehrerApp.Desktop/Views/ViewCodeBehind.cs
Normal file
17
LehrerApp.Desktop/Views/ViewCodeBehind.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LehrerApp.Desktop.Views;
|
||||
|
||||
// Code-behind Stubs für Views ohne eigene .axaml.cs
|
||||
// NavButton → eigene NavButton.axaml.cs
|
||||
// SyncStatusBar → eigene SyncStatusBar.axaml.cs
|
||||
|
||||
public partial class DashboardView : UserControl
|
||||
{
|
||||
public DashboardView() => InitializeComponent();
|
||||
}
|
||||
|
||||
public partial class PlaceholderView : UserControl
|
||||
{
|
||||
public PlaceholderView() => InitializeComponent();
|
||||
}
|
||||
11
LehrerApp.Desktop/app.manifest
Normal file
11
LehrerApp.Desktop/app.manifest
Normal file
@@ -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>
|
||||
64
LehrerApp.Sync/ConflictResolver.cs
Normal file
64
LehrerApp.Sync/ConflictResolver.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet bei kollidierenden Events welcher gewinnt.
|
||||
/// Desktop hat Vorrang vor Companion. Bei Gleichstand gewinnt der spätere Timestamp.
|
||||
/// </summary>
|
||||
public class ConflictResolver
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
|
||||
public ConflictResolver(EventQueue queue)
|
||||
{
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob ein Remote-Event mit einem lokalen Event kollidiert.
|
||||
/// Gibt null zurück wenn kein Konflikt besteht.
|
||||
/// </summary>
|
||||
public ConflictEntry? TryResolve(SyncEvent remoteEvent, string localDeviceId)
|
||||
{
|
||||
// Lokalen Event für dieselbe Entity suchen
|
||||
var localEvent = _queue.GetPending()
|
||||
.FirstOrDefault(e =>
|
||||
e.EntityType == remoteEvent.EntityType &&
|
||||
e.EntityId == remoteEvent.EntityId &&
|
||||
e.DeviceId != remoteEvent.DeviceId);
|
||||
|
||||
if (localEvent is null)
|
||||
return null; // kein Konflikt – Remote-Event einfach anwenden
|
||||
|
||||
// Konflikt aufgelöst – Gewinner bestimmen
|
||||
var winner = DetermineWinner(localEvent, remoteEvent);
|
||||
var resolution = winner == localEvent ? "LocalWon" : "RemoteWon";
|
||||
|
||||
// Wenn Remote gewinnt: lokalen Event aus Queue entfernen
|
||||
if (winner == remoteEvent)
|
||||
_queue.Acknowledge([localEvent.EventId]);
|
||||
|
||||
return new ConflictEntry
|
||||
{
|
||||
LocalEvent = localEvent,
|
||||
RemoteEvent = remoteEvent,
|
||||
Resolution = resolution,
|
||||
};
|
||||
}
|
||||
|
||||
private static SyncEvent DetermineWinner(SyncEvent local, SyncEvent remote)
|
||||
{
|
||||
// Regel 1: Desktop schlägt Companion
|
||||
if (local.DeviceType == DeviceType.Desktop &&
|
||||
remote.DeviceType == DeviceType.Companion)
|
||||
return local;
|
||||
|
||||
if (remote.DeviceType == DeviceType.Desktop &&
|
||||
local.DeviceType == DeviceType.Companion)
|
||||
return remote;
|
||||
|
||||
// Regel 2: Späterer Timestamp gewinnt
|
||||
return local.Timestamp >= remote.Timestamp ? local : remote;
|
||||
}
|
||||
}
|
||||
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Sync.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM Verschlüsselung für Sync-Payloads.
|
||||
/// Der Schlüssel verlässt nie den Client – der Server sieht nur Ciphertext.
|
||||
/// </summary>
|
||||
public 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
|
||||
|
||||
// PBKDF2-Parameter für Schlüsselableitung aus Einmal-Code
|
||||
private const int Pbkdf2Iterations = 100_000;
|
||||
private const int Pbkdf2KeySize = 32; // 256 bit
|
||||
|
||||
// ── Schlüsselverwaltung ───────────────────────────────────────────────────
|
||||
|
||||
public static byte[] GenerateKey()
|
||||
{
|
||||
var key = new byte[KeySize];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public static string KeyToBase64(byte[] key) =>
|
||||
Convert.ToBase64String(key);
|
||||
|
||||
public static byte[] KeyFromBase64(string base64) =>
|
||||
Convert.FromBase64String(base64);
|
||||
|
||||
// ── Schlüssel aus Einmal-Code ableiten (PBKDF2) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Leitet einen symmetrischen Schlüssel aus dem Einmal-Code ab.
|
||||
/// PBKDF2 macht Brute-Force bei kurzem Code teuer.
|
||||
///
|
||||
/// Salt ist fest definiert (kein Geheimnis, nur Domänentrennung).
|
||||
/// Der Code selbst ist das Geheimnis – 24h TTL schützt vor Angriffsfenstern.
|
||||
/// </summary>
|
||||
public static byte[] DeriveKeyFromCode(string code)
|
||||
{
|
||||
// Normalisieren: Groß, Leerzeichen entfernen
|
||||
var normalized = code.Trim().ToUpperInvariant();
|
||||
var codeBytes = Encoding.UTF8.GetBytes(normalized);
|
||||
|
||||
// Salt: App-spezifisch, öffentlich bekannt – verhindert
|
||||
// Rainbow-Tables gegen andere Anwendungen
|
||||
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
|
||||
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
codeBytes,
|
||||
salt,
|
||||
Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
Pbkdf2KeySize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt den Sync-Schlüssel mit dem Code-Key.
|
||||
/// Ergebnis wird als Base64 auf dem Server hinterlegt.
|
||||
/// </summary>
|
||||
public static string EncryptKeyWithCode(byte[] syncKey, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Encrypt(syncKey, codeKey);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entschlüsselt den Sync-Schlüssel mit dem Code.
|
||||
/// Wirft CryptographicException wenn der Code falsch ist.
|
||||
/// </summary>
|
||||
public static byte[] DecryptKeyWithCode(string encryptedKeyBase64, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Convert.FromBase64String(encryptedKeyBase64);
|
||||
return Decrypt(encrypted, codeKey);
|
||||
}
|
||||
|
||||
// ── Ver-/Entschlüsselung ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt mit AES-256-GCM.
|
||||
/// Format: [Nonce 12 Bytes][Ciphertext][Auth-Tag 16 Bytes]
|
||||
/// </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 verschlüsseltes Format.");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Komfort-Methoden für JSON-Payloads ────────────────────────────────────
|
||||
|
||||
public static string EncryptObject<T>(T obj, byte[] key)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj);
|
||||
var encrypted = Encrypt(json, key);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
public static T? DecryptObject<T>(string base64, byte[] key)
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(base64);
|
||||
var json = Decrypt(encrypted, key);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
// ── Schlüsselspeicherung ──────────────────────────────────────────────────
|
||||
|
||||
public static void SaveKey(byte[] key, string keyFilePath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(keyFilePath);
|
||||
if (dir != null) Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(keyFilePath, KeyToBase64(key));
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(keyFilePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? LoadKey(string keyFilePath)
|
||||
{
|
||||
if (!File.Exists(keyFilePath)) return null;
|
||||
return KeyFromBase64(File.ReadAllText(keyFilePath).Trim());
|
||||
}
|
||||
}
|
||||
214
LehrerApp.Sync/EventApplier.cs
Normal file
214
LehrerApp.Sync/EventApplier.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wendet vom Server gezogene Events auf die lokale LiteDB an.
|
||||
///
|
||||
/// Unterscheidet zwischen:
|
||||
/// - Verschlüsselten Desktop-Events → erst entschlüsseln, dann anwenden
|
||||
/// - Klartext-Events von WebApp/Companion → direkt anwenden
|
||||
/// </summary>
|
||||
public class EventApplier
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
// Repositories für alle Entity-Typen
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IDocumentationRepository _docs;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly ITimeEntryRepository _timeEntries;
|
||||
|
||||
public EventApplier(
|
||||
byte[] key,
|
||||
IStudentRepository students,
|
||||
IGroupRepository groups,
|
||||
IEnrollmentRepository enrollments,
|
||||
IExamRepository exams,
|
||||
IExamResultRepository examResults,
|
||||
IGradeRepository grades,
|
||||
IUnitRepository units,
|
||||
ILessonRepository lessons,
|
||||
IDocumentationRepository docs,
|
||||
IWorkTaskRepository tasks,
|
||||
ITimeEntryRepository timeEntries)
|
||||
{
|
||||
_key = key;
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_enrollments = enrollments;
|
||||
_exams = exams;
|
||||
_examResults = examResults;
|
||||
_grades = grades;
|
||||
_units = units;
|
||||
_lessons = lessons;
|
||||
_docs = docs;
|
||||
_tasks = tasks;
|
||||
_timeEntries = timeEntries;
|
||||
}
|
||||
|
||||
// ── Hauptmethode ──────────────────────────────────────────────────────────
|
||||
|
||||
public void Apply(SyncEvent evt)
|
||||
{
|
||||
var json = ResolvePayload(evt);
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
|
||||
try
|
||||
{
|
||||
ApplyJson(evt.EntityType, evt.Operation, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Einzelne fehlerhafte Events nicht die ganze Sync-Session abbrechen
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Fehler bei {evt.EntityType}/{evt.Operation} " +
|
||||
$"({evt.EventId}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyAll(IEnumerable<SyncEvent> events)
|
||||
{
|
||||
// Chronologisch anwenden
|
||||
foreach (var evt in events.OrderBy(e => e.Timestamp))
|
||||
Apply(evt);
|
||||
}
|
||||
|
||||
// ── Payload auflösen ──────────────────────────────────────────────────────
|
||||
|
||||
private string ResolvePayload(SyncEvent evt)
|
||||
{
|
||||
// Klartext-Events von WebApp/Companion
|
||||
if (evt.DeviceType == DeviceType.Companion)
|
||||
return evt.Payload;
|
||||
|
||||
// Verschlüsselte Events vom Desktop entschlüsseln
|
||||
try
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(evt.Payload);
|
||||
var decrypted = SyncCrypto.Decrypt(encrypted, _key);
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Entschlüsselung fehlgeschlagen " +
|
||||
$"({evt.EventId}): {ex.Message}");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entity-spezifische Anwendung ──────────────────────────────────────────
|
||||
|
||||
private void ApplyJson(string entityType, string operation, string json)
|
||||
{
|
||||
switch (entityType)
|
||||
{
|
||||
case "Student":
|
||||
ApplyEntity<Student>(operation, json,
|
||||
e => _students.Save(e),
|
||||
id => _students.Delete(id));
|
||||
break;
|
||||
|
||||
case "LearningGroup":
|
||||
ApplyEntity<LearningGroup>(operation, json,
|
||||
e => _groups.Save(e),
|
||||
id => _groups.Delete(id));
|
||||
break;
|
||||
|
||||
case "Enrollment":
|
||||
ApplyEntity<Enrollment>(operation, json,
|
||||
e => _enrollments.Save(e),
|
||||
id => _enrollments.Delete(id));
|
||||
break;
|
||||
|
||||
case "Exam":
|
||||
ApplyEntity<Exam>(operation, json,
|
||||
e => _exams.Save(e),
|
||||
id => _exams.Delete(id));
|
||||
break;
|
||||
|
||||
case "ExamResult":
|
||||
ApplyEntity<ExamResult>(operation, json,
|
||||
e => _examResults.Save(e),
|
||||
_ => { }); // ExamResults werden nicht einzeln gelöscht
|
||||
break;
|
||||
|
||||
case "Grade":
|
||||
ApplyEntity<Grade>(operation, json,
|
||||
e => _grades.Save(e),
|
||||
id => _grades.Delete(id));
|
||||
break;
|
||||
|
||||
case "Unit":
|
||||
ApplyEntity<Unit>(operation, json,
|
||||
e => _units.Save(e),
|
||||
id => _units.Delete(id));
|
||||
break;
|
||||
|
||||
case "Lesson":
|
||||
ApplyEntity<Lesson>(operation, json,
|
||||
e => _lessons.Save(e),
|
||||
id => _lessons.Delete(id));
|
||||
break;
|
||||
|
||||
case "Documentation":
|
||||
ApplyEntity<Documentation>(operation, json,
|
||||
e => _docs.Save(e),
|
||||
id => _docs.Delete(id));
|
||||
break;
|
||||
|
||||
case "WorkTask":
|
||||
ApplyEntity<WorkTask>(operation, json,
|
||||
e => _tasks.Save(e),
|
||||
id => _tasks.Delete(id));
|
||||
break;
|
||||
|
||||
case "TimeEntry":
|
||||
ApplyEntity<TimeEntry>(operation, json,
|
||||
e => _timeEntries.Save(e),
|
||||
id => _timeEntries.Delete(id));
|
||||
break;
|
||||
|
||||
default:
|
||||
Console.Error.WriteLine(
|
||||
$"EventApplier: Unbekannter EntityType '{entityType}'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEntity<T>(
|
||||
string operation,
|
||||
string json,
|
||||
Action<T> save,
|
||||
Action<Guid> delete) where T : class
|
||||
{
|
||||
switch (operation)
|
||||
{
|
||||
case "Upsert":
|
||||
case "Create":
|
||||
case "Update":
|
||||
var entity = JsonSerializer.Deserialize<T>(json);
|
||||
if (entity is not null) save(entity);
|
||||
break;
|
||||
|
||||
case "Delete":
|
||||
// Payload ist bei Delete nur die ID
|
||||
if (Guid.TryParse(json.Trim('"'), out var id))
|
||||
delete(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
LehrerApp.Sync/EventQueue.cs
Normal file
129
LehrerApp.Sync/EventQueue.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lokale Event-Queue in LiteDB.
|
||||
/// Jede Datenänderung erzeugt einen Event – dieser wird hier gepuffert
|
||||
/// bis er erfolgreich zum Server gepusht wurde.
|
||||
/// </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 _currentSequenceNr;
|
||||
|
||||
public EventQueue(string queueDbPath)
|
||||
{
|
||||
_db = new LiteDatabase(queueDbPath);
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_queue.EnsureIndex(x => x.Timestamp);
|
||||
|
||||
// Letzte SequenceNr wiederherstellen
|
||||
var meta = _meta.FindById("seq");
|
||||
_currentSequenceNr = meta?.Value ?? 0;
|
||||
}
|
||||
|
||||
// ── Events einreihen ──────────────────────────────────────────────────────
|
||||
|
||||
public SyncEvent Enqueue(
|
||||
string entityType,
|
||||
string entityId,
|
||||
string operation,
|
||||
string encryptedPayload,
|
||||
string deviceId,
|
||||
DeviceType deviceType)
|
||||
{
|
||||
var evt = new SyncEvent
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceType = deviceType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
SequenceNr = ++_currentSequenceNr,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Operation = operation,
|
||||
Payload = encryptedPayload,
|
||||
};
|
||||
|
||||
_queue.Insert(evt);
|
||||
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSequenceNr });
|
||||
|
||||
return evt;
|
||||
}
|
||||
|
||||
// ── Ausstehende Events ────────────────────────────────────────────────────
|
||||
|
||||
public List<SyncEvent> GetPending(int maxBatch = 100) =>
|
||||
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr)))
|
||||
.Take(maxBatch)
|
||||
.ToList();
|
||||
|
||||
public int PendingCount() => _queue.Count();
|
||||
|
||||
// ── Bestätigung nach erfolgreichem Push ───────────────────────────────────
|
||||
|
||||
public void Acknowledge(IEnumerable<Guid> eventIds)
|
||||
{
|
||||
foreach (var id in eventIds)
|
||||
_queue.Delete(id);
|
||||
}
|
||||
|
||||
// ── Letzte Sync-Metadaten ─────────────────────────────────────────────────
|
||||
|
||||
public long GetLastServerSequenceNr() =>
|
||||
_meta.FindById("serverSeq")?.Value ?? 0;
|
||||
|
||||
public void SetLastServerSequenceNr(long nr) =>
|
||||
_meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
|
||||
|
||||
public DateTime? GetLastSyncAt()
|
||||
{
|
||||
var meta = _meta.FindById("lastSync");
|
||||
return meta?.Timestamp;
|
||||
}
|
||||
|
||||
public void SetLastSyncAt(DateTime timestamp) =>
|
||||
_meta.Upsert(new SyncMeta
|
||||
{
|
||||
Id = "lastSync",
|
||||
Value = 0,
|
||||
Timestamp = timestamp
|
||||
});
|
||||
|
||||
// ── Konflikte ─────────────────────────────────────────────────────────────
|
||||
|
||||
public void AddConflict(ConflictEntry conflict) =>
|
||||
_conflicts.Insert(conflict);
|
||||
|
||||
public List<ConflictEntry> GetUnreviewedConflicts() =>
|
||||
_conflicts.Find(c => !c.Reviewed).ToList();
|
||||
|
||||
public int ConflictCount() =>
|
||||
_conflicts.Count(c => !c.Reviewed);
|
||||
|
||||
public void MarkConflictReviewed(Guid id)
|
||||
{
|
||||
var conflict = _conflicts.FindById(id);
|
||||
if (conflict is null) return;
|
||||
conflict.Reviewed = true;
|
||||
_conflicts.Update(conflict);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
// Internes Hilfsdokument für Metadaten
|
||||
internal class SyncMeta
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
18
LehrerApp.Sync/LehrerApp.Sync.csproj
Normal file
18
LehrerApp.Sync/LehrerApp.Sync.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LiteDB" />
|
||||
<!-- System.Text.Json ist in .NET 9 bereits im SDK enthalten,
|
||||
explizite Referenz nur für Versionspin nötig -->
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
LehrerApp.Sync/Models/PlainSyncModels.cs
Normal file
30
LehrerApp.Sync/Models/PlainSyncModels.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Event das die WebApp oder Companion-App erzeugt.
|
||||
/// Payload ist NICHT verschlüsselt – JWT schützt den Transport.
|
||||
/// Der Desktop-Client erkennt PlainEvents am DeviceType
|
||||
/// und wendet sie ohne Entschlüsselung an.
|
||||
/// </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; } = ""; // "Grade", "Exam", ...
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = ""; // "Upsert", "Delete"
|
||||
public string Payload { get; init; } = ""; // JSON, Klartext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort auf einen Plain-Push.
|
||||
/// </summary>
|
||||
public class PlainPushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> RejectedEventIds { get; init; } = [];
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
61
LehrerApp.Sync/Models/SnapshotModels.cs
Normal file
61
LehrerApp.Sync/Models/SnapshotModels.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Anfrage zum Upload eines initialen Snapshots.
|
||||
/// Enthält sowohl den verschlüsselten DB-Snapshot
|
||||
/// als auch den mit dem Code verschlüsselten Sync-Schlüssel.
|
||||
/// </summary>
|
||||
public class SnapshotUploadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// AES-256-GCM verschlüsselter Dump der lokalen LiteDB.
|
||||
/// Verschlüsselt mit dem Sync-Schlüssel des Nutzers.
|
||||
/// Der Server versteht den Inhalt nicht.
|
||||
/// </summary>
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Der Sync-Schlüssel, verschlüsselt mit dem aus dem
|
||||
/// Einmal-Code abgeleiteten Key (PBKDF2).
|
||||
/// Ermöglicht dem Empfänger den Schlüssel ohne Vorabübertragung
|
||||
/// zu rekonstruieren – nur der Code wird benötigt.
|
||||
/// </summary>
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
|
||||
public DeviceType DeviceType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort nach erfolgreichem Snapshot-Upload.
|
||||
/// </summary>
|
||||
public class SnapshotUploadResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Menschenlesbarer Einmal-Code.
|
||||
/// Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU"
|
||||
/// Dient gleichzeitig zum Abrufen UND zum Entschlüsseln des Sync-Schlüssels.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = "";
|
||||
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort beim Abrufen eines Snapshots per Code.
|
||||
/// Nach dem ersten Abruf wird der Snapshot vom Server gelöscht.
|
||||
/// </summary>
|
||||
public class SnapshotDownloadResponse
|
||||
{
|
||||
/// <summary>Der verschlüsselte DB-Snapshot.</summary>
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Der mit dem Code verschlüsselte Sync-Schlüssel.
|
||||
/// Empfänger: Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln
|
||||
/// → Sync-Key → EncryptedPayload entschlüsseln.
|
||||
/// </summary>
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DeviceType SourceDeviceType { get; init; }
|
||||
}
|
||||
66
LehrerApp.Sync/Models/SyncModels.cs
Normal file
66
LehrerApp.Sync/Models/SyncModels.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ein einzelnes Ereignis im Event-Log.
|
||||
/// Wird verschlüsselt zum Server übertragen – der Server versteht den Payload nicht.
|
||||
/// </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; } // monoton steigend pro Gerät
|
||||
public string EntityType { get; init; } = ""; // "Student", "Exam", "Grade" ...
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = ""; // "Create", "Update", "Delete"
|
||||
public string Payload { get; init; } = ""; // JSON, AES-256 verschlüsselt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eintrag im lokalen Konflikt-Log – für UI-Anzeige.
|
||||
/// </summary>
|
||||
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; } = ""; // "LocalWon", "RemoteWon", "Pending"
|
||||
public bool Reviewed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort des Servers auf einen Pull-Request.
|
||||
/// </summary>
|
||||
public class PullResponse
|
||||
{
|
||||
public List<SyncEvent> Events { get; init; } = [];
|
||||
public long ServerSequenceNr { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort des Servers auf einen Push-Request.
|
||||
/// </summary>
|
||||
public class PushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> ConflictingEventIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum DeviceType { Desktop, Companion }
|
||||
|
||||
/// <summary>
|
||||
/// Sync-Status für UI-Anzeige.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
public enum SyncState { Idle, Syncing, Error, Offline }
|
||||
155
LehrerApp.Sync/ReadableSnapshotService.cs
Normal file
155
LehrerApp.Sync/ReadableSnapshotService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Exportiert einen lesbaren Snapshot der lokalen LiteDB
|
||||
/// und pusht ihn zur API – wo er für die WebApp abrufbar ist.
|
||||
///
|
||||
/// Wird automatisch nach jedem Sync-Zyklus ausgeführt
|
||||
/// und kann manuell angestoßen werden.
|
||||
/// </summary>
|
||||
public class ReadableSnapshotService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly IEnrollmentRepository _enrollments;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IGradeRepository _grades;
|
||||
private readonly IUnitRepository _units;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
private readonly string _deviceId;
|
||||
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
public ReadableSnapshotService(
|
||||
HttpClient http,
|
||||
IStudentRepository students,
|
||||
IGroupRepository groups,
|
||||
IEnrollmentRepository enrollments,
|
||||
IExamRepository exams,
|
||||
IExamResultRepository examResults,
|
||||
IGradeRepository grades,
|
||||
IUnitRepository units,
|
||||
ILessonRepository lessons,
|
||||
IWorkTaskRepository tasks,
|
||||
SchoolYearService schoolYear,
|
||||
string deviceId)
|
||||
{
|
||||
_http = http;
|
||||
_students = students;
|
||||
_groups = groups;
|
||||
_enrollments = enrollments;
|
||||
_exams = exams;
|
||||
_examResults = examResults;
|
||||
_grades = grades;
|
||||
_units = units;
|
||||
_lessons = lessons;
|
||||
_tasks = tasks;
|
||||
_schoolYear = schoolYear;
|
||||
_deviceId = deviceId;
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<bool> ExportAndPushAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
StatusChanged?.Invoke("Snapshot wird erstellt…");
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = BuildSnapshot();
|
||||
|
||||
StatusChanged?.Invoke("Snapshot wird übertragen…");
|
||||
var response = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/readable", snapshot, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
StatusChanged?.Invoke(
|
||||
$"Übertragung fehlgeschlagen: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
StatusChanged?.Invoke(
|
||||
$"Snapshot übertragen – {snapshot.Meta.StudentCount} Schüler, " +
|
||||
$"{snapshot.Meta.GroupCount} Gruppen");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusChanged?.Invoke($"Fehler: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot aufbauen ─────────────────────────────────────────────────────
|
||||
|
||||
private ReadableSnapshot BuildSnapshot()
|
||||
{
|
||||
var schoolYear = _schoolYear.CurrentSchoolYear();
|
||||
var groups = _groups.GetBySchoolYear(schoolYear);
|
||||
var groupIds = groups.Select(g => g.Id).ToHashSet();
|
||||
|
||||
var students = _students.GetAll();
|
||||
var enrollments = groups
|
||||
.SelectMany(g => _enrollments.GetByGroupAndYear(g.Id, schoolYear))
|
||||
.ToList();
|
||||
|
||||
var exams = groupIds
|
||||
.SelectMany(gid => _exams.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var examResults = exams
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.ToList();
|
||||
|
||||
var grades = groupIds
|
||||
.SelectMany(gid => _grades.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var units = groupIds
|
||||
.SelectMany(gid => _units.GetByGroup(gid))
|
||||
.ToList();
|
||||
|
||||
var lessons = units
|
||||
.SelectMany(u => _lessons.GetByUnit(u.Id))
|
||||
.ToList();
|
||||
|
||||
var tasks = _tasks.GetAll()
|
||||
.Where(t => t.Status != WorkTaskStatus.Done)
|
||||
.ToList();
|
||||
|
||||
var snapshot = new ReadableSnapshot
|
||||
{
|
||||
SchoolYear = schoolYear,
|
||||
Students = students,
|
||||
Groups = groups,
|
||||
Enrollments = enrollments,
|
||||
Exams = exams,
|
||||
ExamResults = examResults,
|
||||
Grades = grades,
|
||||
Units = units,
|
||||
Lessons = lessons,
|
||||
Tasks = tasks,
|
||||
Meta = new SnapshotMeta
|
||||
{
|
||||
StudentCount = students.Count,
|
||||
GroupCount = groups.Count,
|
||||
ExamCount = exams.Count,
|
||||
ExportedByDevice = _deviceId,
|
||||
OldestData = DateTime.UtcNow.AddYears(-1), // vereinfacht
|
||||
},
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
245
LehrerApp.Sync/SnapshotService.cs
Normal file
245
LehrerApp.Sync/SnapshotService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet den initialen Snapshot-Austausch beim Einrichten eines neuen Geräts.
|
||||
///
|
||||
/// Ablauf Sender (Desktop):
|
||||
/// 1. LiteDB checkpoint + lesen
|
||||
/// 2. Mit Sync-Schlüssel (AES-256) verschlüsseln → EncryptedPayload
|
||||
/// 3. Sync-Schlüssel mit Code-Key (PBKDF2 aus Einmal-Code) verschlüsseln → EncryptedSyncKey
|
||||
/// 4. Beides zum Server pushen → Einmal-Code erhalten
|
||||
/// 5. Code dem Nutzer anzeigen – das ist alles was nötig ist
|
||||
///
|
||||
/// Ablauf Empfänger (neues Gerät):
|
||||
/// 1. Einmal-Code eingeben
|
||||
/// 2. EncryptedPayload + EncryptedSyncKey laden
|
||||
/// 3. Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln → Sync-Schlüssel
|
||||
/// 4. Sync-Schlüssel lokal speichern (sync.key)
|
||||
/// 5. EncryptedPayload mit Sync-Schlüssel entschlüsseln → LiteDB
|
||||
/// 6. Normaler Event-Sync startet
|
||||
/// </summary>
|
||||
public class SnapshotService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly LiteDbContext _db;
|
||||
private readonly byte[] _syncKey;
|
||||
private readonly DeviceType _deviceType;
|
||||
private readonly string _dbPath;
|
||||
private readonly string _keyPath;
|
||||
|
||||
public event Action<SnapshotProgress>? ProgressChanged;
|
||||
|
||||
public SnapshotService(
|
||||
HttpClient http,
|
||||
LiteDbContext db,
|
||||
byte[] syncKey,
|
||||
DeviceType deviceType,
|
||||
string dbPath,
|
||||
string keyPath)
|
||||
{
|
||||
_http = http;
|
||||
_db = db;
|
||||
_syncKey = syncKey;
|
||||
_deviceType = deviceType;
|
||||
_dbPath = dbPath;
|
||||
_keyPath = keyPath;
|
||||
}
|
||||
|
||||
// ── Sender ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt Snapshot + verschlüsselten Schlüssel und lädt beides hoch.
|
||||
/// Der zurückgegebene Code ist das einzige was der Empfänger braucht.
|
||||
/// </summary>
|
||||
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 1. DB sauber schreiben
|
||||
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
|
||||
_db.Checkpoint();
|
||||
|
||||
// 2. DB-Bytes lesen
|
||||
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
|
||||
var dbBytes = await File.ReadAllBytesAsync(_dbPath, ct);
|
||||
|
||||
// 3. Snapshot mit Sync-Schlüssel verschlüsseln
|
||||
Report(SnapshotStep.Encrypting, "Datenbank wird verschlüsselt…");
|
||||
var encryptedPayload = Convert.ToBase64String(
|
||||
SyncCrypto.Encrypt(dbBytes, _syncKey));
|
||||
|
||||
// 4. Hochladen – Server generiert den Einmal-Code
|
||||
// Der EncryptedSyncKey wird NACH dem Upload erzeugt,
|
||||
// weil wir dafür den Code brauchen den der Server zurückgibt.
|
||||
// Deshalb: zweistufiger Upload.
|
||||
Report(SnapshotStep.Uploading, "Erster Upload – Code wird angefordert…");
|
||||
|
||||
// Schritt 4a: Initialer Upload ohne EncryptedSyncKey → Code erhalten
|
||||
var initRequest = new SnapshotUploadRequest
|
||||
{
|
||||
EncryptedPayload = encryptedPayload,
|
||||
EncryptedSyncKey = "", // noch leer
|
||||
DeviceType = _deviceType,
|
||||
};
|
||||
|
||||
var initResponse = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/upload", initRequest, ct);
|
||||
initResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var initResult = await initResponse.Content
|
||||
.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
// Schritt 4b: Sync-Schlüssel mit dem erhaltenen Code verschlüsseln
|
||||
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt und ergänzt…");
|
||||
var encryptedSyncKey = SyncCrypto.EncryptKeyWithCode(
|
||||
_syncKey, initResult.Code);
|
||||
|
||||
// Schritt 4c: Vollständiger Upload mit EncryptedSyncKey
|
||||
var fullRequest = new SnapshotUploadRequest
|
||||
{
|
||||
EncryptedPayload = encryptedPayload,
|
||||
EncryptedSyncKey = encryptedSyncKey,
|
||||
DeviceType = _deviceType,
|
||||
};
|
||||
|
||||
var fullResponse = await _http.PostAsJsonAsync(
|
||||
"/api/snapshot/upload", fullRequest, ct);
|
||||
fullResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await fullResponse.Content
|
||||
.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
Report(SnapshotStep.Done, $"Bereit. Code: {result.Code}");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Empfänger ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Lädt Snapshot und Schlüssel anhand des Einmal-Codes.
|
||||
/// Extrahiert den Sync-Schlüssel, speichert ihn lokal,
|
||||
/// und baut die neue LiteDB auf.
|
||||
/// </summary>
|
||||
public async Task<byte[]> RestoreFromCodeAsync(
|
||||
string code,
|
||||
string targetDbPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sanitized = code.Trim().ToUpperInvariant();
|
||||
|
||||
// 1. Snapshot + verschlüsselten Schlüssel laden
|
||||
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
|
||||
var response = await _http.GetAsync(
|
||||
$"/api/snapshot/{sanitized}", ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
throw new SnapshotNotFoundException(
|
||||
$"Kein Snapshot für Code '{sanitized}'. " +
|
||||
"Abgelaufen oder bereits verwendet.");
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var download = await response.Content
|
||||
.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
// 2. Sync-Schlüssel aus dem Code extrahieren
|
||||
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
|
||||
byte[] syncKey;
|
||||
try
|
||||
{
|
||||
syncKey = SyncCrypto.DecryptKeyWithCode(
|
||||
download.EncryptedSyncKey, sanitized);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Schlüssel-Entschlüsselung fehlgeschlagen. " +
|
||||
"Ist der Code korrekt eingegeben?");
|
||||
}
|
||||
|
||||
// 3. Sync-Schlüssel lokal speichern – ab jetzt kann normal gesynct werden
|
||||
SyncCrypto.SaveKey(syncKey, _keyPath);
|
||||
|
||||
// 4. LiteDB entschlüsseln
|
||||
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
|
||||
byte[] dbBytes;
|
||||
try
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(download.EncryptedPayload);
|
||||
dbBytes = SyncCrypto.Decrypt(encrypted, syncKey);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// Sollte nicht passieren wenn der Schlüssel korrekt war
|
||||
throw new InvalidOperationException(
|
||||
"Datenbank-Entschlüsselung fehlgeschlagen. " +
|
||||
"Der Snapshot könnte beschädigt sein.");
|
||||
}
|
||||
|
||||
// 5. LiteDB schreiben
|
||||
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
|
||||
var dir = Path.GetDirectoryName(targetDbPath);
|
||||
if (dir != null) Directory.CreateDirectory(dir);
|
||||
|
||||
if (File.Exists(targetDbPath))
|
||||
{
|
||||
var backup = targetDbPath +
|
||||
$".backup-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
File.Move(targetDbPath, backup);
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
|
||||
|
||||
Report(SnapshotStep.Done,
|
||||
$"Wiederhergestellt vom {download.CreatedAt:dd.MM.yyyy HH:mm}. " +
|
||||
"Bitte App neu starten.");
|
||||
|
||||
// Neuen Sync-Schlüssel zurückgeben – AppBootstrapper muss ihn neu laden
|
||||
return syncKey;
|
||||
}
|
||||
|
||||
private void Report(SnapshotStep step, string message) =>
|
||||
ProgressChanged?.Invoke(new SnapshotProgress(step, message));
|
||||
}
|
||||
|
||||
// ── Fortschritts-Modelle ──────────────────────────────────────────────────────
|
||||
|
||||
public record SnapshotProgress(SnapshotStep Step, string Message)
|
||||
{
|
||||
public int PercentComplete => 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 message) : Exception(message);
|
||||
186
LehrerApp.Sync/SyncEngine.cs
Normal file
186
LehrerApp.Sync/SyncEngine.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert Push, Pull, EventApply und Snapshot-Export.
|
||||
/// Automatisch alle N Minuten + manuell auslösbar.
|
||||
/// </summary>
|
||||
public class SyncEngine : IDisposable
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier? _applier;
|
||||
private readonly ReadableSnapshotService? _snapshotExport;
|
||||
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,
|
||||
EventApplier? applier = null,
|
||||
ReadableSnapshotService? snapshotExport = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_applier = applier;
|
||||
_snapshotExport = snapshotExport;
|
||||
|
||||
_timer = new Timer(
|
||||
async _ => await SyncNowAsync(isAutomatic: true),
|
||||
null,
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
||||
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
// ── Öffentliche API ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||||
{
|
||||
if (Status.State == SyncState.Syncing)
|
||||
return new SyncResult { Skipped = true, Reason = "Sync bereits aktiv" };
|
||||
|
||||
SetState(SyncState.Syncing);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Ausstehende Events pushen
|
||||
var pushResult = await PushAsync();
|
||||
|
||||
// 2. Neue Events vom Server holen
|
||||
var pullResult = await PullAsync();
|
||||
|
||||
// 3. Geholte Events auf LiteDB anwenden
|
||||
if (_applier is not null && pullResult.Events.Count > 0)
|
||||
_applier.ApplyAll(pullResult.Events);
|
||||
|
||||
// 4. Nach erfolgreichem Sync: lesbaren Snapshot exportieren
|
||||
// (nur bei automatischem Sync oder explizit – nicht bei jedem
|
||||
// manuellen Push um Traffic zu sparen)
|
||||
if (_snapshotExport is not null && (isAutomatic || pushResult.Pushed > 0))
|
||||
await _snapshotExport.ExportAndPushAsync();
|
||||
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
Success = true,
|
||||
EventsPushed = pushResult.Pushed,
|
||||
EventsPulled = pullResult.Events.Count,
|
||||
Conflicts = pullResult.Conflicts,
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
SetState(SyncState.Offline);
|
||||
return new SyncResult { Success = false, Reason = "Server nicht erreichbar" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetState(SyncState.Error, ex.Message);
|
||||
return new SyncResult { Success = false, Reason = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||
{
|
||||
var pending = _queue.GetPending(maxBatch: 200);
|
||||
if (pending.Count == 0) return (0, 0);
|
||||
|
||||
var response = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PushResponse>();
|
||||
if (result is null) return (0, 0);
|
||||
|
||||
var sentIds = pending
|
||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||
.Select(e => e.EventId);
|
||||
_queue.Acknowledge(sentIds);
|
||||
_queue.SetLastServerSequenceNr(result.ServerSequenceNr);
|
||||
|
||||
return (pending.Count - result.ConflictingEventIds.Count,
|
||||
result.ConflictingEventIds.Count);
|
||||
}
|
||||
|
||||
// ── Pull ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(List<SyncEvent> Events, int Conflicts)> PullAsync()
|
||||
{
|
||||
var since = _queue.GetLastServerSequenceNr();
|
||||
var response = await _http.GetFromJsonAsync<PullResponse>(
|
||||
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
||||
|
||||
if (response is null || response.Events.Count == 0)
|
||||
return ([], 0);
|
||||
|
||||
var conflicts = 0;
|
||||
foreach (var remoteEvent in response.Events)
|
||||
{
|
||||
var conflict = _resolver.TryResolve(remoteEvent, _config.DeviceId);
|
||||
if (conflict is not null)
|
||||
{
|
||||
_queue.AddConflict(conflict);
|
||||
conflicts++;
|
||||
}
|
||||
}
|
||||
|
||||
_queue.SetLastServerSequenceNr(response.ServerSequenceNr);
|
||||
return (response.Events, conflicts);
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
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; }
|
||||
}
|
||||
28
LehrerApp.sln
Normal file
28
LehrerApp.sln
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
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
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/api/sync/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.0",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user