diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs index 2f20bb1..423d853 100644 --- a/GerbilManager.Tests/ImportServiceTests.cs +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -580,6 +580,74 @@ namespace GerbilManager.Tests Assert.Equal(new DateOnly(y, m, d), date); } + [Fact] + public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw() + { + // CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches + // what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the + // gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the + // stable ExternalRef without throwing. + var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); + File.WriteAllText(Path.Combine(dir, "animals.json"), """ + [{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]}, + "conflict":false}] + """); + using var db = NewDb(); + // Run 1: load the animal normally + await new ImportService(db, dir, dir).RunAsync(execute: true); + Assert.Equal(1, await db.Gerbils.CountAsync()); + + // Simulate drift: manually rename the animal in the DB (UI rename scenario) + var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift"); + g.Name = "Umbenannt Tier"; + await db.SaveChangesAsync(); + + // Run 2: animals.json still has old name "Drift Tier" — must NOT throw + var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false); + // Dry-run should complete without throwing; animal is found by ExternalRef fallback + Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created + } + finally { try { Directory.Delete(dir, recursive: true); } catch { } } + } + + [Fact] + public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag() + { + // CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader + // must derive ColorVarietyId from the catalog when the name-match yields nothing. + // "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001). + var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); + // Exact Agouti genotype, no explicit Farbschlag name + File.WriteAllText(Path.Combine(dir, "animals.json"), """ + [{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"", + "genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]}, + "rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]}, + "conflict":false}] + """); + using var db = NewDb(); + var report = await new ImportService(db, dir, dir).RunAsync(execute: true); + + var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep"); + // ColorVarietyId must be set even though no explicit Farbschlag name was given + Assert.NotNull(tier.ColorVarietyId); + // Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001) + var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId); + Assert.Equal("Agouti", variety!.Name); + // Report counter should reflect the genotype derivation + Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0); + } + finally { try { Directory.Delete(dir, recursive: true); } catch { } } + } + // ---- fixtures ---- private const string LittersJson = """ [ diff --git a/GerbilManager.Tests/PartialUpdateTests.cs b/GerbilManager.Tests/PartialUpdateTests.cs index 60f14f8..eb0738f 100644 --- a/GerbilManager.Tests/PartialUpdateTests.cs +++ b/GerbilManager.Tests/PartialUpdateTests.cs @@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture /// Create a fully-populated gerbil and return its id. private async Task CreateFullGerbil(string name = "TestTier") { + // Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire + // when multiple tests in the same fixture share the SQLite connection. var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, @@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture characterTraits = new[] { "neugierig", "zutraulich" }, characterNote = "Liebling der Familie", notes = "Eine Notiz", - externalRef = "ext-001", + externalRef = $"ext-{name.GetHashCode():X8}", })); Assert.Equal(HttpStatusCode.Created, resp.StatusCode); return ExtractId(await resp.Content.ReadAsStringAsync()); @@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Eine Notiz", GetStr(json, "notes")); - Assert.Equal("ext-001", GetStr(json, "externalRef")); + Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Equal("Liebling der Familie", GetStr(json, "characterNote")); } @@ -95,7 +97,7 @@ public class PartialUpdateTests : IClassFixture Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Eine Notiz", GetStr(json, "notes")); - Assert.Equal("ext-001", GetStr(json, "externalRef")); + Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? ""); Assert.Equal("Neue Notiz", GetStr(json, "characterNote")); } @@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name")); Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); - Assert.Equal("ext-001", GetStr(json, "externalRef")); + Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Equal("Liebling der Familie", GetStr(json, "characterNote")); } diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index 4d1a4ca..ef63edf 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -81,6 +81,12 @@ public class ApplicationContext : DbContext .HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull); e.HasOne(g => g.ColorVariety).WithMany() .HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull); + + // DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level. + // Filtered (nulls allowed: manually-entered animals have no ExternalRef). + e.HasIndex(g => g.ExternalRef) + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); }); modelBuilder.Entity(e => diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs index 3c7a510..463a8e0 100644 --- a/GerbilManagerWebAPI/Import/ImportModels.cs +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import int AlreadyImported, QuarantineSummary Quarantined, int ParentLinksFromChart = 0, - int ConflictsResolvedByDecision = 0); + int ConflictsResolvedByDecision = 0, + int FarbschlagDerivedFromGenotype = 0); public sealed record QuarantineSummary( int Conflicts, diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index af9e2c3..7bb886b 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -145,25 +145,63 @@ namespace GerbilManagerWebAPI.Import .ToDictionary(g => g.ExternalRef!, g => g.LitterId); var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null) .ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId); + // CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import + var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null) + .ToDictionary(g => g.ExternalRef!, g => g.Id); + + // CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching + var varietiesWithGeno = await _db.ColorVarieties + .Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync(); // PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet). var plan = new List(); + int fbDerivedFromGenotype = 0; foreach (var a in loadable) { bool exists = existingGerbilSet.Contains(a.Id); - var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid(); + // CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift + // (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException. + Guid gid; + if (exists) + { + if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid)) + { + if (existingGidByExtRef.TryGetValue(a.Id, out gid)) + notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung)."); + else + { + notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen."); + continue; + } + } + } + else gid = 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; + // CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation + // (fill-NULL-only — never overwrites an explicit name-match or manual assignment). 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; } + if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8) + { + // CR-11: only derive from a fully-specified genotype (all 8 loci known, + // no "??" wildcards). Partial genotypes (single-locus or sparse records) + // would match any catalog entry via wildcards and produce false positives. + var composed = ComposeGenotype(a.Genotype); + if (!composed.Contains("??")) + foreach (var v in varietiesWithGeno) + if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype) + && GenotypePotentiallyMatches(composed, v.CanonicalGenotype)) + { colorVarietyId = v.Id; fbDerivedFromGenotype++; break; } + } var gender = InferGender(a, sireNames, damNames); var norm = Normalize(StripZucht(a.Name)); @@ -469,6 +507,33 @@ namespace GerbilManagerWebAPI.Import int farbschlagWouldRebackfill = plan.Count(p => p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId); + // CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals + // with null ColorVarietyId whose stored Genotype matches a catalog entry get filled. + // Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value. + { + var noColor = await _db.Gerbils + .Where(g => g.ColorVarietyId == null && g.Genotype != null) + .Select(g => new { g.Id, g.Genotype }) + .ToListAsync(); + foreach (var g in noColor) + { + if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue; + Guid? derivedVid = null; + foreach (var v in varietiesWithGeno) + if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype) + && GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype)) + { derivedVid = v.Id; break; } + if (derivedVid is null) continue; + fbDerivedFromGenotype++; + if (execute) + { + var row = await _db.Gerbils.FindAsync(g.Id); + if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid; + } + } + if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync(); + } + // HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose // OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'. // NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen"). @@ -524,6 +589,8 @@ namespace GerbilManagerWebAPI.Import 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 (fbDerivedFromGenotype > 0) + notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere)."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); return new ImportReport( @@ -532,7 +599,7 @@ namespace GerbilManagerWebAPI.Import Animals: new AnimalSummary( animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), - parentLinksAdded, conflictsResolvedByDecision), + parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype), Photos: new PhotoSummary(photosAttached, photosMissing), Samples: samples, Notes: notes, @@ -570,6 +637,23 @@ namespace GerbilManagerWebAPI.Import private static string StripCaret(string allele) => allele.Replace("^", ""); + /// CR-11: check if a composed animal genotype is compatible with a catalog canonical + /// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere"). + /// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing + /// Sls token is ignored (it is outside the base 8-locus contract). + private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno) + { + var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (a.Length < 8 || c.Length < 8) return false; + for (int i = 0; i < 8; i++) + { + if (a[i] == "??" || c[i] == "??") continue; + if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false; + } + return true; + } + private static Gender InferGender(SourceAnimal a, HashSet sires, HashSet dams) { // Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it diff --git a/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs new file mode 100644 index 0000000..dacc417 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs @@ -0,0 +1,1387 @@ +// +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("20260606154816_UniqueExternalRef")] + partial class UniqueExternalRef + { + /// + 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.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"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.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"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.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("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 = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Blaufuchs dd", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 65 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSearch") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"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\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + 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"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.cs b/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.cs new file mode 100644 index 0000000..6a7a216 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class UniqueExternalRef : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Gerbils_ExternalRef", + table: "Gerbils", + column: "ExternalRef", + unique: true, + filter: "\"ExternalRef\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Gerbils_ExternalRef", + table: "Gerbils"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 79f5acb..bf67d5f 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -807,6 +807,10 @@ namespace GerbilManagerWebAPI.Migrations b.HasIndex("EnclosureId"); + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + b.HasIndex("LitterId"); b.HasIndex("OriginContactId"); diff --git a/tools/import/extract.py b/tools/import/extract.py index ece9b9e..f8a3d22 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -973,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path): continue a["resolvedByDecision"] = True if d.get("genotype"): - a["genotype"] = gt.parse(d["genotype"]) + # CR-10: validate the parsed genotype — a typo'd decision string yields empty + # mapped8locus and would silently blank the animal's genotype while marking it + # 'resolved'. Only apply if the parse produces non-empty loci. + parsed = gt.parse(d["genotype"]) + if parsed.get("mapped8locus"): + a["genotype"] = parsed + else: + # Keep the existing genotype; flag as a warning in the report. + a.setdefault("decisionWarnings", []).append( + f"Ungültiger Override-Genotyp '{d['genotype']}' — " + "konnte nicht geparst werden (mapped8locus leer). " + "Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst." + ) if d.get("farbschlag"): a["farbschlag"] = d["farbschlag"] a["farbschlagVariants"] = [d["farbschlag"]] diff --git a/tools/import/test_extract.py b/tools/import/test_extract.py index 3de2ed3..6d35b13 100644 --- a/tools/import/test_extract.py +++ b/tools/import/test_extract.py @@ -280,6 +280,36 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel")) check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell")) +# --- CR-10: malformed decision genotype must NOT blank the existing genotype --- +dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json") +_json.dump({"resolutions": [ + # Valid decision (genotype parses OK) -> should be applied + {"name": "Agouti OK", "dob": "01.01.2020", "decision": "test", + "genotype": "aa CC DD ee GG PP spsp rere", "source": "test"}, + # Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved + {"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test", + "genotype": "BLÖDSINN!!!", "source": "test"}, +]}, open(dec_cr10, "w", encoding="utf-8")) +merged_cr10 = [ + {"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "", + "genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}}, + {"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "", + "genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}}, +] +conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}] +n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10) +check("CR-10: valid decision genotype is applied (A-locus updated)", + merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"]) +check("CR-10: malformed decision genotype NOT applied (C-locus preserved)", + merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"]) +check("CR-10: malformed decision still un-quarantines the animal", + merged_cr10[1].get("conflict") is False) +check("CR-10: malformed decision adds a decisionWarning", + bool(merged_cr10[1].get("decisionWarnings"))) +check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2) +try: os.remove(dec_cr10) +except OSError: pass + # --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' --- # Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen. # The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so