Compare commits
153 Commits
e4b7558abd
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 97acaf6b10 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| db85a6e0dc | |||
| 0c94cfcbf1 | |||
| 5eadd89bb6 | |||
| f9a68deb7a | |||
| a693095886 | |||
| d5544412bd | |||
| d93e8d1586 | |||
| 114bbd92c8 | |||
| a8d8ae0dfc | |||
| 522f2eec51 | |||
| 3f71d8e28e | |||
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a | |||
| 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 |
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 }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -131,3 +131,6 @@ $RECYCLE.BIN/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
@@ -108,19 +108,13 @@ public static class Extensions
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
// 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
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
87
GerbilManager.Tests/CmsTests.cs
Normal file
87
GerbilManager.Tests/CmsTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
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_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();
|
||||
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||
|
||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
||||
Assert.Equal(8, pages.Count);
|
||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
<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" />
|
||||
|
||||
551
GerbilManager.Tests/ImportServiceTests.cs
Normal file
551
GerbilManager.Tests/ImportServiceTests.cs
Normal file
@@ -0,0 +1,551 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||
{
|
||||
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: Vater is in conflict -> not loaded
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||
|
||||
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||
var animals2 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||
{
|
||||
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||
//
|
||||
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||
// Run 2: sire loaded (new animal).
|
||||
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||
// Backfill must use allDbNormToGid to find him.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: sire quarantined
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""");
|
||||
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);
|
||||
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||
|
||||
// Run 2: sire now loaded
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||
// For the test we care about run 3.
|
||||
|
||||
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||
// by manually resetting FatherId to null so run 3 must fix it.
|
||||
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
litter3.FatherId = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||
{
|
||||
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
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); // run 1
|
||||
|
||||
// Run 2 dry-run with papi un-quarantined
|
||||
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
|
||||
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||
Assert.Null(litter.FatherId); // not written in dry-run
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
410
GerbilManager.Tests/SiteRendererTests.cs
Normal file
410
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,410 @@
|
||||
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, string? gender, string? dob, string? note)[] animals)
|
||||
{
|
||||
var animalArr = new JsonArray();
|
||||
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
||||
{
|
||||
var photos = new JsonArray();
|
||||
if (photo is not null) photos.Add(photo);
|
||||
animalArr.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["farbschlag"] = farbe,
|
||||
["group"] = group,
|
||||
["gender"] = gender,
|
||||
["dateOfBirth"] = dob,
|
||||
["characterNote"] = note,
|
||||
["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_group_card_with_name_farbschlag_and_photo()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
||||
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-group-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
||||
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Weibchen", html);
|
||||
Assert.Contains("Verfügbar", html);
|
||||
Assert.Contains("cms-badge--free", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("März 2024", html);
|
||||
Assert.Contains("Sehr neugierig und verspielt.", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Einzelkind", html);
|
||||
Assert.Contains("Männchen", html);
|
||||
Assert.Contains("cms-group-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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
||||
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("impressum/index.html"));
|
||||
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
||||
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_contains_impressum_and_datenschutz_links()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("impressum/index.html", startHtml);
|
||||
Assert.Contains("datenschutz/index.html", startHtml);
|
||||
// links in footer, not nav
|
||||
Assert.Contains("site-footer-link", startHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_links_use_correct_relative_path_from_subpage()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
// subpage needs ../ prefix for footer links
|
||||
Assert.Contains("../impressum/index.html", kontaktHtml);
|
||||
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,37 @@ public class ApplicationContext : DbContext
|
||||
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)
|
||||
{
|
||||
@@ -24,6 +55,22 @@ public class ApplicationContext : DbContext
|
||||
e.Property(g => g.Gender).HasConversion<string>();
|
||||
e.Property(g => g.Status).HasConversion<string>();
|
||||
|
||||
// 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()
|
||||
@@ -59,9 +106,138 @@ public class ApplicationContext : DbContext
|
||||
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"),
|
||||
};
|
||||
|
||||
// WEB-6a: footer-only legal pages (not in main nav)
|
||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||
{
|
||||
(7, "impressum", "Impressum",
|
||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
(8, "datenschutz", "Datenschutz",
|
||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
};
|
||||
|
||||
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\":\"\"}",
|
||||
});
|
||||
foreach (var lp in legalPages)
|
||||
{
|
||||
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
||||
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
||||
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
||||
}
|
||||
|
||||
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
|
||||
@@ -76,7 +252,6 @@ public class ApplicationContext : DbContext
|
||||
("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"),
|
||||
("Schwarzschimmel", "AA CC DD efef 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"),
|
||||
@@ -96,55 +271,44 @@ public class ApplicationContext : DbContext
|
||||
("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"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||
("Schimmel (Orangeschimmel)", "AA CC DD efef 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"),
|
||||
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef 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"),
|
||||
("Polarfuchs-Hell CP", "AA cchmcchm DD ee 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"),
|
||||
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
||||
("Agouti-Hell dd CP", "AA cchmcchm 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"),
|
||||
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Polarfuchs CP", "AA cchmcchm 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"),
|
||||
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
|
||||
("Blaufuchs 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];
|
||||
|
||||
667
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
667
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
@@ -0,0 +1,667 @@
|
||||
// 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 ?? [])
|
||||
.OfType<JsonObject>().ToList();
|
||||
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");
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Group by Becken name; null group → solo card keyed by animal name
|
||||
var groups = animals
|
||||
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
||||
.ToList();
|
||||
|
||||
sb.Append(" <div class=\"cms-group-list\">\n");
|
||||
foreach (var grp in groups)
|
||||
{
|
||||
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
||||
var groupName = isSolo ? null : grp.Key;
|
||||
var memberList = grp.ToList();
|
||||
|
||||
// Determine group gender label
|
||||
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
||||
string genderLabel = genders.Count == 1
|
||||
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
||||
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
||||
|
||||
sb.Append(" <article class=\"cms-group-card\">\n");
|
||||
sb.Append(" <div class=\"cms-group-header\">\n");
|
||||
if (!string.IsNullOrEmpty(groupName))
|
||||
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
||||
var metaParts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
||||
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
||||
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
||||
sb.Append(" </div>\n");
|
||||
|
||||
// Gallery: first photo of each animal in the group
|
||||
var gallery = memberList
|
||||
.Select(a => (
|
||||
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
||||
name: Str(a["name"]) ?? ""))
|
||||
.Where(x => !string.IsNullOrEmpty(x.url))
|
||||
.ToList();
|
||||
if (gallery.Count > 0)
|
||||
{
|
||||
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
||||
foreach (var (url, name) in gallery)
|
||||
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
||||
sb.Append(" </div>\n");
|
||||
}
|
||||
|
||||
// Per-animal detail rows
|
||||
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
||||
foreach (var a in memberList)
|
||||
{
|
||||
var name = H(Str(a["name"]) ?? "");
|
||||
var farbe = Str(a["farbschlag"]);
|
||||
var dob = Str(a["dateOfBirth"]);
|
||||
var note = Str(a["characterNote"]);
|
||||
var saleText = Str(a["aiSaleText"]);
|
||||
|
||||
sb.Append(" <li class=\"cms-animal-row\">\n");
|
||||
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
||||
var details = new List<string>();
|
||||
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
||||
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
||||
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
||||
if (details.Count > 0)
|
||||
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
||||
var personalityText = note ?? saleText;
|
||||
if (!string.IsNullOrWhiteSpace(personalityText))
|
||||
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
||||
sb.Append(" </li>\n");
|
||||
}
|
||||
sb.Append(" </ul>\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>
|
||||
<nav class="site-footer-nav" aria-label="Rechtliches">
|
||||
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
||||
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
||||
</nav>
|
||||
</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",
|
||||
["impressum"] = "Impressum",
|
||||
["datenschutz"] = "Datenschutz",
|
||||
};
|
||||
|
||||
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: #fdf7f0;
|
||||
--color-surface: #fff;
|
||||
--color-surface-warm: #fef9f4;
|
||||
--color-text: #2a2118;
|
||||
--color-muted: #7a6d62;
|
||||
--color-accent: #b5331a;
|
||||
--color-accent-hover: #8f2815;
|
||||
--color-accent-soft: #fdf0ed;
|
||||
--color-border: #e8ddd4;
|
||||
--color-badge-free: #2d7a4a;
|
||||
--color-badge-free-bg: #e6f4ec;
|
||||
--font-body: Georgia, "Times New Roman", serif;
|
||||
--font-ui: system-ui, -apple-system, sans-serif;
|
||||
--max-w: 900px;
|
||||
--radius: 10px;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
p { margin-bottom: .75rem; }
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
min-height: 56px;
|
||||
}
|
||||
.site-logo {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 1.5rem;
|
||||
border-right: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
.site-nav::-webkit-scrollbar { display: none; }
|
||||
.site-nav-link {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
padding: .5rem .65rem;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
||||
.site-nav-link[aria-current="page"] {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.site-main {
|
||||
max-width: var(--max-w);
|
||||
margin: 2.5rem auto;
|
||||
padding: 0 1rem 4rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
background: var(--color-surface);
|
||||
border-top: 2px solid var(--color-border);
|
||||
padding: 1.75rem 1rem;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: .85rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
||||
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
||||
.site-footer-link:hover { color: var(--color-accent); }
|
||||
|
||||
/* ── CMS blocks ── */
|
||||
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
||||
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
||||
h2.cms-heading { font-size: 1.5rem; }
|
||||
h3.cms-heading { font-size: 1.2rem; }
|
||||
.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: .35rem; }
|
||||
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
||||
.cms-image { margin: 1.75rem 0; }
|
||||
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||
.cms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
||||
.cms-contact {
|
||||
font-style: normal;
|
||||
font-family: var(--font-ui);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
margin: 1.25rem 0;
|
||||
background: var(--color-surface-warm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
||||
|
||||
/* ── Abgabetiere — group cards ── */
|
||||
.cms-abgabe { margin: 1.25rem 0; }
|
||||
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||
|
||||
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
||||
|
||||
.cms-group-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
}
|
||||
.cms-group-header {
|
||||
padding: 1rem 1.25rem .75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem .75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-warm);
|
||||
}
|
||||
.cms-group-name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cms-group-meta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* availability badge */
|
||||
.cms-badge {
|
||||
display: inline-block;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
padding: .2rem .55rem;
|
||||
border-radius: 20px;
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cms-badge--free {
|
||||
background: var(--color-badge-free-bg);
|
||||
color: var(--color-badge-free);
|
||||
}
|
||||
|
||||
/* group photo strip */
|
||||
.cms-group-gallery {
|
||||
display: flex;
|
||||
gap: .375rem;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: .75rem 1.25rem;
|
||||
background: #f5ede4;
|
||||
}
|
||||
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
||||
.cms-group-photo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* animal detail list */
|
||||
.cms-animal-list {
|
||||
list-style: none;
|
||||
padding: .75rem 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .875rem;
|
||||
}
|
||||
.cms-animal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .15rem;
|
||||
padding-bottom: .875rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.cms-animal-name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cms-animal-detail {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.cms-animal-note {
|
||||
font-style: italic;
|
||||
font-size: .925rem;
|
||||
color: var(--color-text);
|
||||
margin-top: .1rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.site-header { padding: 0 .75rem; min-height: 50px; }
|
||||
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
||||
h1.cms-heading { font-size: 1.5rem; }
|
||||
.site-main { margin-top: 1.5rem; }
|
||||
.cms-group-photo { width: 110px; height: 110px; }
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
||||
}
|
||||
""";
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
}
|
||||
130
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
130
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
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,
|
||||
g.Gender,
|
||||
g.DateOfBirth,
|
||||
g.CharacterNote,
|
||||
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,
|
||||
["gender"] = a.Gender.ToString(),
|
||||
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
||||
["characterNote"] = a.CharacterNote,
|
||||
["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;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -24,7 +24,12 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef);
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -69,7 +74,12 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef);
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
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) =>
|
||||
{
|
||||
@@ -87,11 +94,17 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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">
|
||||
@@ -24,4 +26,14 @@
|
||||
<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>
|
||||
|
||||
111
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
111
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
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,
|
||||
int ParentFksBackfilled = 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);
|
||||
}
|
||||
574
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
574
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
@@ -0,0 +1,574 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||
// resolvable. Two lookup sources — must check BOTH:
|
||||
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
|
||||
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
|
||||
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
|
||||
// animals absent from this run's animals.json, or name normalization mismatch
|
||||
// between animals.json and the Wurfchronik sire/dam field).
|
||||
// Counted for dry-run too; writes only when execute=true.
|
||||
int parentFksBackfilled = 0;
|
||||
{
|
||||
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
|
||||
var allDbNormToGid = existingRows
|
||||
.GroupBy(g => Normalize(StripZucht(g.Name)))
|
||||
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
|
||||
|
||||
var existingWithNullParent = await _db.Litters
|
||||
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||
.ToListAsync();
|
||||
var sourceByName = litters
|
||||
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
Guid? ResolveParentForBackfill(string rawName)
|
||||
{
|
||||
var n = Normalize(StripZucht(rawName));
|
||||
if (n.Length == 0) return null;
|
||||
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
|
||||
return fromLoadable;
|
||||
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
|
||||
return fromDb;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var el in existingWithNullParent)
|
||||
{
|
||||
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
|
||||
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
|
||||
if (newF is null && newM is null) continue;
|
||||
parentFksBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||
if (row is not null)
|
||||
{
|
||||
if (newF is not null) row.FatherId = newF;
|
||||
if (newM is not null) row.MotherId = newM;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute && parentFksBackfilled > 0) 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.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
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, parentFksBackfilled),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSearchFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
// Backfill NameSearch for existing rows (lowercase, strip whitespace/.-_),
|
||||
// matching GerbilSearch.Normalize. New/updated rows stay in sync via
|
||||
// ApplicationContext.SaveChanges.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"Gerbils\" SET \"NameSearch\" = lower(regexp_replace(\"Name\", '[[:space:]._-]', '', 'g'));");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCharacterFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CharacterNote",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CharacterTraits",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterNote",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterTraits",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1263
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs
generated
Normal file
1263
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
141
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs
Normal file
141
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCmsEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Media",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Url = table.Column<string>(type: "text", nullable: false),
|
||||
Alt = table.Column<string>(type: "text", nullable: true),
|
||||
Width = table.Column<int>(type: "integer", nullable: true),
|
||||
Height = table.Column<int>(type: "integer", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Media", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Pages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Slug = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
SeoDescription = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Pages", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DefaultLocale = table.Column<string>(type: "text", nullable: false),
|
||||
NavOrder = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Sites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Blocks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PageId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
Type = table.Column<string>(type: "text", nullable: false),
|
||||
Data = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Blocks", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Blocks_Pages_PageId",
|
||||
column: x => x.PageId,
|
||||
principalTable: "Pages",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Pages",
|
||||
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720001-0000-0000-0000-000000000001"), null, "start", "Published", "Startseite" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000002"), null, "ueber-die-zucht", "Published", "Über die Zucht" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000003"), null, "abgabetiere", "Published", "Abgabetiere" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000004"), null, "abgabebedingungen", "Published", "Abgabebedingungen" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000005"), null, "farben-genetik", "Published", "Farben & Genetik" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000006"), null, "kontakt", "Published", "Kontakt" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Sites",
|
||||
columns: new[] { "Id", "DefaultLocale", "NavOrder" },
|
||||
values: new object[] { new Guid("5172e000-0000-0000-0000-000000000001"), "de", "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Blocks",
|
||||
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720002-0000-0000-0000-000000000001"), "{\"text\":\"Startseite\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000001"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000002"), "{\"text\":\"Über die Zucht\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000002"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000003"), "{\"text\":\"Abgabetiere\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000003"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000004"), "{\"text\":\"Abgabebedingungen\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000004"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000005"), "{\"text\":\"Farben & Genetik\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000005"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000006"), "{\"text\":\"Kontakt\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000006"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000010"), "{\"mode\":\"auto\",\"intro\":\"\"}", 1, new Guid("51720001-0000-0000-0000-000000000003"), "AbgabetiereList" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Blocks_PageId",
|
||||
table: "Blocks",
|
||||
column: "PageId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Pages_Slug",
|
||||
table: "Pages",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Blocks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Media");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sites");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Pages");
|
||||
}
|
||||
}
|
||||
}
|
||||
1378
GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
1378
GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequestsAndMailSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MailSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GmailAddress = table.Column<string>(type: "text", nullable: true),
|
||||
AppPasswordProtected = table.Column<string>(type: "text", nullable: true),
|
||||
PollIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
Folder = table.Column<string>(type: "text", nullable: false),
|
||||
BackgroundPollEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LastUid = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MailSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Requests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GmailMessageId = table.Column<string>(type: "text", nullable: false),
|
||||
ThreadId = table.Column<string>(type: "text", nullable: true),
|
||||
InReplyToMessageId = table.Column<string>(type: "text", nullable: true),
|
||||
ReferencesHeader = table.Column<string>(type: "text", nullable: true),
|
||||
FromAddress = table.Column<string>(type: "text", nullable: false),
|
||||
FromName = table.Column<string>(type: "text", nullable: true),
|
||||
Subject = table.Column<string>(type: "text", nullable: true),
|
||||
BodyText = table.Column<string>(type: "text", nullable: true),
|
||||
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
AssignedContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DraftReply = table.Column<string>(type: "text", nullable: true),
|
||||
AnsweredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Requests", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Requests_Contacts_AssignedContactId",
|
||||
column: x => x.AssignedContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "MailSettings",
|
||||
columns: new[] { "Id", "AppPasswordProtected", "BackgroundPollEnabled", "Folder", "GmailAddress", "LastUid", "PollIntervalMinutes" },
|
||||
values: new object[] { new Guid("ab0c0000-0000-0000-0000-000000000001"), null, false, "INBOX", null, 0L, 15 });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Requests_AssignedContactId",
|
||||
table: "Requests",
|
||||
column: "AssignedContactId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Requests_GmailMessageId",
|
||||
table: "Requests",
|
||||
column: "GmailMessageId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MailSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Requests");
|
||||
}
|
||||
}
|
||||
}
|
||||
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilDeafFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilResidency : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsResident",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsResident",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SyncColorVarietySeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell)");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Marder dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schwarzschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Marder");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell) dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg PP spsp rere", "Blaufuchs dd" });
|
||||
}
|
||||
}
|
||||
}
|
||||
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesGen3f : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ColorVarieties",
|
||||
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddImpressumDatenschutzPages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "Pages",
|
||||
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Blocks",
|
||||
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Pages",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "Pages",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
36
GerbilManagerWebAPI/Models/BreederSettings.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeilen-Entity
|
||||
/// (Singleton, fixe Id, leer geseedet). Die Züchterin pflegt ihre Daten in
|
||||
/// der App unter /einstellungen statt in einer JSON-Datei; FEAT-13 phase B
|
||||
/// ersetzt damit die appsettings-Variante aus phase A.
|
||||
/// </summary>
|
||||
public class BreederSettings
|
||||
{
|
||||
/// <summary>Fixe Id der einzigen Zeile (per HasData geseedet).</summary>
|
||||
public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
80
GerbilManagerWebAPI/Models/CmsModels.cs
Normal file
80
GerbilManagerWebAPI/Models/CmsModels.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Publication state of a CMS page (serialised as string name).</summary>
|
||||
public enum PageStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Published = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CMS block kinds. RichText holds MARKDOWN (sanitised on render). AbgabetiereList is
|
||||
/// dynamic — resolved from live ForSale animals at snapshot time. Serialised as string.
|
||||
/// </summary>
|
||||
public enum BlockType
|
||||
{
|
||||
Heading = 0,
|
||||
RichText = 1,
|
||||
Image = 2,
|
||||
Gallery = 3,
|
||||
ContactInfo = 4,
|
||||
AbgabetiereList = 5,
|
||||
}
|
||||
|
||||
/// <summary>Singleton site settings (WEB epic CMS).</summary>
|
||||
public class Site
|
||||
{
|
||||
/// <summary>Fixed id — there is exactly one Site row.</summary>
|
||||
public static readonly Guid SingletonId = new("5172e000-0000-0000-0000-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public string DefaultLocale { get; set; } = "de";
|
||||
/// <summary>Ordered page ids for the public nav (JSON list).</summary>
|
||||
public List<Guid> NavOrder { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A CMS page (one of the public website's pages).</summary>
|
||||
public class Page
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>Stable URL slug (e.g. "abgabetiere") — the human key.</summary>
|
||||
public required string Slug { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public PageStatus Status { get; set; } = PageStatus.Draft;
|
||||
|
||||
public ICollection<Block> Blocks { get; } = new List<Block>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A content block on a page (single table, discriminated by <see cref="Type"/>).
|
||||
/// Type-specific payload lives in <see cref="Data"/> as JSON (RichText = Markdown).
|
||||
/// </summary>
|
||||
public class Block
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public Guid PageId { get; set; }
|
||||
/// <summary>Explicit ordering within the page (AI tools reorder by this).</summary>
|
||||
public int Order { get; set; }
|
||||
public BlockType Type { get; set; }
|
||||
/// <summary>Type-specific fields as a JSON object string.</summary>
|
||||
public string Data { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>A media asset referenced by Image/Gallery blocks.</summary>
|
||||
public class Media
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public string? Alt { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -46,5 +46,51 @@ namespace GerbilManagerWebAPI.Models
|
||||
// Provenance (for the FEAT-8 spreadsheet import).
|
||||
public string? ImportSource { get; set; }
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
||||
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
||||
public string? RawImportData { get; set; }
|
||||
|
||||
/// <summary>Breeder/Herkunft (cattery/Zucht) as free text — set by the FEAT-8 import from
|
||||
/// the source Zucht (imported animals have no OriginContact). Gridify-filterable; the
|
||||
/// distinct values back the Tiere "Herkunft" dropdown (GET /gerbils/breeders).</summary>
|
||||
public string? OriginBreeder { get; set; }
|
||||
|
||||
/// <summary>Separator-insensitive search key: Name lowercased with whitespace/.-_ stripped.
|
||||
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
|
||||
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
|
||||
public string? NameSearch { get; set; }
|
||||
|
||||
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
|
||||
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
|
||||
public List<string> CharacterTraits { get; set; } = new();
|
||||
|
||||
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
|
||||
public string? CharacterNote { get; set; }
|
||||
|
||||
/// <summary>GEN-3b: hearing/deaf phenotype flag (NOT a genotype locus — it's the
|
||||
/// downstream effect of high white load / Sp×Sls). null = not stated, true = deaf
|
||||
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
|
||||
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
|
||||
public bool? IsDeaf { get; set; }
|
||||
|
||||
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
|
||||
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
|
||||
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
|
||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
public static class GerbilSearch
|
||||
{
|
||||
public static string Normalize(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return "";
|
||||
var chars = name.ToLowerInvariant()
|
||||
.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != '.' && c != '_');
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal file
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Triage state of an incoming Gmail request (serialised as string name).</summary>
|
||||
public enum RequestStatus
|
||||
{
|
||||
New = 0,
|
||||
InProgress = 1,
|
||||
Assigned = 2,
|
||||
Answered = 3,
|
||||
Abandoned = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An incoming inquiry imported from Gmail (INBOX epic). One row per email,
|
||||
/// deduped on the RFC Message-Id.
|
||||
/// </summary>
|
||||
public class Request
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>RFC 5322 Message-Id — unique; the dedup key.</summary>
|
||||
public required string GmailMessageId { get; set; }
|
||||
/// <summary>Gmail conversation id (X-GM-THRID).</summary>
|
||||
public string? ThreadId { get; set; }
|
||||
public string? InReplyToMessageId { get; set; }
|
||||
public string? ReferencesHeader { get; set; }
|
||||
|
||||
public required string FromAddress { get; set; }
|
||||
public string? FromName { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? BodyText { get; set; }
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
|
||||
public RequestStatus Status { get; set; } = RequestStatus.New;
|
||||
|
||||
public Guid? AssignedContactId { get; set; }
|
||||
public Contact? AssignedContact { get; set; }
|
||||
|
||||
/// <summary>AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted.</summary>
|
||||
public string? DraftReply { get; set; }
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton mail configuration (INBOX epic). The Gmail App Password is stored
|
||||
/// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire.
|
||||
/// </summary>
|
||||
public class MailSettings
|
||||
{
|
||||
public static readonly Guid SingletonId = new("ab0c0000-0000-0000-0000-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public string? GmailAddress { get; set; }
|
||||
/// <summary>Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire).</summary>
|
||||
public string? AppPasswordProtected { get; set; }
|
||||
public int PollIntervalMinutes { get; set; } = 15;
|
||||
public string Folder { get; set; } = "INBOX";
|
||||
public bool BackgroundPollEnabled { get; set; }
|
||||
/// <summary>Highest IMAP UID seen, to avoid rescanning the whole folder.</summary>
|
||||
public uint LastUid { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,9 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Computed ~35 days after Date by default; editable.</summary>
|
||||
public DateOnly? ExpectedGoHomeDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||
public string? PairingCode { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
45
GerbilManagerWebAPI/Models/SaleContract.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein erzeugter Abgabevertrag (FEAT-13). Die .docx liegt wie die Fotos im
|
||||
/// Datei-Root (Contracts:RootPath bzw. contract-storage/), in der DB steht
|
||||
/// nur der Dateiname. Tiere hängen über <see cref="SaleContractAnimal"/>
|
||||
/// (Join-Entity statt uuid[]-Spalte: referenzielle Integrität, „Verträge
|
||||
/// eines Tieres“ bleibt abfragbar, und pro Tier-Zeile ist später Platz für
|
||||
/// Zusatzdaten wie den Einzelpreis).
|
||||
/// </summary>
|
||||
public class SaleContract
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Abnehmer (Käufer-Block des Vertrags).</summary>
|
||||
public Guid ContactId { get; set; }
|
||||
public Contact? Contact { get; set; }
|
||||
|
||||
/// <summary>Kaufpreis in Euro (gesamt).</summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public DateOnly HandoverDate { get; set; }
|
||||
|
||||
/// <summary>Datum der Unterschriftszeile (Standard: Übergabedatum).</summary>
|
||||
public DateOnly ContractDate { get; set; }
|
||||
|
||||
/// <summary>Dateiname der erzeugten .docx im Vertrags-Dateiroot.</summary>
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public List<SaleContractAnimal> Animals { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel).</summary>
|
||||
public class SaleContractAnimal
|
||||
{
|
||||
public Guid SaleContractId { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public Gerbil? Gerbil { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,24 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
|
||||
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// FEAT-12a: provider-agnostic AI config (env vars AI__BaseUrl/AI__ApiKey/AI__Model
|
||||
// or user-secrets — never committed) + typed client for sale-ad generation.
|
||||
builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
|
||||
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
@@ -43,9 +61,11 @@ app.MapDefaultEndpoints();
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
// Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy).
|
||||
// Unter "Testing" übersprungen: die Endpoint-Tests laufen auf SQLite in-memory
|
||||
// (EnsureCreated) — die Npgsql-Migrationen sind dort nicht anwendbar.
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
// Aspire provisions an empty database in dev — bring the schema up to date.
|
||||
using var scope = app.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||
}
|
||||
@@ -62,5 +82,15 @@ app.MapHealthRecordEndpoints();
|
||||
app.MapWeightRecordEndpoints();
|
||||
app.MapInbreedingEndpoints();
|
||||
app.MapPhotoEndpoints();
|
||||
app.MapSaleAdEndpoints();
|
||||
app.MapImportEndpoints();
|
||||
app.MapContractEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Sichtbarer Programmtyp für WebApplicationFactory<Program> (Endpoint-Tests).
|
||||
public partial class Program { }
|
||||
|
||||
30
GerbilManagerWebAPI/SaleAd/AiOptions.cs
Normal file
30
GerbilManagerWebAPI/SaleAd/AiOptions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: provider-agnostic AI configuration (section "AI").
|
||||
///
|
||||
/// Values come from ENVIRONMENT VARIABLES or user-secrets — NEVER from a
|
||||
/// committed appsettings file (Julian's directive). Env-var names:
|
||||
/// AI__BaseUrl, AI__ApiKey, AI__Model
|
||||
///
|
||||
/// Any endpoint that speaks the OpenAI-compatible chat-completions shape works:
|
||||
/// Google Gemini : https://generativelanguage.googleapis.com/v1beta/openai
|
||||
/// Groq : https://api.groq.com/openai/v1
|
||||
/// Mistral : https://api.mistral.ai/v1
|
||||
/// Ollama (lokal): http://<host>:11434/v1 (ApiKey beliebig, z. B. "ollama")
|
||||
/// </summary>
|
||||
public sealed class AiOptions
|
||||
{
|
||||
public const string SectionName = "AI";
|
||||
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? ApiKey { get; set; }
|
||||
public string? Model { get; set; }
|
||||
|
||||
/// <summary>All three settings present -> the sale-ad endpoint is live.</summary>
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(BaseUrl)
|
||||
&& !string.IsNullOrWhiteSpace(ApiKey)
|
||||
&& !string.IsNullOrWhiteSpace(Model);
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
Normal file
43
GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN,
|
||||
/// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts).
|
||||
/// </summary>
|
||||
public sealed record SaleAdAnimal(
|
||||
string Name,
|
||||
string? Farbschlag,
|
||||
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
|
||||
string? DateOfBirth,
|
||||
string? Notes,
|
||||
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
|
||||
List<string>? Traits = null,
|
||||
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
|
||||
string? CharacterNote = null);
|
||||
|
||||
public sealed record SaleAdRequest(
|
||||
List<SaleAdAnimal> Animals,
|
||||
/// <summary>e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT".</summary>
|
||||
string StatusLine,
|
||||
/// <summary>Free-text style hints/wishes from the user.</summary>
|
||||
string Hints);
|
||||
|
||||
public sealed record SaleAdResponse(string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Error body for the non-200 paths. The code string "AiKeyMissing" is FROZEN —
|
||||
/// Kevin's UI maps it to the German "API-Schlüssel noch nicht konfiguriert" hint.
|
||||
/// </summary>
|
||||
public sealed record SaleAdError(string Code, string Message);
|
||||
|
||||
public enum SaleAdStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>AI section not (fully) configured -> HTTP 503, code "AiKeyMissing".</summary>
|
||||
NotConfigured,
|
||||
/// <summary>Provider call failed -> HTTP 502, code "AiUpstreamError".</summary>
|
||||
UpstreamError,
|
||||
}
|
||||
|
||||
public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null);
|
||||
}
|
||||
118
GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
Normal file
118
GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: assembles the German prompt for the sale-ad (Abgabe-Inserat)
|
||||
/// generation. Style and rules follow the breeder's real Jimdo listings
|
||||
/// (analysed in hive/agents/god/FEAT12-jimdo-publish-research.md):
|
||||
/// group-based listings, status line, bold emotive tagline, per-animal
|
||||
/// Farbschlag + "geboren am DD.MM.YYYY" + personality prose, NO PRICES.
|
||||
/// </summary>
|
||||
public static class SaleAdPromptBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Two few-shot examples derived from the documented listing template.
|
||||
/// NOTE: the research file documents the format and one real tagline;
|
||||
/// these examples are synthesized to that template. Swap in verbatim
|
||||
/// listings from kleine-chaoten.jimdofree.com when available.
|
||||
/// </summary>
|
||||
private const string ExampleListing1 = """
|
||||
Status: FREI
|
||||
|
||||
„Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
|
||||
|
||||
Balu – CP-Agouti, geboren am 12.03.2024
|
||||
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
|
||||
|
||||
Benny – Schwarz Schecke, geboren am 12.03.2024
|
||||
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht.
|
||||
|
||||
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
||||
""";
|
||||
|
||||
private const string ExampleListing2 = """
|
||||
Status: LOCKER RESERVIERT Anna
|
||||
|
||||
„Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen
|
||||
|
||||
Frieda – Gold, geboren am 28.06.2024
|
||||
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
|
||||
|
||||
Fine – Agouti, geboren am 28.06.2024
|
||||
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
|
||||
|
||||
Die Schwestern ziehen selbstverständlich nur zusammen um.
|
||||
""";
|
||||
|
||||
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
||||
public static string BuildSystemPrompt() => $"""
|
||||
Du schreibst Abgabe-Inserate für Mongolische Rennmäuse im Stil der „Zucht der kleinen Chaoten“.
|
||||
|
||||
Stil und Aufbau (verbindlich):
|
||||
- Erste Zeile: die Status-Zeile, exakt wie vorgegeben (z. B. „Status: FREI“).
|
||||
- Danach eine fette, emotionale Überschrift (Tagline) in Anführungszeichen mit kurzem Untertitel, die den Charakter der Gruppe einfängt.
|
||||
- Danach pro Tier ein Absatz: Name – Farbschlag, geboren am TT.MM.JJJJ, gefolgt von warmherziger Persönlichkeits-Prosa auf Basis der mitgelieferten Notizen.
|
||||
- Abschluss: ein Satz, dass die Tiere nur gemeinsam in ein artgerechtes Zuhause abgegeben werden.
|
||||
|
||||
Harte Regeln:
|
||||
- NIEMALS Preise oder Schutzgebühren nennen.
|
||||
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Charakter-Angaben, Notizen). Fehlt eine Angabe, lässt du sie weg.
|
||||
- Charakter-Eigenschaften (Stichworte wie „zutraulich“, „buddelt gern“) und die Charakter-Notiz sind die Grundlage der Persönlichkeits-Prosa: Verwebe sie zu FLIESSENDEM Text — niemals als Aufzählung oder Stichwortliste ausgeben.
|
||||
- Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
|
||||
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
|
||||
|
||||
Beispiel 1:
|
||||
{ExampleListing1}
|
||||
|
||||
Beispiel 2:
|
||||
{ExampleListing2}
|
||||
""";
|
||||
|
||||
/// <summary>User prompt: the actual group data + the user's free-text hints.</summary>
|
||||
public static string BuildUserPrompt(SaleAdRequest request)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Erstelle ein Abgabe-Inserat für folgende Gruppe:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Status-Zeile: {request.StatusLine}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Tiere:");
|
||||
foreach (var animal in request.Animals)
|
||||
{
|
||||
sb.Append($"- Name: {animal.Name}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.Farbschlag))
|
||||
sb.Append($" | Farbschlag: {animal.Farbschlag}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
|
||||
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
|
||||
// FEAT-14c: Charakterbogen (Traits + Notiz) ist die BEVORZUGTE
|
||||
// Charakterquelle; die generischen Notizen dienen nur als
|
||||
// Fallback, wenn kein Charakterbogen gepflegt ist (sie enthalten
|
||||
// oft Verwaltungs-Infos, die nicht ins Inserat gehören).
|
||||
var hasCharacter = animal.Traits is { Count: > 0 }
|
||||
|| !string.IsNullOrWhiteSpace(animal.CharacterNote);
|
||||
if (animal.Traits is { Count: > 0 })
|
||||
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
|
||||
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
|
||||
if (!hasCharacter && !string.IsNullOrWhiteSpace(animal.Notes))
|
||||
sb.Append($" | Notizen: {animal.Notes}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Hints))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Wünsche/Hinweise: {request.Hints}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim.</summary>
|
||||
internal static string FormatGermanDate(string isoDate) =>
|
||||
DateOnly.TryParseExact(isoDate, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date)
|
||||
? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)
|
||||
: isoDate;
|
||||
}
|
||||
}
|
||||
35
GerbilManagerWebAPI/SaleAd/SaleAdService.cs
Normal file
35
GerbilManagerWebAPI/SaleAd/SaleAdService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
|
||||
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
|
||||
/// future AI features reuse the SAME implementation); this service contributes
|
||||
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
|
||||
/// </summary>
|
||||
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _client.CompleteAsync(
|
||||
SaleAdPromptBuilder.BuildSystemPrompt(),
|
||||
SaleAdPromptBuilder.BuildUserPrompt(request),
|
||||
ct);
|
||||
var status = result.Status switch
|
||||
{
|
||||
AiCallStatus.Ok => SaleAdStatus.Ok,
|
||||
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
|
||||
_ => SaleAdStatus.UpstreamError,
|
||||
};
|
||||
return new SaleAdResult(status, result.Text, result.Error);
|
||||
}
|
||||
|
||||
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
OpenAiChatClient.BuildCompletionsUri(baseUrl);
|
||||
}
|
||||
}
|
||||
168
HUMANQUESTION.md
Normal file
168
HUMANQUESTION.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# HUMANQUESTION.md — offene Fragen & benötigte Daten
|
||||
|
||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
||||
> alles wartet geduldig, bis ihr antwortet.
|
||||
|
||||
_Stand: 2026-06-06._
|
||||
|
||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
||||
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
|
||||
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
|
||||
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||
|
||||
---
|
||||
|
||||
## A. Für Julian — Konten, Schlüssel, Technik
|
||||
|
||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||
|---|---|---|---|
|
||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
|
||||
|
||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
||||
|
||||
| # | Aktion | Warum |
|
||||
|---|---|---|
|
||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
||||
|
||||
---
|
||||
|
||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
||||
|
||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
||||
|
||||
| # | Frage | Blockiert |
|
||||
|---|---|---|
|
||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||
|
||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
||||
|
||||
---
|
||||
|
||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
||||
|
||||
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
|
||||
|
||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
||||
|
||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
||||
|
||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
||||
|---|---|---|
|
||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
||||
|
||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
||||
|
||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
||||
|
||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
||||
>
|
||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
||||
|
||||
### D5 · **Sterbedatum** widersprüchlich
|
||||
| Tier | Problem |
|
||||
|---|---|
|
||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
||||
|
||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
||||
|
||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
||||
| Tier | Konkreter Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
||||
|
||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||
|
||||
---
|
||||
|
||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
||||
|
||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
||||
sagen, was ergänzt oder gestrichen werden soll:**
|
||||
|
||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
||||
|
||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
||||
|
||||
---
|
||||
|
||||
## F. Was hängt woran (Kurzübersicht)
|
||||
|
||||
| Funktion | Status | Wartet auf |
|
||||
|---|---|---|
|
||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
||||
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
|
||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||
|
||||
---
|
||||
|
||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
||||
sie hier und meldet, was dadurch live geht.*
|
||||
23
deploy/truenas/.env.example
Normal file
23
deploy/truenas/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# GerbilManager Produktionskonfiguration
|
||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||
|
||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||
POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
|
||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
|
||||
ANTHROPIC_API_KEY=
|
||||
109
deploy/truenas/compose.yaml
Normal file
109
deploy/truenas/compose.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# GerbilManager — TrueNAS Custom Application
|
||||
# ==============================================
|
||||
# Vor dem ersten Start:
|
||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||
# 2. Lege die Dataset-Pfade auf der NAS an (pgdata, photos, backups).
|
||||
# 3. docker compose -f deploy/truenas/compose.yaml up -d
|
||||
#
|
||||
# Zugriff: http://<NAS-IP>:${PORT:-80}
|
||||
# API-Doku: http://<NAS-IP>:${PORT:-80}/scalar
|
||||
|
||||
services:
|
||||
|
||||
# --- PostgreSQL-Datenbank ---
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: gerbilmanager
|
||||
POSTGRES_USER: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gerbilmanager"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# --- .NET API (GerbilManagerWebAPI) ---
|
||||
api:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
# Verbindung zur Postgres-DB im selben Compose-Netz
|
||||
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
||||
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
||||
Photos__RootPath: /data/photos
|
||||
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-80}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||
backup:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGPASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: gerbilmanager
|
||||
BACKUP_KEEP_DAYS: "${BACKUP_KEEP_DAYS:-7}"
|
||||
volumes:
|
||||
- photos:/data/photos:ro
|
||||
- backups:/backups
|
||||
- ./scripts:/scripts:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/scripts/entrypoint.sh"]
|
||||
|
||||
volumes:
|
||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
56
deploy/truenas/scripts/backup.sh
Normal file
56
deploy/truenas/scripts/backup.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# GerbilManager Backup-Skript (Container-Welt)
|
||||
# Laeuft als Cron-Job im backup-Sidecar-Container.
|
||||
# Volumes: /data/photos (read-only), /backups (read-write)
|
||||
# Umgebungsvariablen: PGPASSWORD, POSTGRES_HOST, POSTGRES_USER, POSTGRES_DB, BACKUP_KEEP_DAYS
|
||||
set -e
|
||||
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
|
||||
BACKUP_DIR="/backups/$TIMESTAMP"
|
||||
DB_DUMP="$BACKUP_DIR/gerbilmanager_${TIMESTAMP}.sql"
|
||||
PHOTO_ARCHIVE="$BACKUP_DIR/photos_${TIMESTAMP}.tar.gz"
|
||||
KEEP_DAYS="${BACKUP_KEEP_DAYS:-7}"
|
||||
HOST="${POSTGRES_HOST:-db}"
|
||||
USER="${POSTGRES_USER:-postgres}"
|
||||
DB="${POSTGRES_DB:-gerbilmanager}"
|
||||
|
||||
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"; }
|
||||
|
||||
log "=== Backup gestartet ($TIMESTAMP) ==="
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# --- Datenbank-Dump ---
|
||||
log "Erstelle pg_dump fuer $DB@$HOST..."
|
||||
if ! pg_dump -h "$HOST" -U "$USER" -d "$DB" --clean --if-exists --format=plain \
|
||||
> "$DB_DUMP" 2>/tmp/pg_err; then
|
||||
log "FEHLER bei pg_dump: $(cat /tmp/pg_err)"
|
||||
exit 1
|
||||
fi
|
||||
DUMP_SIZE=$(du -k "$DB_DUMP" | cut -f1)
|
||||
log "Dump erstellt: $DB_DUMP (${DUMP_SIZE} KB)"
|
||||
|
||||
# Validierung: mindestens CREATE TABLE muss vorhanden sein
|
||||
if ! grep -q "CREATE TABLE" "$DB_DUMP" 2>/dev/null; then
|
||||
log "WARNUNG: Dump sieht ungueltig aus -- bitte $DB_DUMP pruefen"
|
||||
fi
|
||||
|
||||
# --- Fotos archivieren ---
|
||||
if [ -d /data/photos ] && [ "$(ls -A /data/photos 2>/dev/null)" ]; then
|
||||
PHOTO_COUNT=$(find /data/photos -type f | wc -l)
|
||||
log "Archiviere $PHOTO_COUNT Fotos..."
|
||||
tar czf "$PHOTO_ARCHIVE" -C /data photos/ 2>/tmp/tar_err || {
|
||||
log "WARNUNG: Foto-Archiv fehlgeschlagen: $(cat /tmp/tar_err)"
|
||||
}
|
||||
PHOTO_SIZE=$(du -k "$PHOTO_ARCHIVE" 2>/dev/null | cut -f1)
|
||||
log "Foto-Archiv erstellt: $PHOTO_ARCHIVE (${PHOTO_SIZE} KB)"
|
||||
else
|
||||
log "Keine Fotos gefunden -- Foto-Backup uebersprungen"
|
||||
fi
|
||||
|
||||
# --- Rotation: alte Backups loeschen ---
|
||||
log "Rotiere Backups (behalte $KEEP_DAYS Tage)..."
|
||||
find /backups -maxdepth 1 -type d -name '????-??-??_??-??' \
|
||||
-mtime "+$KEEP_DAYS" -exec rm -rf {} + 2>/dev/null || true
|
||||
REMAINING=$(find /backups -maxdepth 1 -type d -name '????-??-??_??-??' | wc -l)
|
||||
log "Backup abgeschlossen. Vorhandene Sicherungen: $REMAINING"
|
||||
log "=== Ende ==="
|
||||
16
deploy/truenas/scripts/entrypoint.sh
Normal file
16
deploy/truenas/scripts/entrypoint.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Backup-Sidecar Entrypoint
|
||||
# Installiert den Cron-Job und startet crond im Vordergrund.
|
||||
set -e
|
||||
|
||||
# Cron-Job: taeglich um 03:00 Uhr
|
||||
CRON_SCHEDULE="${BACKUP_CRON:-0 3 * * *}"
|
||||
echo "$CRON_SCHEDULE /bin/sh /scripts/backup.sh >> /backups/backup.log 2>&1" > /etc/crontabs/root
|
||||
|
||||
echo "[$(date)] Backup-Sidecar gestartet. Naechste Sicherung: $CRON_SCHEDULE"
|
||||
echo "[$(date)] Backup-Verzeichnis: /backups Fotos: /data/photos"
|
||||
|
||||
# Ersten Backup-Lauf direkt beim Start ausfuehren (optional, auskommentieren wenn unerwuenscht)
|
||||
# /bin/sh /scripts/backup.sh
|
||||
|
||||
exec crond -f -l 6
|
||||
77
deploy/truenas/scripts/restore.sh
Normal file
77
deploy/truenas/scripts/restore.sh
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
# GerbilManager Wiederherstellung (Container-Welt)
|
||||
# Aufruf: docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/restore.sh
|
||||
# Optional: .../restore.sh 2026-06-06_03-00 (bestimmtes Backup)
|
||||
#
|
||||
# WARNUNG: Ueberschreibt alle aktuellen Datenbankdaten und Fotos!
|
||||
set -e
|
||||
|
||||
HOST="${POSTGRES_HOST:-db}"
|
||||
USER="${POSTGRES_USER:-postgres}"
|
||||
DB="${POSTGRES_DB:-gerbilmanager}"
|
||||
BACKUP_ROOT=/backups
|
||||
|
||||
log() { echo "[$(date +'%H:%M:%S')] $1"; }
|
||||
|
||||
# --- Backup-Verzeichnis bestimmen ---
|
||||
if [ -n "$1" ]; then
|
||||
BACKUP_DIR="$BACKUP_ROOT/$1"
|
||||
else
|
||||
BACKUP_DIR=$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' \
|
||||
| sort -r | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$BACKUP_DIR" ] || [ ! -d "$BACKUP_DIR" ]; then
|
||||
log "FEHLER: Backup-Verzeichnis nicht gefunden: ${BACKUP_DIR:-$BACKUP_ROOT}"
|
||||
log "Verfuegbare Backups:"
|
||||
find "$BACKUP_ROOT" -maxdepth 1 -type d -name '????-??-??_??-??' | sort -r
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SQL_FILE=$(find "$BACKUP_DIR" -name '*.sql' | head -1)
|
||||
PHOTO_ARCHIVE=$(find "$BACKUP_DIR" -name '*.tar.gz' | head -1)
|
||||
|
||||
if [ -z "$SQL_FILE" ]; then
|
||||
log "FEHLER: Kein SQL-Dump in $BACKUP_DIR gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Wiederherstellung aus: $BACKUP_DIR"
|
||||
log " Datenbank : $(basename $SQL_FILE)"
|
||||
[ -n "$PHOTO_ARCHIVE" ] && log " Fotos : $(basename $PHOTO_ARCHIVE)"
|
||||
|
||||
# --- Bestaetigung (interaktiv; mit -f ueberspringen) ---
|
||||
if [ "$1" != "-f" ] && [ "$2" != "-f" ]; then
|
||||
printf "WARNUNG: Aktuelle Daten werden unwiderruflich ueberschrieben!\nFortfahren? (ja/nein): "
|
||||
read -r CONFIRM
|
||||
if [ "$CONFIRM" != "ja" ]; then
|
||||
log "Abgebrochen."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Datenbankwiederherstellung ---
|
||||
log "Trenne laufende DB-Verbindungen..."
|
||||
psql -h "$HOST" -U "$USER" -d postgres -c \
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB' AND pid<>pg_backend_pid();" \
|
||||
> /dev/null 2>&1 || true
|
||||
|
||||
log "Spiele SQL-Dump ein..."
|
||||
if ! psql -h "$HOST" -U "$USER" -d "$DB" < "$SQL_FILE" > /tmp/restore_out 2>&1; then
|
||||
log "FEHLER bei der DB-Wiederherstellung:"
|
||||
cat /tmp/restore_out
|
||||
exit 1
|
||||
fi
|
||||
log "Datenbankwiederherstellung erfolgreich."
|
||||
|
||||
# --- Fotos wiederherstellen ---
|
||||
if [ -n "$PHOTO_ARCHIVE" ]; then
|
||||
log "Stelle Fotos wieder her..."
|
||||
rm -rf /data/photos/* 2>/dev/null || true
|
||||
tar xzf "$PHOTO_ARCHIVE" -C /data
|
||||
PHOTO_COUNT=$(find /data/photos -type f 2>/dev/null | wc -l)
|
||||
log "Fotos wiederhergestellt: $PHOTO_COUNT Dateien."
|
||||
fi
|
||||
|
||||
log "Wiederherstellung abgeschlossen."
|
||||
log "Starte API-Container neu: docker compose ... restart api"
|
||||
@@ -1,18 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
gerbilmanagerwebapi:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
args:
|
||||
- configuration=Debug
|
||||
ports:
|
||||
- 80:80
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
- ~/.vsdbg:/remote_debugger:rw
|
||||
@@ -1,57 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
|
||||
frontend:
|
||||
image: gerbilmanagerweb
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
args:
|
||||
# Der Browser erreicht die API über den am Host veröffentlichten Port
|
||||
- VITE_API_BASE_URL=http://localhost:80
|
||||
ports:
|
||||
- 3000:3000
|
||||
networks:
|
||||
- net2
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
environment:
|
||||
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
|
||||
ports:
|
||||
- 80:80
|
||||
networks:
|
||||
- net1
|
||||
- net2
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
database:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- MSSQL_SA_PASSWORD=StrongerThenYYou1
|
||||
- MSSQL_PID=Evaluation
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- db-data:/var/opt/mssql
|
||||
networks:
|
||||
- net1
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
networks:
|
||||
net1:
|
||||
name: network1
|
||||
net2:
|
||||
name: network2
|
||||
50
docs/ai-provider.md
Normal file
50
docs/ai-provider.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||
|
||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||
gesetzt als **Umgebungsvariablen oder user-secrets, niemals in appsettings
|
||||
committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
||||
`503 {code:"AiKeyMissing"}` und die Oberfläche zeigt den deutschen Hinweis.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `AI__BaseUrl` | Basis-URL des Anbieters (ohne `/chat/completions`) |
|
||||
| `AI__ApiKey` | API-Schlüssel (Bearer) |
|
||||
| `AI__Model` | Modellname des Anbieters |
|
||||
|
||||
## Anbieter-Matrix (alle mit derselben Implementierung getestet)
|
||||
|
||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||
|---|---|---|---|
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||
|
||||
Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
||||
(die Einstellung darf nur nicht leer sein).
|
||||
|
||||
## Setzen der Variablen
|
||||
|
||||
**Entwicklung (PowerShell, vor dem AppHost-Start):**
|
||||
|
||||
```powershell
|
||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||
$env:AI__ApiKey = '<schlüssel>'
|
||||
$env:AI__Model = 'gemini-2.0-flash'
|
||||
```
|
||||
|
||||
oder per user-secrets im API-Projekt:
|
||||
`dotnet user-secrets set "AI:ApiKey" "<schlüssel>" --project GerbilManagerWebAPI` (usw.).
|
||||
|
||||
**TrueNAS / Docker Compose** (für Dwights Ops-Doku): im `environment:`-Block des
|
||||
API-Containers —
|
||||
|
||||
```yaml
|
||||
services:
|
||||
webapi:
|
||||
environment:
|
||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||
AI__Model: gemini-2.0-flash
|
||||
```
|
||||
381
docs/ops.md
Normal file
381
docs/ops.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
2. [Voraussetzungen](#2-voraussetzungen)
|
||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht & Architektur
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
└──────┬───────────┘
|
||||
│ /api/* → http://api:8080/*
|
||||
│ /scalar → http://api:8080/scalar
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ api (.NET 10) │ GerbilManagerWebAPI, Minimal API
|
||||
└──────┬───────────┘
|
||||
│ ConnectionStrings__gerbilmanager
|
||||
v
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
---
|
||||
|
||||
## 2. Voraussetzungen
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Erstinstallation auf TrueNAS
|
||||
|
||||
### 3.1 ZFS-Datasets anlegen
|
||||
|
||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
||||
|
||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
||||
|---|---|---|
|
||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
||||
|
||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
||||
|
||||
### 3.2 Repository klonen
|
||||
|
||||
```bash
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
cd /opt/gerbilmanager
|
||||
```
|
||||
|
||||
### 3.3 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
Mindestens setzen:
|
||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
||||
|
||||
### 3.4 Images bauen und App starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||
|
||||
### 3.5 Prüfen
|
||||
|
||||
```bash
|
||||
# Alle Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. App starten / stoppen / aktualisieren
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
### Stoppen
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
```
|
||||
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||
|
||||
### Mit Gitea CI (wenn Actions aktiviert)
|
||||
|
||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
||||
Danach auf der NAS:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Wiederherstellung
|
||||
|
||||
### Automatisches Backup
|
||||
|
||||
Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00 Uhr**:
|
||||
- Kompletter `pg_dump` der Datenbank als `.sql`
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||
|
||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
||||
|
||||
```
|
||||
/mnt/SSD/gerbil/backups/
|
||||
2026-06-06_03-00/
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
|
||||
### Manuelles Backup auslösen
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Backup-Log prüfen
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
||||
```
|
||||
|
||||
### Wiederherstellung — Runbook
|
||||
|
||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
||||
|
||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**Schritt 2:** Restore ausführen
|
||||
```bash
|
||||
# Neuestes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh
|
||||
|
||||
# Bestimmtes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```
|
||||
|
||||
**Schritt 3:** API neu starten
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4:** Prüfen
|
||||
```bash
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
```
|
||||
|
||||
### Restore-Nachweis (Round-Trip-Test)
|
||||
|
||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
||||
```
|
||||
73 ColorVarieties vorhanden
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ pg_restore eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
Exit-Code: 0
|
||||
```
|
||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
||||
|
||||
---
|
||||
|
||||
## 6. ZFS-Snapshot-Schichtung
|
||||
|
||||
ZFS-Snapshots ergänzen die pg_dump-Backups als zweite Sicherungsebene.
|
||||
Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
||||
|
||||
### Empfohlene Snapshot-Konfiguration
|
||||
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
|
||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||
> nicht den ZFS-Snapshot von `pgdata`.
|
||||
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD via Gitea Actions
|
||||
|
||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
||||
|
||||
### Aktivierung
|
||||
|
||||
1. **Gitea Actions aktivieren:**
|
||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
||||
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|-------|-----------|
|
||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
docker compose -f deploy/truenas/compose.yaml logs
|
||||
|
||||
# Logs eines bestimmten Containers
|
||||
docker compose -f deploy/truenas/compose.yaml logs api
|
||||
docker compose -f deploy/truenas/compose.yaml logs db
|
||||
```
|
||||
|
||||
### Datenbank nicht erreichbar
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||
```
|
||||
|
||||
### Container-Status zurücksetzen (Neustart)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml restart api
|
||||
```
|
||||
|
||||
### Kompletter Neustart (Daten bleiben erhalten)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
3
gerbil-manager-web/.gitattributes
vendored
Normal file
3
gerbil-manager-web/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# de.ts is large and edited by many agents — pin to LF so EOL flips don't
|
||||
# create noisy whole-file diffs (GEN-3 follow-up, god-approved).
|
||||
src/strings/de.ts text eol=lf
|
||||
6
gerbil-manager-web/.gitignore
vendored
6
gerbil-manager-web/.gitignore
vendored
@@ -22,3 +22,9 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# QA-1: Playwright-Artefakte
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
playwright/.cache/
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
# Build-Stufe: Vite-Produktionsbuild
|
||||
# Build context: repo root (Dockerfile liest aus gerbil-manager-web/).
|
||||
# docker build -f gerbil-manager-web/Dockerfile -t gerbilmanager-frontend .
|
||||
#
|
||||
# Produktion: kein VITE_API_BASE_URL nötig — client.ts fällt auf '/api' zurück,
|
||||
# nginx proxyt /api/* transparent zum API-Container (keine Host-Kopplung zur Build-Zeit).
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY gerbil-manager-web/package.json gerbil-manager-web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY gerbil-manager-web/ .
|
||||
# Basis-URL der API (zur Build-Zeit eingebettet; Browser erreicht die API über den Host)
|
||||
ARG VITE_API_BASE_URL=http://localhost:80
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
RUN npm run build
|
||||
|
||||
# Laufzeit-Stufe: statische Auslieferung über nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# SPA-Fallback: alle Pfade auf index.html umleiten (react-router)
|
||||
|
||||
# nginx: SPA-Fallback + Reverse-Proxy für /api, /scalar, /openapi zum API-Container.
|
||||
RUN printf 'server {\n\
|
||||
listen 3000;\n\
|
||||
listen 80;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
# API-Aufrufe: /api/* -> API-Container /* (Praefix wird entfernt)\n\
|
||||
location /api/ {\n\
|
||||
proxy_pass http://api:8080/;\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
proxy_set_header X-Real-IP $remote_addr;\n\
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Scalar API-Doku und OpenAPI-Spec direkt vom Backend\n\
|
||||
location /scalar {\n\
|
||||
proxy_pass http://api:8080/scalar;\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
}\n\
|
||||
location /openapi/ {\n\
|
||||
proxy_pass http://api:8080/openapi/;\n\
|
||||
proxy_set_header Host $host;\n\
|
||||
}\n\
|
||||
\n\
|
||||
# SPA-Fallback: alle anderen Pfade laden index.html (React Router)\n\
|
||||
location / {\n\
|
||||
try_files $uri $uri/ /index.html;\n\
|
||||
}\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 3000
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -39,3 +39,21 @@ Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## E2E-Tests (QA-1, Playwright)
|
||||
|
||||
```bash
|
||||
npm run e2e # Mock-Modus: startet Vite auf :5199, API wird gemockt
|
||||
npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
||||
```
|
||||
|
||||
- **Mock-Modus (Standard):** kein Backend nötig. `e2e/mock-api.ts` fängt alle
|
||||
API-Aufrufe ab (DATA-2-Vertrag: Gridify-Paging, camelCase, 409-Konflikte)
|
||||
mit frischem Datenbestand pro Test (`e2e/mock-data.ts`).
|
||||
- **Live-Modus:** dieselbe Suite gegen den echten Stack —
|
||||
`$env:E2E_BASE_URL = 'http://localhost:5173'; npm run e2e`
|
||||
(vorher AppHost starten). Tests, die Mock-Seed-Daten voraussetzen,
|
||||
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||
`src/strings/de.ts`.
|
||||
|
||||
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
|
||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||
*/
|
||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const ta = de.pages.anfragen
|
||||
const td = ta.detail
|
||||
|
||||
test.describe('Anfragen', () => {
|
||||
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.requests)
|
||||
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
|
||||
|
||||
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
|
||||
const cards = page.locator('.anfrage-card')
|
||||
await expect(cards).toHaveCount(3)
|
||||
await expect(cards.nth(0)).toContainText('Anna Albrecht')
|
||||
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
|
||||
|
||||
// Status-Badge auf der Karte
|
||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||
|
||||
// Filter: nur Beantwortet
|
||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards.first()).toContainText('Danke!')
|
||||
})
|
||||
|
||||
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.mailConfigured = false
|
||||
await page.goto('/anfragen')
|
||||
await page.getByRole('button', { name: ta.sync }).click()
|
||||
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
|
||||
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
|
||||
|
||||
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
|
||||
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
|
||||
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.abandon }).click()
|
||||
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
})
|
||||
|
||||
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
const textarea = page.locator('.anfrage-reply__text')
|
||||
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
|
||||
|
||||
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
|
||||
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sent)).toBeVisible()
|
||||
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
|
||||
})
|
||||
|
||||
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.aiConfigured = false
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user