From 9c98c37d2a1e335a42daf80956b6e5a5e3e19c77 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 15:45:15 +0200 Subject: [PATCH] feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt, aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume + Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung (Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet", „Konflikt gelöst", „aus N Datensätzen zusammengeführt"). Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend: ProvenanceDialog + Typen + Strings. Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts. dotnet(212)/vitest(129)/playwright/tsc/eslint grün. Co-Authored-By: Claude Opus 4.8 --- .../IngestResolvedServiceTests.cs | 12 +- GerbilManagerWebAPI/Dtos/ApiDtos.cs | 3 +- .../Endpoints/GerbilEndpoints.cs | 3 +- .../Import/IngestResolvedService.cs | 1 + ...0622133758_AddGerbilProvenance.Designer.cs | 1539 +++++++++++++++++ .../20260622133758_AddGerbilProvenance.cs | 28 + .../ApplicationContextModelSnapshot.cs | 3 + GerbilManagerWebAPI/Models/Gerbil.cs | 7 + gerbil-manager-web/e2e/mock-data.ts | 9 + gerbil-manager-web/e2e/provenance.spec.ts | 46 + gerbil-manager-web/src/api/types.ts | 26 + .../src/components/ProvenanceDialog.tsx | 157 ++ .../src/components/provenanceDialog.css | 157 ++ .../src/pages/GerbilDetailPage.tsx | 11 + gerbil-manager-web/src/strings/de.ts | 31 + tools/import/merge_and_resolve.py | 99 +- 16 files changed, 2126 insertions(+), 6 deletions(-) create mode 100644 GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.cs create mode 100644 gerbil-manager-web/e2e/provenance.spec.ts create mode 100644 gerbil-manager-web/src/components/ProvenanceDialog.tsx create mode 100644 gerbil-manager-web/src/components/provenanceDialog.css diff --git a/GerbilManager.Tests/IngestResolvedServiceTests.cs b/GerbilManager.Tests/IngestResolvedServiceTests.cs index a3ef0b9..715154e 100644 --- a/GerbilManager.Tests/IngestResolvedServiceTests.cs +++ b/GerbilManager.Tests/IngestResolvedServiceTests.cs @@ -84,7 +84,8 @@ namespace GerbilManager.Tests CharacterTraits = new string[] {}, CharacterNote = (string?)null, IsDeaf = false, - IsResident = true + IsResident = true, + Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"]}" }, new { @@ -111,7 +112,8 @@ namespace GerbilManager.Tests CharacterTraits = new string[] {}, CharacterNote = (string?)null, IsDeaf = false, - IsResident = true + IsResident = true, + Provenance = (string?)null } }, GerbilPhotos = new[] @@ -176,6 +178,12 @@ namespace GerbilManager.Tests Assert.Equal(mother.Id, litter.MotherId); Assert.Equal(father.Id, photo.GerbilId); Assert.Equal(father.OriginContactId, mother.OriginContactId); + + // Provenance round-trips verbatim through ingest (and stays null when absent). + Assert.NotNull(father.Provenance); + Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance); + Assert.Contains("mergedRecordCount", father.Provenance); + Assert.Null(mother.Provenance); } } } diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs index 74281f0..92d61dd 100644 --- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -32,7 +32,8 @@ namespace GerbilManagerWebAPI.Dtos bool? IsDeaf, bool IsResident, string? ProfilePhotoUrl, - bool IsCastrated); + bool IsCastrated, + string? Provenance); public record LitterDto( Guid Id, diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs index 513504c..4829453 100644 --- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs @@ -137,6 +137,7 @@ namespace GerbilManagerWebAPI.Endpoints 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.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, - g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated); + g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated, + g.Provenance); } } diff --git a/GerbilManagerWebAPI/Import/IngestResolvedService.cs b/GerbilManagerWebAPI/Import/IngestResolvedService.cs index 14852f1..5feee80 100644 --- a/GerbilManagerWebAPI/Import/IngestResolvedService.cs +++ b/GerbilManagerWebAPI/Import/IngestResolvedService.cs @@ -160,6 +160,7 @@ namespace GerbilManagerWebAPI.Import ImportSource = g.ImportSource, ExternalRef = g.ExternalRef, RawImportData = g.RawImportData, + Provenance = g.Provenance, OriginBreeder = g.OriginBreeder, NameSearch = g.NameSearch, CharacterTraits = g.CharacterTraits, diff --git a/GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs new file mode 100644 index 0000000..b5ee8e3 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs @@ -0,0 +1,1539 @@ +// +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("20260622133758_AddGerbilProvenance")] + partial class AddGerbilProvenance + { + /// + 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("NameSuffix") + .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 = "", + NameSuffix = "", + 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") + .UseCollation("de-x-icu"); + + 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 = "REW", + 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 = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute 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 = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + 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 = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .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.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + 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("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + 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("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + 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("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + 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.Property("PhotoId") + .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.EnclosurePhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null) + .WithMany() + .HasForeignKey("EnclosureId") + .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/20260622133758_AddGerbilProvenance.cs b/GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.cs new file mode 100644 index 0000000..2a59de7 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddGerbilProvenance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Provenance", + table: "Gerbils", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Provenance", + table: "Gerbils"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 11980d5..153ffe2 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -912,6 +912,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("OriginContactId") .HasColumnType("uuid"); + b.Property("Provenance") + .HasColumnType("text"); + b.Property("RawImportData") .HasColumnType("text"); diff --git a/GerbilManagerWebAPI/Models/Gerbil.cs b/GerbilManagerWebAPI/Models/Gerbil.cs index 59a3e9d..68fc531 100644 --- a/GerbilManagerWebAPI/Models/Gerbil.cs +++ b/GerbilManagerWebAPI/Models/Gerbil.cs @@ -49,6 +49,13 @@ namespace GerbilManagerWebAPI.Models public string? ImportSource { get; set; } public string? ExternalRef { get; set; } + /// Data-provenance / traceability for the import: a JSON object describing + /// WHICH source information produced this entry (sourceFiles, mergedRecordCount, + /// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python + /// merge_and_resolve step and surfaced read-only in the Rennmausakte + /// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data. + public string? Provenance { get; set; } + /// Raw import payload preserved verbatim (rawGenotype + unmappedTokens like /// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text. public string? RawImportData { get; set; } diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 00cc2b1..c44b6bb 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -122,6 +122,15 @@ export function seedDb(): MockDb { enclosureId: 'enc-gross', originContactId: 'con-meier', originBreeder: 'Clan-Kleine-Chaoten', + // NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String). + provenance: JSON.stringify({ + sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'], + mergedRecordCount: 2, + fromWurfchronik: true, + parentMethod: 'chart-position', + parentConfidence: 'medium', + notes: ['aus 2 Datensätzen zusammengeführt'], + }), }, { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' }, gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), diff --git a/gerbil-manager-web/e2e/provenance.spec.ts b/gerbil-manager-web/e2e/provenance.spec.ts new file mode 100644 index 0000000..d74498a --- /dev/null +++ b/gerbil-manager-web/e2e/provenance.spec.ts @@ -0,0 +1,46 @@ +/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den + * Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const p = de.provenance + +test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/kruemel') + + // Button in den Aktionen öffnet den Dialog. + await page.getByRole('button', { name: p.button }).click() + const dialog = page.getByRole('dialog', { name: p.dialogTitle }) + await expect(dialog).toBeVisible() + + // Quelldateien werden angezeigt. + await expect(dialog).toContainText(p.sourceFilesTitle) + await expect(dialog).toContainText('Stammbaum von Krümel.xlsx') + await expect(dialog).toContainText('Wurfchronik-Detail.docx') + + // Zusammenführungs-Info + Wurfchronik-Hinweis. + await expect(dialog).toContainText(p.mergedCount(2)) + await expect(dialog).toContainText(p.fromWurfchronik) + + // Eltern-Herkunft (übersetzte Methode/Konfidenz). + await expect(dialog).toContainText(p.methods['chart-position']) + await expect(dialog).toContainText(p.confidences.medium) + + // Hinweis aus dem Import. + await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt') + + // Schließen (Footer-Button — Header-Button trägt dasselbe aria-label). + await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click() + await expect(dialog).not.toBeVisible() +}) + +test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => { + skipUnlessMock() + // Luna hat keine provenance im Mock → Leer-Zustand. + await page.goto('/rennmaeuse/luna') + + await page.getByRole('button', { name: p.button }).click() + const dialog = page.getByRole('dialog', { name: p.dialogTitle }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText(p.empty) +}) diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts index 1c0cf71..ded51d6 100644 --- a/gerbil-manager-web/src/api/types.ts +++ b/gerbil-manager-web/src/api/types.ts @@ -62,6 +62,32 @@ export interface Gerbil { profilePhotoUrl?: string | null spottingType?: string | null isCastrated?: boolean + /** + * NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom + * Python-Merge erzeugt; siehe {@link GerbilProvenance} für die geparste Form). + * null = manuell angelegtes Tier / keine Importdaten. + */ + provenance?: string | null +} + +/** + * NACHVERFOLGUNG: geparste Datenherkunft eines importierten Tiers (JSON aus + * {@link Gerbil.provenance}). Beschreibt, WELCHE Quellinformationen den + * Datenbankeintrag erzeugt haben. + */ +export interface GerbilProvenance { + /** Alle Quelldateien (Stammbäume / Wurfchronik), die beigetragen haben. */ + sourceFiles: string[] + /** Anzahl der Rohdatensätze, die zu diesem Tier zusammengeführt wurden. */ + mergedRecordCount: number + /** Hat ein Wurfchronik-Treffer beigetragen? */ + fromWurfchronik: boolean + /** Wie die Eltern abgeleitet wurden (z. B. "chart-position", "decision"). */ + parentMethod?: string + /** Konfidenz der Eltern-Ableitung (z. B. "low" | "medium" | "high"). */ + parentConfidence?: string + /** Menschlich lesbare Herkunftshinweise (deutsch). */ + notes: string[] } /** Payload for POST /gerbils. */ diff --git a/gerbil-manager-web/src/components/ProvenanceDialog.tsx b/gerbil-manager-web/src/components/ProvenanceDialog.tsx new file mode 100644 index 0000000..4a492b4 --- /dev/null +++ b/gerbil-manager-web/src/components/ProvenanceDialog.tsx @@ -0,0 +1,157 @@ +/** + * NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the + * data provenance of an imported gerbil (which source files contributed, how many + * raw records were merged, how the parents were derived, and human-readable notes). + * + * The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python + * merge step). We parse it here defensively; anything unparseable / absent is shown + * as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern. + */ +import { useEffect } from 'react' +import { de } from '../strings/de' +import type { GerbilProvenance } from '../api/types' +import './provenanceDialog.css' + +interface ProvenanceDialogProps { + open: boolean + onClose: () => void + /** Raw provenance JSON string from Gerbil.provenance (null = no import data). */ + provenance?: string | null +} + +/** Parse the JSON provenance string defensively; null on absence / parse error. */ +function parseProvenance(raw?: string | null): GerbilProvenance | null { + if (!raw || !raw.trim()) return null + try { + const p = JSON.parse(raw) as Partial + return { + sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [], + mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0, + fromWurfchronik: Boolean(p.fromWurfchronik), + parentMethod: p.parentMethod, + parentConfidence: p.parentConfidence, + notes: Array.isArray(p.notes) ? p.notes : [], + } + } catch { + return null + } +} + +export default function ProvenanceDialog({ open, onClose, provenance }: ProvenanceDialogProps) { + // Close on Escape (only while mounted). + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + if (!open) return null + + const t = de.provenance + const p = parseProvenance(provenance) + const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null + const confidence = p?.parentConfidence + ? (t.confidences[p.parentConfidence] ?? p.parentConfidence) + : null + + return ( +
+
e.stopPropagation()} + > +
+

+ {t.dialogTitle} +

+ +
+ + {!p ? ( +

{t.empty}

+ ) : ( + <> +

{t.intro}

+ +
+
+ {t.mergedTitle} +
+

{t.mergedCount(p.mergedRecordCount)}

+ {p.fromWurfchronik && ( +

📖 {t.fromWurfchronik}

+ )} +
+ +
+
+ {t.sourceFilesTitle} — {t.sourceFilesCount(p.sourceFiles.length)} +
+ {p.sourceFiles.length === 0 ? ( +

{t.empty}

+ ) : ( +
    + {p.sourceFiles.map((f) => ( +
  • + {f} +
  • + ))} +
+ )} +
+ + {(method || confidence) && ( +
+
{t.parentsTitle}
+
+ {method && ( +
+
{t.parentMethodLabel}
+
{method}
+
+ )} + {confidence && ( +
+
{t.parentConfidenceLabel}
+
{confidence}
+
+ )} +
+
+ )} + + {p.notes.length > 0 && ( +
+
{t.notesTitle}
+
    + {p.notes.map((n) => ( +
  • {n}
  • + ))} +
+
+ )} + + )} + +
+ +
+
+
+ ) +} diff --git a/gerbil-manager-web/src/components/provenanceDialog.css b/gerbil-manager-web/src/components/provenanceDialog.css new file mode 100644 index 0000000..a8318ba --- /dev/null +++ b/gerbil-manager-web/src/components/provenanceDialog.css @@ -0,0 +1,157 @@ +/* NACHVERFOLGUNG: "Nachverfolgungsinformationen" modal dialog. Mirrors the + reportErrorDialog modal pattern. */ +.provenance__backdrop { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(43, 33, 25, 0.45); +} + +.provenance__dialog { + width: 100%; + max-width: 32rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + background: var(--color-bg, #fff); + color: var(--color-text); + border-radius: 14px; + box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32); + padding: 1.25rem; + animation: provenanceIn 0.18s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes provenanceIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } +} + +.provenance__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.provenance__title { + margin: 0; + font-size: 1.15rem; +} + +.provenance__close { + background: none; + border: none; + font: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + color: var(--color-text-muted, #666); + padding: 0.25rem 0.4rem; + border-radius: 6px; +} + +.provenance__close:hover { + background: var(--color-border, #eee); +} + +.provenance__intro { + margin: 0 0 0.85rem; + font-size: 0.9rem; + color: var(--color-text-muted, #666); +} + +.provenance__empty { + margin: 0.4rem 0; + font-size: 0.9rem; + color: var(--color-text-muted, #888); + font-style: italic; +} + +.provenance__section { + margin-top: 0.9rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--color-border, #e5e5e5); + border-radius: 8px; + background: var(--color-surface-2, rgba(0, 0, 0, 0.03)); +} + +.provenance__section-title { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #888); + margin-bottom: 0.4rem; +} + +.provenance__merged { + margin: 0; + font-size: 0.9rem; +} + +.provenance__wurfchronik { + margin: 0.4rem 0 0; + font-size: 0.85rem; + color: var(--color-text-muted, #666); +} + +.provenance__files { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.2rem; +} + +.provenance__file { + font-size: 0.85rem; + word-break: break-word; +} + +.provenance__kvlist { + margin: 0; + display: grid; + gap: 0.25rem; +} + +.provenance__kv { + display: flex; + gap: 0.5rem; + font-size: 0.85rem; +} + +.provenance__kv dt { + flex: 0 0 6rem; + font-weight: 600; + color: var(--color-text-muted, #777); +} + +.provenance__kv dd { + margin: 0; + min-width: 0; + word-break: break-word; +} + +.provenance__notes { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.2rem; + font-size: 0.85rem; +} + +.provenance__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.1rem; +} diff --git a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx index 0620321..8aa26db 100644 --- a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx @@ -14,6 +14,7 @@ import GerbilHealthTab from '../components/GerbilHealthTab' import GerbilPhotosTab from '../components/GerbilPhotosTab' import GerbilProfilePhoto from '../components/GerbilProfilePhoto' import GerbilWeightTab from '../components/GerbilWeightTab' +import ProvenanceDialog from '../components/ProvenanceDialog' import ReportErrorDialog from '../components/ReportErrorDialog' import { useGerbilName } from '../components/breederSuffix' import { useToast } from '../components/toast' @@ -59,6 +60,7 @@ export default function GerbilDetailPage() { const { id = '' } = useParams() const [tab, setTab] = useState('photos') const [reportOpen, setReportOpen] = useState(false) + const [provenanceOpen, setProvenanceOpen] = useState(false) const gerbil = useApi(() => getGerbil(id), [id]) const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' })) @@ -258,6 +260,9 @@ export default function GerbilDetailPage() { {de.pages.vertraege.wizard.title} )} + @@ -482,6 +487,12 @@ export default function GerbilDetailPage() { onClose={() => setReportOpen(false)} context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }} /> + + setProvenanceOpen(false)} + provenance={g.provenance} + /> ) } diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index fc2997c..1ae8d08 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -878,6 +878,37 @@ export const de = { idCopied: 'ID kopiert', idCopyFailed: 'ID konnte nicht kopiert werden.', }, + /** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */ + provenance: { + /** Button-Beschriftung in den Akten-Aktionen. */ + button: 'Datenherkunft', + dialogTitle: 'Nachverfolgungsinformationen', + intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.', + /** Überschriften / Feldbeschriftungen. */ + sourceFilesTitle: 'Quelldateien', + sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`), + mergedTitle: 'Datensätze', + mergedCount: (n: number) => + n === 1 ? 'aus 1 Datensatz' : `zusammengeführt aus ${n} Datensätzen`, + fromWurfchronik: 'Enthält Angaben aus der Wurfchronik', + parentsTitle: 'Eltern-Herkunft', + parentMethodLabel: 'Methode', + parentConfidenceLabel: 'Konfidenz', + /** Übersetzungen für die technischen Methoden-/Konfidenz-Werte. */ + methods: { + 'chart-position': 'Position im Stammbaum', + decision: 'Manuelle Entscheidung', + } as Record, + confidences: { + low: 'niedrig', + medium: 'mittel', + high: 'hoch', + } as Record, + notesTitle: 'Hinweise', + /** Wenn ein Tier keine Importdaten hat (manuell angelegt). */ + empty: 'Keine Herkunftsdaten vorhanden.', + close: 'Schließen', + }, common: { loading: 'Lädt …', retry: 'Erneut versuchen', diff --git a/tools/import/merge_and_resolve.py b/tools/import/merge_and_resolve.py index 66bf8fc..faefc15 100644 --- a/tools/import/merge_and_resolve.py +++ b/tools/import/merge_and_resolve.py @@ -1520,6 +1520,8 @@ def main(): "IsResident": a_id in stammbaum_resident_ids, "parentRefs": a.get("parentRefs", []), "_photos": a.get("photos", []), + "_conflict": bool(a.get("conflict")), + "_resolved_by_decision": bool(a.get("resolvedByDecision")), "_old_scoped_litter_id": scoped_litter_id, "_eff_dob": dob_val or "2010-01-01", "_birth_date": dob_val, @@ -1678,6 +1680,80 @@ def main(): return True + def _record_source_files(g): + """All distinct source files a single raw record drew from. + + ImportSource is either a comma-joined Stammbaum file list, a single + Wurfchronik filename, or the per-litter filename; _filename is the + primary file. We union both so nothing is lost.""" + files = set() + imp = g.get("ImportSource") + if imp: + for part in str(imp).split(","): + part = part.strip() + if part: + files.add(part) + fn = g.get("_filename") + if fn: + files.add(fn) + return files + + def build_provenance(records, best_g, extra_notes=None): + """Aggregate data-provenance across every raw record merged into one + resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a + nullable text column) so the Rennmausakte can show where the entry came + from. `records` is the list of raw records that became this gerbil; + `best_g` is the chosen primary record.""" + source_files = set() + from_wurfchronik = False + any_conflict = False + any_decision = False + for r in records: + source_files |= _record_source_files(r) + for f in _record_source_files(r): + if "wurfchronik" in f.lower(): + from_wurfchronik = True + if r.get("_conflict"): + any_conflict = True + if r.get("_resolved_by_decision"): + any_decision = True + + notes = [] + merged_count = len(records) + if merged_count > 1: + notes.append(f"aus {merged_count} Datensätzen zusammengeführt") + if any_decision: + notes.append("per manueller Entscheidung zugeordnet") + if any_conflict: + notes.append("Konflikt per Entscheidung gelöst") + + # Parent derivation: surface the strongest parentRef method/confidence + # the primary record carries (chart-position etc.). + parent_method = None + parent_confidence = None + for ref in best_g.get("parentRefs", []) or []: + if ref.get("method") and not parent_method: + parent_method = ref.get("method") + if ref.get("confidence") and not parent_confidence: + parent_confidence = ref.get("confidence") + + if extra_notes: + for n in extra_notes: + if n and n not in notes: + notes.append(n) + + prov = { + "sourceFiles": sorted(source_files), + "mergedRecordCount": merged_count, + "fromWurfchronik": from_wurfchronik, + "notes": notes, + } + if parent_method: + prov["parentMethod"] = parent_method + if parent_confidence: + prov["parentConfidence"] = parent_confidence + return json.dumps(prov, ensure_ascii=False) + # Group gerbils by name to perform deduplication gerbil_groups = {} for g in all_processed_gerbils: @@ -1701,6 +1777,7 @@ def main(): if is_placeholder: # Placeholders: do NOT merge, keep all separate for g in group: + g["Provenance"] = build_provenance([g], g) resolved_gerbils.append(g) gerbil_id_map[g["Id"]] = g["Id"] continue @@ -1721,6 +1798,7 @@ def main(): for sub in sub_groups: if len(sub) == 1: g = sub[0] + g["Provenance"] = build_provenance([g], g) resolved_gerbils.append(g) gerbil_id_map[g["Id"]] = g["Id"] continue @@ -1806,6 +1884,7 @@ def main(): merged_notes.append(g["Notes"]) # Reconcile fields based on number of source files supporting them + conflict_notes = [] for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]: votes = {} for g in sub: @@ -1816,6 +1895,19 @@ def main(): votes[val] = votes.get(val, 0) + sources_count if votes: best_val = max(votes, key=votes.get) + # If the records disagreed on a field, the merge had to pick a + # winner — record that as a provenance note. + if len(votes) > 1: + FIELD_LABEL = { + "DateOfBirth": "Geburtsdatum", + "DateOfDeath": "Sterbedatum", + "Gender": "Geschlecht", + "Genotype": "Genotyp", + "ColorVarietyId": "Farbschlag", + } + conflict_notes.append( + f"Konflikt bei {FIELD_LABEL[field]} per Mehrheitsentscheidung gelöst" + ) best_g[field] = best_val # Keep helper fields in sync if we changed DateOfBirth if field == "DateOfBirth": @@ -1824,12 +1916,13 @@ def main(): if merged_notes: best_g["Notes"] = " | ".join(merged_notes) - + best_g["_photos"] = merged_photos + best_g["Provenance"] = build_provenance(sub, best_g, extra_notes=conflict_notes) # Print merge trace print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}") - + resolved_gerbils.append(best_g) gerbil_id_map[best_g["Id"]] = best_g["Id"] @@ -1885,6 +1978,8 @@ def main(): del g["_birth_date"] del g["_filename"] del g["_old_id"] + g.pop("_conflict", None) + g.pop("_resolved_by_decision", None) # Gather final valid gerbil IDs valid_gerbil_ids = {g["Id"] for g in resolved_gerbils}