Compare commits
219 Commits
9b8c5412f8
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| d5544412bd | |||
| aa345aa796 | |||
| 0317c5d454 | |||
| ea8c3e9966 | |||
| 4d3383a4f1 | |||
| 19a0a6c79b | |||
| b9033238c9 | |||
| 594923e10f | |||
| aa20d6f809 | |||
| afd321ec9f | |||
| ae825ae80a | |||
| e055061d85 | |||
| df13136955 | |||
| 4aca1d528b | |||
| 4e02133d95 | |||
| f3efd4aa2b | |||
| 461d7feb2d | |||
| 7536aba474 | |||
| 972570b142 | |||
| 029ffb9895 | |||
| 6decd59ce4 | |||
| f015274d28 | |||
| fd48f7fc09 | |||
| 105e63b9c4 | |||
| 0334fce275 | |||
| aaaf5b6fb7 | |||
| c20e6b5426 | |||
| 36454f3747 | |||
| 3335502ef1 | |||
| 0766f8303c | |||
| 6357e77060 | |||
| 5edbd67c96 | |||
| 0651e54343 | |||
| 4dcd416ff6 | |||
| 5fd8cf4395 | |||
| 76fcad6168 | |||
| 91fa9058de | |||
| 6775707387 | |||
| 8125c453d0 | |||
| 8101cc7be5 | |||
| ea79703dbf | |||
| ed576c0339 | |||
| c35a6eca04 | |||
| 2e20df624f | |||
| a57b481a7e | |||
| 27424c574f | |||
| fd28b2a954 | |||
| 8aab9d34f7 | |||
| 6dabb5572c | |||
| 36db013b76 | |||
| 60d034cde4 | |||
| 5ec04984c9 | |||
| e2fd32ea12 | |||
| 2f089a902d | |||
| 7aac59f3e5 | |||
| a0b720df73 | |||
| 1e7dc8f91d | |||
| c93c24f37c | |||
| bd9cc83205 | |||
| 8d0fc43c0a | |||
| 98596b5586 | |||
| 9013ff488e | |||
| 2a1c4a9eb7 | |||
| d79f039b8b | |||
| 8202dfc0e4 | |||
| 68366ad8c9 | |||
| dcf849cfa7 | |||
| 958a2dbac0 | |||
| 5d7cbc66bf | |||
| 84181ed74d | |||
| d510b0f822 | |||
| 3716dcb1f3 | |||
| d6e35f40ec | |||
| 70f7c88419 | |||
| acd7f4c386 | |||
| f73ac0d4d8 | |||
| 94f055f9bf | |||
| cab8c133c8 | |||
| 13ff97a12b | |||
| 681e8c28fb | |||
| ba81f48e0f | |||
| 3afb04f646 | |||
| 5d8bbfa93b | |||
| 5b11780074 | |||
| f858a02f50 | |||
| 09f11cb4e3 | |||
| 45b213e11d | |||
| 38b9717ad2 | |||
| 0ee2b53b24 | |||
| 459c66bc3e | |||
| 0cee3e2710 | |||
| 385cccb352 | |||
| e73b561b77 | |||
| 55a72bfe12 | |||
| e87d0aafea | |||
| db53fad5a4 | |||
| d3f38b9be4 | |||
| f08dbd2219 | |||
| cd795d3831 | |||
| be62e785db | |||
| d8ccb451d9 | |||
| 9c3bcfc9f9 | |||
| 0c05a5acf1 | |||
| b7ee48401a | |||
| ac5ab9d0ba | |||
| a1549266c3 | |||
| 8fa02a5d49 | |||
| e431c50347 | |||
| f86a044143 | |||
| ce1e578bf2 | |||
| 98060b890b | |||
| 78da534c7c | |||
| 42718606a9 | |||
| d6bf6c6fe3 | |||
| 04f9e087aa | |||
| 95ecb1c0ee | |||
| 36c97e8a7a | |||
| d9e5a757e9 | |||
| f6bb13ca2f | |||
| a221e366e6 | |||
| 3ec4f660b0 | |||
| daa6683405 | |||
| 69a0279bb7 | |||
| 2f2e5b1f56 | |||
| adea82a0e7 | |||
| ebd61d73a5 | |||
| ec5e755e72 | |||
| 8d7999c4a5 | |||
| c9723d2fc8 | |||
| 07dd5c9b8e | |||
| aa79f9dbe5 | |||
| 3b3497079b | |||
| dada3af968 | |||
| 6a37f9e46e | |||
| 44f306d18f | |||
| cbe77c927a | |||
| e4b7558abd | |||
| 188b33618e | |||
| f3b1ecb7a3 | |||
| 7627468b82 | |||
| c937b6d5a8 | |||
| 6cc007ba55 | |||
| 7ea2f97e72 | |||
| a9778ad1d2 | |||
| 263ba32e3c | |||
| 180d53b203 | |||
| a47fbef785 | |||
| 7ab463d47e | |||
| 27299e225c | |||
| 3476b71525 | |||
| 34911fdee1 | |||
| 1b776cd994 | |||
| 016b5a1521 | |||
| c6699a9592 | |||
| e76554342b | |||
| 79f260d62e | |||
| 74ac6ddb5a | |||
| d3550bbc16 | |||
| 784d3d3f40 | |||
| cd4328cef8 | |||
| 87250ba007 | |||
| d7be812ad0 | |||
| 1bf94c2040 | |||
| 17879b1cfb | |||
| f772464401 | |||
| e12588c338 | |||
| 9c0a2fec56 | |||
| 61cfab046a | |||
| 01923d1b87 | |||
| caba259a28 | |||
| 5ef27a70d3 | |||
| 16d3cefe68 | |||
| 0c94a6ecd3 | |||
| bca97ed10a | |||
| 92c930c165 | |||
| d8e94f6d05 | |||
| 5ee4b9874b | |||
| 16a0016de9 | |||
| a1d02c0d4e | |||
| 12e787671c | |||
| 5a6326a967 | |||
| f7ba1fa70e | |||
| e077766cc5 | |||
| e9f88b7889 | |||
| 0237f5de5e | |||
| 9b586dd7fa | |||
| f162ef4fed | |||
| e04b69ce49 | |||
| 3629e89e87 | |||
| 3cf6413fcc | |||
| 0408a482cf | |||
| 0553ceda66 | |||
| 3561b435d5 | |||
| 2bee5caaa2 | |||
| d99294e30e | |||
| f641334982 | |||
| ebc61acd18 | |||
| 76d8380ea1 | |||
| 3fc430f1a0 | |||
| 9d5608f46e | |||
| 2b9a0cd648 | |||
| 9b9cf2b35a | |||
| cc589b13d0 | |||
| 84de905469 | |||
| 4d541fe9cd | |||
| 8820cc7751 | |||
| b041d84b18 | |||
| 6ee85bc909 | |||
| 42a43ae24e | |||
| a7157a5a2e | |||
| 74dee4a6e2 | |||
| 47e2117325 | |||
| 6e5f0fb312 | |||
| 7d54a53005 | |||
| c337c3dfd5 | |||
| 08983ba30d | |||
| 5792d2fcf4 | |||
| 6556e17146 | |||
| d3d2b4b989 |
115
.gitea/workflows/ci.yml
Normal file
115
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,115 @@
|
||||
# GerbilManager CI — Gitea Actions
|
||||
# =================================
|
||||
# STATUS: ENTWURF — inaktiv bis Julian Gitea Actions aktiviert.
|
||||
# Aktivierung: Gitea -> Repository -> Einstellungen -> Actions -> "Actions aktivieren"
|
||||
# Anschliessend: Gitea Actions Runner auf der NAS installieren (siehe docs/ops.md).
|
||||
#
|
||||
# Was dieser Workflow tut:
|
||||
# 1. dotnet test (alle xUnit-Projekte)
|
||||
# 2. npm test + npm run build (Frontend)
|
||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||
#
|
||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
||||
# Images:
|
||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: truenas:13000
|
||||
REGISTRY_OWNER: gulum
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
name: Backend Tests (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: .NET SDK einrichten
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Pakete wiederherstellen
|
||||
run: dotnet restore GerbilManager.slnx
|
||||
|
||||
- name: Build
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
test-frontend:
|
||||
name: Frontend Tests (Node/Vite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Node.js einrichten
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: gerbil-manager-web/package-lock.json
|
||||
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: npm ci
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: npm test
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
- name: Produktionsbuild pruefen
|
||||
run: npm run build
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
build-and-push:
|
||||
name: Docker Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend, test-frontend]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Bei Gitea Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: API-Image bauen und pushen
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: GerbilManagerWebAPI/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:${{ github.sha }}
|
||||
|
||||
- name: Frontend-Image bauen und pushen
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: gerbil-manager-web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -127,3 +127,10 @@ $RECYCLE.BIN/
|
||||
# Mac desktop service store files
|
||||
.DS_Store
|
||||
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
74
.vscode/launch.json
vendored
74
.vscode/launch.json
vendored
@@ -1,42 +1,32 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": ".NET Core Launch (web)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/GerbilManagerWebAPI/bin/Debug/net7.0/GerbilManagerWebAPI.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/GerbilManagerWebAPI",
|
||||
"stopAtEntry": false,
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/GerbilManagerWebAPI/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
},
|
||||
{
|
||||
"name": "Docker .NET Launch",
|
||||
"type": "docker",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "docker-run: debug",
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
// Play button (F5) starts the WHOLE stack via .NET Aspire:
|
||||
// dashboard + Postgres container + WebAPI + React frontend (Vite).
|
||||
// The Aspire dashboard (with login token) opens automatically.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "GerbilManager (Aspire — alles starten)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/GerbilManager.AppHost/bin/Debug/net10.0/GerbilManager.AppHost.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/GerbilManager.AppHost",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "Login to the dashboard at\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
.vscode/tasks.json
vendored
6
.vscode/tasks.json
vendored
@@ -7,7 +7,7 @@
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/GerbilManager.sln",
|
||||
"${workspaceFolder}/GerbilManager.slnx",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
@@ -19,7 +19,7 @@
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/GerbilManager.sln",
|
||||
"${workspaceFolder}/GerbilManager.slnx",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
@@ -33,7 +33,7 @@
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/GerbilManager.sln"
|
||||
"${workspaceFolder}/GerbilManager.slnx"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
|
||||
73
GerbilManager.AppHost/AppHost.cs
Normal file
73
GerbilManager.AppHost/AppHost.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithDataVolume();
|
||||
|
||||
var gerbilDb = postgres.AddDatabase("gerbilmanager");
|
||||
|
||||
// Stable, LAN-facing HTTP port for the API (matches the SPA's default base URL).
|
||||
const int ApiHttpPort = 5179;
|
||||
|
||||
var webapi = builder.AddProject<Projects.GerbilManagerWebAPI>("webapi")
|
||||
.WithReference(gerbilDb)
|
||||
.WaitFor(gerbilDb)
|
||||
// Pin the port so the phone-facing URL doesn't change between launches.
|
||||
// Proxyless so the app binds the port directly — Program.cs then widens the
|
||||
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
|
||||
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
|
||||
// the API at this address, not "localhost" (which would be the phone itself).
|
||||
var lanIp = GetLanIpAddress();
|
||||
|
||||
builder.AddViteApp("frontend", "../gerbil-manager-web")
|
||||
.WithReference(webapi)
|
||||
.WaitFor(webapi)
|
||||
// Pin a stable port for the phone bookmark (Vite default 5173). Proxyless so
|
||||
// the Vite dev server binds the port directly; vite.config host:true makes it
|
||||
// bind 0.0.0.0 for LAN access.
|
||||
.WithEndpoint("http", e => { e.Port = 5173; e.IsProxied = false; }, createIfNotExists: false)
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithEnvironment(context =>
|
||||
{
|
||||
// VITE_API_BASE_URL is read by the Vite dev server at launch (see
|
||||
// src/api/client.ts). Point it at the API's LAN-reachable URL.
|
||||
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
|
||||
});
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
|
||||
// skipping loopback and virtual adapters (WSL/Hyper-V). Recomputed each launch
|
||||
// so it follows DHCP changes.
|
||||
static string GetLanIpAddress()
|
||||
{
|
||||
var candidates = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType is NetworkInterfaceType.Wireless80211
|
||||
or NetworkInterfaceType.Ethernet)
|
||||
.Where(ni => !ni.Description.Contains("Hyper-V", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Name.Contains("vEthernet", StringComparison.OrdinalIgnoreCase))
|
||||
// Prefer adapters that have a default gateway (i.e. a real network path).
|
||||
.OrderByDescending(ni => ni.GetIPProperties().GatewayAddresses.Count > 0);
|
||||
|
||||
foreach (var ni in candidates)
|
||||
{
|
||||
var ip = ni.GetIPProperties().UnicastAddresses
|
||||
.FirstOrDefault(a => a.Address.AddressFamily == AddressFamily.InterNetwork
|
||||
&& !IPAddress.IsLoopback(a.Address));
|
||||
if (ip is not null)
|
||||
{
|
||||
return ip.Address.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return "localhost";
|
||||
}
|
||||
20
GerbilManager.AppHost/GerbilManager.AppHost.csproj
Normal file
20
GerbilManager.AppHost/GerbilManager.AppHost.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b153f50c-bec1-401e-9893-f9df7cff2ebd</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
29
GerbilManager.AppHost/Properties/launchSettings.json
Normal file
29
GerbilManager.AppHost/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17004;http://localhost:15265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21284",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22113"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19171",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20259"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
GerbilManager.AppHost/appsettings.Development.json
Normal file
8
GerbilManager.AppHost/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
GerbilManager.AppHost/appsettings.json
Normal file
9
GerbilManager.AppHost/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
GerbilManager.AppHost/aspire.config.json
Normal file
5
GerbilManager.AppHost/aspire.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "GerbilManager.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
121
GerbilManager.ServiceDefaults/Extensions.cs
Normal file
121
GerbilManager.ServiceDefaults/Extensions.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.ServiceDiscovery;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
// Exclude health check requests from tracing
|
||||
tracing.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||
)
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Health checks always exposed — compose healthchecks and Aspire both rely on them.
|
||||
// Trusted home LAN only (no public exposure, no auth — see board constraint).
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
70
GerbilManager.Tests/ApiFactory.cs
Normal file
70
GerbilManager.Tests/ApiFactory.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die
|
||||
/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung
|
||||
/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate()
|
||||
/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated,
|
||||
/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton).
|
||||
/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt.
|
||||
/// </summary>
|
||||
public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
|
||||
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
|
||||
/// EINEN öffentlichen (parameterlosen) Konstruktor.
|
||||
/// </summary>
|
||||
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
|
||||
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
// Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist;
|
||||
// die eigentliche Registrierung wird unten durch SQLite ersetzt.
|
||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||
"Host=localhost;Database=test;Username=test;Password=test");
|
||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EF 9+: AddDbContext registriert die Provider-Konfiguration als
|
||||
// IDbContextOptionsConfiguration<T> — ohne deren Entfernung blieben
|
||||
// Npgsql UND SQLite registriert (ein Provider pro ServiceProvider).
|
||||
services.RemoveAll<Microsoft.EntityFrameworkCore.Infrastructure.IDbContextOptionsConfiguration<ApplicationContext>>();
|
||||
services.RemoveAll<DbContextOptions<ApplicationContext>>();
|
||||
services.RemoveAll<ApplicationContext>();
|
||||
|
||||
_connection.Open();
|
||||
services.AddDbContext<ApplicationContext>(o => o.UseSqlite(_connection));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||
|
||||
ConfigureTestServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
|
||||
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
|
||||
/// styles.css kommt mit CSS-Content-Type.
|
||||
/// </summary>
|
||||
public class CmsPreviewTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private static object PageInput(string slug, string title, string status) =>
|
||||
new { slug, title, seoDescription = (string?)null, status };
|
||||
|
||||
private sealed record PageRow(Guid Id, string Slug);
|
||||
|
||||
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
|
||||
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
|
||||
{
|
||||
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
|
||||
.FirstOrDefault(p => p.Slug == slug);
|
||||
if (existing is null)
|
||||
{
|
||||
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
|
||||
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
|
||||
|
||||
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
|
||||
var index = await client.GetAsync("/api/preview/");
|
||||
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
|
||||
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
|
||||
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
|
||||
|
||||
// Expliziter Pfad funktioniert ebenso
|
||||
var explicitIndex = await client.GetAsync("/api/preview/index.html");
|
||||
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
|
||||
|
||||
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
|
||||
var css = await client.GetAsync("/api/preview/assets/site.css");
|
||||
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
|
||||
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
|
||||
|
||||
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
|
||||
|
||||
// Unsinnige Pfade → 404
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
|
||||
}
|
||||
}
|
||||
83
GerbilManager.Tests/CmsTests.cs
Normal file
83
GerbilManager.Tests/CmsTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder.</summary>
|
||||
public class CmsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||
|
||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||
Assert.Equal(6, pages.Count);
|
||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList");
|
||||
Assert.Equal("auto", listBlock.GetProperty("data").GetProperty("mode").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, listBlock.GetProperty("data").GetProperty("animals").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abgabetiere_resolves_ForSale_animals_with_becken_group()
|
||||
{
|
||||
// a Becken + a ForSale gerbil in it
|
||||
var encResp = await _client.PostAsJsonAsync("/enclosures", new { name = "Verkaufsbecken" });
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
.Single(p => p.GetProperty("slug").GetString() == "abgabetiere")
|
||||
.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList")
|
||||
.GetProperty("data").GetProperty("animals").EnumerateArray().ToList();
|
||||
|
||||
var sale = animals.SingleOrDefault(a => a.GetProperty("name").GetString() == "Verkaufsmaus");
|
||||
Assert.Equal(JsonValueKind.Object, sale.ValueKind);
|
||||
Assert.Equal("Verkaufsbecken", sale.GetProperty("group").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, sale.GetProperty("photos").ValueKind);
|
||||
Assert.DoesNotContain(animals, a => a.GetProperty("name").GetString() == "Bleibtmaus");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_crud_and_reorder()
|
||||
{
|
||||
// create a page + two blocks
|
||||
var pageResp = await _client.PostAsJsonAsync("/api/pages", new { slug = "test-page", title = "Testseite" });
|
||||
Assert.Equal(HttpStatusCode.Created, pageResp.StatusCode);
|
||||
var pageId = JsonDocument.Parse(await pageResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
async Task<Guid> AddBlock(string type, object data) =>
|
||||
JsonDocument.Parse(await (await _client.PostAsJsonAsync($"/api/pages/{pageId}/blocks", new { type, data }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var b1 = await AddBlock("Heading", new { text = "Erstes", level = 2 });
|
||||
var b2 = await AddBlock("RichText", new { markdown = "**hallo**" });
|
||||
|
||||
// reorder: b2 before b1
|
||||
var reorder = await _client.PutAsJsonAsync($"/api/pages/{pageId}/blocks/order", new { blockIds = new[] { b2, b1 } });
|
||||
Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode);
|
||||
|
||||
var blocks = JsonDocument.Parse(await _client.GetStringAsync("/api/pages/test-page"))
|
||||
.RootElement.GetProperty("blocks").EnumerateArray().ToList();
|
||||
Assert.Equal(b2.ToString(), blocks[0].GetProperty("id").GetString());
|
||||
Assert.Equal(b1.ToString(), blocks[1].GetProperty("id").GetString());
|
||||
// RichText markdown round-trips inside data
|
||||
Assert.Equal("**hallo**", blocks[0].GetProperty("data").GetProperty("markdown").GetString());
|
||||
}
|
||||
}
|
||||
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile
|
||||
/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab:
|
||||
/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar.
|
||||
/// </summary>
|
||||
public class ContractEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = CreateJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var o = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
o.Converters.Add(new JsonStringEnumConverter());
|
||||
return o;
|
||||
}
|
||||
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public ContractEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private sealed record IdDto(Guid Id);
|
||||
|
||||
private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate);
|
||||
|
||||
private sealed record ContractView(
|
||||
Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate,
|
||||
string FileName, List<Guid> GerbilIds, string Url);
|
||||
|
||||
private sealed record Paged<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
|
||||
private async Task<Guid> CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel")
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/contacts", new
|
||||
{
|
||||
name,
|
||||
email = "max@example.com",
|
||||
phone = "0987 654321",
|
||||
address = "Beispielallee 7, 54321 Beispielstadt",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateGerbilAsync(HttpClient client, string name)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/gerbils", new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
dateOfBirth = "2025-03-09",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZuchtprofilRoundTrip_PutDannGet()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var put = await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var profile = await client.GetFromJsonAsync<Dictionary<string, string>>("/settings/breeder-profile", Json);
|
||||
Assert.Equal("Zucht Testhausen", profile!["zuchtName"]);
|
||||
Assert.Equal("Testhausen", profile["city"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragErzeugen_SchließtDieAbgabeAb()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet.
|
||||
await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
|
||||
var contactId = await CreateContactAsync(client);
|
||||
var krümel = await CreateGerbilAsync(client, "Krümel");
|
||||
var luna = await CreateGerbilAsync(client, "Luna");
|
||||
|
||||
// POST /contracts — der Abschluss.
|
||||
var post = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { krümel, luna },
|
||||
price = 72.0m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, post.StatusCode);
|
||||
var contract = (await post.Content.ReadFromJsonAsync<ContractView>(Json))!;
|
||||
|
||||
Assert.Equal(contactId, contract.ContactId);
|
||||
Assert.Equal(2, contract.GerbilIds.Count);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum
|
||||
Assert.Equal($"/contracts/{contract.Id}/file", contract.Url);
|
||||
|
||||
// Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum.
|
||||
foreach (var id in new[] { krümel, luna })
|
||||
{
|
||||
var gerbil = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{id}", Json);
|
||||
Assert.Equal("GivenAway", gerbil!.Status);
|
||||
Assert.Equal(contactId, gerbil.ReceiverContactId);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate);
|
||||
}
|
||||
|
||||
// Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis.
|
||||
var file = await client.GetAsync(contract.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
Assert.Equal(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx",
|
||||
file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName);
|
||||
|
||||
var text = DocumentText(await file.Content.ReadAsByteArrayAsync());
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Luna", text);
|
||||
Assert.Contains("Herr Max Beispiel", text);
|
||||
Assert.Contains("Kaufpreis von 72,00 €", text);
|
||||
Assert.Contains("Testhausen, den 05.06.2026", text);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
|
||||
// Liste: nach Abnehmer filterbar (Gridify).
|
||||
var list = await client.GetFromJsonAsync<Paged<ContractView>>(
|
||||
$"/contracts?filter=contactId=={contactId}", Json);
|
||||
Assert.Equal(1, list!.TotalCount);
|
||||
Assert.Equal(contract.Id, list.Items[0].Id);
|
||||
|
||||
// DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet.
|
||||
var delete = await client.DeleteAsync($"/contracts/{contract.Id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode);
|
||||
var still = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{krümel}", Json);
|
||||
Assert.Equal("GivenAway", still!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var contactId = await CreateContactAsync(client, "Frau Lisa Test");
|
||||
|
||||
var empty = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = Array.Empty<Guid>(),
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
var unknown = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { Guid.NewGuid() },
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Sichtbarer Text aus word/document.xml (Tags entfernt).</summary>
|
||||
private static string DocumentText(byte[] docx)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
|
||||
}
|
||||
}
|
||||
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal file
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Validation;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
|
||||
/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
|
||||
/// </summary>
|
||||
public class ContractGeneratorTests
|
||||
{
|
||||
private static BreederProfile Seller() => new()
|
||||
{
|
||||
ZuchtName = "Zucht Testhausen",
|
||||
Name = "Frau Erika Muster",
|
||||
Address = "Musterweg 1, 12345 Testhausen",
|
||||
Phone = "0123 456789",
|
||||
Email = "zucht@example.org",
|
||||
Homepage = "https://zucht.example.org/",
|
||||
City = "Testhausen",
|
||||
};
|
||||
|
||||
private static ContractAnimal Krümel() => new(
|
||||
Name: "Krümel",
|
||||
Geschlecht: "Weiblich",
|
||||
Geburtsdatum: new DateOnly(2025, 3, 9),
|
||||
Farbschlag: "Agouti");
|
||||
|
||||
private static ContractData OneAnimal() => new(
|
||||
Seller: Seller(),
|
||||
Buyer: new ContractBuyer(
|
||||
Name: "Herr Max Beispiel",
|
||||
Address: "Beispielallee 7, 54321 Beispielstadt",
|
||||
Phone: "0987 654321",
|
||||
Email: "max@example.com"),
|
||||
Animals: [Krümel()],
|
||||
Price: 72m,
|
||||
HandoverDate: new DateOnly(2026, 6, 5));
|
||||
|
||||
/// <summary>
|
||||
/// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
|
||||
/// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
|
||||
/// und Fließtext liegen in getrennten w:t-Runs).
|
||||
/// </summary>
|
||||
private static string DocumentText(byte[] docx)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EinTier_EnthaeltAlleVertragswerte()
|
||||
{
|
||||
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
|
||||
|
||||
// Verkäufer-Block (aus dem Zuchtprofil)
|
||||
Assert.Contains("Zucht Testhausen", text);
|
||||
Assert.Contains("Frau Erika Muster", text);
|
||||
Assert.Contains("Musterweg 1, 12345 Testhausen", text);
|
||||
Assert.Contains("0123 456789", text);
|
||||
Assert.Contains("zucht@example.org", text);
|
||||
Assert.Contains("https://zucht.example.org/", text);
|
||||
|
||||
// Käufer-Block
|
||||
Assert.Contains("Herr Max Beispiel", text);
|
||||
Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
|
||||
Assert.Contains("0987 654321", text);
|
||||
Assert.Contains("max@example.com", text);
|
||||
|
||||
// Tier
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Weiblich", text);
|
||||
Assert.Contains("09.03.2025", text);
|
||||
Assert.Contains("Agouti", text);
|
||||
|
||||
// Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
|
||||
Assert.Contains("Kaufpreis von 72,00 €", text);
|
||||
Assert.Contains("am 05.06.2026 dem Käufer", text);
|
||||
|
||||
// Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
|
||||
Assert.Contains("Testhausen, den 05.06.2026", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeineUnersetztenTokens()
|
||||
{
|
||||
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
|
||||
Assert.DoesNotContain("{{", text);
|
||||
Assert.DoesNotContain("}}", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DreiTiere_KlontDieTierTabelleProTier()
|
||||
{
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Animals =
|
||||
[
|
||||
Krümel(),
|
||||
new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
|
||||
new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
|
||||
],
|
||||
};
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Fridolin", text);
|
||||
Assert.Contains("Luna", text);
|
||||
Assert.Contains("01.05.2023", text);
|
||||
Assert.Contains("15.08.2023", text);
|
||||
|
||||
// Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
|
||||
Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreisUndDatum_DeutscheFormate()
|
||||
{
|
||||
var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Kaufpreis von 1.234,50 €", text);
|
||||
Assert.Contains("am 03.01.2026 dem Käufer", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
|
||||
{
|
||||
var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Testhausen, den 07.06.2026", text);
|
||||
Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionaleFelder_LeerStattPlatzhalter()
|
||||
{
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
|
||||
Animals = [new ContractAnimal("Momo", "Unbekannt")],
|
||||
};
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Frau Lisa Test", text);
|
||||
Assert.Contains("Momo", text);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneTiere_WirftArgumentException()
|
||||
{
|
||||
var data = OneAnimal() with { Animals = [] };
|
||||
var ex = Assert.Throws<ArgumentException>(() => ContractGenerator.Generate(data));
|
||||
Assert.Contains("mindestens ein Tier", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErzeugteDatei_IstEinGueltigesDocxPaket()
|
||||
{
|
||||
var docx = ContractGenerator.Generate(OneAnimal());
|
||||
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
// Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
|
||||
Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
|
||||
{
|
||||
// Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
|
||||
// Word-2010-Form, die der 2007-Default des Validators nicht kennt.
|
||||
var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
|
||||
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
|
||||
};
|
||||
using var generatedDoc = WordprocessingDocument.Open(
|
||||
new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
|
||||
var errors = validator.Validate(generatedDoc)
|
||||
.Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
|
||||
.ToList();
|
||||
|
||||
Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorlageSelbst_EnthaeltKeineEchtdaten()
|
||||
{
|
||||
// Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
|
||||
// mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
|
||||
var data = new ContractData(
|
||||
new BreederProfile(),
|
||||
new ContractBuyer("", ""),
|
||||
[new ContractAnimal("", "")],
|
||||
0m,
|
||||
new DateOnly(2026, 1, 1));
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
// Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
|
||||
Assert.DoesNotContain("Nießner", text);
|
||||
Assert.DoesNotContain("Hartengrund", text);
|
||||
Assert.DoesNotContain("Ronneburg", text);
|
||||
Assert.DoesNotContain("Schädtler", text);
|
||||
Assert.DoesNotContain("gmx.de", text);
|
||||
Assert.DoesNotContain("jimdofree", text);
|
||||
}
|
||||
}
|
||||
144
GerbilManager.Tests/ExportTests.cs
Normal file
144
GerbilManager.Tests/ExportTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Export;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
|
||||
/// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
|
||||
/// </summary>
|
||||
public class ExportTests
|
||||
{
|
||||
// ── CSV-Escaping ───────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
|
||||
[InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
|
||||
[InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
|
||||
[InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
|
||||
[InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
|
||||
public void Escape_behandelt_Sonderfälle(string? input, string expected)
|
||||
=> Assert.Equal(expected, ExportService.Escape(input));
|
||||
|
||||
[Fact]
|
||||
public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
|
||||
{
|
||||
var csv = ExportService.BuildCsv(
|
||||
["Name", "Notizen"],
|
||||
[["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
|
||||
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal("Name;Notizen", lines[0]);
|
||||
Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
|
||||
Assert.Equal("Bo;", lines[2]);
|
||||
}
|
||||
|
||||
// ── Round-Trip über die API ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
|
||||
// Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
|
||||
Guid gerbilId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
|
||||
var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
|
||||
var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
|
||||
var colorVariety = db.ColorVarieties.First(); // HasData-Seed
|
||||
var gerbil = new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Active,
|
||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||
LitterId = litter.Id,
|
||||
EnclosureId = enclosure.Id,
|
||||
OriginContactId = contact.Id,
|
||||
ColorVarietyId = colorVariety.Id,
|
||||
Genotype = "Aa CC Dd EE GG Pp Spsp rere",
|
||||
ImportSource = "Stammbaum von Akio Kids.xlsx",
|
||||
};
|
||||
gerbilId = gerbil.Id;
|
||||
db.AddRange(contact, enclosure, litter, gerbil,
|
||||
new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
|
||||
Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
new WeightRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbil.Id,
|
||||
Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/export");
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
|
||||
|
||||
using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
|
||||
string[] expectedEntries =
|
||||
["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
|
||||
"gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
|
||||
foreach (var name in expectedEntries)
|
||||
Assert.NotNull(zip.GetEntry(name));
|
||||
|
||||
// export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
|
||||
using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
|
||||
var root = json.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
|
||||
var g = root.GetProperty("gerbils")[0];
|
||||
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
||||
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
|
||||
|
||||
// tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
|
||||
var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
|
||||
Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
|
||||
var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
|
||||
var row = lines[1];
|
||||
Assert.Contains("Krümel", row);
|
||||
Assert.Contains("Weiblich", row);
|
||||
Assert.Contains("Aktiv", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
// kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
|
||||
var kontakte = ReadEntry(zip, "kontakte.csv", out _);
|
||||
Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
|
||||
|
||||
// LIESMICH erklärt den Foto-Speicherort
|
||||
Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
|
||||
}
|
||||
|
||||
private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
|
||||
{
|
||||
using var stream = zip.GetEntry(name)!.Open();
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
var bytes = ms.ToArray();
|
||||
hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
|
||||
return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
|
||||
/// sale-ad prompt includes the character info.</summary>
|
||||
public class GerbilCharacterTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterTraits_and_note_round_trip()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name = "Charakter Test",
|
||||
gender = "female",
|
||||
characterTraits = new[] { "zutraulich", "handzahm" },
|
||||
characterNote = "sehr aktiv und neugierig",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
var created = await create.Content.ReadAsStringAsync();
|
||||
// POST response is the created GerbilDto — traits serialise straight back
|
||||
Assert.Contains("zutraulich", created);
|
||||
Assert.Contains("handzahm", created);
|
||||
Assert.Contains("sehr aktiv und neugierig", created);
|
||||
|
||||
var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value;
|
||||
Assert.NotEqual("", id);
|
||||
|
||||
// re-read from the DB to prove persistence (not just echo)
|
||||
var fetched = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
Assert.Contains("zutraulich", fetched);
|
||||
Assert.Contains("handzahm", fetched);
|
||||
Assert.Contains("sehr aktiv und neugierig", fetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Omitting_traits_defaults_to_empty_list()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name = "Ohne Charakter",
|
||||
gender = "male",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var body = await create.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"characterTraits\":[]", body.Replace(" ", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaleAd_prompt_includes_traits_and_note()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
Animals: new List<SaleAdAnimal>
|
||||
{
|
||||
new("Bella", "Agouti", "2025-01-15", "verspielt",
|
||||
Traits: new List<string> { "zutraulich", "handzahm" },
|
||||
CharacterNote: "liebt Sonnenblumenkerne"),
|
||||
},
|
||||
StatusLine: "FREI",
|
||||
Hints: "");
|
||||
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
|
||||
Assert.Contains("liebt Sonnenblumenkerne", prompt);
|
||||
}
|
||||
}
|
||||
28
GerbilManager.Tests/GerbilManager.Tests.csproj
Normal file
28
GerbilManager.Tests/GerbilManager.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
365
GerbilManager.Tests/ImportServiceTests.cs
Normal file
365
GerbilManager.Tests/ImportServiceTests.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's
|
||||
/// instance). Covers categorisation, the quarantine policy, genotype composition,
|
||||
/// Farbschlag matching, high-confidence linking, and idempotency.
|
||||
/// </summary>
|
||||
public class ImportServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ImportServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson);
|
||||
File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // applies the 73-variety HasData seed
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_categorises_without_writing()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false);
|
||||
|
||||
Assert.False(report.Executed);
|
||||
Assert.Equal(2, report.Litters.InSource);
|
||||
Assert.Equal(2, report.Litters.Created);
|
||||
// 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined
|
||||
Assert.Equal(4, report.Animals.InSource);
|
||||
Assert.Equal(2, report.Animals.Created);
|
||||
Assert.Equal(1, report.Animals.LinkedToLitter); // only a1 (hoch)
|
||||
Assert.Equal(1, report.Animals.FarbschlagMatched); // a1 -> Agouti
|
||||
Assert.Equal(1, report.Animals.Quarantined.Conflicts);
|
||||
Assert.Equal(1, report.Animals.Quarantined.Stubs);
|
||||
Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks);
|
||||
|
||||
// nothing written in dry-run
|
||||
Assert.Equal(0, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(0, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_loads_conflict_free_and_links_high_confidence()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync()); // a1, a4 only
|
||||
|
||||
// conflict + stub never loaded
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2"));
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3"));
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
Assert.NotNull(a1.LitterId); // high-confidence link
|
||||
Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource);
|
||||
Assert.NotNull(a1.ColorVarietyId); // Agouti matched
|
||||
Assert.Contains("aa CC", a1.Genotype); // composed from mapped8locus
|
||||
Assert.Contains("??", a1.Genotype!); // missing loci -> ??
|
||||
Assert.Contains("eef", a1.Genotype!); // e^f caret stripped
|
||||
Assert.Contains("RawGenotype", a1.RawImportData!); // raw verbatim preserved
|
||||
|
||||
var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4");
|
||||
Assert.Null(a4.LitterId); // date-only link quarantined
|
||||
|
||||
// PairingCode carried through
|
||||
Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_is_idempotent()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(0, second.Animals.Created);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
|
||||
Assert.True(a1.IsDeaf);
|
||||
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
|
||||
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
|
||||
Assert.Contains("Sls", a1.RawImportData!);
|
||||
Assert.Contains("WFNZ", a1.RawImportData!);
|
||||
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
|
||||
Assert.EndsWith("Slsl", a1.Genotype!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_appends_Slsl_only_for_carriers()
|
||||
{
|
||||
// wild-type sl/sl is omitted -> plain 8-locus string
|
||||
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
|
||||
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
|
||||
// WP heterozygote -> trailing Slsl
|
||||
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
|
||||
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
|
||||
{
|
||||
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
|
||||
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
|
||||
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ParentLinksFromChart);
|
||||
Assert.Equal(1, report.Litters.DerivedFromChart);
|
||||
|
||||
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
|
||||
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
|
||||
|
||||
// box-colour sex flows through (blue=male, white=female)
|
||||
Assert.Equal(Gender.male, papa.Gender);
|
||||
Assert.Equal(Gender.female, mama.Gender);
|
||||
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal(papa.Id, litter.FatherId);
|
||||
Assert.Equal(mama.Id, litter.MotherId);
|
||||
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
|
||||
|
||||
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
|
||||
// resident (rule b); the unrelated foreign animal stays external.
|
||||
Assert.True(c.IsResident); // rule (a)
|
||||
Assert.True(papa.IsResident); // rule (b) parent exception
|
||||
Assert.True(mama.IsResident); // rule (b)
|
||||
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
|
||||
Assert.NotNull(report.Residency);
|
||||
Assert.Equal(3, report.Residency!.Resident);
|
||||
Assert.Equal(1, report.Residency.External);
|
||||
Assert.Equal(2, report.Residency.FlippedByParentRule);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
|
||||
{
|
||||
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
|
||||
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
|
||||
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
|
||||
|
||||
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId);
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
|
||||
Assert.Equal(0, report.Litters.ParentFksDropped);
|
||||
Assert.Equal(0, report.Litters.DerivedSkipped);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||
{
|
||||
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
|
||||
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
|
||||
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
|
||||
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
|
||||
Assert.NotNull(c2.LitterId); // still linked (via mother)
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
|
||||
Assert.Null(litter.FatherId); // quarantined father -> null FK
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decision_resolved_animal_loads_and_is_counted()
|
||||
{
|
||||
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
|
||||
// un-quarantines an animal; the loader must then LOAD it and surface the count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
|
||||
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
|
||||
"conflict":false,"resolvedByDecision":true}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
|
||||
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
|
||||
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
||||
{
|
||||
var g = new SourceGenotype
|
||||
{
|
||||
Mapped8locus = new()
|
||||
{
|
||||
["A"] = new() { "a", "a" },
|
||||
["C"] = new() { "C", "c^chm" },
|
||||
["E"] = new() { "e", "e^f" },
|
||||
},
|
||||
};
|
||||
// order A C D E G P Sp Re ; missing -> ??
|
||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||
[InlineData("5.3.21", 2021, 3, 5)]
|
||||
public void ParseDate_handles_german_dates(string s, int y, int m, int d)
|
||||
{
|
||||
var date = ImportService.ParseDate(s);
|
||||
Assert.Equal(new DateOnly(y, m, d), date);
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
private const string LittersJson = """
|
||||
[
|
||||
{"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"},
|
||||
{"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""}
|
||||
]
|
||||
""";
|
||||
|
||||
private const string AnimalsJson = """
|
||||
[
|
||||
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
|
||||
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
|
||||
"deaf":true,"tags":["WFNZ"],
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
|
||||
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
|
||||
"farbschlag":"Schwarz","farbschlagVariants":["Schwarz"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true},
|
||||
{"id":"a3","name":"Namenlos","dob":"","death":"","gender":null,
|
||||
"farbschlag":"","farbschlagVariants":[],
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false},
|
||||
{"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null,
|
||||
"farbschlag":"","farbschlagVariants":[],
|
||||
"genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}}
|
||||
]
|
||||
""";
|
||||
}
|
||||
}
|
||||
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung +
|
||||
/// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip
|
||||
/// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben).
|
||||
/// </summary>
|
||||
public class InboxDraftTests
|
||||
{
|
||||
private static Request SampleRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>",
|
||||
FromAddress = "anna.musterfrau@example.de",
|
||||
FromName = "Anna Musterfrau",
|
||||
Subject = "Anfrage: zwei Weibchen?",
|
||||
BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna",
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// ── Zitat-/Signatur-Stripping ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header()
|
||||
{
|
||||
var body = string.Join('\n',
|
||||
"Hallo, habt ihr Tiere abzugeben?",
|
||||
"> alte zitierte Zeile",
|
||||
"Von: jemand@example.de",
|
||||
"Danke!",
|
||||
"-- ",
|
||||
"Anna Musterfrau",
|
||||
"Musterweg 1");
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped);
|
||||
Assert.Contains("Danke!", stripped);
|
||||
Assert.DoesNotContain("alte zitierte Zeile", stripped);
|
||||
Assert.DoesNotContain("jemand@example.de", stripped);
|
||||
Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab()
|
||||
{
|
||||
var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf";
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Equal("Meine Frage steht oben.", stripped);
|
||||
}
|
||||
|
||||
// ── Prompt-Assembly + Datenminimierung ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(),
|
||||
[new DraftReplyService.ForSaleAnimal("Frieda", "Gold"),
|
||||
new DraftReplyService.ForSaleAnimal("Fine", null)]);
|
||||
|
||||
Assert.Contains("Von: Anna Musterfrau", prompt);
|
||||
Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt);
|
||||
Assert.Contains("zwei junge Weibchen zur Abgabe", prompt);
|
||||
Assert.Contains("- Frieda (Gold)", prompt);
|
||||
Assert.Contains("- Fine", prompt);
|
||||
// Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []);
|
||||
Assert.DoesNotContain("abzugebende Tiere", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildSystemPrompt();
|
||||
Assert.Contains("ENTWERFEN", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
Assert.Contains("Keine Preise", prompt);
|
||||
Assert.Contains("keine Adressen", prompt);
|
||||
}
|
||||
|
||||
// ── 503: unkonfiguriert (Service-Ebene) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration()
|
||||
{
|
||||
var service = new DraftReplyService(
|
||||
new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))),
|
||||
Options.Create(new AiOptions()));
|
||||
|
||||
var result = await service.DraftAsync(SampleRequest(), []);
|
||||
|
||||
Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status);
|
||||
}
|
||||
|
||||
// ── Endpoint-Round-Trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var id = await SeedRequestAsync(factory);
|
||||
|
||||
var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs()
|
||||
{
|
||||
const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße";
|
||||
var stub = new StubHandler(_ => Canned(canned));
|
||||
using var factory = new ApiFactory
|
||||
{
|
||||
ConfigureTestServices = services =>
|
||||
{
|
||||
services.PostConfigure<AiOptions>(o =>
|
||||
{
|
||||
o.BaseUrl = "https://api.example.com/v1";
|
||||
o.ApiKey = "test";
|
||||
o.Model = "test-model";
|
||||
});
|
||||
services.AddHttpClient<DraftReplyService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => stub);
|
||||
},
|
||||
};
|
||||
|
||||
var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true);
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"/api/requests/{id}/draft", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString());
|
||||
|
||||
// Persistiert: erneutes GET liefert den Entwurf
|
||||
var again = await client.GetFromJsonAsync<JsonElement>($"/api/requests/{id}");
|
||||
Assert.Equal(canned, again.GetProperty("draftReply").GetString());
|
||||
|
||||
// Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die
|
||||
// Mailadresse der Absenderin NICHT.
|
||||
Assert.NotNull(stub.LastRequestBody);
|
||||
Assert.Contains("zwei junge Weibchen", stub.LastRequestBody);
|
||||
Assert.Contains("Aki", stub.LastRequestBody);
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var request = SampleRequest();
|
||||
db.Add(request);
|
||||
if (alsoForSaleGerbil)
|
||||
{
|
||||
db.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Aki",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.ForSale,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return request.Id;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
GerbilManager.Tests/PedigreeCalculatorTests.cs
Normal file
192
GerbilManager.Tests/PedigreeCalculatorTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using GerbilManagerWebAPI.Genetics;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact-math checks for the inbreeding coefficient (Inzuchtkoeffizient) against
|
||||
/// the textbook reference values for canonical pedigrees.
|
||||
/// </summary>
|
||||
public class PedigreeCalculatorTests
|
||||
{
|
||||
private const int Precision = 10;
|
||||
|
||||
[Fact]
|
||||
public void UnknownAncestry_IsZero()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var lonely = ped.Add("Lonely");
|
||||
var calc = ped.Build();
|
||||
|
||||
var result = calc.ForIndividual(lonely);
|
||||
|
||||
Assert.Equal(0.0, result.Coefficient, Precision);
|
||||
Assert.Equal(0, result.GenerationsAvailable);
|
||||
Assert.Empty(result.CommonAncestors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneKnownParent_IsZero()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var sire = ped.Add("Sire");
|
||||
var calc = ped.Build();
|
||||
|
||||
// Only one parent known → no inbreeding can be established.
|
||||
var result = calc.ForOffspringOf(sire, null);
|
||||
|
||||
Assert.Equal(0.0, result.Coefficient, Precision);
|
||||
Assert.Empty(result.CommonAncestors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentOffspring_IsQuarter()
|
||||
{
|
||||
// Z is the offspring of P mated with its own daughter C.
|
||||
var ped = new PedigreeBuilder();
|
||||
var p = ped.Add("P");
|
||||
var mate = ped.Add("Mate");
|
||||
var c = ped.AddChild("C", p, mate);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(p, c);
|
||||
|
||||
Assert.Equal(0.25, result.Coefficient, Precision);
|
||||
Assert.Equal(25.0, result.Percent, Precision);
|
||||
var ancestor = Assert.Single(result.CommonAncestors);
|
||||
Assert.Equal(p, ancestor.Id);
|
||||
Assert.Equal(0.25, ancestor.Contribution, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullSiblings_IsQuarter()
|
||||
{
|
||||
// Parents S and D are full siblings (both children of GF and GM).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var s = ped.AddChild("S", gf, gm);
|
||||
var d = ped.AddChild("D", gf, gm);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.25, result.Coefficient, Precision);
|
||||
Assert.Equal(2, result.CommonAncestors.Count);
|
||||
Assert.All(result.CommonAncestors, a => Assert.Equal(0.125, a.Contribution, Precision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HalfSiblings_IsEighth()
|
||||
{
|
||||
// Parents S and D share only GF (other parents unrelated).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var x = ped.Add("X");
|
||||
var y = ped.Add("Y");
|
||||
var s = ped.AddChild("S", gf, x);
|
||||
var d = ped.AddChild("D", gf, y);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.125, result.Coefficient, Precision);
|
||||
var ancestor = Assert.Single(result.CommonAncestors);
|
||||
Assert.Equal(gf, ancestor.Id);
|
||||
Assert.Equal(0.125, ancestor.Contribution, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstCousins_IsSixteenth()
|
||||
{
|
||||
// Parents S and D are first cousins: their respective parents A1 and B1
|
||||
// are full siblings (children of GF and GM).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var a1 = ped.AddChild("A1", gf, gm);
|
||||
var b1 = ped.AddChild("B1", gf, gm);
|
||||
var x = ped.Add("X");
|
||||
var y = ped.Add("Y");
|
||||
var s = ped.AddChild("S", a1, x);
|
||||
var d = ped.AddChild("D", b1, y);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.0625, result.Coefficient, Precision);
|
||||
Assert.Equal(2, result.CommonAncestors.Count);
|
||||
Assert.All(result.CommonAncestors, a => Assert.Equal(0.03125, a.Contribution, Precision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepCommonAncestry_SumsAllDisjointPaths()
|
||||
{
|
||||
// Full siblings S and D whose shared parents (GF, GM) are themselves full
|
||||
// siblings (children of GGF, GGM). GF and GM are NOT inbred (their parents
|
||||
// are unrelated founders, so F_GF = F_GM = 0), but the great-grandparents
|
||||
// are reached by additional disjoint paths:
|
||||
// GF + GM: 2 * (1/2)^3 = 0.25
|
||||
// GGF: 2 * (1/2)^5 = 0.0625 (S-GF-GGF / D-GM-GGF and swap)
|
||||
// GGM: 2 * (1/2)^5 = 0.0625
|
||||
// ---------------------------------------------
|
||||
// F = 0.375 (verified against the recursive kinship method)
|
||||
var ped = new PedigreeBuilder();
|
||||
var ggf = ped.Add("GGF");
|
||||
var ggm = ped.Add("GGM");
|
||||
var gf = ped.AddChild("GF", ggf, ggm);
|
||||
var gm = ped.AddChild("GM", ggf, ggm);
|
||||
var s = ped.AddChild("S", gf, gm);
|
||||
var d = ped.AddChild("D", gf, gm);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.375, result.Coefficient, Precision);
|
||||
// Four common ancestors: GF, GM, GGF, GGM.
|
||||
Assert.Equal(4, result.CommonAncestors.Count);
|
||||
Assert.Equal(0.375, result.CommonAncestors.Sum(a => a.Contribution), Precision);
|
||||
// Deepest known generation above the offspring: S/D (1) → GF/GM (2) → GGF/GGM (3).
|
||||
Assert.Equal(3, result.GenerationsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationsAvailable_CountsDeepestKnownGeneration()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var father = ped.AddChild("Father", gf, gm);
|
||||
var mother = ped.Add("Mother");
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(father, mother);
|
||||
|
||||
// Father side reaches grandparents (2 generations); mother side reaches 1.
|
||||
Assert.Equal(2, result.GenerationsAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Small helper to assemble a pedigree of plain GUIDs for the calculator.</summary>
|
||||
private sealed class PedigreeBuilder
|
||||
{
|
||||
private readonly Dictionary<Guid, (Guid? Father, Guid? Mother)> _parents = new();
|
||||
private readonly Dictionary<Guid, string> _names = new();
|
||||
|
||||
public Guid Add(string name)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_parents[id] = (null, null);
|
||||
_names[id] = name;
|
||||
return id;
|
||||
}
|
||||
|
||||
public Guid AddChild(string name, Guid father, Guid mother)
|
||||
{
|
||||
var id = Add(name);
|
||||
_parents[id] = (father, mother);
|
||||
return id;
|
||||
}
|
||||
|
||||
public PedigreeCalculator Build() => new(_parents, _names);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage,
|
||||
/// and the Gmail App Password is never returned by GET /api/mail-settings.</summary>
|
||||
public class RequestEndpointsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>Test double for the IMAP reader: returns whatever the test stages, filtered by UID.</summary>
|
||||
private sealed class CannedReader : IGmailMailReader
|
||||
{
|
||||
public static List<MailSummary> Messages { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Messages.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private HttpClient ClientWithCannedReader() =>
|
||||
_factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
|
||||
{
|
||||
s.RemoveAll<IGmailMailReader>();
|
||||
s.AddScoped<IGmailMailReader, CannedReader>();
|
||||
})).CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_then_triage_assigns_contact()
|
||||
{
|
||||
var client = ClientWithCannedReader();
|
||||
CannedReader.Messages = new()
|
||||
{
|
||||
new("<triage@1>", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!",
|
||||
DateTimeOffset.UtcNow, Uid: 1001),
|
||||
};
|
||||
|
||||
// configure mail (otherwise sync reports MailNotConfigured)
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode);
|
||||
|
||||
var sync = await client.PostAsync("/api/requests/sync", null);
|
||||
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
|
||||
Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1);
|
||||
|
||||
// find the imported request via the status filter
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100"));
|
||||
var item = listed.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Single(r => r.GetProperty("gmailMessageId").GetString() == "<triage@1>");
|
||||
var reqId = item.GetProperty("id").GetString();
|
||||
|
||||
// a contact + triage
|
||||
var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement;
|
||||
Assert.Equal("Assigned", got.GetProperty("status").GetString());
|
||||
Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MailSettings_stores_password_but_never_returns_it()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var put = await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var json = await client.GetStringAsync("/api/mail-settings");
|
||||
Assert.Contains("sekret@gmail.com", json);
|
||||
Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", ""));
|
||||
Assert.DoesNotContain("abcd", json); // the app password must never leave the server
|
||||
Assert.DoesNotContain("appPassword", json);
|
||||
}
|
||||
}
|
||||
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a
|
||||
/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password).</summary>
|
||||
public class RequestSyncServiceTests
|
||||
{
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // seeds the MailSettings singleton
|
||||
return db;
|
||||
}
|
||||
|
||||
private sealed class FakeReader : IGmailMailReader
|
||||
{
|
||||
public List<MailSummary> Canned { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Canned.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private static MailSummary M(uint uid, string msgId, string from) =>
|
||||
new(msgId, ThreadId: null, InReplyTo: null, References: null,
|
||||
FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo",
|
||||
ReceivedAt: DateTimeOffset.UtcNow, Uid: uid);
|
||||
|
||||
private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true)
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured)
|
||||
{
|
||||
s.GmailAddress = "zucht@gmail.com";
|
||||
settings.SetPassword(s, "app-pw-1234");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var reader = new FakeReader();
|
||||
return (db, new RequestSyncService(db, reader, settings), reader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_new_and_dedups_within_batch()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de"), M(3, "<b@y>", "b@y.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Null(res.Error);
|
||||
Assert.Equal(2, res.Imported); // <b@y> deduped
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resync_imports_nothing_new()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de") };
|
||||
await sync.SyncAsync();
|
||||
|
||||
var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer
|
||||
|
||||
Assert.Equal(0, second.Imported);
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_without_config_reports_not_configured()
|
||||
{
|
||||
var (_, sync, reader) = await SetupAsync(configured: false);
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Equal("MailNotConfigured", res.Error);
|
||||
Assert.Equal(0, res.Imported);
|
||||
}
|
||||
}
|
||||
282
GerbilManager.Tests/SaleAdTests.cs
Normal file
282
GerbilManager.Tests/SaleAdTests.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path,
|
||||
/// the OpenAI-compatible wire handling against a stubbed provider, and the
|
||||
/// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs).
|
||||
/// </summary>
|
||||
public class SaleAdTests
|
||||
{
|
||||
private static SaleAdRequest SampleRequest() => new(
|
||||
Animals:
|
||||
[
|
||||
new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"),
|
||||
new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"),
|
||||
],
|
||||
StatusLine: "FREI",
|
||||
Hints: "bitte die Bruder-Dynamik betonen");
|
||||
|
||||
// ── Prompt assembly ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Tiere_Status_und_Hinweise()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||
|
||||
Assert.Contains("Status-Zeile: FREI", prompt);
|
||||
Assert.Contains("Name: Balu", prompt);
|
||||
Assert.Contains("Farbschlag: CP-Agouti", prompt);
|
||||
Assert.Contains("Name: Benny", prompt);
|
||||
Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt);
|
||||
Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_formatiert_Geburtsdatum_deutsch()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||
Assert.Contains("geboren am 12.03.2024", prompt);
|
||||
Assert.DoesNotContain("2024-03-12", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_lässt_fehlende_Angaben_weg()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Name: Mysti", prompt);
|
||||
Assert.DoesNotContain("Farbschlag:", prompt);
|
||||
Assert.DoesNotContain("geboren am", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Wünsche/Hinweise:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
|
||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||
Assert.Contains("NIEMALS Preise", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
|
||||
Assert.Contains("Status: FREI", prompt);
|
||||
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
|
||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||
}
|
||||
|
||||
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Traits_und_Charakternotiz()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
|
||||
Traits: ["zutraulich", "buddelt gern"],
|
||||
CharacterNote: "klettert abends auf die Hand")],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
|
||||
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
|
||||
{
|
||||
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
|
||||
// gehört NICHT in den Prompt.
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
|
||||
Traits: ["neugierig"])],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: neugierig", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Käfig wurde", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
|
||||
{
|
||||
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
|
||||
var request = new SaleAdRequest(
|
||||
[
|
||||
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
|
||||
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
|
||||
],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Notizen: sehr verschmust", prompt);
|
||||
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
|
||||
Assert.DoesNotContain("Charakter:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
Assert.Contains("Charakter-Eigenschaften", prompt);
|
||||
Assert.Contains("niemals als Aufzählung", prompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2024-03-12", "12.03.2024")]
|
||||
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
|
||||
public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected)
|
||||
=> Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input));
|
||||
|
||||
// ── 503: unkonfiguriert ────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Text);
|
||||
}
|
||||
|
||||
// ── Fake-Server: OpenAI-kompatible Antwort wird geparst ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …"));
|
||||
var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.Ok, result.Status);
|
||||
Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("ok"));
|
||||
var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler);
|
||||
|
||||
await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter);
|
||||
|
||||
using var body = JsonDocument.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString());
|
||||
var messages = body.RootElement.GetProperty("messages");
|
||||
Assert.Equal(2, messages.GetArrayLength());
|
||||
Assert.Equal("system", messages[0].GetProperty("role").GetString());
|
||||
Assert.Equal("user", messages[1].GetProperty("role").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
Content = new StringContent("rate limited"),
|
||||
});
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned(""));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini
|
||||
[InlineData("https://api.groq.com/openai/v1",
|
||||
"https://api.groq.com/openai/v1/chat/completions")] // Groq
|
||||
[InlineData("https://api.mistral.ai/v1",
|
||||
"https://api.mistral.ai/v1/chat/completions")] // Mistral
|
||||
[InlineData("http://truenas:11434/v1",
|
||||
"http://truenas:11434/v1/chat/completions")] // Ollama (lokal)
|
||||
[InlineData("https://api.groq.com/openai/v1/",
|
||||
"https://api.groq.com/openai/v1/chat/completions")] // trailing slash
|
||||
public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL(
|
||||
string baseUrl, string expectedUri)
|
||||
{
|
||||
Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl));
|
||||
|
||||
// und der Stub sieht den Aufruf tatsächlich auf dieser URL
|
||||
var handler = new StubHandler(_ => Canned("ok"));
|
||||
var service = CreateService(baseUrl, "k", "m", handler);
|
||||
await service.GenerateAsync(SampleRequest());
|
||||
Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static SaleAdService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new SaleAdService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SEARCH verification — every search variation Julian asked about, against the
|
||||
/// REAL endpoints (SQLite host). Covers: substring/contains, case-insensitivity,
|
||||
/// the no-trailing-* contains fix, separator-insensitive nameSearch (spaces ==
|
||||
/// hyphens), umlauts, the OriginBreeder (Herkunft) filter, the /gerbils/breeders
|
||||
/// distinct dropdown source, paged envelope, and a combined status+name filter.
|
||||
/// Each test uses a unique token so the shared class DB doesn't cross-contaminate.
|
||||
/// </summary>
|
||||
public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>GET /gerbils?filter=... → the list of returned names.</summary>
|
||||
private async Task<List<string>> Names(string filter)
|
||||
{
|
||||
var url = $"/gerbils?filter={Uri.EscapeDataString(filter)}&page=1&pageSize=200";
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync(url));
|
||||
return doc.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(x => x.GetProperty("name").GetString()!).ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_matches_substring_case_insensitively()
|
||||
{
|
||||
await Create("SuchSchokoEins");
|
||||
await Create("SuchSchokoladeZwei");
|
||||
await Create("SuchKeksDrei");
|
||||
|
||||
// lowercase query, capitalised names → exercises /i contains
|
||||
var hits = await Names("name=*suchschoko/i");
|
||||
Assert.Contains("SuchSchokoEins", hits);
|
||||
Assert.Contains("SuchSchokoladeZwei", hits);
|
||||
Assert.DoesNotContain("SuchKeksDrei", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_single_letter_returns_matches_not_the_trailing_star_bug()
|
||||
{
|
||||
// Regression guard for the contains hotfix (0ee2b53): the frontend used to
|
||||
// build name=*VALUE* which Gridify read as literal "VALUE*" → 0 rows.
|
||||
// Correct form name=*VALUE must return every name containing VALUE.
|
||||
await Create("BugGuardAlpha");
|
||||
await Create("BugGuardBeta");
|
||||
var hits = await Names("name=*bugguard/i");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("BugGuard")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_is_separator_insensitive_spaces_equal_hyphens()
|
||||
{
|
||||
// The headline ask: "clan kleine chaoten" must match "clan-kleine-chaoten".
|
||||
await Create("SepClan-Kleine-Chaoten");
|
||||
await Create("SepClan Kleine Chaoten");
|
||||
await Create("SepClanKleineChaoten");
|
||||
await Create("SepUnrelatedMaus");
|
||||
|
||||
// Frontend normalizes the typed term the same way the backend stores it:
|
||||
// lowercase + strip whitespace/.-_ → "sepclankleinechaoten".
|
||||
var hits = await Names("nameSearch=*sepclankleinechaoten");
|
||||
|
||||
Assert.Contains("SepClan-Kleine-Chaoten", hits); // hyphens
|
||||
Assert.Contains("SepClan Kleine Chaoten", hits); // spaces
|
||||
Assert.Contains("SepClanKleineChaoten", hits); // none
|
||||
Assert.DoesNotContain("SepUnrelatedMaus", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_dotted_and_underscored_separators_also_match()
|
||||
{
|
||||
await Create("SepDot.Von.Den.Chaoten");
|
||||
await Create("SepDot_Von_Den_Chaoten");
|
||||
var hits = await Names("nameSearch=*sepdotvondenchaoten");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("SepDot")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_handles_umlauts()
|
||||
{
|
||||
await Create("SuchKrümel von den Chaoten");
|
||||
// nameSearch is stored pre-lowercased; the frontend sends a pre-lowercased
|
||||
// normalized term, so this works without provider unicode case-folding.
|
||||
var hits = await Names("nameSearch=*suchkrümelvondenchaoten");
|
||||
Assert.Contains("SuchKrümel von den Chaoten", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_filter_returns_only_that_breeder()
|
||||
{
|
||||
await Create("HerkunftA1", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftA2", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftB1", originBreeder: "Zucht HerkunftTest Zwei");
|
||||
|
||||
var hits = await Names("originBreeder==Zucht HerkunftTest Eins");
|
||||
Assert.Contains("HerkunftA1", hits);
|
||||
Assert.Contains("HerkunftA2", hits);
|
||||
Assert.DoesNotContain("HerkunftB1", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Breeders_endpoint_returns_distinct_nonempty_sorted_values()
|
||||
{
|
||||
await Create("DistinctX1", originBreeder: "ZZZ Distinct Eins");
|
||||
await Create("DistinctX2", originBreeder: "ZZZ Distinct Eins"); // duplicate breeder
|
||||
await Create("DistinctX3", originBreeder: "ZZZ Distinct Zwei");
|
||||
await Create("DistinctNoBreeder", originBreeder: null);
|
||||
|
||||
var list = JsonDocument.Parse(await _client.GetStringAsync("/gerbils/breeders"))
|
||||
.RootElement.EnumerateArray().Select(x => x.GetString()!).ToList();
|
||||
|
||||
Assert.Contains("ZZZ Distinct Eins", list);
|
||||
Assert.Contains("ZZZ Distinct Zwei", list);
|
||||
Assert.Equal(1, list.Count(v => v == "ZZZ Distinct Eins")); // de-duplicated
|
||||
Assert.DoesNotContain(list, string.IsNullOrWhiteSpace); // no empty/null
|
||||
Assert.Equal(list.OrderBy(v => v, StringComparer.Ordinal).ToList(), list); // sorted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_returns_paged_envelope()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/gerbils?page=1&pageSize=5"));
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("items", out _));
|
||||
Assert.True(root.TryGetProperty("totalCount", out _));
|
||||
Assert.Equal(1, root.GetProperty("page").GetInt32());
|
||||
Assert.Equal(5, root.GetProperty("pageSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Active");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
}
|
||||
}
|
||||
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using MailKit.Security;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status
|
||||
/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP
|
||||
/// sender (live Gmail gated on Julian's App Password).</summary>
|
||||
public class SendReplyServiceTests
|
||||
{
|
||||
private sealed class CapturingSender : IGmailMailSender
|
||||
{
|
||||
public OutgoingMail? Last;
|
||||
public bool ThrowAuth;
|
||||
public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowAuth) throw new AuthenticationException("bad app password");
|
||||
Last = m;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)>
|
||||
SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = "<a@x> <b@y>")
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); }
|
||||
var req = new Request
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = "<orig@gmail>",
|
||||
ReferencesHeader = refs,
|
||||
FromAddress = "kunde@web.de",
|
||||
Subject = subject,
|
||||
Status = RequestStatus.New,
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Requests.Add(req);
|
||||
await db.SaveChangesAsync();
|
||||
var sender = new CapturingSender();
|
||||
return (db, new SendReplyService(db, sender, settings), sender, req);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_threads_and_marks_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
|
||||
var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!");
|
||||
|
||||
Assert.True(outcome.Sent);
|
||||
Assert.Null(outcome.Error);
|
||||
Assert.NotNull(sender.Last);
|
||||
Assert.Equal("kunde@web.de", sender.Last!.To);
|
||||
Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.InReplyTo);
|
||||
Assert.Equal("<a@x> <b@y> <orig@gmail>", sender.Last.References); // chain + original
|
||||
|
||||
var saved = await db.Requests.SingleAsync();
|
||||
Assert.Equal(RequestStatus.Answered, saved.Status);
|
||||
Assert.NotNull(saved.AnsweredAt);
|
||||
Assert.Equal("Hallo, sehr gerne!", saved.DraftReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_does_not_double_prefix_re_subject()
|
||||
{
|
||||
var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null);
|
||||
await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("Re: läuft schon", sender.Last!.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.References); // no prior chain -> just the original
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_without_config_fails_and_keeps_status()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync(configured: false);
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailNotConfigured", outcome.Error);
|
||||
Assert.Null(sender.Last);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_auth_failure_does_not_mark_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
sender.ThrowAuth = true;
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailAuthFailed", outcome.Error);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMimeMessage_sets_threading_headers()
|
||||
{
|
||||
var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com",
|
||||
new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "<orig@gmail>", "<a@x> <orig@gmail>"));
|
||||
|
||||
Assert.Equal("Re: Test", msg.Subject);
|
||||
Assert.Contains("kunde@web.de", msg.To.ToString());
|
||||
Assert.Contains("orig@gmail", msg.InReplyTo);
|
||||
Assert.Contains("orig@gmail", string.Join(' ', msg.References));
|
||||
Assert.Contains("a@x", string.Join(' ', msg.References));
|
||||
}
|
||||
}
|
||||
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-1: static site renderer — snapshot → HTML file map.
|
||||
/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject
|
||||
/// snapshots directly and assert on rendered HTML.
|
||||
/// </summary>
|
||||
public class SiteRendererTests
|
||||
{
|
||||
// ── Snapshot builder helpers ─────────────────────────────────────────────
|
||||
|
||||
private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages)
|
||||
{
|
||||
var navOrder = new JsonArray();
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var (slug, title, blocks) in pages)
|
||||
{
|
||||
navOrder.Add(slug);
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = slug,
|
||||
["title"] = title,
|
||||
["seoDescription"] = $"SEO für {title}",
|
||||
["status"] = "Published",
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder },
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonArray Blocks(params JsonObject[] blocks)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
int i = 0;
|
||||
foreach (var b in blocks) { b["order"] = i++; arr.Add(b); }
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonObject HeadingBlock(string text, int level = 1) => new()
|
||||
{
|
||||
["type"] = "Heading",
|
||||
["data"] = new JsonObject { ["text"] = text, ["level"] = level },
|
||||
};
|
||||
|
||||
private static JsonObject RichTextBlock(string md) => new()
|
||||
{
|
||||
["type"] = "RichText",
|
||||
["data"] = new JsonObject { ["markdown"] = md },
|
||||
};
|
||||
|
||||
private static JsonObject ImageBlock(string url, string alt) => new()
|
||||
{
|
||||
["type"] = "Image",
|
||||
["data"] = new JsonObject { ["url"] = url, ["alt"] = alt },
|
||||
};
|
||||
|
||||
private static JsonObject ContactInfoBlock(string name, string email, string phone) => new()
|
||||
{
|
||||
["type"] = "ContactInfo",
|
||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||
};
|
||||
|
||||
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
||||
{
|
||||
var animalArr = new JsonArray();
|
||||
foreach (var (name, farbe, group, photo) in animals)
|
||||
{
|
||||
var photos = new JsonArray();
|
||||
if (photo is not null) photos.Add(photo);
|
||||
animalArr.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["farbschlag"] = farbe,
|
||||
["group"] = group,
|
||||
["photos"] = photos,
|
||||
["aiSaleText"] = (JsonNode?)null,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "AbgabetiereList",
|
||||
["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_index_html_for_start_page()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_slug_subfolder_for_non_start_pages()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_includes_css_asset()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
Assert.Contains("site-header", files[SiteRenderer.CssPath]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_is_valid_doctype_and_contains_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.StartsWith("<!DOCTYPE html>", html);
|
||||
Assert.Contains("<title>Startseite |", html);
|
||||
Assert.Contains("lang=\"de\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_includes_seo_description()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
// seoDescription is set to "SEO für Start" by our helper
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("meta name=\"description\"", html);
|
||||
Assert.Contains("SEO für Start", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nav_links_are_rendered_with_all_slugs()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("Abgabetiere", html);
|
||||
Assert.Contains("Kontakt", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_page_nav_link_has_aria_current()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("aria-current=\"page\"", abgHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_block_renders_h_tag_with_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<h2", html);
|
||||
Assert.Contains("Meine Rennmäuse", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_bold_and_paragraph()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<strong>Hallo</strong>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_list()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<ul>", html);
|
||||
Assert.Contains("<li>", html);
|
||||
Assert.Contains("Eins", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Image_block_renders_img_with_alt()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("src=\"/img/maus.jpg\"", html);
|
||||
Assert.Contains("alt=\"Eine Rennmaus\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactInfo_block_renders_name_and_email()
|
||||
{
|
||||
var snap = MakeSnapshot(("kontakt", "Kontakt",
|
||||
Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789"))));
|
||||
var html = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
Assert.Contains("Kleine Chaoten", html);
|
||||
Assert.Contains("info@kleine-chaoten.de", html);
|
||||
Assert.Contains("+49 123 456789", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Krümel", html);
|
||||
Assert.Contains("CP-Agouti", html);
|
||||
Assert.Contains("Großbecken", html);
|
||||
Assert.Contains("/photos/files/abc.jpg", html);
|
||||
Assert.Contains("cms-animal-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_empty_shows_placeholder_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock(""))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("cms-abgabe-empty", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_pages_are_excluded_from_output()
|
||||
{
|
||||
var pages = new JsonArray
|
||||
{
|
||||
new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
};
|
||||
var snap = new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } },
|
||||
["pages"] = pages,
|
||||
};
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.False(files.ContainsKey("entwurf/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Html_encoding_prevents_xss_in_page_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.Contains("<script>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_six_seeded_pages_produces_expected_paths()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("ueber-die-zucht", "Über die Zucht", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))),
|
||||
("abgabebedingungen", "Abgabebedingungen", Blocks()),
|
||||
("farben-genetik", "Farben & Genetik", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.True(files.ContainsKey("ueber-die-zucht/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabebedingungen/index.html"));
|
||||
Assert.True(files.ContainsKey("farben-genetik/index.html"));
|
||||
Assert.True(files.ContainsKey("kontakt/index.html"));
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
// 6 pages + 1 CSS
|
||||
Assert.Equal(7, files.Count);
|
||||
}
|
||||
|
||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("**fett**", "<strong>fett</strong>")]
|
||||
[InlineData("*kursiv*", "<em>kursiv</em>")]
|
||||
[InlineData("`code`", "<code>code</code>")]
|
||||
public void Markdown_inline_renders_correctly(string md, string expectedFragment)
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml(md);
|
||||
Assert.Contains(expectedFragment, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_heading_renders_h_tag()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("## Abschnitt");
|
||||
Assert.Contains("<h2>Abschnitt</h2>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_xss_text_is_encoded()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>");
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary>
|
||||
public class RenderSiteEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSite_returns_file_list_with_css_and_pages()
|
||||
{
|
||||
var json = await _client.GetStringAsync("/api/render-site");
|
||||
var files = JsonSerializer.Deserialize<List<RenderEntry>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
Assert.Contains(files, f => f.Path == SiteRenderer.CssPath);
|
||||
Assert.Contains(files, f => f.Path == "index.html");
|
||||
Assert.All(files, f => Assert.True(f.Size > 0));
|
||||
}
|
||||
|
||||
private record RenderEntry(string Path, int Size);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.002.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GerbilManagerWebAPI", "GerbilManagerWebAPI\GerbilManagerWebAPI.csproj", "{48875578-7112-4F6F-A4C0-F1E7637B513D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {7F573F67-6F28-4056-8D14-0E04C8C54B7E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
6
GerbilManager.slnx
Normal file
6
GerbilManager.slnx
Normal file
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="GerbilManager.AppHost/GerbilManager.AppHost.csproj" />
|
||||
<Project Path="GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj" />
|
||||
<Project Path="GerbilManager.Tests/GerbilManager.Tests.csproj" />
|
||||
<Project Path="GerbilManagerWebAPI/GerbilManagerWebAPI.csproj" />
|
||||
</Solution>
|
||||
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Ai
|
||||
{
|
||||
public enum AiCallStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
|
||||
NotConfigured,
|
||||
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
|
||||
UpstreamError,
|
||||
}
|
||||
|
||||
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the
|
||||
/// reply-draft (and future AI features) reuse the SAME wire implementation:
|
||||
/// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers
|
||||
/// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK.
|
||||
/// Configuration stays the single AI section (AiOptions, env-only).
|
||||
/// </summary>
|
||||
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public async Task<AiCallResult> CompleteAsync(
|
||||
string systemPrompt, string userPrompt, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", userPrompt),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new AiCallResult(AiCallStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,313 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
public class ApplicationContext : DbContext
|
||||
{
|
||||
public ApplicationContext(DbContextOptions options)
|
||||
: base(options)
|
||||
public ApplicationContext(DbContextOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
public DbSet<Gerbil> Gerbils { get; set; }
|
||||
public DbSet<Litter> Litters { get; set; }
|
||||
public DbSet<Breeder> Breeders { get; set; }
|
||||
|
||||
public DbSet<Gerbil> Gerbils => Set<Gerbil>();
|
||||
public DbSet<Litter> Litters => Set<Litter>();
|
||||
public DbSet<Contact> Contacts => Set<Contact>();
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<Page> Pages => Set<Page>();
|
||||
public DbSet<Block> Blocks => Set<Block>();
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
|
||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
private void SyncNameSearch()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<Gerbil>())
|
||||
{
|
||||
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Gerbil>().HasOne(entity => entity.Litter);
|
||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Father);
|
||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Mother);
|
||||
modelBuilder.Entity<Gerbil>(e =>
|
||||
{
|
||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||
e.Property(g => g.Gender).HasConversion<string>();
|
||||
e.Property(g => g.Status).HasConversion<string>();
|
||||
|
||||
modelBuilder
|
||||
.Entity<Gerbil>()
|
||||
.Property(d => d.Gender)
|
||||
.HasConversion(new EnumToStringConverter<Gender>());
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<string>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
|
||||
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
|
||||
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
|
||||
v => v.ToList());
|
||||
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
|
||||
|
||||
e.HasOne(g => g.Litter).WithMany()
|
||||
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.OriginContact).WithMany()
|
||||
.HasForeignKey(g => g.OriginContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(g => g.ReceiverContact).WithMany()
|
||||
.HasForeignKey(g => g.ReceiverContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(g => g.Enclosure).WithMany(en => en.Gerbils)
|
||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.ColorVariety).WithMany()
|
||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Litter>(e =>
|
||||
{
|
||||
e.HasOne(l => l.Father).WithMany()
|
||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(l => l.Mother).WithMany()
|
||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
{
|
||||
e.Property(h => h.Type).HasConversion<string>();
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WeightRecord>(e =>
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<GerbilPhoto>(e =>
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
e.Property(c => c.Price).HasPrecision(10, 2);
|
||||
// Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand,
|
||||
// kein versehentlich löschbarer Datensatz.
|
||||
e.HasOne(c => c.Contact).WithMany()
|
||||
.HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SaleContractAnimal>(e =>
|
||||
{
|
||||
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
|
||||
e.HasOne<SaleContract>().WithMany(c => c.Animals)
|
||||
.HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade);
|
||||
// Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung —
|
||||
// der Vertrag (und seine .docx als Beleg) bleibt bestehen.
|
||||
e.HasOne(a => a.Gerbil).WithMany()
|
||||
.HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Zuchtprofil: genau eine (leere) Zeile mit fixer Id.
|
||||
modelBuilder.Entity<BreederSettings>()
|
||||
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
|
||||
|
||||
// WEB epic: CMS content model.
|
||||
modelBuilder.Entity<Site>(e =>
|
||||
{
|
||||
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<Guid>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
|
||||
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
|
||||
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
|
||||
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
|
||||
v => v.ToList());
|
||||
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Page>(e =>
|
||||
{
|
||||
e.HasIndex(p => p.Slug).IsUnique();
|
||||
e.Property(p => p.Status).HasConversion<string>();
|
||||
e.HasMany(p => p.Blocks).WithOne()
|
||||
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
|
||||
|
||||
SeedCms(modelBuilder);
|
||||
|
||||
// INBOX epic: Gmail request inbox.
|
||||
modelBuilder.Entity<Request>(e =>
|
||||
{
|
||||
e.Property(r => r.Status).HasConversion<string>();
|
||||
e.HasIndex(r => r.GmailMessageId).IsUnique();
|
||||
e.HasOne(r => r.AssignedContact).WithMany()
|
||||
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
// exactly one MailSettings row, fixed id.
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a
|
||||
/// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block
|
||||
/// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards.
|
||||
/// </summary>
|
||||
private static void SeedCms(ModelBuilder modelBuilder)
|
||||
{
|
||||
Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}");
|
||||
Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}");
|
||||
|
||||
(int n, string Slug, string Title)[] pages =
|
||||
{
|
||||
(1, "start", "Startseite"),
|
||||
(2, "ueber-die-zucht", "Über die Zucht"),
|
||||
(3, "abgabetiere", "Abgabetiere"),
|
||||
(4, "abgabebedingungen", "Abgabebedingungen"),
|
||||
(5, "farben-genetik", "Farben & Genetik"),
|
||||
(6, "kontakt", "Kontakt"),
|
||||
};
|
||||
|
||||
var pageRows = new List<Page>();
|
||||
var blockRows = new List<Block>();
|
||||
foreach (var p in pages)
|
||||
{
|
||||
pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published });
|
||||
// placeholder Heading per page
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading,
|
||||
Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}",
|
||||
});
|
||||
}
|
||||
// abgabetiere (n=3): auto AbgabetiereList as the second block
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||
|
||||
modelBuilder.Entity<Site>().HasData(new Site
|
||||
{
|
||||
Id = Site.SingletonId,
|
||||
DefaultLocale = "de",
|
||||
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
|
||||
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
|
||||
/// (the 18 frozen-contract names first, then the baseportal.de extension), in stable
|
||||
/// SortOrder. Names are UI filter keys — spelling changes route through god.
|
||||
/// </summary>
|
||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||
{
|
||||
(string Name, string Genotype)[] catalog =
|
||||
{
|
||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
||||
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
|
||||
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
|
||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
|
||||
("Blau", "aa CC dd EE GG PP spsp rere"),
|
||||
("Gold", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin", "aa CC DD EE GG pp spsp rere"),
|
||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
|
||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
|
||||
("dd Gold", "AA CC dd EE GG pp spsp rere"),
|
||||
("dd Platin", "aa CC dd EE GG pp spsp rere"),
|
||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
|
||||
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
|
||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
for (int i = 0; i < catalog.Length; i++)
|
||||
{
|
||||
rows[i] = new ColorVariety
|
||||
{
|
||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||
Name = catalog[i].Name,
|
||||
CanonicalGenotype = catalog[i].Genotype,
|
||||
SortOrder = i,
|
||||
};
|
||||
}
|
||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
@@ -0,0 +1,484 @@
|
||||
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
|
||||
// characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages.
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
|
||||
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
|
||||
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
|
||||
/// support handles the common subset used in RichText blocks.
|
||||
/// </summary>
|
||||
public static class SiteRenderer
|
||||
{
|
||||
public const string SiteName = "Kleine Chaoten";
|
||||
public const string CssPath = "assets/site.css";
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Renders the snapshot to a flat file map (relative-path → file-content).
|
||||
/// Only <c>Published</c> pages are included. The "start" page maps to
|
||||
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
files[CssPath] = SiteCss();
|
||||
|
||||
var navSlugs = NavSlugs(snapshot);
|
||||
var pages = snapshot["pages"] as JsonArray ?? [];
|
||||
|
||||
foreach (var node in pages)
|
||||
{
|
||||
if (node is not JsonObject page) continue;
|
||||
if (Str(page["status"]) != "Published") continue;
|
||||
|
||||
var slug = Str(page["slug"]) ?? "page";
|
||||
var title = Str(page["title"]) ?? slug;
|
||||
var seoDes = Str(page["seoDescription"]);
|
||||
var blocks = page["blocks"] as JsonArray ?? [];
|
||||
|
||||
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
|
||||
var body = RenderBlocks(blocks, slug);
|
||||
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
|
||||
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
|
||||
files[path] = html;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Block rendering ──────────────────────────────────────────────────
|
||||
|
||||
private static string RenderBlocks(JsonArray blocks, string currentSlug)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
|
||||
{
|
||||
if (node is not JsonObject b) continue;
|
||||
var type = Str(b["type"]);
|
||||
var data = b["data"] as JsonObject ?? [];
|
||||
sb.Append(type switch
|
||||
{
|
||||
"Heading" => RenderHeading(data),
|
||||
"RichText" => RenderRichText(data),
|
||||
"Image" => RenderImage(data),
|
||||
"Gallery" => RenderGallery(data),
|
||||
"ContactInfo" => RenderContactInfo(data),
|
||||
"AbgabetiereList" => RenderAbgabetiereList(data),
|
||||
_ => string.Empty,
|
||||
});
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderHeading(JsonObject d)
|
||||
{
|
||||
var level = d["level"]?.GetValue<int>() ?? 2;
|
||||
level = Math.Clamp(level, 1, 6);
|
||||
var text = H(Str(d["text"]) ?? "");
|
||||
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
|
||||
}
|
||||
|
||||
private static string RenderRichText(JsonObject d)
|
||||
{
|
||||
var md = Str(d["markdown"]) ?? "";
|
||||
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
|
||||
}
|
||||
|
||||
private static string RenderImage(JsonObject d)
|
||||
{
|
||||
var url = H(Str(d["url"]) ?? "");
|
||||
var alt = H(Str(d["alt"]) ?? "");
|
||||
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
|
||||
}
|
||||
|
||||
private static string RenderGallery(JsonObject d)
|
||||
{
|
||||
var images = d["images"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img is not JsonObject o) continue;
|
||||
var url = H(Str(o["url"]) ?? "");
|
||||
var alt = H(Str(o["alt"]) ?? "");
|
||||
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderContactInfo(JsonObject d)
|
||||
{
|
||||
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
|
||||
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
|
||||
Row(Str(d["name"]), "👤");
|
||||
Row(Str(d["address"]), "📍");
|
||||
var phone = Str(d["phone"]);
|
||||
if (!string.IsNullOrWhiteSpace(phone))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
|
||||
var email = Str(d["email"]);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
|
||||
sb.Append("</address>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderAbgabetiereList(JsonObject d)
|
||||
{
|
||||
var intro = Str(d["intro"]);
|
||||
var animals = d["animals"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(intro))
|
||||
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
|
||||
|
||||
if (animals.Count == 0)
|
||||
{
|
||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
||||
foreach (var node in animals)
|
||||
{
|
||||
if (node is not JsonObject a) continue;
|
||||
var name = H(Str(a["name"]) ?? "");
|
||||
var farbe = H(Str(a["farbschlag"]) ?? "");
|
||||
var group = Str(a["group"]);
|
||||
var saleText = Str(a["aiSaleText"]);
|
||||
var photos = a["photos"] as JsonArray ?? [];
|
||||
|
||||
sb.Append(" <article class=\"cms-animal-card\">\n");
|
||||
|
||||
// Profile photo (first photo)
|
||||
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
||||
if (!string.IsNullOrEmpty(firstPhoto))
|
||||
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
||||
|
||||
sb.Append(" <div class=\"cms-animal-info\">\n");
|
||||
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
||||
if (!string.IsNullOrEmpty(farbe))
|
||||
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(group))
|
||||
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(saleText))
|
||||
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
||||
sb.Append(" </div>\n");
|
||||
sb.Append(" </article>\n");
|
||||
}
|
||||
sb.Append(" </div>\n");
|
||||
}
|
||||
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Page template ────────────────────────────────────────────────────
|
||||
|
||||
private static string PageHtml(
|
||||
string title, string? seoDesc, string body,
|
||||
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
|
||||
{
|
||||
var fullTitle = H($"{title} | {SiteName}");
|
||||
var metaDesc = seoDesc is null ? ""
|
||||
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
|
||||
|
||||
var navBase = currentSlug == "start" ? "" : "../";
|
||||
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
|
||||
|
||||
return $"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
|
||||
<title>{fullTitle}</title>
|
||||
<link rel="stylesheet" href="{H(cssHref)}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
|
||||
<nav class="site-nav" aria-label="Hauptnavigation">
|
||||
{navHtml}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
{body}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
|
||||
{
|
||||
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["start"] = "Start",
|
||||
["ueber-die-zucht"] = "Über die Zucht",
|
||||
["abgabetiere"] = "Abgabetiere",
|
||||
["abgabebedingungen"] = "Abgabebedingungen",
|
||||
["farben-genetik"] = "Farben & Genetik",
|
||||
["kontakt"] = "Kontakt",
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
var label = labels.TryGetValue(slug, out var l) ? l : slug;
|
||||
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
|
||||
var active = slug == current ? " aria-current=\"page\"" : "";
|
||||
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ── Minimal Markdown → HTML ──────────────────────────────────────────
|
||||
|
||||
internal static string MarkdownToHtml(string md)
|
||||
{
|
||||
if (string.IsNullOrEmpty(md)) return "";
|
||||
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
||||
var sb = new StringBuilder();
|
||||
var inList = false;
|
||||
var isOrdered = false;
|
||||
|
||||
void CloseList()
|
||||
{
|
||||
if (!inList) return;
|
||||
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
|
||||
inList = false;
|
||||
}
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw;
|
||||
|
||||
// ATX headings
|
||||
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
|
||||
if (hm.Success)
|
||||
{
|
||||
CloseList();
|
||||
var lvl = hm.Groups[1].Length;
|
||||
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
|
||||
if (ulm.Success)
|
||||
{
|
||||
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
|
||||
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
|
||||
if (olm.Success)
|
||||
{
|
||||
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
|
||||
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
CloseList();
|
||||
|
||||
// Blank line
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
sb.AppendLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
|
||||
{
|
||||
sb.AppendLine("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
|
||||
}
|
||||
|
||||
CloseList();
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string InlineHtml(string text)
|
||||
{
|
||||
// First HTML-encode, then selectively un-encode our safe inline patterns
|
||||
// to avoid encoding the tags we are about to add.
|
||||
// Order matters: bold before italic.
|
||||
text = H(text);
|
||||
// Bold **text**
|
||||
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||
// Italic *text* (single, not preceded/followed by *)
|
||||
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
|
||||
// Inline code `text`
|
||||
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
|
||||
// Links [text](url) — note: url is already HTML-encoded by H()
|
||||
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────
|
||||
|
||||
internal static string SiteCss() => """
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-bg: #fdf8f3;
|
||||
--color-surface: #fff;
|
||||
--color-text: #2c2c2c;
|
||||
--color-muted: #6b6b6b;
|
||||
--color-accent: #c0392b;
|
||||
--color-border: #e0d8d0;
|
||||
--font-body: system-ui, sans-serif;
|
||||
--max-w: 860px;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.65;
|
||||
}
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: .75rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem 1.5rem;
|
||||
}
|
||||
.site-logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
||||
.site-nav-link {
|
||||
font-size: .9rem;
|
||||
color: var(--color-muted);
|
||||
padding: .2rem .4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
||||
color: var(--color-accent);
|
||||
background: #fef0ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.site-main {
|
||||
max-width: var(--max-w);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 3rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
font-size: .85rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ── CMS blocks ── */
|
||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
||||
h1.cms-heading { font-size: 1.8rem; }
|
||||
h2.cms-heading { font-size: 1.4rem; }
|
||||
.cms-richtext { margin: 1rem 0; }
|
||||
.cms-richtext p { margin-bottom: .75rem; }
|
||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||
.cms-richtext li { margin-bottom: .3rem; }
|
||||
.cms-image { margin: 1.5rem 0; }
|
||||
.cms-image img { border-radius: 6px; }
|
||||
.cms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
||||
.cms-contact {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
||||
|
||||
/* ── Abgabetiere ── */
|
||||
.cms-abgabe { margin: 1rem 0; }
|
||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||
.cms-animal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.cms-animal-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
||||
.cms-animal-info { padding: .875rem; }
|
||||
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
||||
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
||||
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
||||
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
h1.cms-heading { font-size: 1.4rem; }
|
||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
""";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
|
||||
{
|
||||
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
|
||||
if (navArr is null) return [];
|
||||
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
|
||||
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
|
||||
/// </summary>
|
||||
private static string H(string? s)
|
||||
{
|
||||
if (s is null) return "";
|
||||
return s.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
|
||||
private static string? Str(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
124
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
124
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The
|
||||
/// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale
|
||||
/// animals — nothing private is ever exposed (publish = snapshot of public content).
|
||||
/// </summary>
|
||||
public sealed class SiteSnapshotService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
|
||||
public SiteSnapshotService(ApplicationContext db) => _db = db;
|
||||
|
||||
public async Task<JsonObject> BuildAsync()
|
||||
{
|
||||
var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync()
|
||||
?? new Site { Id = Site.SingletonId };
|
||||
var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync();
|
||||
var pageById = pages.ToDictionary(p => p.Id);
|
||||
|
||||
var navOrder = new JsonArray();
|
||||
foreach (var id in site.NavOrder)
|
||||
if (pageById.TryGetValue(id, out var p))
|
||||
navOrder.Add(p.Slug);
|
||||
|
||||
JsonArray? forSale = null; // resolved lazily, reused across blocks
|
||||
|
||||
int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; }
|
||||
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug))
|
||||
{
|
||||
var blocks = new JsonArray();
|
||||
foreach (var b in page.Blocks.OrderBy(b => b.Order))
|
||||
{
|
||||
var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject
|
||||
?? new JsonObject();
|
||||
if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto")
|
||||
{
|
||||
forSale ??= await BuildForSaleAsync();
|
||||
data["animals"] = forSale.DeepClone();
|
||||
}
|
||||
blocks.Add(new JsonObject
|
||||
{
|
||||
["id"] = b.Id.ToString(),
|
||||
["order"] = b.Order,
|
||||
["type"] = b.Type.ToString(),
|
||||
["data"] = data,
|
||||
});
|
||||
}
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = page.Slug,
|
||||
["title"] = page.Title,
|
||||
["seoDescription"] = page.SeoDescription,
|
||||
["status"] = page.Status.ToString(),
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject
|
||||
{
|
||||
["defaultLocale"] = site.DefaultLocale,
|
||||
["navOrder"] = navOrder,
|
||||
},
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JsonArray> BuildForSaleAsync()
|
||||
{
|
||||
var animals = await _db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new
|
||||
{
|
||||
g.Id,
|
||||
g.Name,
|
||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||
Group = g.Enclosure != null ? g.Enclosure.Name : null,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ids = animals.Select(a => a.Id).ToList();
|
||||
var photos = await _db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new { p.GerbilId, p.FileName })
|
||||
.ToListAsync();
|
||||
var photosByGerbil = photos
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList());
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
var photoArr = new JsonArray();
|
||||
if (photosByGerbil.TryGetValue(a.Id, out var urls))
|
||||
foreach (var u in urls) photoArr.Add(u);
|
||||
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["name"] = a.Name,
|
||||
["farbschlag"] = a.Farbschlag,
|
||||
["group"] = a.Group,
|
||||
["photos"] = photoArr,
|
||||
["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
57
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
57
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
|
||||
namespace GerbilManagerWebAPI.Common
|
||||
{
|
||||
/// <summary>Standard paged list envelope returned by every list endpoint.</summary>
|
||||
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Query-string parameters for list endpoints. All optional (nullable) so the
|
||||
/// frontend can call a list with no params; Gridify's own GridifyQuery has
|
||||
/// non-nullable int Page/PageSize which [AsParameters] would make REQUIRED.
|
||||
/// Bound via [AsParameters]: ?filter=…&orderBy=…&page=1&pageSize=20.
|
||||
/// </summary>
|
||||
public class GridifyParams
|
||||
{
|
||||
public string? Filter { get; set; }
|
||||
public string? OrderBy { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
|
||||
public GridifyQuery ToQuery() => new()
|
||||
{
|
||||
Filter = NormalizeFilter(Filter),
|
||||
OrderBy = OrderBy,
|
||||
Page = Page is > 0 ? Page.Value : 1,
|
||||
PageSize = PageSize is > 0 ? PageSize.Value : 20,
|
||||
};
|
||||
|
||||
// The frontend's gridify.ts emits "==" for equals (its documented convention),
|
||||
// but Gridify's equals operator is a single "=". Translate "==" -> "=". This is
|
||||
// safe because the frontend backslash-escapes any "=" inside values, and the
|
||||
// other operators it uses ( != >= <= =* ) contain no literal "==".
|
||||
private static string? NormalizeFilter(string? filter) =>
|
||||
string.IsNullOrEmpty(filter) ? filter : filter.Replace("==", "=");
|
||||
}
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply a Gridify query (filter/order/page) to an EF query and project each
|
||||
/// row to a DTO, returning the standard paged envelope. Filter/orderBy names
|
||||
/// are the ENTITY property names (case-insensitive), e.g. "status==Active",
|
||||
/// "orderBy=dateOfBirth", "litterId==…".
|
||||
/// </summary>
|
||||
public static async Task<PagedResult<TDto>> ToPagedResultAsync<TEntity, TDto>(
|
||||
this IQueryable<TEntity> source,
|
||||
GridifyParams parameters,
|
||||
Func<TEntity, TDto> map)
|
||||
{
|
||||
var query = parameters.ToQuery();
|
||||
Paging<TEntity> paging = await source.GridifyAsync(query);
|
||||
var items = paging.Data.Select(map).ToList();
|
||||
return new PagedResult<TDto>(items, paging.Count, query.Page, query.PageSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
|
||||
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
|
||||
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get<BreederProfile>()</c>);
|
||||
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
|
||||
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
|
||||
/// </summary>
|
||||
public sealed class BreederProfile
|
||||
{
|
||||
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
public string Phone { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public string Homepage { get; set; } = "";
|
||||
|
||||
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
|
||||
public string City { get; set; } = "";
|
||||
}
|
||||
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
|
||||
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
|
||||
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
|
||||
public sealed record ContractBuyer(
|
||||
string Name,
|
||||
string Address,
|
||||
string? Phone = null,
|
||||
string? Email = null);
|
||||
|
||||
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||
public sealed record ContractAnimal(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||
/// </summary>
|
||||
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
|
||||
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
|
||||
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
|
||||
public sealed record ContractData(
|
||||
BreederProfile Seller,
|
||||
ContractBuyer Buyer,
|
||||
IReadOnlyList<ContractAnimal> Animals,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate = null);
|
||||
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Globalization;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
|
||||
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
|
||||
/// Mustervertrag; Boilerplate-Abschnitte 4–8 stehen verbatim in der Vorlage).
|
||||
///
|
||||
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
|
||||
/// <see cref="ContractData"/>, Ausgabe sind die fertigen .docx-Bytes.
|
||||
/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
|
||||
/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
|
||||
/// wird pro Tier geklont (variable Anzahl 1..n).
|
||||
/// </summary>
|
||||
public static class ContractGenerator
|
||||
{
|
||||
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
|
||||
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
|
||||
|
||||
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
|
||||
public static byte[] Generate(ContractData data, byte[] template)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
if (data.Animals.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(template);
|
||||
|
||||
using (var document = WordprocessingDocument.Open(stream, isEditable: true))
|
||||
{
|
||||
var body = document.MainDocumentPart?.Document.Body
|
||||
?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
|
||||
|
||||
FillAnimalTables(body, data.Animals);
|
||||
ReplaceTokens(body, GlobalTokens(data));
|
||||
document.MainDocumentPart!.Document.Save();
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
|
||||
/// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
|
||||
/// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
|
||||
/// </summary>
|
||||
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
|
||||
{
|
||||
var templateTable = body.Descendants<Table>()
|
||||
.Single(t => t.InnerText.Contains("{{TierName}}"));
|
||||
|
||||
var tables = new List<Table> { templateTable };
|
||||
// Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
|
||||
OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
|
||||
? spacer
|
||||
: templateTable;
|
||||
|
||||
for (var i = 1; i < animals.Count; i++)
|
||||
{
|
||||
var clone = (Table)templateTable.CloneNode(deep: true);
|
||||
anchor = anchor.InsertAfterSelf(clone);
|
||||
anchor = anchor.InsertAfterSelf(new Paragraph());
|
||||
tables.Add(clone);
|
||||
}
|
||||
|
||||
for (var i = 0; i < animals.Count; i++)
|
||||
{
|
||||
ReplaceTokens(tables[i], AnimalTokens(animals[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GlobalTokens(ContractData data) => new()
|
||||
{
|
||||
["{{ZuchtName}}"] = data.Seller.ZuchtName,
|
||||
["{{VerkaeuferName}}"] = data.Seller.Name,
|
||||
["{{VerkaeuferAdresse}}"] = data.Seller.Address,
|
||||
["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
|
||||
["{{VerkaeuferEmail}}"] = data.Seller.Email,
|
||||
["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
|
||||
["{{KaeuferName}}"] = data.Buyer.Name,
|
||||
["{{KaeuferAdresse}}"] = data.Buyer.Address,
|
||||
["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
|
||||
["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
|
||||
["{{Kaufpreis}}"] = FormatPrice(data.Price),
|
||||
["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
|
||||
["{{VertragsOrt}}"] = data.Seller.City,
|
||||
["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> AnimalTokens(ContractAnimal animal) => new()
|
||||
{
|
||||
["{{TierName}}"] = animal.Name,
|
||||
["{{TierGeschlecht}}"] = animal.Geschlecht,
|
||||
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
|
||||
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
|
||||
};
|
||||
|
||||
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
|
||||
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
|
||||
|
||||
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
|
||||
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
|
||||
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
|
||||
/// Run-übergreifendes Matching nötig.
|
||||
/// </summary>
|
||||
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
|
||||
{
|
||||
foreach (var text in root.Descendants<Text>())
|
||||
{
|
||||
if (!text.Text.Contains("{{"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (token, value) in tokens)
|
||||
{
|
||||
if (text.Text.Contains(token))
|
||||
{
|
||||
text.Text = text.Text.Replace(token, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
|
||||
{
|
||||
text.Space = SpaceProcessingModeValues.Preserve;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] LoadEmbeddedTemplate()
|
||||
{
|
||||
using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
|
||||
?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
|
||||
using var buffer = new MemoryStream();
|
||||
resource.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
BIN
GerbilManagerWebAPI/Contracts/Templates/Abgabevertrag.docx
Normal file
BIN
GerbilManagerWebAPI/Contracts/Templates/Abgabevertrag.docx
Normal file
Binary file not shown.
@@ -1,91 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Xml;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("breeders")]
|
||||
public class BreederController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository gerbilrepository;
|
||||
private readonly IBreederRepository repository;
|
||||
private readonly ILitterRepository litterRepository;
|
||||
|
||||
public BreederController(IBreederRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<BreederDto> GetBreeders()
|
||||
{
|
||||
var items = repository.GetBreeders().Select( breeder => breeder.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<BreederDto> GetBreeder(Guid id)
|
||||
{
|
||||
var item = repository.GetBreeder(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<BreederDto> CreateBreeder(CreateBreederDto dto)
|
||||
{
|
||||
Breeder breeder = new Breeder{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
};
|
||||
|
||||
this.repository.CreateBreeder(breeder);
|
||||
|
||||
return CreatedAtAction(nameof(GetBreeder), new { id = breeder.Id}, breeder.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateBreeder(Guid id, UpdateBreederDto breederDto)
|
||||
{
|
||||
var existingItem = repository.GetBreeder(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Breeder updatedBreeder = existingItem with{
|
||||
Name = breederDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateBreeder(updatedBreeder);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteBreeder(Guid id)
|
||||
{
|
||||
var existingLitter = repository.GetBreeder(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteBreeder(existingLitter);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("gerbils")]
|
||||
public class GerbilsController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository repository;
|
||||
private readonly IBreederRepository breederRepository;
|
||||
private readonly ILitterRepository litterRepository;
|
||||
|
||||
public GerbilsController(IGerbilRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<GerbilDto> GetGerbils()
|
||||
{
|
||||
var items = repository.GetGerbils().Select( gerbil => gerbil.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<GerbilDto> GetGerbil(Guid id)
|
||||
{
|
||||
var item = repository.GetGerbil(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<GerbilDto> CreateGerbil(CreateGerbilDto dto)
|
||||
{
|
||||
Gerbil gerbil = new Gerbil{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
Gender = (Gender) (int) dto.Gender
|
||||
};
|
||||
|
||||
if(dto.Litter != Guid.Empty)
|
||||
{
|
||||
gerbil.Litter = litterRepository.GetLitter(dto.Litter);
|
||||
}
|
||||
|
||||
if(dto.Breeder != Guid.Empty)
|
||||
{
|
||||
gerbil.Breeder = breederRepository.GetBreeder(dto.Breeder);
|
||||
}
|
||||
|
||||
this.repository.CreateGerbil(gerbil);
|
||||
|
||||
return CreatedAtAction(nameof(GetGerbil), new { id = gerbil.Id}, gerbil.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateGerbil(Guid id, UpdateGerbilDto gerbilDto)
|
||||
{
|
||||
var existingItem = repository.GetGerbil(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Gerbil updatedGerbil = existingItem with{
|
||||
Breeder = breederRepository.GetBreeder(gerbilDto.Breeder.Id),
|
||||
Litter = litterRepository.GetLitter(gerbilDto.Litter.Id),
|
||||
Gender = (Gender) (int) gerbilDto.Gender,
|
||||
Name = gerbilDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateGerbil(updatedGerbil);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteGerbil(Guid id)
|
||||
{
|
||||
var existingGerbil = repository.GetGerbil(id);
|
||||
|
||||
if(existingGerbil is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteGerbil(existingGerbil);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Xml;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("litters")]
|
||||
public class LittersController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository gerbilrepository;
|
||||
private readonly IBreederRepository breederRepository;
|
||||
private readonly ILitterRepository repository;
|
||||
|
||||
public LittersController(ILitterRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<LitterDto> GetLitters()
|
||||
{
|
||||
var items = repository.GetLitters().Select( litter => litter.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<LitterDto> GetLitter(Guid id)
|
||||
{
|
||||
var item = repository.GetLitter(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<LitterDto> CreateLitter(CreateLitterDto dto)
|
||||
{
|
||||
Litter litter = new Litter{
|
||||
Id = Guid.NewGuid(),
|
||||
Date = dto.Date,
|
||||
Father = gerbilrepository.GetGerbil(dto.Father ?? Guid.Empty),
|
||||
Mother = gerbilrepository.GetGerbil(dto.Mother ?? Guid.Empty),
|
||||
Name = dto.Name,
|
||||
Strength = dto.Strength
|
||||
};
|
||||
|
||||
this.repository.CreateLitter(litter);
|
||||
|
||||
return CreatedAtAction(nameof(GetLitter), new { id = litter.Id}, litter.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateLitter(Guid id, UpdateLitterDto litterDto)
|
||||
{
|
||||
var existingItem = repository.GetLitter(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Litter updatedLitter = existingItem with{
|
||||
Date = litterDto.Date ?? DateTime.Now,
|
||||
Strength = litterDto.Strength,
|
||||
Father = gerbilrepository.GetGerbil(litterDto.Father ?? Guid.Empty),
|
||||
Mother = gerbilrepository.GetGerbil(litterDto.Mother ?? Guid.Empty),
|
||||
Name = litterDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateLitter(updatedLitter);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteLitter(Guid id)
|
||||
{
|
||||
var existingLitter = repository.GetLitter(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteLitter(existingLitter);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class BreederConverterExtension
|
||||
{
|
||||
public static BreederDto AsDto(this Breeder breeder)
|
||||
{
|
||||
return new BreederDto{
|
||||
Id = breeder.Id,
|
||||
Name = breeder.Name
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class GerbilConverterExtension
|
||||
{
|
||||
public static GerbilDto AsDto(this Gerbil gerbil)
|
||||
{
|
||||
return new GerbilDto{
|
||||
Id = gerbil.Id,
|
||||
Gender = (GenderDto) (int) gerbil.Gender,
|
||||
Name = gerbil.Name,
|
||||
Breeder = gerbil.Breeder?.AsDto(),
|
||||
Litter = gerbil.Litter?.AsDto()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class LitterConverterExtension
|
||||
{
|
||||
public static LitterDto AsDto(this Litter litter)
|
||||
{
|
||||
return new LitterDto{
|
||||
Id = litter.Id,
|
||||
Date = litter.Date,
|
||||
Father = litter.Father?.AsDto(),
|
||||
Mother = litter.Mother?.AsDto(),
|
||||
Name = litter.Name,
|
||||
Strength = litter.Strength
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
# Build context: repo root (includes GerbilManager.ServiceDefaults).
|
||||
# docker build -f GerbilManagerWebAPI/Dockerfile -t gerbilmanager-api .
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:80
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:7.0 AS build
|
||||
ARG configuration=Release
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["GerbilManagerWebAPI/GerbilManagerWebAPI.csproj", "GerbilManagerWebAPI/"]
|
||||
RUN dotnet restore "GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/GerbilManagerWebAPI"
|
||||
ARG configuration=Release
|
||||
RUN dotnet publish "GerbilManagerWebAPI.csproj" -c $configuration -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
# Restore: project files only for layer-cache efficiency.
|
||||
COPY GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj GerbilManager.ServiceDefaults/
|
||||
COPY GerbilManagerWebAPI/GerbilManagerWebAPI.csproj GerbilManagerWebAPI/
|
||||
RUN dotnet restore GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
|
||||
|
||||
# Full source copy + publish.
|
||||
COPY GerbilManager.ServiceDefaults/ GerbilManager.ServiceDefaults/
|
||||
COPY GerbilManagerWebAPI/ GerbilManagerWebAPI/
|
||||
WORKDIR /src/GerbilManagerWebAPI
|
||||
RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "GerbilManagerWebAPI.dll"]
|
||||
|
||||
106
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
106
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// Response DTOs ----------------------------------------------------------
|
||||
// Enums serialise as string names; JSON property names are camelCase (configured
|
||||
// globally in Program.cs). FK references are FLAT ids (no nested objects) per the
|
||||
// frontend contract (Kelly's pedigree + Oscar's filters depend on this).
|
||||
|
||||
public record GerbilDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
Gender Gender,
|
||||
GerbilStatus Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
Guid? ReceiverContactId,
|
||||
Guid? EnclosureId,
|
||||
Guid? ColorVarietyId,
|
||||
DateOnly? DateOfBirth,
|
||||
DateOnly? DateOfDeath,
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
|
||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||
|
||||
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
||||
|
||||
public record HealthRecordDto(
|
||||
Guid Id, Guid GerbilId, DateOnly Date, HealthRecordType Type,
|
||||
string Description, string? Veterinarian, DateTimeOffset CreatedAt);
|
||||
|
||||
public record WeightRecordDto(
|
||||
Guid Id, Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
|
||||
|
||||
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record GerbilInput(
|
||||
string Name,
|
||||
Gender Gender,
|
||||
GerbilStatus? Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
Guid? ReceiverContactId,
|
||||
Guid? EnclosureId,
|
||||
Guid? ColorVarietyId,
|
||||
DateOnly? DateOfBirth,
|
||||
DateOnly? DateOfDeath,
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
|
||||
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||
|
||||
public record HealthRecordInput(
|
||||
Guid GerbilId, DateOnly Date, HealthRecordType Type, string Description, string? Veterinarian);
|
||||
|
||||
public record WeightRecordInput(Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
|
||||
|
||||
/// <summary>Hypothetical pairing request for POST /genetics/test-inbreeding.</summary>
|
||||
public record TestInbreedingDto(Guid? FatherId, Guid? MotherId);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record BreederDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
}
|
||||
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// Response DTOs ----------------------------------------------------------
|
||||
|
||||
public record PageSummaryDto(Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status);
|
||||
|
||||
public record PageDto(
|
||||
Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status,
|
||||
IReadOnlyList<BlockDto> Blocks);
|
||||
|
||||
/// <summary>Block.Data is emitted as a parsed JSON object (not a string).</summary>
|
||||
public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status);
|
||||
|
||||
/// <summary>Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText).</summary>
|
||||
public record BlockInput(BlockType Type, JsonElement Data, int? Order);
|
||||
|
||||
public record BlockOrderInput(List<Guid> BlockIds);
|
||||
}
|
||||
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
33
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei).
|
||||
|
||||
public record SaleContractDto(
|
||||
Guid Id,
|
||||
Guid ContactId,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly ContractDate,
|
||||
string FileName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate);
|
||||
|
||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||
public record BreederProfileDto(
|
||||
string ZuchtName,
|
||||
string Name,
|
||||
string Address,
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateBreederDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateGerbilDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required GenderDto Gender { get; init; }
|
||||
public Guid Litter { get; init; }
|
||||
public Guid Breeder { get; init; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateLitterDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required DateTime Date { get; init; }
|
||||
public int? Strength { get; init; }
|
||||
public Guid? Father { get; init; }
|
||||
public Guid? Mother { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public enum GenderDto
|
||||
{
|
||||
unknown = 0,
|
||||
male = 1,
|
||||
female = 2
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record GerbilDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required GenderDto Gender { get; init; }
|
||||
public LitterDto? Litter { get; init; }
|
||||
public BreederDto? Breeder { get; init; }
|
||||
}
|
||||
}
|
||||
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record RequestDto(
|
||||
Guid Id,
|
||||
string GmailMessageId,
|
||||
string? ThreadId,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
DateTimeOffset ReceivedAt,
|
||||
RequestStatus Status,
|
||||
Guid? AssignedContactId,
|
||||
string? DraftReply,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
|
||||
/// <summary>Triage update: change status and/or assign a contact.</summary>
|
||||
public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
|
||||
|
||||
/// <summary>Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).</summary>
|
||||
public record MailSettingsDto(
|
||||
string? GmailAddress,
|
||||
int PollIntervalMinutes,
|
||||
string Folder,
|
||||
bool BackgroundPollEnabled,
|
||||
bool HasAppPassword);
|
||||
|
||||
/// <summary>Body of the (edited) reply to send for POST /api/requests/{id}/send.</summary>
|
||||
public record SendReplyInput(string Body);
|
||||
|
||||
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
|
||||
/// empty string clears it.</summary>
|
||||
public record MailSettingsInput(
|
||||
string? GmailAddress,
|
||||
string? AppPassword,
|
||||
int? PollIntervalMinutes,
|
||||
string? Folder,
|
||||
bool? BackgroundPollEnabled);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record LitterDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required DateTime Date { get; init; }
|
||||
public int? Strength { get; init; }
|
||||
public GerbilDto? Father { get; init; }
|
||||
public GerbilDto? Mother { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateBreederDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateGerbilDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public GenderDto? Gender { get; init; }
|
||||
public LitterDto? Litter { get; init; }
|
||||
public BreederDto? Breeder { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateLitterDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public DateTime? Date { get; init; }
|
||||
public int? Strength { get; init; }
|
||||
public Guid? Father { get; init; }
|
||||
public Guid? Mother { get; init; }
|
||||
}
|
||||
}
|
||||
186
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
186
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export
|
||||
/// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks
|
||||
/// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the
|
||||
/// rest of the API.
|
||||
/// </summary>
|
||||
public static class CmsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("CMS");
|
||||
|
||||
// ---- snapshot (consumed by the static renderer) ----
|
||||
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
|
||||
|
||||
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
|
||||
api.MapGet("/render-site", async (ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
|
||||
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
|
||||
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
|
||||
if (!files.TryGetValue(key, out var content) &&
|
||||
!files.TryGetValue($"{key}/index.html", out content))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
return Results.Content(content, PreviewContentType(key));
|
||||
});
|
||||
|
||||
// ---- pages ----
|
||||
api.MapGet("/pages", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
||||
.Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status))
|
||||
.ToListAsync()));
|
||||
|
||||
api.MapGet("/pages/{slug}", async Task<Results<Ok<PageDto>, NotFound>> (string slug, ApplicationContext db) =>
|
||||
{
|
||||
var p = await db.Pages.AsNoTracking().Include(x => x.Blocks)
|
||||
.FirstOrDefaultAsync(x => x.Slug == slug);
|
||||
return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p));
|
||||
});
|
||||
|
||||
api.MapPost("/pages", async Task<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["slug"] = ["Slug and title are required."] });
|
||||
if (await db.Pages.AnyAsync(p => p.Slug == input.Slug))
|
||||
return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists.");
|
||||
|
||||
var page = new Page
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = input.Slug,
|
||||
Title = input.Title,
|
||||
SeoDescription = input.SeoDescription,
|
||||
Status = input.Status ?? PageStatus.Draft,
|
||||
};
|
||||
db.Pages.Add(page);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page));
|
||||
});
|
||||
|
||||
api.MapPut("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
page.Slug = input.Slug;
|
||||
page.Title = input.Title;
|
||||
page.SeoDescription = input.SeoDescription;
|
||||
if (input.Status is PageStatus s) page.Status = s;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
db.Pages.Remove(page); // cascades blocks
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// ---- blocks ----
|
||||
api.MapPost("/pages/{pageId:guid}/blocks", async Task<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
|
||||
int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId)
|
||||
.Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1);
|
||||
var block = new Block
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PageId = pageId,
|
||||
Order = order,
|
||||
Type = input.Type,
|
||||
Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}",
|
||||
};
|
||||
db.Blocks.Add(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block));
|
||||
});
|
||||
|
||||
api.MapPut("/blocks/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
block.Type = input.Type;
|
||||
if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText();
|
||||
if (input.Order is int o) block.Order = o;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/blocks/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
db.Blocks.Remove(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// reorder: body = ordered block ids; Order set to the list index
|
||||
api.MapPut("/pages/{pageId:guid}/blocks/order", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockOrderInput input, ApplicationContext db) =>
|
||||
{
|
||||
var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync();
|
||||
if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId))
|
||||
return TypedResults.NotFound();
|
||||
var byId = blocks.ToDictionary(b => b.Id);
|
||||
if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid)))
|
||||
return TypedResults.BadRequest("blockIds must list exactly the page's block ids.");
|
||||
for (int i = 0; i < input.BlockIds.Count; i++)
|
||||
byId[input.BlockIds[i]].Order = i;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
|
||||
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
|
||||
: "text/html; charset=utf-8";
|
||||
|
||||
private static BlockDto ToBlockDto(Block b) =>
|
||||
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
||||
|
||||
private static PageDto ToPageDto(Page p) => new(
|
||||
p.Id, p.Slug, p.Title, p.SeoDescription, p.Status,
|
||||
p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList());
|
||||
}
|
||||
}
|
||||
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal file
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ColorVarietyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapColorVarietyEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ColorVarietyDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return v is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(v));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (ColorVarietyInput input, ApplicationContext db) =>
|
||||
{
|
||||
var v = new ColorVariety
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = input.Name,
|
||||
CanonicalGenotype = input.CanonicalGenotype,
|
||||
SortOrder = input.SortOrder ?? 1000,
|
||||
};
|
||||
db.ColorVarieties.Add(v);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/color-varieties/{v.Id}", ToDto(v));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ColorVarietyInput input, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (v is null) return TypedResults.NotFound();
|
||||
v.Name = input.Name;
|
||||
v.CanonicalGenotype = input.CanonicalGenotype;
|
||||
if (input.SortOrder is int so) v.SortOrder = so;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (v is null) return TypedResults.NotFound();
|
||||
bool inUse = await db.Gerbils.AnyAsync(g => g.ColorVarietyId == id);
|
||||
if (inUse) return TypedResults.Conflict("Color variety is in use by one or more gerbils and cannot be deleted.");
|
||||
db.ColorVarieties.Remove(v);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ColorVarietyDto ToDto(ColorVariety v) => new(v.Id, v.Name, v.CanonicalGenotype, v.SortOrder);
|
||||
}
|
||||
}
|
||||
59
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal file
59
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ContactEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapContactEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contacts").WithTags("Contacts");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// 409 if any gerbil references this contact as origin or receiver.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
bool linked = await db.Gerbils.AnyAsync(g => g.OriginContactId == id || g.ReceiverContactId == id);
|
||||
if (linked) return TypedResults.Conflict("Contact is linked to one or more gerbils and cannot be deleted.");
|
||||
db.Contacts.Remove(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
|
||||
}
|
||||
}
|
||||
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Abgabeverträge.
|
||||
/// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im
|
||||
/// Vertrags-Dateiroot, legt die Vertragszeile an und stellt
|
||||
/// die Tiere in DERSELBEN Transaktion auf Abgegeben
|
||||
/// (ReceiverContactId, GoHomeDate, Status).
|
||||
/// GET /contracts -> Gridify-paged (z. B. filter=contactId==…)
|
||||
/// GET /contracts/{id} -> Metadaten
|
||||
/// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname)
|
||||
/// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt)
|
||||
/// </summary>
|
||||
public static class ContractEndpoints
|
||||
{
|
||||
private const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
group.MapPost("/", async Task<Results<Created<SaleContractDto>, ValidationProblem>> (
|
||||
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
errors["price"] = ["Der Kaufpreis darf nicht negativ sein."];
|
||||
|
||||
var contact = await db.Contacts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == input.ContactId);
|
||||
if (contact is null)
|
||||
errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."];
|
||||
|
||||
var gerbils = await db.Gerbils
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
if (gerbils.Count != gerbilIds.Count)
|
||||
errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."];
|
||||
|
||||
if (errors.Count > 0) return TypedResults.ValidationProblem(errors);
|
||||
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
var contractDate = input.ContractDate ?? input.HandoverDate;
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name))
|
||||
.ToList(),
|
||||
Price: input.Price,
|
||||
HandoverDate: input.HandoverDate,
|
||||
ContractDate: contractDate);
|
||||
|
||||
var bytes = ContractGenerator.Generate(data);
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}.docx";
|
||||
var root = ContractRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes);
|
||||
|
||||
var entity = new SaleContract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContactId = contact.Id,
|
||||
Price = input.Price,
|
||||
HandoverDate = input.HandoverDate,
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
||||
};
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DB fehlgeschlagen -> verwaiste Datei wieder aufräumen.
|
||||
var orphan = Path.Combine(root, fileName);
|
||||
if (File.Exists(orphan)) File.Delete(orphan);
|
||||
throw;
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen.
|
||||
group.MapGet("/{id:guid}/file", async Task<Results<PhysicalFileHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (!File.Exists(path)) return TypedResults.NotFound();
|
||||
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx";
|
||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||
});
|
||||
|
||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
|
||||
db.SaleContracts.Remove(c); // Joins kaskadieren
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||
|
||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
Name = s.Name,
|
||||
Address = s.Address,
|
||||
Phone = s.Phone,
|
||||
Email = s.Email,
|
||||
Homepage = s.Homepage,
|
||||
City = s.City,
|
||||
};
|
||||
|
||||
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
|
||||
internal static string GeschlechtText(Gender gender) => gender switch
|
||||
{
|
||||
Gender.male => "Männlich",
|
||||
Gender.female => "Weiblich",
|
||||
_ => "Unbekannt",
|
||||
};
|
||||
|
||||
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
|
||||
private static string Sanitize(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "Abnehmer";
|
||||
var cleaned = new string(name.Trim()
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray());
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
}
|
||||
}
|
||||
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal file
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class EnclosureEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEnclosureEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/enclosures").WithTags("Enclosures");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return e is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(e));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||
db.Enclosures.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.Name = input.Name; e.Notes = input.Notes;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// 409 if the enclosure still houses gerbils.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
bool occupied = await db.Gerbils.AnyAsync(g => g.EnclosureId == id);
|
||||
if (occupied) return TypedResults.Conflict("Enclosure still contains gerbils and cannot be deleted.");
|
||||
db.Enclosures.Remove(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
|
||||
}
|
||||
}
|
||||
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal file
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using GerbilManagerWebAPI.Export;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
|
||||
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
|
||||
/// </summary>
|
||||
public static class ExportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var data = new ExportService.ExportData(
|
||||
await db.Gerbils.AsNoTracking().ToListAsync(ct),
|
||||
await db.Litters.AsNoTracking().ToListAsync(ct),
|
||||
await db.Contacts.AsNoTracking().ToListAsync(ct),
|
||||
await db.Enclosures.AsNoTracking().ToListAsync(ct),
|
||||
await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
|
||||
await db.HealthRecords.AsNoTracking().ToListAsync(ct),
|
||||
await db.WeightRecords.AsNoTracking().ToListAsync(ct),
|
||||
await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var bytes = ExportService.BuildZip(data, today);
|
||||
return Results.File(bytes, "application/zip",
|
||||
$"rennmaus-export-{today:yyyy-MM-dd}.zip");
|
||||
})
|
||||
.WithTags("Export");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal file
110
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class GerbilEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGerbilEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/gerbils").WithTags("Gerbils");
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
|
||||
.Select(g => g.OriginBreeder!)
|
||||
.Distinct().OrderBy(b => b).ToListAsync()));
|
||||
|
||||
// GET /gerbils/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
group.MapPost("/", async Task<Results<Created<GerbilDto>, ValidationProblem>> (GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id}
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
g.Name = input.Name;
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
db.Gerbils.Remove(g);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return TypedResults.Conflict("Gerbil is referenced as a litter parent and cannot be deleted.");
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||
{
|
||||
g.Gender = i.Gender;
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
g.LitterId = i.LitterId;
|
||||
g.OriginContactId = i.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate;
|
||||
g.Genotype = i.Genotype;
|
||||
g.Notes = i.Notes;
|
||||
g.ImportSource = i.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
||||
}
|
||||
}
|
||||
35
GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs
Normal file
35
GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output.
|
||||
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
||||
/// against Julian's DB under god-supervision after he approves the dry-run.
|
||||
/// </summary>
|
||||
public static class ImportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/import").WithTags("Import");
|
||||
|
||||
group.MapPost("/dry-run", async Task<Ok<ImportReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var report = await new ImportService(db, config, env).RunAsync(execute: false);
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/execute", async Task<Ok<ImportReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var report = await new ImportService(db, config, env).RunAsync(execute: true);
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal file
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Genetics;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Inzuchtkoeffizient endpoints (FEAT-1b). Routes/response shapes are IDENTICAL to
|
||||
/// the former InbreedingController — only the hosting style changed to Minimal API.
|
||||
/// </summary>
|
||||
public static class InbreedingEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInbreedingEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// GET /gerbils/{id}/inbreeding-coefficient
|
||||
app.MapGet("/gerbils/{id:guid}/inbreeding-coefficient",
|
||||
Results<Ok<InbreedingResult>, NotFound> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var result = new InbreedingService(db).ForGerbil(id);
|
||||
return result is null ? TypedResults.NotFound() : TypedResults.Ok(result);
|
||||
})
|
||||
.WithTags("Inbreeding");
|
||||
|
||||
// POST /genetics/test-inbreeding
|
||||
app.MapPost("/genetics/test-inbreeding",
|
||||
Ok<InbreedingResult> (TestInbreedingDto dto, ApplicationContext db) =>
|
||||
TypedResults.Ok(new InbreedingService(db).ForPairing(dto.FatherId, dto.MotherId)))
|
||||
.WithTags("Inbreeding");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal file
91
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class LitterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLitterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/litters").WithTags("Litters");
|
||||
|
||||
// GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
Apply(l, input);
|
||||
db.Litters.Add(l);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
l.Name = input.Name;
|
||||
l.Date = input.Date;
|
||||
Apply(l, input);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
db.Litters.Remove(l);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// father must not be female; mother must not be male (unknown is allowed).
|
||||
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
||||
{
|
||||
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
|
||||
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
|
||||
if (father?.Gender == Gender.female || mother?.Gender == Gender.male)
|
||||
{
|
||||
return new ParentGenderError("InvalidParentGender",
|
||||
father?.Gender ?? Gender.unknown, mother?.Gender ?? Gender.unknown);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Apply(Litter l, LitterInput i)
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
public record ParentGenderError(string Code, Gender FatherGender, Gender MotherGender);
|
||||
}
|
||||
104
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal file
104
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar:
|
||||
/// GET /gerbils/{id}/photos -> plain array (not paged)
|
||||
/// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto
|
||||
/// DELETE /photos/{id} -> 204
|
||||
/// GET /photos/files/{fileName} -> the image bytes
|
||||
/// url = "/photos/files/{fileName}"; profile photo = first by SortOrder.
|
||||
/// </summary>
|
||||
public static class PhotoEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/gerbils/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/gerbils/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.GerbilPhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.GerbilPhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapGet("/photos/files/{fileName}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
// guard against path traversal: only a bare file name is allowed
|
||||
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
|
||||
return TypedResults.BadRequest("Invalid file name.");
|
||||
var path = Path.Combine(PhotoRoot(config, env), fileName);
|
||||
if (!File.Exists(path)) return TypedResults.NotFound();
|
||||
return TypedResults.PhysicalFile(path, ContentType(fileName));
|
||||
}).WithTags("Photos");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||
|
||||
private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
}
|
||||
}
|
||||
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal file
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class RecordEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapHealthRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/health-records").WithTags("HealthRecords");
|
||||
|
||||
// GET /health-records?filter=gerbilId==…
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HealthRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
Date = input.Date,
|
||||
Type = input.Type,
|
||||
Description = input.Description,
|
||||
Veterinarian = input.Veterinarian,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.HealthRecords.Add(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/health-records/{r.Id}", ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, HealthRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
r.GerbilId = input.GerbilId; r.Date = input.Date; r.Type = input.Type;
|
||||
r.Description = input.Description; r.Veterinarian = input.Veterinarian;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
db.HealthRecords.Remove(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapWeightRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/weight-records").WithTags("WeightRecords");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (WeightRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = new WeightRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
Date = input.Date,
|
||||
WeightGrams = input.WeightGrams,
|
||||
Notes = input.Notes,
|
||||
};
|
||||
db.WeightRecords.Add(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/weight-records/{r.Id}", ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, WeightRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
r.GerbilId = input.GerbilId; r.Date = input.Date;
|
||||
r.WeightGrams = input.WeightGrams; r.Notes = input.Notes;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
db.WeightRecords.Remove(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HealthRecordDto ToDto(HealthRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.Date, r.Type, r.Description, r.Veterinarian, r.CreatedAt);
|
||||
|
||||
private static WeightRecordDto ToDto(WeightRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.Date, r.WeightGrams, r.Notes);
|
||||
}
|
||||
}
|
||||
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage
|
||||
/// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API.
|
||||
/// </summary>
|
||||
public static class RequestEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("Inbox");
|
||||
|
||||
// POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id)
|
||||
api.MapPost("/requests/sync", async (RequestSyncService sync) =>
|
||||
TypedResults.Ok(await sync.SyncAsync()));
|
||||
|
||||
// GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged)
|
||||
api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Requests.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
api.MapGet("/requests/{id:guid}", async Task<Results<Ok<RequestDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// PUT /api/requests/{id} — triage: status and/or assigned contact
|
||||
api.MapPut("/requests/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, RequestTriageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid))
|
||||
return TypedResults.BadRequest("Assigned contact does not exist.");
|
||||
|
||||
r.AssignedContactId = input.AssignedContactId;
|
||||
if (input.Status is RequestStatus s)
|
||||
{
|
||||
r.Status = s;
|
||||
if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf
|
||||
// (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die
|
||||
// AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad).
|
||||
api.MapPost("/requests/{id:guid}/draft", async Task<IResult> (
|
||||
Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
// Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht
|
||||
// zusätzlich zum Anfragetext an den Anbieter (arch §privacy).
|
||||
var forSale = await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = await drafter.DraftAsync(r, forSale, ct);
|
||||
if (result.Status == Ai.AiCallStatus.NotConfigured)
|
||||
return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503);
|
||||
if (result.Status != Ai.AiCallStatus.Ok)
|
||||
return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502);
|
||||
|
||||
r.DraftReply = result.Text;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
|
||||
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
|
||||
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
var outcome = await sender.SendAsync(r, input.Body ?? "");
|
||||
return outcome.Error switch
|
||||
{
|
||||
null => TypedResults.Ok(ToDto(r)),
|
||||
"MailNotConfigured" => TypedResults.Problem(
|
||||
"Gmail ist noch nicht konfiguriert.", statusCode: 503, title: "MailNotConfigured"),
|
||||
"MailAuthFailed" => TypedResults.Problem(
|
||||
"Gmail-Anmeldung fehlgeschlagen — App-Passwort erneut eingeben.", statusCode: 502, title: "MailAuthFailed"),
|
||||
var code => TypedResults.Problem(code, statusCode: 500, title: code),
|
||||
};
|
||||
});
|
||||
|
||||
// GET/PUT /api/mail-settings — App Password never leaves the server
|
||||
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
return TypedResults.Ok(new MailSettingsDto(
|
||||
s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s)));
|
||||
});
|
||||
|
||||
api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim();
|
||||
if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m;
|
||||
if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim();
|
||||
if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b;
|
||||
// AppPassword: null => leave unchanged; "" => clear; value => set (encrypted)
|
||||
if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static RequestDto ToDto(Request r) => new(
|
||||
r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText,
|
||||
r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt);
|
||||
}
|
||||
}
|
||||
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: POST /gerbils/sale-ad — AI-gestütztes Abgabe-Inserat (frozen
|
||||
/// contract, see gerbil-manager-web/src/api/saleAd.ts).
|
||||
///
|
||||
/// 503 {code:"AiKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class SaleAdEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSaleAdEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/gerbils/sale-ad",
|
||||
async (SaleAdRequest request, SaleAdService service, CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.GenerateAsync(request, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
SaleAdStatus.Ok => Results.Ok(new SaleAdResponse(result.Text!)),
|
||||
SaleAdStatus.NotConfigured => Results.Json(
|
||||
new SaleAdError("AiKeyMissing", result.Error ?? ""), statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new SaleAdError("AiUpstreamError", result.Error ?? ""), statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("SaleAd");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
60
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile,
|
||||
/// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt.
|
||||
/// GET /settings/breeder-profile -> BreederProfileDto
|
||||
/// PUT /settings/breeder-profile -> 204
|
||||
/// </summary>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/settings").WithTags("Settings");
|
||||
|
||||
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: false);
|
||||
return TypedResults.Ok(ToDto(s));
|
||||
});
|
||||
|
||||
group.MapPut("/breeder-profile", async Task<NoContent> (BreederProfileDto input, ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: true);
|
||||
s.ZuchtName = input.ZuchtName ?? "";
|
||||
s.Name = input.Name ?? "";
|
||||
s.Address = input.Address ?? "";
|
||||
s.Phone = input.Phone ?? "";
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
|
||||
private static async Task<BreederSettings> LoadAsync(ApplicationContext db, bool track)
|
||||
{
|
||||
var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking();
|
||||
var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId);
|
||||
if (s is null)
|
||||
{
|
||||
s = new BreederSettings { Id = BreederSettings.SingletonId };
|
||||
db.BreederSettings.Add(s);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
||||
}
|
||||
}
|
||||
212
GerbilManagerWebAPI/Export/ExportService.cs
Normal file
212
GerbilManagerWebAPI/Export/ExportService.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Export
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: user-facing Datenexport — builds a zip containing
|
||||
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
|
||||
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
|
||||
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
|
||||
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
|
||||
///
|
||||
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
|
||||
/// and escaping tests run without a database.
|
||||
/// </summary>
|
||||
public static class ExportService
|
||||
{
|
||||
public sealed record ExportData(
|
||||
List<Gerbil> Gerbils,
|
||||
List<Litter> Litters,
|
||||
List<Contact> Contacts,
|
||||
List<Enclosure> Enclosures,
|
||||
List<ColorVariety> ColorVarieties,
|
||||
List<HealthRecord> HealthRecords,
|
||||
List<WeightRecord> WeightRecords,
|
||||
List<GerbilPhoto> Photos);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
WriteIndented = true,
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
};
|
||||
|
||||
// German labels for enum values (CSV is the wife-facing backup format;
|
||||
// the JSON keeps the enum member names for lossless re-import).
|
||||
private static readonly Dictionary<Gender, string> GenderDe = new()
|
||||
{
|
||||
[Gender.unknown] = "Unbekannt",
|
||||
[Gender.male] = "Männlich",
|
||||
[Gender.female] = "Weiblich",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Active] = "Aktiv",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
{
|
||||
[HealthRecordType.Examination] = "Untersuchung",
|
||||
[HealthRecordType.Treatment] = "Behandlung",
|
||||
[HealthRecordType.Injury] = "Verletzung",
|
||||
[HealthRecordType.Vaccination] = "Impfung",
|
||||
[HealthRecordType.Other] = "Sonstiges",
|
||||
};
|
||||
|
||||
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
|
||||
{
|
||||
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
|
||||
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
|
||||
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
|
||||
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
|
||||
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
|
||||
|
||||
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
|
||||
key is null ? null : map.GetValueOrDefault(key.Value);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
AddText(zip, "export.json", JsonSerializer.Serialize(new
|
||||
{
|
||||
exportedAt = exportDate.ToString("yyyy-MM-dd"),
|
||||
data.Gerbils,
|
||||
data.Litters,
|
||||
data.Contacts,
|
||||
data.Enclosures,
|
||||
data.ColorVarieties,
|
||||
data.HealthRecords,
|
||||
data.WeightRecords,
|
||||
data.Photos,
|
||||
}, JsonOptions), bom: false);
|
||||
|
||||
AddCsv(zip, "tiere.csv",
|
||||
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
|
||||
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
|
||||
"Genotyp", "Notizen"],
|
||||
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
|
||||
{
|
||||
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
|
||||
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
|
||||
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
|
||||
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
|
||||
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
|
||||
g.Genotype, g.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "wuerfe.csv",
|
||||
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
|
||||
"Voraussichtliches Abgabedatum", "Notizen"],
|
||||
data.Litters.OrderBy(l => l.Date).Select(l => new[]
|
||||
{
|
||||
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
|
||||
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
|
||||
De(l.ExpectedGoHomeDate), l.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "kontakte.csv",
|
||||
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
|
||||
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
|
||||
{
|
||||
c.Name, c.Email, c.Phone, c.Address, c.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "gesundheit.csv",
|
||||
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
|
||||
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
|
||||
{
|
||||
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
|
||||
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "gewichte.csv",
|
||||
["Tier", "Datum", "Gewicht (g)", "Notizen"],
|
||||
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
|
||||
{
|
||||
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
|
||||
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
|
||||
}));
|
||||
|
||||
AddText(zip, "LIESMICH.txt", $"""
|
||||
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
|
||||
|
||||
Enthalten:
|
||||
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
|
||||
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
|
||||
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
|
||||
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
|
||||
- wuerfe.csv : alle Würfe.
|
||||
- kontakte.csv : alle Kontakte.
|
||||
- gesundheit.csv: alle Gesundheitseinträge.
|
||||
- gewichte.csv : alle Gewichtseinträge.
|
||||
|
||||
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
|
||||
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
|
||||
|
||||
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
|
||||
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
|
||||
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
|
||||
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
|
||||
steht in export.json (Abschnitt "photos").
|
||||
""", bom: false);
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
// ── CSV building blocks ────────────────────────────────────────────
|
||||
|
||||
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
|
||||
private static string? De(DateOnly? date) =>
|
||||
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string De(DateOnly date) =>
|
||||
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
|
||||
/// they contain separator/quote/newline; quotes doubled.
|
||||
/// </summary>
|
||||
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
|
||||
foreach (var row in rows)
|
||||
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static string Escape(string? field)
|
||||
{
|
||||
if (string.IsNullOrEmpty(field)) return "";
|
||||
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
|
||||
? $"\"{field.Replace("\"", "\"\"")}\""
|
||||
: field;
|
||||
}
|
||||
|
||||
private static void AddCsv(ZipArchive zip, string name, string[] header,
|
||||
IEnumerable<string?[]> rows) =>
|
||||
AddText(zip, name, BuildCsv(header, rows), bom: true);
|
||||
|
||||
private static void AddText(ZipArchive zip, string name, string content, bool bom)
|
||||
{
|
||||
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
if (bom)
|
||||
{
|
||||
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
|
||||
entryStream.Write(Encoding.UTF8.GetPreamble());
|
||||
}
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entryStream.Write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
GerbilManagerWebAPI/Genetics/InbreedingResult.cs
Normal file
25
GerbilManagerWebAPI/Genetics/InbreedingResult.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an inbreeding-coefficient (Inzuchtkoeffizient) calculation for an
|
||||
/// individual or a hypothetical pairing.
|
||||
/// </summary>
|
||||
/// <param name="Coefficient">Wright's inbreeding coefficient F, in the range 0..1.</param>
|
||||
/// <param name="Percent">The coefficient expressed as a percentage (F * 100).</param>
|
||||
/// <param name="GenerationsAvailable">
|
||||
/// The deepest generation of known ancestry above the individual (parents = 1,
|
||||
/// grandparents = 2, …). 0 when no parents are recorded.
|
||||
/// </param>
|
||||
/// <param name="CommonAncestors">
|
||||
/// The ancestors common to both parents, each with its additive contribution to F,
|
||||
/// ordered by contribution descending. Empty when there is no inbreeding.
|
||||
/// </param>
|
||||
public record InbreedingResult(
|
||||
double Coefficient,
|
||||
double Percent,
|
||||
int GenerationsAvailable,
|
||||
IReadOnlyList<CommonAncestorContribution> CommonAncestors);
|
||||
|
||||
/// <summary>A single common ancestor and how much it contributes to F.</summary>
|
||||
public record CommonAncestorContribution(Guid Id, string Name, double Contribution);
|
||||
}
|
||||
64
GerbilManagerWebAPI/Genetics/InbreedingService.cs
Normal file
64
GerbilManagerWebAPI/Genetics/InbreedingService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the pedigree out of the database into in-memory lookups (the home dataset
|
||||
/// is small) and delegates the actual maths to <see cref="PedigreeCalculator"/>.
|
||||
///
|
||||
/// Parent links live in EF shadow foreign keys: a gerbil's birth litter is
|
||||
/// <c>Gerbils.LitterId</c>; that litter's parents are <c>Litters.FatherId</c> and
|
||||
/// <c>Litters.MotherId</c>.
|
||||
/// </summary>
|
||||
public sealed class InbreedingService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
|
||||
public InbreedingService(ApplicationContext db) => _db = db;
|
||||
|
||||
/// <summary>F for an existing gerbil, or null if no such gerbil exists.</summary>
|
||||
public InbreedingResult? ForGerbil(Guid id)
|
||||
{
|
||||
var calculator = BuildCalculator();
|
||||
return calculator.Contains(id) ? calculator.ForIndividual(id) : null;
|
||||
}
|
||||
|
||||
/// <summary>F for the hypothetical offspring of the given sire and dam.</summary>
|
||||
public InbreedingResult ForPairing(Guid? fatherId, Guid? motherId)
|
||||
{
|
||||
return BuildCalculator().ForOffspringOf(fatherId, motherId);
|
||||
}
|
||||
|
||||
private PedigreeCalculator BuildCalculator()
|
||||
{
|
||||
var litters = _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId })
|
||||
.ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId));
|
||||
|
||||
var gerbils = _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.LitterId })
|
||||
.ToList();
|
||||
|
||||
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);
|
||||
var names = new Dictionary<Guid, string>(gerbils.Count);
|
||||
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
names[g.Id] = g.Name;
|
||||
|
||||
Guid? father = null;
|
||||
Guid? mother = null;
|
||||
if (g.LitterId is Guid litterId && litters.TryGetValue(litterId, out var litterParents))
|
||||
{
|
||||
father = litterParents.FatherId;
|
||||
mother = litterParents.MotherId;
|
||||
}
|
||||
|
||||
parents[g.Id] = (father, mother);
|
||||
}
|
||||
|
||||
return new PedigreeCalculator(parents, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs
Normal file
219
GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the inbreeding coefficient (Inzuchtkoeffizient) using Wright's path
|
||||
/// method:
|
||||
///
|
||||
/// F = Σ over common ancestors, over every valid path, of
|
||||
/// (1/2)^(n1 + n2 + 1) * (1 + F_A)
|
||||
///
|
||||
/// where, for a given common ancestor A, n1 is the number of links from the sire
|
||||
/// up to A and n2 the number of links from the dam up to A, and F_A is A's own
|
||||
/// inbreeding coefficient (computed recursively at full available pedigree depth).
|
||||
/// A path counts only if the sire-side and dam-side chains share no individual
|
||||
/// other than A — this is what prevents double counting.
|
||||
///
|
||||
/// This type is deliberately free of any database / EF dependency: it works purely
|
||||
/// from a parent lookup, so it can be unit-tested with hand-built pedigrees.
|
||||
/// </summary>
|
||||
public sealed class PedigreeCalculator
|
||||
{
|
||||
private readonly IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> _parents;
|
||||
private readonly IReadOnlyDictionary<Guid, string> _names;
|
||||
|
||||
// F_A memo + re-entrancy guard (the latter only matters if the data somehow
|
||||
// contains a cycle, which is biologically impossible but cheap to defend).
|
||||
private readonly Dictionary<Guid, double> _fCache = new();
|
||||
private readonly HashSet<Guid> _fInProgress = new();
|
||||
private readonly Dictionary<Guid, int> _depthCache = new();
|
||||
|
||||
public PedigreeCalculator(
|
||||
IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> parents,
|
||||
IReadOnlyDictionary<Guid, string> names)
|
||||
{
|
||||
_parents = parents;
|
||||
_names = names;
|
||||
}
|
||||
|
||||
/// <summary>True if the given individual exists in the pedigree.</summary>
|
||||
public bool Contains(Guid id) => _parents.ContainsKey(id);
|
||||
|
||||
/// <summary>Inbreeding coefficient of an existing individual.</summary>
|
||||
public InbreedingResult ForIndividual(Guid id)
|
||||
{
|
||||
var (father, mother) = ParentsOf(id);
|
||||
return ForOffspringOf(father, mother);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inbreeding coefficient of the (possibly hypothetical) offspring of the given
|
||||
/// sire and dam — equivalently, the kinship between the two parents.
|
||||
/// </summary>
|
||||
public InbreedingResult ForOffspringOf(Guid? sire, Guid? dam)
|
||||
{
|
||||
int generations = GenerationsAvailable(sire, dam);
|
||||
|
||||
if (sire is not Guid s || dam is not Guid d)
|
||||
{
|
||||
// A parent is unknown → no inbreeding can be established.
|
||||
return new InbreedingResult(0.0, 0.0, generations, Array.Empty<CommonAncestorContribution>());
|
||||
}
|
||||
|
||||
var contributions = Coefficient(s, d);
|
||||
double f = contributions.Values.Sum();
|
||||
|
||||
var commonAncestors = contributions
|
||||
.Select(kv => new CommonAncestorContribution(kv.Key, NameOf(kv.Key), kv.Value))
|
||||
.OrderByDescending(c => c.Contribution)
|
||||
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new InbreedingResult(f, f * 100.0, generations, commonAncestors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-common-ancestor contributions to the kinship of <paramref name="sire"/>
|
||||
/// and <paramref name="dam"/>. The sum of the values is the coefficient.
|
||||
/// </summary>
|
||||
private Dictionary<Guid, double> Coefficient(Guid sire, Guid dam)
|
||||
{
|
||||
var sirePaths = EnumerateAncestorPaths(sire);
|
||||
var damPaths = EnumerateAncestorPaths(dam);
|
||||
|
||||
// Group the dam's paths by the ancestor they terminate at for quick lookup.
|
||||
var damPathsByAncestor = damPaths
|
||||
.GroupBy(p => p[^1])
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var contributions = new Dictionary<Guid, double>();
|
||||
|
||||
foreach (var sirePath in sirePaths)
|
||||
{
|
||||
var ancestor = sirePath[^1];
|
||||
if (!damPathsByAncestor.TryGetValue(ancestor, out var matchingDamPaths))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sireNodes = new HashSet<Guid>(sirePath);
|
||||
|
||||
foreach (var damPath in matchingDamPaths)
|
||||
{
|
||||
// The two chains may meet only at the common ancestor itself.
|
||||
if (damPath.Any(node => node != ancestor && sireNodes.Contains(node)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int n1 = sirePath.Count - 1;
|
||||
int n2 = damPath.Count - 1;
|
||||
double term = Math.Pow(0.5, n1 + n2 + 1) * (1.0 + F(ancestor));
|
||||
|
||||
contributions[ancestor] = contributions.GetValueOrDefault(ancestor) + term;
|
||||
}
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/// <summary>Inbreeding coefficient F of a single individual (memoized).</summary>
|
||||
private double F(Guid id)
|
||||
{
|
||||
if (_fCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Defend against accidental cycles in the data.
|
||||
if (!_fInProgress.Add(id))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double f = 0.0;
|
||||
var (father, mother) = ParentsOf(id);
|
||||
if (father is Guid s && mother is Guid d)
|
||||
{
|
||||
f = Coefficient(s, d).Values.Sum();
|
||||
}
|
||||
|
||||
_fInProgress.Remove(id);
|
||||
_fCache[id] = f;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every chain of parent links starting at <paramref name="start"/>, including
|
||||
/// the trivial chain [start] itself. Each returned list runs from the start
|
||||
/// individual down to one of its ancestors (inclusive). A visited set keeps the
|
||||
/// walk finite even if the data contains a cycle.
|
||||
/// </summary>
|
||||
private List<List<Guid>> EnumerateAncestorPaths(Guid start)
|
||||
{
|
||||
var all = new List<List<Guid>>();
|
||||
var path = new List<Guid>();
|
||||
|
||||
void Walk(Guid node)
|
||||
{
|
||||
path.Add(node);
|
||||
all.Add(new List<Guid>(path));
|
||||
|
||||
var (father, mother) = ParentsOf(node);
|
||||
foreach (var parent in new[] { father, mother })
|
||||
{
|
||||
if (parent is Guid p && !path.Contains(p))
|
||||
{
|
||||
Walk(p);
|
||||
}
|
||||
}
|
||||
|
||||
path.RemoveAt(path.Count - 1);
|
||||
}
|
||||
|
||||
Walk(start);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deepest generation of known ancestry for the offspring of the two parents
|
||||
/// (parents = 1, grandparents = 2, …). 0 when neither parent is known.
|
||||
/// </summary>
|
||||
private int GenerationsAvailable(Guid? sire, Guid? dam)
|
||||
{
|
||||
int FromParent(Guid? parent) => parent is Guid p ? 1 + AncestorDepth(p) : 0;
|
||||
return Math.Max(FromParent(sire), FromParent(dam));
|
||||
}
|
||||
|
||||
/// <summary>Number of ancestral generations above <paramref name="id"/> (0 for a founder).</summary>
|
||||
private int AncestorDepth(Guid id)
|
||||
{
|
||||
if (_depthCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Temporary 0 breaks any cycle while the real value is computed.
|
||||
_depthCache[id] = 0;
|
||||
|
||||
var (father, mother) = ParentsOf(id);
|
||||
int depth = 0;
|
||||
if (father is Guid f)
|
||||
{
|
||||
depth = Math.Max(depth, 1 + AncestorDepth(f));
|
||||
}
|
||||
if (mother is Guid m)
|
||||
{
|
||||
depth = Math.Max(depth, 1 + AncestorDepth(m));
|
||||
}
|
||||
|
||||
_depthCache[id] = depth;
|
||||
return depth;
|
||||
}
|
||||
|
||||
private (Guid? Father, Guid? Mother) ParentsOf(Guid id) =>
|
||||
_parents.TryGetValue(id, out var p) ? p : (null, null);
|
||||
|
||||
private string NameOf(Guid id) =>
|
||||
_names.TryGetValue(id, out var name) ? name : id.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.12" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.12" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
110
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
110
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
// ---- Source shapes (tools/import/output JSON, produced by extract.py) ----
|
||||
|
||||
public sealed class SourceAnimal
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public List<string> NameVariants { get; set; } = new();
|
||||
public string Dob { get; set; } = "";
|
||||
public string Death { get; set; } = "";
|
||||
public string? Gender { get; set; }
|
||||
public string Farbschlag { get; set; } = "";
|
||||
public List<string> FarbschlagVariants { get; set; } = new();
|
||||
public SourceGenotype Genotype { get; set; } = new();
|
||||
public string Zucht { get; set; } = "";
|
||||
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
|
||||
public List<SourceParentRef> ParentRefs { get; set; } = new();
|
||||
public List<string> Photos { get; set; } = new();
|
||||
public List<string> SourceFiles { get; set; } = new();
|
||||
public bool Conflict { get; set; }
|
||||
public SourceLitterRef? LitterRef { get; set; }
|
||||
|
||||
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
|
||||
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
||||
public bool? Deaf { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
|
||||
// this animal (its genotype/farbschlag are then authoritative). For reporting.
|
||||
public bool ResolvedByDecision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceGenotype
|
||||
{
|
||||
public Dictionary<string, List<string>> Mapped8locus { get; set; } = new();
|
||||
public string RawGenotype { get; set; } = "";
|
||||
public List<string> UnmappedTokens { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SourceParentRef
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = ""; // "father" | "mother"
|
||||
public string Method { get; set; } = ""; // e.g. "chart-position"
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
|
||||
}
|
||||
|
||||
public sealed class SourceLitterRef
|
||||
{
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Method { get; set; } = "";
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates)
|
||||
public List<string>? Candidates { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceLitter
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Date { get; set; } = "";
|
||||
public string DamName { get; set; } = "";
|
||||
public string SireName { get; set; } = "";
|
||||
public int? TotalBorn { get; set; }
|
||||
public string Zuchtnummer { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---- Report shapes (Julian-readable: counts per category + samples) ----
|
||||
|
||||
public sealed record ImportReport(
|
||||
bool Executed,
|
||||
LitterSummary Litters,
|
||||
AnimalSummary Animals,
|
||||
PhotoSummary Photos,
|
||||
IReadOnlyList<string> Samples,
|
||||
IReadOnlyList<string> Notes,
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
int Created,
|
||||
int LinkedToLitter,
|
||||
int FarbschlagMatched,
|
||||
int FarbschlagUnmatched,
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
int Stubs,
|
||||
int DateOnlyLinks,
|
||||
int AmbiguousLinks,
|
||||
int Total);
|
||||
|
||||
public sealed record PhotoSummary(int Attached, int SourceFilesMissing);
|
||||
}
|
||||
517
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
517
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
@@ -0,0 +1,517 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8c import loader. Consumes tools/import/output (animals.json + litters.json
|
||||
/// produced by extract.py) and loads conflict-free data into the database:
|
||||
/// litters first (Wurfchronik = authoritative), then animals matched onto them.
|
||||
///
|
||||
/// Load policy:
|
||||
/// - Litters: all created (idempotent by ExternalRef = source litter id).
|
||||
/// - Animals: created when they have a DOB and are NOT in conflict. The birth-litter
|
||||
/// link is set only for HIGH-confidence matches (litterRef.confidence == "hoch");
|
||||
/// date-only/ambiguous links are quarantined (animal loads with LitterId = null).
|
||||
/// - QUARANTINED (never loaded): conflicts + stubs (no DOB) — await the wife's review.
|
||||
/// Idempotent: re-running matches on ExternalRef and skips existing rows.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportService
|
||||
{
|
||||
private static readonly string[] LocusOrder = { "A", "C", "D", "E", "G", "P", "Sp", "Re" };
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
private const string ImportSourceTag = "FEAT-8 Stammbaum/Wurfchronik";
|
||||
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
/// <summary>Test-friendly constructor with explicit paths.</summary>
|
||||
public ImportService(ApplicationContext db, string sourceDir, string photoRoot)
|
||||
{
|
||||
_db = db;
|
||||
_sourceDir = sourceDir;
|
||||
_photoRoot = photoRoot;
|
||||
}
|
||||
|
||||
public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||
: this(db,
|
||||
config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")),
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"))
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<ImportReport> RunAsync(bool execute)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var samples = new List<string>();
|
||||
|
||||
var animals = Load<List<SourceAnimal>>("animals.json") ?? new();
|
||||
var litters = Load<List<SourceLitter>>("litters.json") ?? new();
|
||||
if (animals.Count == 0 && litters.Count == 0)
|
||||
notes.Add($"Keine Quelldaten gefunden in {_sourceDir} (animals.json/litters.json). extract.py zuerst ausführen.");
|
||||
|
||||
// ---- categorise animals ----
|
||||
var loadable = new List<SourceAnimal>();
|
||||
int conflicts = 0, stubs = 0, dateOnly = 0, ambiguous = 0;
|
||||
foreach (var a in animals)
|
||||
{
|
||||
if (a.Conflict) { conflicts++; continue; }
|
||||
if (string.IsNullOrEmpty(a.Dob)) { stubs++; continue; }
|
||||
loadable.Add(a);
|
||||
var conf = a.LitterRef?.Confidence;
|
||||
if (a.LitterRef?.Candidates is { Count: > 0 }) ambiguous++;
|
||||
else if (conf == "niedrig") dateOnly++;
|
||||
}
|
||||
|
||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
||||
var existingGerbilExtRefs = await _db.Gerbils
|
||||
.Where(g => g.ExternalRef != null)
|
||||
.Select(g => g.ExternalRef!).ToListAsync();
|
||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||
|
||||
var existingLitterKeys = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterKeys
|
||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||
|
||||
// colour-variety name -> id (case-insensitive)
|
||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||
var varietyByName = varieties
|
||||
.GroupBy(v => v.Name.Trim().ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// gender inference from litter roles (sire -> male, dam -> female; both -> unknown)
|
||||
var sireNames = litters.Select(l => Normalize(StripZucht(l.SireName))).Where(s => s.Length > 0).ToHashSet();
|
||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||
|
||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||
int littersCreated = 0, littersExisting = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
var date = ParseDate(sl.Date);
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
littersCreated++;
|
||||
if (execute && date is DateOnly d)
|
||||
{
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Date = d,
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8 && date is not null)
|
||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// ---- animals ----
|
||||
int animalsCreated = 0, linked = 0, fbMatched = 0, fbUnmatched = 0, animalsExisting = 0;
|
||||
int photosAttached = 0, photosMissing = 0;
|
||||
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
|
||||
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
||||
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
wurfLitterId = lid;
|
||||
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
// any animal that has no Wurfchronik litter and isn't already litter-linked. Siblings
|
||||
// (same father+mother+dob) share one derived litter. Computed for dry-run counts too.
|
||||
int parentLinksAdded = 0, derivedLitters = 0;
|
||||
var synthLitterForGid = new Dictionary<Guid, Guid>(); // offspring gid -> synth litter id
|
||||
var synthLitters = new Dictionary<string, SynthLitter>(); // parents+date key -> synth litter
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (p.WurfLitterId is not null || p.CurrentLitterId is not null) continue;
|
||||
if (p.A.ParentRefs is not { Count: > 0 }) continue;
|
||||
var father = ResolveParentGid(p.A, "father", gidByNameDob);
|
||||
var mother = ResolveParentGid(p.A, "mother", gidByNameDob);
|
||||
if (father is null && mother is null) continue; // nothing resolvable to link
|
||||
var dob = ParseDate(p.A.Dob);
|
||||
var conf = p.A.ParentRefs.FirstOrDefault()?.Confidence ?? "medium";
|
||||
var key = $"{father}|{mother}|{dob:yyyy-MM-dd}";
|
||||
if (!synthLitters.TryGetValue(key, out var sl))
|
||||
{
|
||||
sl = new SynthLitter(Guid.NewGuid(), father, mother, dob, conf);
|
||||
synthLitters[key] = sl;
|
||||
derivedLitters++;
|
||||
}
|
||||
synthLitterForGid[p.Gid] = sl.Id;
|
||||
parentLinksAdded++;
|
||||
}
|
||||
|
||||
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
|
||||
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
|
||||
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
|
||||
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
|
||||
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
|
||||
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
|
||||
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
|
||||
foreach (var p in plan) persisted.Add(p.Gid);
|
||||
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
|
||||
foreach (var key in synthLitters.Keys.ToList())
|
||||
{
|
||||
var sl = synthLitters[key];
|
||||
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
|
||||
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
|
||||
if (sl.Father is not null && f is null) litterParentFksDropped++;
|
||||
if (sl.Mother is not null && m is null) litterParentFksDropped++;
|
||||
if (f is null && m is null)
|
||||
{
|
||||
derivedLittersSkipped++; derivedLitters--;
|
||||
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid.Remove(gid);
|
||||
synthLitters.Remove(key);
|
||||
continue;
|
||||
}
|
||||
synthLitters[key] = sl with { Father = f, Mother = m };
|
||||
}
|
||||
parentLinksAdded = synthLitterForGid.Count;
|
||||
|
||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
|
||||
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
|
||||
// existing DB litters under execute.
|
||||
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
||||
foreach (var sl in synthLitters.Values)
|
||||
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
foreach (var sl in litters)
|
||||
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
||||
{
|
||||
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
|
||||
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
var existingLitterRows = await _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
foreach (var g in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid[g] = existingId;
|
||||
derivedLitters--;
|
||||
continue;
|
||||
}
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
FatherId = sl.Father,
|
||||
MotherId = sl.Mother,
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||
}
|
||||
|
||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
|
||||
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
|
||||
static bool ClanCanon(string? zc) =>
|
||||
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = new HashSet<Guid>();
|
||||
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
|
||||
int residentByA = resident.Count, flippedByParentRule = 0;
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (!ClanCanon(p.A.ZuchtCanon)) continue;
|
||||
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
|
||||
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
|
||||
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
|
||||
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
|
||||
}
|
||||
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
|
||||
int externalTotal = plan.Count - residentTotal;
|
||||
|
||||
foreach (var p in plan)
|
||||
{
|
||||
Guid? litterId = p.WurfLitterId
|
||||
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
|
||||
if (litterId is not null) linked++;
|
||||
bool isResident = resident.Contains(p.Gid);
|
||||
|
||||
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
if (samples.Count < 16)
|
||||
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
|
||||
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
|
||||
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
|
||||
|
||||
if (p.Exists)
|
||||
{
|
||||
animalsExisting++;
|
||||
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
|
||||
if (row is not null)
|
||||
{
|
||||
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
|
||||
row.IsResident = isResident;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
animalsCreated++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
ColorVarietyId = p.ColorVarietyId,
|
||||
Genotype = ComposeGenotype(p.A.Genotype),
|
||||
IsDeaf = p.A.Deaf,
|
||||
IsResident = isResident,
|
||||
ImportSource = ImportSourceTag,
|
||||
ExternalRef = p.A.Id,
|
||||
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
|
||||
RawImportData = JsonSerializer.Serialize(new
|
||||
{
|
||||
p.A.Genotype.RawGenotype,
|
||||
p.A.Genotype.UnmappedTokens,
|
||||
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
|
||||
// parser adopts it into the compact Genotype contract; tags + deaf too.
|
||||
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
|
||||
p.A.Tags,
|
||||
p.A.Deaf,
|
||||
p.A.Zucht,
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// photos
|
||||
foreach (var rel in p.A.Photos)
|
||||
{
|
||||
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(src)) { photosMissing++; continue; }
|
||||
photosAttached++;
|
||||
if (execute)
|
||||
{
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
var fileName = $"{Guid.NewGuid():N}{Path.GetExtension(src)}";
|
||||
File.Copy(src, Path.Combine(_photoRoot, fileName), overwrite: true);
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = p.Gid,
|
||||
FileName = fileName,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||
if (execute)
|
||||
{
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
||||
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
||||
if (litter is null) continue;
|
||||
// guard: only link parents that are actually persisted (avoid an orphan FK).
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
|
||||
litter.FatherId = fId;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
|
||||
litter.MotherId = mId;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
if (parentLinksAdded > 0)
|
||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
Executed: execute,
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
{
|
||||
var path = Path.Combine(_sourceDir, file);
|
||||
if (!File.Exists(path)) return default;
|
||||
using var fs = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||
}
|
||||
|
||||
public static string ComposeGenotype(SourceGenotype g)
|
||||
{
|
||||
var tokens = LocusOrder.Select(locus =>
|
||||
{
|
||||
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
|
||||
return StripCaret(pair[0]) + StripCaret(pair[1]);
|
||||
return "??";
|
||||
}).ToList();
|
||||
|
||||
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
|
||||
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
|
||||
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
|
||||
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
|
||||
&& !(sls[0] == "sl" && sls[1] == "sl"))
|
||||
{
|
||||
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
|
||||
}
|
||||
return string.Join(' ', tokens);
|
||||
}
|
||||
|
||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
|
||||
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
|
||||
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
|
||||
|
||||
var n = Normalize(StripZucht(a.Name));
|
||||
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
|
||||
if (isSire && !isDam) return Gender.male;
|
||||
if (isDam && !isSire) return Gender.female;
|
||||
return Gender.unknown;
|
||||
}
|
||||
|
||||
public static DateOnly? ParseDate(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||
if (!m.Success) return null;
|
||||
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||
int y = int.Parse(m.Groups[3].Value);
|
||||
if (y < 100) y += 2000;
|
||||
try { return new DateOnly(y, mo, d); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string StripZucht(string name)
|
||||
{
|
||||
var n = Regex.Replace(name ?? "", @"\[.*?\]", " ");
|
||||
n = Regex.Replace(n, @"\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+.*$", "", RegexOptions.IgnoreCase);
|
||||
return n.Trim();
|
||||
}
|
||||
|
||||
private static string Normalize(string name)
|
||||
{
|
||||
var n = (name ?? "").ToLowerInvariant();
|
||||
n = Regex.Replace(n, @"\bgen\.\b", " ");
|
||||
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
|
||||
private static string NameDobKey(string name, DateOnly? dob) =>
|
||||
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
|
||||
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
|
||||
{
|
||||
var pr = a.ParentRefs.FirstOrDefault(p =>
|
||||
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
|
||||
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
|
||||
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
}
|
||||
}
|
||||
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: AI reply DRAFT for an incoming inquiry (human-in-the-loop — the
|
||||
/// breeder edits and sends herself, NEVER auto-send; send is INBOX-3).
|
||||
///
|
||||
/// PRIVACY / data minimization (INBOX-architecture.md §privacy): the prompt
|
||||
/// carries ONLY the inquiry's own text (signatures/quoted history stripped),
|
||||
/// the sender's display name for the greeting, a short German context, and
|
||||
/// optionally the current ForSale animals (name + Farbschlag). NOT the
|
||||
/// contact DB, NOT other requests, NOT any addresses.
|
||||
/// </summary>
|
||||
public sealed class DraftReplyService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public sealed record ForSaleAnimal(string Name, string? Farbschlag);
|
||||
|
||||
public Task<AiCallResult> DraftAsync(
|
||||
Request request, IReadOnlyList<ForSaleAnimal> forSale, CancellationToken ct = default)
|
||||
=> _client.CompleteAsync(BuildSystemPrompt(), BuildUserPrompt(request, forSale), ct);
|
||||
|
||||
internal static string BuildSystemPrompt() => """
|
||||
Du hilfst einer Hobby-Rennmaus-Züchterin („Zucht der kleinen Chaoten“), eine
|
||||
freundliche deutsche Antwort auf eine Anfrage zu ENTWERFEN. Sie liest den
|
||||
Entwurf, passt ihn an und versendet selbst.
|
||||
|
||||
Regeln:
|
||||
- Ton: warm, persönlich, hilfsbereit — wie eine erfahrene Hobby-Züchterin,
|
||||
nicht wie ein Unternehmen. Anrede per „du“, Gruß mit dem Namen der
|
||||
anfragenden Person, falls bekannt.
|
||||
- KEINE Fakten erfinden: Verfügbarkeit, Tiere und Eigenschaften NUR aus den
|
||||
mitgelieferten Daten. Ist keine Abgabetier-Liste mitgeliefert oder passt
|
||||
nichts, verweise freundlich darauf, dass sie aktuelle Infos persönlich gibt.
|
||||
- Keine Preise/Schutzgebühren nennen und keine Adressen oder sonstige
|
||||
persönliche Daten — solche Details klärt sie selbst im Gespräch.
|
||||
- Gib NUR den Antworttext aus (ohne Betreff, ohne Erklärungen) und beende
|
||||
mit einem herzlichen Gruß ohne Namens-Signatur.
|
||||
""";
|
||||
|
||||
internal static string BuildUserPrompt(Request request, IReadOnlyList<ForSaleAnimal> forSale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Entwirf eine Antwort auf folgende Anfrage:");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(request.FromName))
|
||||
sb.AppendLine($"Von: {request.FromName}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Subject))
|
||||
sb.AppendLine($"Betreff: {request.Subject}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Anfrage-Text:");
|
||||
sb.AppendLine(StripQuotedText(request.BodyText ?? ""));
|
||||
if (forSale.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Aktuell abzugebende Tiere (einzige zulässige Quelle für Verfügbarkeits-Aussagen):");
|
||||
foreach (var a in forSale)
|
||||
{
|
||||
sb.AppendLine(string.IsNullOrWhiteSpace(a.Farbschlag)
|
||||
? $"- {a.Name}"
|
||||
: $"- {a.Name} ({a.Farbschlag})");
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Signatur-/Zitat-Stripping (best effort, bewusst konservativ) ────
|
||||
|
||||
private static readonly Regex QuoteIntro = new(
|
||||
@"^Am .+ schrieb .+:\s*$", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex OutlookHeader = new(
|
||||
@"^(Von|Gesendet|An|Betreff):\s", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Drops quoted history ('>'-lines, the German Gmail "Am … schrieb …:"
|
||||
/// intro, Outlook-style forwarded headers) and everything below a signature
|
||||
/// delimiter ("-- "). Keeps the sender's own words untouched.
|
||||
/// </summary>
|
||||
internal static string StripQuotedText(string body)
|
||||
{
|
||||
var lines = body.Replace("\r\n", "\n").Split('\n');
|
||||
var kept = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.TrimEnd();
|
||||
if (trimmed == "--" || trimmed == "-- ")
|
||||
break; // signature delimiter: everything below is signature
|
||||
if (QuoteIntro.IsMatch(trimmed))
|
||||
break; // quoted history follows
|
||||
if (trimmed.StartsWith('>'))
|
||||
continue;
|
||||
if (OutlookHeader.IsMatch(trimmed))
|
||||
continue;
|
||||
kept.Add(line);
|
||||
}
|
||||
// collapse the whitespace the stripping may have left behind
|
||||
return string.Join("\n", kept).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal file
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Live Gmail reader over IMAP (App Password auth). Reads UIDs newer than the last seen,
|
||||
/// fetches envelope + Gmail thread id + the plain-text body. Gated on Julian's App Password
|
||||
/// (tests use a fake IGmailMailReader).
|
||||
/// </summary>
|
||||
public sealed class GmailMailReader : IGmailMailReader
|
||||
{
|
||||
public async Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default)
|
||||
{
|
||||
using var client = new ImapClient();
|
||||
await client.ConnectAsync("imap.gmail.com", 993, SecureSocketOptions.SslOnConnect, ct);
|
||||
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
|
||||
|
||||
var folder = string.Equals(connection.Folder, "INBOX", StringComparison.OrdinalIgnoreCase)
|
||||
? client.Inbox
|
||||
: await client.GetFolderAsync(connection.Folder, ct);
|
||||
await folder.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
|
||||
var range = new UniqueIdRange(new UniqueId(sinceUid + 1), UniqueId.MaxValue);
|
||||
var uids = await folder.SearchAsync(SearchQuery.Uids(range), ct);
|
||||
|
||||
const MessageSummaryItems items = MessageSummaryItems.Envelope
|
||||
| MessageSummaryItems.UniqueId
|
||||
| MessageSummaryItems.GMailThreadId
|
||||
| MessageSummaryItems.InternalDate;
|
||||
var summaries = await folder.FetchAsync(uids, items, ct);
|
||||
|
||||
var result = new List<MailSummary>();
|
||||
foreach (var s in summaries)
|
||||
{
|
||||
var env = s.Envelope;
|
||||
var msg = await folder.GetMessageAsync(s.UniqueId, ct); // body + threading headers
|
||||
var from = env?.From?.Mailboxes?.FirstOrDefault();
|
||||
result.Add(new MailSummary(
|
||||
MessageId: env?.MessageId ?? msg.MessageId ?? "",
|
||||
ThreadId: s.GMailThreadId?.ToString(),
|
||||
InReplyTo: msg.InReplyTo,
|
||||
References: msg.References is { Count: > 0 } ? string.Join(' ', msg.References) : null,
|
||||
FromAddress: from?.Address ?? "",
|
||||
FromName: string.IsNullOrWhiteSpace(from?.Name) ? null : from!.Name,
|
||||
Subject: env?.Subject ?? msg.Subject,
|
||||
BodyText: msg.TextBody,
|
||||
ReceivedAt: s.InternalDate ?? env?.Date ?? DateTimeOffset.UtcNow,
|
||||
Uid: s.UniqueId.Id));
|
||||
}
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal file
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Live Gmail sender over SMTP (App Password auth, STARTTLS). Builds a correctly-threaded
|
||||
/// reply so it lands in the same Gmail conversation. Gated on Julian's App Password
|
||||
/// (tests use a fake IGmailMailSender + assert BuildMimeMessage).
|
||||
/// </summary>
|
||||
public sealed class GmailMailSender : IGmailMailSender
|
||||
{
|
||||
public async Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default)
|
||||
{
|
||||
var message = BuildMimeMessage(connection.GmailAddress, mail);
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls, ct);
|
||||
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
/// <summary>Builds the reply MimeMessage with In-Reply-To / References / Subject set for
|
||||
/// Gmail threading. Public so the threading headers are unit-testable without SMTP.</summary>
|
||||
public static MimeMessage BuildMimeMessage(string fromAddress, OutgoingMail mail)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(fromAddress));
|
||||
message.To.Add(MailboxAddress.Parse(mail.To));
|
||||
message.Subject = mail.Subject;
|
||||
message.Body = new TextPart("plain") { Text = mail.Body };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mail.InReplyTo))
|
||||
message.InReplyTo = mail.InReplyTo;
|
||||
if (!string.IsNullOrWhiteSpace(mail.References))
|
||||
foreach (var id in mail.References.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
message.References.Add(id);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal file
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>Connection params for an IMAP fetch (decrypted password — never persisted/logged).</summary>
|
||||
public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder);
|
||||
|
||||
/// <summary>A fetched email reduced to what the Request entity needs.</summary>
|
||||
public sealed record MailSummary(
|
||||
string MessageId,
|
||||
string? ThreadId,
|
||||
string? InReplyTo,
|
||||
string? References,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
DateTimeOffset ReceivedAt,
|
||||
uint Uid);
|
||||
|
||||
/// <summary>Reads mail from a folder. Abstracted so tests feed canned summaries
|
||||
/// (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
|
||||
public interface IGmailMailReader
|
||||
{
|
||||
/// <summary>Fetch messages with UID > <paramref name="sinceUid"/> from the configured folder.</summary>
|
||||
Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a sync run.</summary>
|
||||
public sealed record SyncResult(int Imported, string? Error);
|
||||
}
|
||||
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal file
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>A reply to send, with RFC threading headers already computed.</summary>
|
||||
public sealed record OutgoingMail(
|
||||
string To,
|
||||
string Subject,
|
||||
string Body,
|
||||
string? InReplyTo,
|
||||
string? References);
|
||||
|
||||
/// <summary>Outcome of a send attempt. Error is null on success; otherwise a stable code
|
||||
/// ("MailNotConfigured", "MailAuthFailed") the UI maps to a German hint.</summary>
|
||||
public sealed record SendOutcome(bool Sent, string? Error)
|
||||
{
|
||||
public static readonly SendOutcome Ok = new(true, null);
|
||||
public static SendOutcome Fail(string code) => new(false, code);
|
||||
}
|
||||
|
||||
/// <summary>Sends mail. Abstracted so tests can assert the message/capture it without a live
|
||||
/// SMTP server (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
|
||||
public interface IGmailMailSender
|
||||
{
|
||||
/// <summary>Sends the reply. Throws <see cref="MailKit.Security.AuthenticationException"/>
|
||||
/// on bad/revoked credentials (surfaced as "MailAuthFailed").</summary>
|
||||
Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal file
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads/updates the singleton MailSettings. The Gmail App Password is encrypted at rest
|
||||
/// with ASP.NET Data Protection and is NEVER returned over the wire (only used server-side
|
||||
/// to open the IMAP/SMTP connection).
|
||||
/// </summary>
|
||||
public sealed class MailSettingsService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public MailSettingsService(ApplicationContext db, IDataProtectionProvider dp)
|
||||
{
|
||||
_db = db;
|
||||
_protector = dp.CreateProtector("GerbilManager.MailSettings.AppPassword.v1");
|
||||
}
|
||||
|
||||
public async Task<MailSettings> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var s = await _db.MailSettings.FirstOrDefaultAsync(ct);
|
||||
if (s is null)
|
||||
{
|
||||
s = new MailSettings { Id = MailSettings.SingletonId };
|
||||
_db.MailSettings.Add(s);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public bool HasAppPassword(MailSettings s) => !string.IsNullOrEmpty(s.AppPasswordProtected);
|
||||
|
||||
public string? DecryptPassword(MailSettings s) =>
|
||||
string.IsNullOrEmpty(s.AppPasswordProtected) ? null : _protector.Unprotect(s.AppPasswordProtected);
|
||||
|
||||
public void SetPassword(MailSettings s, string? plain) =>
|
||||
s.AppPasswordProtected = string.IsNullOrEmpty(plain) ? null : _protector.Protect(plain);
|
||||
}
|
||||
}
|
||||
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal file
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP
|
||||
/// UID to avoid rescans. The actual fetch is behind <see cref="IGmailMailReader"/> (mockable).
|
||||
/// </summary>
|
||||
public sealed class RequestSyncService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IGmailMailReader _reader;
|
||||
private readonly MailSettingsService _settings;
|
||||
|
||||
public RequestSyncService(ApplicationContext db, IGmailMailReader reader, MailSettingsService settings)
|
||||
{
|
||||
_db = db;
|
||||
_reader = reader;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncAsync(CancellationToken ct = default)
|
||||
{
|
||||
var settings = await _settings.GetAsync(ct);
|
||||
var password = _settings.DecryptPassword(settings);
|
||||
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
|
||||
return new SyncResult(0, "MailNotConfigured");
|
||||
|
||||
var conn = new MailConnection(settings.GmailAddress!, password!, settings.Folder);
|
||||
var summaries = await _reader.FetchAsync(conn, settings.LastUid, ct);
|
||||
|
||||
uint maxUid = settings.LastUid;
|
||||
int imported = 0;
|
||||
if (summaries.Count > 0)
|
||||
{
|
||||
var fetchedIds = summaries.Select(s => s.MessageId).Where(id => !string.IsNullOrEmpty(id)).ToList();
|
||||
var existing = (await _db.Requests
|
||||
.Where(r => fetchedIds.Contains(r.GmailMessageId))
|
||||
.Select(r => r.GmailMessageId).ToListAsync(ct)).ToHashSet();
|
||||
|
||||
foreach (var m in summaries)
|
||||
{
|
||||
if (m.Uid > maxUid) maxUid = m.Uid;
|
||||
if (string.IsNullOrEmpty(m.MessageId) || !existing.Add(m.MessageId)) continue;
|
||||
_db.Requests.Add(new Request
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = m.MessageId,
|
||||
ThreadId = m.ThreadId,
|
||||
InReplyToMessageId = m.InReplyTo,
|
||||
ReferencesHeader = m.References,
|
||||
FromAddress = m.FromAddress,
|
||||
FromName = m.FromName,
|
||||
Subject = m.Subject,
|
||||
BodyText = m.BodyText,
|
||||
ReceivedAt = m.ReceivedAt,
|
||||
Status = RequestStatus.New,
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
settings.LastUid = maxUid;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new SyncResult(imported, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal file
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a (human-edited) reply to a Request via SMTP, threaded into the original Gmail
|
||||
/// conversation, and marks the request Answered. Only ever called on the explicit
|
||||
/// POST /api/requests/{id}/send — never auto-sends.
|
||||
/// </summary>
|
||||
public sealed class SendReplyService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IGmailMailSender _sender;
|
||||
private readonly MailSettingsService _settings;
|
||||
|
||||
public SendReplyService(ApplicationContext db, IGmailMailSender sender, MailSettingsService settings)
|
||||
{
|
||||
_db = db;
|
||||
_sender = sender;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task<SendOutcome> SendAsync(Request request, string body, CancellationToken ct = default)
|
||||
{
|
||||
var settings = await _settings.GetAsync(ct);
|
||||
var password = _settings.DecryptPassword(settings);
|
||||
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
|
||||
return SendOutcome.Fail("MailNotConfigured");
|
||||
|
||||
var mail = new OutgoingMail(
|
||||
To: request.FromAddress,
|
||||
Subject: ReplySubject(request.Subject),
|
||||
Body: body,
|
||||
InReplyTo: request.GmailMessageId,
|
||||
References: BuildReferences(request.ReferencesHeader, request.GmailMessageId));
|
||||
|
||||
try
|
||||
{
|
||||
await _sender.SendAsync(new MailConnection(settings.GmailAddress!, password!, settings.Folder), mail, ct);
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
return SendOutcome.Fail("MailAuthFailed");
|
||||
}
|
||||
|
||||
request.Status = RequestStatus.Answered;
|
||||
request.AnsweredAt = DateTimeOffset.UtcNow;
|
||||
request.DraftReply = body; // record what was actually sent
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return SendOutcome.Ok;
|
||||
}
|
||||
|
||||
/// <summary>"Re: " prefix without double-prefixing an existing reply subject.</summary>
|
||||
internal static string ReplySubject(string? original)
|
||||
{
|
||||
var s = (original ?? "").Trim();
|
||||
if (s.Length == 0) return "Re:";
|
||||
return s.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? s : $"Re: {s}";
|
||||
}
|
||||
|
||||
/// <summary>References = existing chain + the original Message-Id (deduped, in order).</summary>
|
||||
internal static string BuildReferences(string? existingChain, string originalMessageId)
|
||||
{
|
||||
var ids = (existingChain ?? "")
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(originalMessageId) && !ids.Contains(originalMessageId))
|
||||
ids.Add(originalMessageId);
|
||||
return string.Join(' ', ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationContext))]
|
||||
[Migration("20231023182219_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "7.0.12")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Breeder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Breeders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("BreederId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Gender")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BreederId");
|
||||
|
||||
b.HasIndex("LitterId");
|
||||
|
||||
b.ToTable("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Strength")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
b.ToTable("Litters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Breeder", "Breeder")
|
||||
.WithMany()
|
||||
.HasForeignKey("BreederId");
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
|
||||
.WithMany()
|
||||
.HasForeignKey("LitterId");
|
||||
|
||||
b.Navigation("Breeder");
|
||||
|
||||
b.Navigation("Litter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
|
||||
.WithMany()
|
||||
.HasForeignKey("FatherId");
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
|
||||
.WithMany()
|
||||
.HasForeignKey("MotherId");
|
||||
|
||||
b.Navigation("Father");
|
||||
|
||||
b.Navigation("Mother");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Breeders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Breeders", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Gerbils",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Gender = table.Column<int>(type: "nvarchar(max)", nullable: false),
|
||||
LitterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
BreederId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Gerbils", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Gerbils_Breeders_BreederId",
|
||||
column: x => x.BreederId,
|
||||
principalTable: "Breeders",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Litters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Strength = table.Column<int>(type: "int", nullable: false),
|
||||
FatherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
MotherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Litters", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Litters_Gerbils_FatherId",
|
||||
column: x => x.FatherId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_Litters_Gerbils_MotherId",
|
||||
column: x => x.MotherId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_BreederId",
|
||||
table: "Gerbils",
|
||||
column: "BreederId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_LitterId",
|
||||
table: "Gerbils",
|
||||
column: "LitterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_FatherId",
|
||||
table: "Litters",
|
||||
column: "FatherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_MotherId",
|
||||
table: "Litters",
|
||||
column: "MotherId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Gerbils_Litters_LitterId",
|
||||
table: "Gerbils",
|
||||
column: "LitterId",
|
||||
principalTable: "Litters",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Gerbils_Breeders_BreederId",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Gerbils_Litters_LitterId",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Breeders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Litters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
891
GerbilManagerWebAPI/Migrations/20260605230614_InitialCreate.Designer.cs
generated
Normal file
891
GerbilManagerWebAPI/Migrations/20260605230614_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,891 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationContext))]
|
||||
[Migration("20260605230614_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CanonicalGenotype")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ColorVarieties");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||
Name = "Pink Eyed White (PEW)",
|
||||
SortOrder = 0
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000002"),
|
||||
CanonicalGenotype = "aa chch DD EE GG PP spsp rere",
|
||||
Name = "Hermelin",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000003"),
|
||||
CanonicalGenotype = "AA chch DD EE GG PP spsp rere",
|
||||
Name = "Himalaya",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000004"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Zobel",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schwarzschimmel",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Rotaugenschimmel",
|
||||
SortOrder = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
|
||||
Name = "Agouti",
|
||||
SortOrder = 6
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
|
||||
Name = "Schwarz",
|
||||
SortOrder = 7
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti",
|
||||
SortOrder = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
|
||||
Name = "Anthrazit",
|
||||
SortOrder = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs",
|
||||
SortOrder = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
|
||||
Name = "Blau",
|
||||
SortOrder = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold",
|
||||
SortOrder = 12
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin",
|
||||
SortOrder = 13
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs",
|
||||
SortOrder = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs",
|
||||
SortOrder = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "dd Gold",
|
||||
SortOrder = 16
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||
Name = "dd Platin",
|
||||
SortOrder = 17
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
|
||||
Name = "Altweiss (REW)",
|
||||
SortOrder = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
|
||||
Name = "Apricot (Blassfuchs)",
|
||||
SortOrder = 19
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs",
|
||||
SortOrder = 20
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
|
||||
Name = "C-Separator",
|
||||
SortOrder = 21
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
|
||||
Name = "Elfenbein",
|
||||
SortOrder = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs",
|
||||
SortOrder = 23
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
SortOrder = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell)",
|
||||
SortOrder = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs",
|
||||
SortOrder = 26
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
SortOrder = 27
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schimmel (Orangeschimmel)",
|
||||
SortOrder = 28
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Topas",
|
||||
SortOrder = 29
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin-Hell",
|
||||
SortOrder = 30
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd",
|
||||
SortOrder = 31
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd",
|
||||
SortOrder = 32
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs dd",
|
||||
SortOrder = 33
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||
Name = "Anthrazit dd",
|
||||
SortOrder = 34
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP-Hell",
|
||||
SortOrder = 35
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs CP",
|
||||
SortOrder = 36
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs-Hell CP",
|
||||
SortOrder = 40
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 41
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 42
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
SortOrder = 43
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs, hell",
|
||||
SortOrder = 44
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 45
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
SortOrder = 46
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell) dd",
|
||||
SortOrder = 47
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Marder dd",
|
||||
SortOrder = 48
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell",
|
||||
SortOrder = 49
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd CP",
|
||||
SortOrder = 50
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti-Hell dd CP",
|
||||
SortOrder = 51
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd CP",
|
||||
SortOrder = 52
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti-Hell dd CP",
|
||||
SortOrder = 53
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
SortOrder = 54
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 55
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
SortOrder = 56
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 57
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, hell",
|
||||
SortOrder = 58
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel dd",
|
||||
SortOrder = 59
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
SortOrder = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs CP",
|
||||
SortOrder = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs CP",
|
||||
SortOrder = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti CP",
|
||||
SortOrder = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP",
|
||||
SortOrder = 64
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs-Hell CP",
|
||||
SortOrder = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs,hell CP",
|
||||
SortOrder = 66
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs CP",
|
||||
SortOrder = 67
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 68
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 69
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell dd",
|
||||
SortOrder = 70
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel CP",
|
||||
SortOrder = 71
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 72
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Enclosures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CauseOfDeath")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ColorVarietyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly?>("DateOfDeath")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid?>("EnclosureId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ExternalRef")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Genotype")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly?>("GoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ImportSource")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ReceiverContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ColorVarietyId");
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.HasIndex("LitterId");
|
||||
|
||||
b.HasIndex("OriginContactId");
|
||||
|
||||
b.HasIndex("ReceiverContactId");
|
||||
|
||||
b.ToTable("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("GerbilPhotos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Veterinarian")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("HealthRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
b.ToTable("Litters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WeightGrams")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("WeightRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||
.WithMany()
|
||||
.HasForeignKey("ColorVarietyId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure")
|
||||
.WithMany("Gerbils")
|
||||
.HasForeignKey("EnclosureId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
|
||||
.WithMany()
|
||||
.HasForeignKey("LitterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact")
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginContactId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReceiverContactId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ColorVariety");
|
||||
|
||||
b.Navigation("Enclosure");
|
||||
|
||||
b.Navigation("Litter");
|
||||
|
||||
b.Navigation("OriginContact");
|
||||
|
||||
b.Navigation("ReceiverContact");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
|
||||
.WithMany()
|
||||
.HasForeignKey("FatherId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
|
||||
.WithMany()
|
||||
.HasForeignKey("MotherId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Father");
|
||||
|
||||
b.Navigation("Mother");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
|
||||
{
|
||||
b.Navigation("Gerbils");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
383
GerbilManagerWebAPI/Migrations/20260605230614_InitialCreate.cs
Normal file
383
GerbilManagerWebAPI/Migrations/20260605230614_InitialCreate.cs
Normal file
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ColorVarieties",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
CanonicalGenotype = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ColorVarieties", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Contacts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Email = table.Column<string>(type: "text", nullable: true),
|
||||
Phone = table.Column<string>(type: "text", nullable: true),
|
||||
Address = table.Column<string>(type: "text", nullable: true),
|
||||
Notes = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Contacts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Enclosures",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Enclosures", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GerbilPhotos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Caption = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GerbilPhotos", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Gerbils",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Gender = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
OriginContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ReceiverContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
EnclosureId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ColorVarietyId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
DateOfDeath = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
CauseOfDeath = table.Column<string>(type: "text", nullable: true),
|
||||
GoHomeDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Genotype = table.Column<string>(type: "text", nullable: true),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
ImportSource = table.Column<string>(type: "text", nullable: true),
|
||||
ExternalRef = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Gerbils", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Gerbils_ColorVarieties_ColorVarietyId",
|
||||
column: x => x.ColorVarietyId,
|
||||
principalTable: "ColorVarieties",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Gerbils_Contacts_OriginContactId",
|
||||
column: x => x.OriginContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Gerbils_Contacts_ReceiverContactId",
|
||||
column: x => x.ReceiverContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Gerbils_Enclosures_EnclosureId",
|
||||
column: x => x.EnclosureId,
|
||||
principalTable: "Enclosures",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
Type = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
Veterinarian = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthRecords_Gerbils_GerbilId",
|
||||
column: x => x.GerbilId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Litters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
TotalBorn = table.Column<int>(type: "integer", nullable: true),
|
||||
FatherId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
MotherId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ExpectedGoHomeDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Notes = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Litters", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Litters_Gerbils_FatherId",
|
||||
column: x => x.FatherId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Litters_Gerbils_MotherId",
|
||||
column: x => x.MotherId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WeightRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
WeightGrams = table.Column<int>(type: "integer", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WeightRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WeightRecords_Gerbils_GerbilId",
|
||||
column: x => x.GerbilId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ColorVarieties",
|
||||
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("00000000-0000-0000-0000-000000000001"), "AA chch DD EE GG pp spsp rere", "Pink Eyed White (PEW)", 0 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000002"), "aa chch DD EE GG PP spsp rere", "Hermelin", 1 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000003"), "AA chch DD EE GG PP spsp rere", "Himalaya", 2 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000004"), "aa cchmcchm DD EE gg PP spsp rere", "Zobel", 3 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000005"), "AA CC DD efef GG PP spsp rere", "Schwarzschimmel", 4 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000006"), "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel", 5 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000007"), "AA CC DD EE GG PP spsp rere", "Agouti", 6 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000008"), "aa CC DD EE GG PP spsp rere", "Schwarz", 7 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000009"), "AA CC DD EE gg PP spsp rere", "Silberagouti", 8 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000010"), "aa CC DD EE gg PP spsp rere", "Anthrazit", 9 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000011"), "AA CC DD ee GG PP spsp rere", "Algierfuchs", 10 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000012"), "aa CC dd EE GG PP spsp rere", "Blau", 11 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000013"), "AA CC DD EE GG pp spsp rere", "Gold", 12 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000014"), "aa CC DD EE GG pp spsp rere", "Platin", 13 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000015"), "AA CC DD ee GG pp spsp rere", "Goldfuchs", 14 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000016"), "aa CC DD ee GG pp spsp rere", "Rotfuchs", 15 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000017"), "AA CC dd EE GG pp spsp rere", "dd Gold", 16 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000018"), "aa CC dd EE GG pp spsp rere", "dd Platin", 17 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000019"), "aa CC DD EE gg pp spsp rere", "Altweiss (REW)", 18 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000020"), "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)", 19 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000021"), "aa CC DD ee gg PP spsp rere", "Blaufuchs", 20 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000022"), "aa CC DD ee gg pp spsp rere", "C-Separator", 21 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000023"), "AA CC DD EE gg pp spsp rere", "Elfenbein", 22 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000024"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs", 23 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000025"), "aa cchmcchm DD EE GG PP spsp rere", "Marder", 24 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000026"), "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)", 25 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000027"), "AA CC DD ee gg PP spsp rere", "Polarfuchs", 26 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000028"), "aa CC DD EE GG pp spsp rere", "Saphir", 27 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000029"), "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)", 28 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000030"), "AA CC DD EE GG pp spsp rere", "Topas", 29 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000031"), "aa CC DD EE GG pp spsp rere", "Platin-Hell", 30 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000032"), "AA CC dd EE GG PP spsp rere", "Agouti dd", 31 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000033"), "AA CC dd EE gg PP spsp rere", "Silberagouti dd", 32 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000034"), "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd", 33 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000035"), "aa CC dd EE gg PP spsp rere", "Anthrazit dd", 34 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000036"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell", 35 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000037"), "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP", 36 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000038"), "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel", 37 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000039"), "AA CC DD efef gg PP spsp rere", "Silberschimmel", 38 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000040"), "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel", 39 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000041"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP", 40 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000042"), "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel", 41 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000043"), "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel", 42 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000044"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell", 43 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000045"), "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell", 44 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000046"), "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel", 45 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000047"), "AA CC DD EE GG pp spsp rere", "Gold-Hell", 46 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000048"), "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd", 47 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000049"), "aa cchmcchm dd EE GG PP spsp rere", "Marder dd", 48 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000050"), "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell", 49 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000051"), "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP", 50 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000052"), "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP", 51 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000053"), "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP", 52 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000054"), "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP", 53 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000055"), "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell", 54 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000056"), "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel", 55 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000057"), "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell", 56 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000058"), "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell", 57 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000059"), "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell", 58 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000060"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd", 59 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000061"), "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell", 60 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000062"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP", 61 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 62 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 63 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 64 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 65 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000067"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 66 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 67 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 68 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd EE GG pp spsp rere", "Topas dd", 69 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 70 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 71 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000073"), "aa CC dd ee gg PP spsp rere", "Blaufuchs dd", 72 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GerbilPhotos_GerbilId",
|
||||
table: "GerbilPhotos",
|
||||
column: "GerbilId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_ColorVarietyId",
|
||||
table: "Gerbils",
|
||||
column: "ColorVarietyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_EnclosureId",
|
||||
table: "Gerbils",
|
||||
column: "EnclosureId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_LitterId",
|
||||
table: "Gerbils",
|
||||
column: "LitterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_OriginContactId",
|
||||
table: "Gerbils",
|
||||
column: "OriginContactId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_ReceiverContactId",
|
||||
table: "Gerbils",
|
||||
column: "ReceiverContactId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_GerbilId",
|
||||
table: "HealthRecords",
|
||||
column: "GerbilId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_FatherId",
|
||||
table: "Litters",
|
||||
column: "FatherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_MotherId",
|
||||
table: "Litters",
|
||||
column: "MotherId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WeightRecords_GerbilId",
|
||||
table: "WeightRecords",
|
||||
column: "GerbilId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GerbilPhotos_Gerbils_GerbilId",
|
||||
table: "GerbilPhotos",
|
||||
column: "GerbilId",
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Gerbils_Litters_LitterId",
|
||||
table: "Gerbils",
|
||||
column: "LitterId",
|
||||
principalTable: "Litters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Litters_Gerbils_FatherId",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Litters_Gerbils_MotherId",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "GerbilPhotos");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WeightRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Gerbils");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ColorVarieties");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Contacts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Enclosures");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
1024
GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SaleContractsAndBreederSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BreederSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ZuchtName = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Address = table.Column<string>(type: "text", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Email = table.Column<string>(type: "text", nullable: false),
|
||||
Homepage = table.Column<string>(type: "text", nullable: false),
|
||||
City = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BreederSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SaleContracts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
|
||||
HandoverDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ContractDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SaleContracts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContracts_Contacts_ContactId",
|
||||
column: x => x.ContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SaleContractAnimal",
|
||||
columns: table => new
|
||||
{
|
||||
SaleContractId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SaleContractAnimal", x => new { x.SaleContractId, x.GerbilId });
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContractAnimal_Gerbils_GerbilId",
|
||||
column: x => x.GerbilId,
|
||||
principalTable: "Gerbils",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SaleContractAnimal_SaleContracts_SaleContractId",
|
||||
column: x => x.SaleContractId,
|
||||
principalTable: "SaleContracts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "BreederSettings",
|
||||
columns: new[] { "Id", "Address", "City", "Email", "Homepage", "Name", "Phone", "ZuchtName" },
|
||||
values: new object[] { new Guid("11111111-1111-1111-1111-000000000001"), "", "", "", "", "", "", "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SaleContractAnimal_GerbilId",
|
||||
table: "SaleContractAnimal",
|
||||
column: "GerbilId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SaleContracts_ContactId",
|
||||
table: "SaleContracts",
|
||||
column: "ContactId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BreederSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SaleContractAnimal");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SaleContracts");
|
||||
}
|
||||
}
|
||||
}
|
||||
897
GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs
generated
Normal file
897
GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs
generated
Normal file
@@ -0,0 +1,897 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationContext))]
|
||||
[Migration("20260606052952_AddImportProvenanceFields")]
|
||||
partial class AddImportProvenanceFields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CanonicalGenotype")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ColorVarieties");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||
Name = "Pink Eyed White (PEW)",
|
||||
SortOrder = 0
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000002"),
|
||||
CanonicalGenotype = "aa chch DD EE GG PP spsp rere",
|
||||
Name = "Hermelin",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000003"),
|
||||
CanonicalGenotype = "AA chch DD EE GG PP spsp rere",
|
||||
Name = "Himalaya",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000004"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Zobel",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schwarzschimmel",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Rotaugenschimmel",
|
||||
SortOrder = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
|
||||
Name = "Agouti",
|
||||
SortOrder = 6
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
|
||||
Name = "Schwarz",
|
||||
SortOrder = 7
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti",
|
||||
SortOrder = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
|
||||
Name = "Anthrazit",
|
||||
SortOrder = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs",
|
||||
SortOrder = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
|
||||
Name = "Blau",
|
||||
SortOrder = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold",
|
||||
SortOrder = 12
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin",
|
||||
SortOrder = 13
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs",
|
||||
SortOrder = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs",
|
||||
SortOrder = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "dd Gold",
|
||||
SortOrder = 16
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||
Name = "dd Platin",
|
||||
SortOrder = 17
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
|
||||
Name = "Altweiss (REW)",
|
||||
SortOrder = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
|
||||
Name = "Apricot (Blassfuchs)",
|
||||
SortOrder = 19
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs",
|
||||
SortOrder = 20
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
|
||||
Name = "C-Separator",
|
||||
SortOrder = 21
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
|
||||
Name = "Elfenbein",
|
||||
SortOrder = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs",
|
||||
SortOrder = 23
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
SortOrder = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell)",
|
||||
SortOrder = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs",
|
||||
SortOrder = 26
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
SortOrder = 27
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schimmel (Orangeschimmel)",
|
||||
SortOrder = 28
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Topas",
|
||||
SortOrder = 29
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin-Hell",
|
||||
SortOrder = 30
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd",
|
||||
SortOrder = 31
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd",
|
||||
SortOrder = 32
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs dd",
|
||||
SortOrder = 33
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||
Name = "Anthrazit dd",
|
||||
SortOrder = 34
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP-Hell",
|
||||
SortOrder = 35
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs CP",
|
||||
SortOrder = 36
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs-Hell CP",
|
||||
SortOrder = 40
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 41
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 42
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
SortOrder = 43
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs, hell",
|
||||
SortOrder = 44
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 45
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
SortOrder = 46
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell) dd",
|
||||
SortOrder = 47
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Marder dd",
|
||||
SortOrder = 48
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell",
|
||||
SortOrder = 49
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd CP",
|
||||
SortOrder = 50
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti-Hell dd CP",
|
||||
SortOrder = 51
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd CP",
|
||||
SortOrder = 52
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti-Hell dd CP",
|
||||
SortOrder = 53
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
SortOrder = 54
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 55
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
SortOrder = 56
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 57
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, hell",
|
||||
SortOrder = 58
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel dd",
|
||||
SortOrder = 59
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
SortOrder = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs CP",
|
||||
SortOrder = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs CP",
|
||||
SortOrder = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti CP",
|
||||
SortOrder = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP",
|
||||
SortOrder = 64
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs-Hell CP",
|
||||
SortOrder = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs,hell CP",
|
||||
SortOrder = 66
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs CP",
|
||||
SortOrder = 67
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 68
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 69
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell dd",
|
||||
SortOrder = 70
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel CP",
|
||||
SortOrder = 71
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 72
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Enclosures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CauseOfDeath")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ColorVarietyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly?>("DateOfDeath")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid?>("EnclosureId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ExternalRef")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Genotype")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly?>("GoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ImportSource")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RawImportData")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ReceiverContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ColorVarietyId");
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.HasIndex("LitterId");
|
||||
|
||||
b.HasIndex("OriginContactId");
|
||||
|
||||
b.HasIndex("ReceiverContactId");
|
||||
|
||||
b.ToTable("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("GerbilPhotos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Veterinarian")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("HealthRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PairingCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
b.ToTable("Litters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WeightGrams")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
|
||||
b.ToTable("WeightRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||
.WithMany()
|
||||
.HasForeignKey("ColorVarietyId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure")
|
||||
.WithMany("Gerbils")
|
||||
.HasForeignKey("EnclosureId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
|
||||
.WithMany()
|
||||
.HasForeignKey("LitterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact")
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginContactId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReceiverContactId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ColorVariety");
|
||||
|
||||
b.Navigation("Enclosure");
|
||||
|
||||
b.Navigation("Litter");
|
||||
|
||||
b.Navigation("OriginContact");
|
||||
|
||||
b.Navigation("ReceiverContact");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
|
||||
.WithMany()
|
||||
.HasForeignKey("FatherId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
|
||||
.WithMany()
|
||||
.HasForeignKey("MotherId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Father");
|
||||
|
||||
b.Navigation("Mother");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GerbilId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
|
||||
{
|
||||
b.Navigation("Gerbils");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddImportProvenanceFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PairingCode",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RawImportData",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PairingCode",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RawImportData",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1036
GerbilManagerWebAPI/Migrations/20260606070015_AddSearchFields.Designer.cs
generated
Normal file
1036
GerbilManagerWebAPI/Migrations/20260606070015_AddSearchFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user