diff --git a/GerbilManager.Tests/GerbilManager.Tests.csproj b/GerbilManager.Tests/GerbilManager.Tests.csproj index d59000b..17f8e2e 100644 --- a/GerbilManager.Tests/GerbilManager.Tests.csproj +++ b/GerbilManager.Tests/GerbilManager.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs new file mode 100644 index 0000000..5be8ea1 --- /dev/null +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -0,0 +1,161 @@ +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManager.Tests +{ + /// + /// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's + /// instance). Covers categorisation, the quarantine policy, genotype composition, + /// Farbschlag matching, high-confidence linking, and idempotency. + /// + public class ImportServiceTests : IDisposable + { + private readonly string _dir; + + public ImportServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson); + File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private ApplicationContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N")) + .Options; + var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); // applies the 73-variety HasData seed + return db; + } + + [Fact] + public async Task DryRun_categorises_without_writing() + { + using var db = NewDb(); + var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false); + + Assert.False(report.Executed); + Assert.Equal(2, report.Litters.InSource); + Assert.Equal(2, report.Litters.Created); + // 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined + Assert.Equal(4, report.Animals.InSource); + Assert.Equal(2, report.Animals.Created); + Assert.Equal(1, report.Animals.LinkedToLitter); // only a1 (hoch) + Assert.Equal(1, report.Animals.FarbschlagMatched); // a1 -> Agouti + Assert.Equal(1, report.Animals.Quarantined.Conflicts); + Assert.Equal(1, report.Animals.Quarantined.Stubs); + Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks); + + // nothing written in dry-run + Assert.Equal(0, await db.Gerbils.CountAsync()); + Assert.Equal(0, await db.Litters.CountAsync()); + } + + [Fact] + public async Task Execute_loads_conflict_free_and_links_high_confidence() + { + using var db = NewDb(); + var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true); + + Assert.True(report.Executed); + Assert.Equal(2, await db.Litters.CountAsync()); + Assert.Equal(2, await db.Gerbils.CountAsync()); // a1, a4 only + + // conflict + stub never loaded + Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2")); + Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3")); + + var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1"); + Assert.NotNull(a1.LitterId); // high-confidence link + Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource); + Assert.NotNull(a1.ColorVarietyId); // Agouti matched + Assert.Contains("aa CC", a1.Genotype); // composed from mapped8locus + Assert.Contains("??", a1.Genotype!); // missing loci -> ?? + Assert.Contains("eef", a1.Genotype!); // e^f caret stripped + Assert.Contains("RawGenotype", a1.RawImportData!); // raw verbatim preserved + + var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4"); + Assert.Null(a4.LitterId); // date-only link quarantined + + // PairingCode carried through + Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC")); + } + + [Fact] + public async Task Execute_is_idempotent() + { + using var db = NewDb(); + await new ImportService(db, _dir, _dir).RunAsync(execute: true); + var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true); + + Assert.Equal(0, second.Animals.Created); + Assert.Equal(0, second.Litters.Created); + Assert.Equal(2, await db.Gerbils.CountAsync()); + Assert.Equal(2, await db.Litters.CountAsync()); + } + + [Fact] + public void ComposeGenotype_strips_carets_and_fills_missing_loci() + { + var g = new SourceGenotype + { + Mapped8locus = new() + { + ["A"] = new() { "a", "a" }, + ["C"] = new() { "C", "c^chm" }, + ["E"] = new() { "e", "e^f" }, + }, + }; + // order A C D E G P Sp Re ; missing -> ?? + Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g)); + } + + [Theory] + [InlineData("01.02.2020", 2020, 2, 1)] + [InlineData("5.3.21", 2021, 3, 5)] + public void ParseDate_handles_german_dates(string s, int y, int m, int d) + { + var date = ImportService.ParseDate(s); + Assert.Equal(new DateOnly(y, m, d), date); + } + + // ---- fixtures ---- + private const string LittersJson = """ + [ + {"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"}, + {"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""} + ] + """; + + private const string AnimalsJson = """ + [ + {"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null, + "farbschlag":"Agouti","farbschlagVariants":["Agouti"], + "genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"]},"rawGenotype":"aa CC D- ee[f]","unmappedTokens":[]}, + "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false, + "litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}}, + {"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null, + "farbschlag":"Schwarz","farbschlagVariants":["Schwarz"], + "genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]}, + "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true}, + {"id":"a3","name":"Namenlos","dob":"","death":"","gender":null, + "farbschlag":"","farbschlagVariants":[], + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]}, + "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false}, + {"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null, + "farbschlag":"","farbschlagVariants":[], + "genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]}, + "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false, + "litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}} + ] + """; + } +} diff --git a/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs new file mode 100644 index 0000000..cfc5b28 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs @@ -0,0 +1,35 @@ +using GerbilManagerWebAPI.Import; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output. + /// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes) + /// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run + /// against Julian's DB under god-supervision after he approves the dry-run. + /// + public static class ImportEndpoints + { + public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/import").WithTags("Import"); + + group.MapPost("/dry-run", async Task> ( + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var report = await new ImportService(db, config, env).RunAsync(execute: false); + return TypedResults.Ok(report); + }); + + group.MapPost("/execute", async Task> ( + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var report = await new ImportService(db, config, env).RunAsync(execute: true); + return TypedResults.Ok(report); + }); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs new file mode 100644 index 0000000..adecdb4 --- /dev/null +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Serialization; + +namespace GerbilManagerWebAPI.Import +{ + // ---- Source shapes (tools/import/output JSON, produced by extract.py) ---- + + public sealed class SourceAnimal + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public List NameVariants { get; set; } = new(); + public string Dob { get; set; } = ""; + public string Death { get; set; } = ""; + public string? Gender { get; set; } + public string Farbschlag { get; set; } = ""; + public List FarbschlagVariants { get; set; } = new(); + public SourceGenotype Genotype { get; set; } = new(); + public string Zucht { get; set; } = ""; + public List ParentRefs { get; set; } = new(); + public List Photos { get; set; } = new(); + public List SourceFiles { get; set; } = new(); + public bool Conflict { get; set; } + public SourceLitterRef? LitterRef { get; set; } + } + + public sealed class SourceGenotype + { + public Dictionary> Mapped8locus { get; set; } = new(); + public string RawGenotype { get; set; } = ""; + public List UnmappedTokens { get; set; } = new(); + } + + public sealed class SourceParentRef + { + public string Name { get; set; } = ""; + public string Dob { get; set; } = ""; + public string RoleGuess { get; set; } = ""; + } + + public sealed class SourceLitterRef + { + public string LitterId { get; set; } = ""; + public string Method { get; set; } = ""; + public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates) + public List? Candidates { get; set; } + } + + public sealed class SourceLitter + { + public string Id { get; set; } = ""; + public string LitterId { get; set; } = ""; + public string Date { get; set; } = ""; + public string DamName { get; set; } = ""; + public string SireName { get; set; } = ""; + public int? TotalBorn { get; set; } + public string Zuchtnummer { get; set; } = ""; + public string Note { get; set; } = ""; + public List Warnings { get; set; } = new(); + } + + // ---- Report shapes (Julian-readable: counts per category + samples) ---- + + public sealed record ImportReport( + bool Executed, + LitterSummary Litters, + AnimalSummary Animals, + PhotoSummary Photos, + IReadOnlyList Samples, + IReadOnlyList Notes); + + public sealed record LitterSummary(int InSource, int Created, int AlreadyImported); + + public sealed record AnimalSummary( + int InSource, + int Created, + int LinkedToLitter, + int FarbschlagMatched, + int FarbschlagUnmatched, + int AlreadyImported, + QuarantineSummary Quarantined); + + public sealed record QuarantineSummary( + int Conflicts, + int Stubs, + int DateOnlyLinks, + int AmbiguousLinks, + int Total); + + public sealed record PhotoSummary(int Attached, int SourceFilesMissing); +} diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs new file mode 100644 index 0000000..6cfad3c --- /dev/null +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -0,0 +1,301 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Import +{ + /// + /// FEAT-8c import loader. Consumes tools/import/output (animals.json + litters.json + /// produced by extract.py) and loads conflict-free data into the database: + /// litters first (Wurfchronik = authoritative), then animals matched onto them. + /// + /// Load policy: + /// - Litters: all created (idempotent by ExternalRef = source litter id). + /// - Animals: created when they have a DOB and are NOT in conflict. The birth-litter + /// link is set only for HIGH-confidence matches (litterRef.confidence == "hoch"); + /// date-only/ambiguous links are quarantined (animal loads with LitterId = null). + /// - QUARANTINED (never loaded): conflicts + stubs (no DOB) — await the wife's review. + /// Idempotent: re-running matches on ExternalRef and skips existing rows. + /// Execute is gated by the endpoint; this service only acts when asked. + /// + public sealed class ImportService + { + private static readonly string[] LocusOrder = { "A", "C", "D", "E", "G", "P", "Sp", "Re" }; + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + }; + private const string ImportSourceTag = "FEAT-8 Stammbaum/Wurfchronik"; + + private readonly ApplicationContext _db; + private readonly string _sourceDir; + private readonly string _photoRoot; + + /// Test-friendly constructor with explicit paths. + public ImportService(ApplicationContext db, string sourceDir, string photoRoot) + { + _db = db; + _sourceDir = sourceDir; + _photoRoot = photoRoot; + } + + public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env) + : this(db, + config["Import:SourcePath"] + ?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")), + config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage")) + { + } + + public async Task RunAsync(bool execute) + { + var notes = new List(); + var samples = new List(); + + var animals = Load>("animals.json") ?? new(); + var litters = Load>("litters.json") ?? new(); + if (animals.Count == 0 && litters.Count == 0) + notes.Add($"Keine Quelldaten gefunden in {_sourceDir} (animals.json/litters.json). extract.py zuerst ausführen."); + + // ---- categorise animals ---- + var loadable = new List(); + int conflicts = 0, stubs = 0, dateOnly = 0, ambiguous = 0; + foreach (var a in animals) + { + if (a.Conflict) { conflicts++; continue; } + if (string.IsNullOrEmpty(a.Dob)) { stubs++; continue; } + loadable.Add(a); + var conf = a.LitterRef?.Confidence; + if (a.LitterRef?.Candidates is { Count: > 0 }) ambiguous++; + else if (conf == "niedrig") dateOnly++; + } + + // existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such + // column, so we key litter idempotency on the stable (Name + Date) pair instead. + var existingGerbilExtRefs = await _db.Gerbils + .Where(g => g.ExternalRef != null) + .Select(g => g.ExternalRef!).ToListAsync(); + var existingGerbilSet = existingGerbilExtRefs.ToHashSet(); + + var existingLitterKeys = await _db.Litters + .Select(l => new { l.Name, l.Date }).ToListAsync(); + var existingLitterKeySet = existingLitterKeys + .Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet(); + + // colour-variety name -> id (case-insensitive) + var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync(); + var varietyByName = varieties + .GroupBy(v => v.Name.Trim().ToLowerInvariant()) + .ToDictionary(g => g.Key, g => g.First().Id); + + // gender inference from litter roles (sire -> male, dam -> female; both -> unknown) + var sireNames = litters.Select(l => Normalize(StripZucht(l.SireName))).Where(s => s.Length > 0).ToHashSet(); + var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); + + // ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- + int littersCreated = 0, littersExisting = 0; + var litterIdMap = new Dictionary(); // source litter id -> Litter.Id + foreach (var sl in litters) + { + var date = ParseDate(sl.Date); + var name = $"Wurf {sl.LitterId}".Trim(); + var key = $"{name}|{date:yyyy-MM-dd}"; + if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } + + var id = Guid.NewGuid(); + litterIdMap[sl.Id] = id; + littersCreated++; + if (execute && date is DateOnly d) + { + _db.Litters.Add(new Litter + { + Id = id, + Name = name, + Date = d, + TotalBorn = sl.TotalBorn, + Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, + PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, + }); + } + if (samples.Count < 8 && date is not null) + samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}"); + } + if (execute) await _db.SaveChangesAsync(); + + // ---- animals ---- + int animalsCreated = 0, linked = 0, fbMatched = 0, fbUnmatched = 0, animalsExisting = 0; + int photosAttached = 0, photosMissing = 0; + var createdAnimalByName = new Dictionary(); // normalized name -> gerbil id (for litter back-link) + + foreach (var a in loadable) + { + if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; } + animalsCreated++; + + Guid? litterId = null; + if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 } + && litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid)) + { + litterId = lid; + linked++; + } + + 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) fbUnmatched++; else fbMatched++; + + var gender = InferGender(a, sireNames, damNames); + var gid = Guid.NewGuid(); + var norm = Normalize(StripZucht(a.Name)); + if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid); + + if (samples.Count < 16) + samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}" + + (litterId is not null ? ", Wurf-verknüpft" : "") + + (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : "")); + + if (execute) + { + _db.Gerbils.Add(new Gerbil + { + Id = gid, + Name = a.Name, + Gender = gender, + Status = GerbilStatus.Active, + DateOfBirth = ParseDate(a.Dob), + DateOfDeath = ParseDate(a.Death), + LitterId = litterId, + ColorVarietyId = colorVarietyId, + Genotype = ComposeGenotype(a.Genotype), + ImportSource = ImportSourceTag, + ExternalRef = a.Id, + RawImportData = JsonSerializer.Serialize(new + { + a.Genotype.RawGenotype, + a.Genotype.UnmappedTokens, + a.Zucht, + a.SourceFiles, + FarbschlagRaw = a.Farbschlag, + }), + }); + } + + // photos + foreach (var rel in a.Photos) + { + var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(src)) { photosMissing++; continue; } + photosAttached++; + if (execute) + { + Directory.CreateDirectory(_photoRoot); + var fileName = $"{Guid.NewGuid():N}{Path.GetExtension(src)}"; + File.Copy(src, Path.Combine(_photoRoot, fileName), overwrite: true); + _db.GerbilPhotos.Add(new GerbilPhoto + { + Id = Guid.NewGuid(), + GerbilId = gid, + FileName = fileName, + SortOrder = 0, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + } + } + if (execute) await _db.SaveChangesAsync(); + + // ---- back-link litter parents by name (best effort) ---- + if (execute) + { + foreach (var sl in litters) + { + if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue; + var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid); + if (litter is null) continue; + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId)) + litter.FatherId = fId; + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId)) + litter.MotherId = mId; + } + await _db.SaveChangesAsync(); + } + + notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); + if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); + + return new ImportReport( + Executed: execute, + Litters: new LitterSummary(litters.Count, littersCreated, littersExisting), + Animals: new AnimalSummary( + animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, + new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs)), + Photos: new PhotoSummary(photosAttached, photosMissing), + Samples: samples, + Notes: notes); + } + + private T? Load(string file) + { + var path = Path.Combine(_sourceDir, file); + if (!File.Exists(path)) return default; + using var fs = File.OpenRead(path); + return JsonSerializer.Deserialize(fs, Json); + } + + public static string ComposeGenotype(SourceGenotype g) + { + var tokens = LocusOrder.Select(locus => + { + if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2) + return StripCaret(pair[0]) + StripCaret(pair[1]); + return "??"; + }); + return string.Join(' ', tokens); + } + + private static string StripCaret(string allele) => allele.Replace("^", ""); + + private static Gender InferGender(SourceAnimal a, HashSet sires, HashSet dams) + { + var n = Normalize(StripZucht(a.Name)); + bool isSire = sires.Contains(n), isDam = dams.Contains(n); + if (isSire && !isDam) return Gender.male; + if (isDam && !isSire) return Gender.female; + return Gender.unknown; + } + + public static DateOnly? ParseDate(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})"); + if (!m.Success) return null; + int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value); + int y = int.Parse(m.Groups[3].Value); + if (y < 100) y += 2000; + try { return new DateOnly(y, mo, d); } + catch { return null; } + } + + private static string StripZucht(string name) + { + var n = Regex.Replace(name ?? "", @"\[.*?\]", " "); + n = Regex.Replace(n, @"\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+.*$", "", RegexOptions.IgnoreCase); + return n.Trim(); + } + + private static string Normalize(string name) + { + var n = (name ?? "").ToLowerInvariant(); + n = Regex.Replace(n, @"\bgen\.\b", " "); + n = Regex.Replace(n, @"[^a-z0-9äöüß]", ""); + return n; + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs new file mode 100644 index 0000000..bf9b777 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs @@ -0,0 +1,897 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260606052952_AddImportProvenanceFields")] + partial class AddImportProvenanceFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("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 = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("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("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("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .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("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.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.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs new file mode 100644 index 0000000..c7c5f08 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddImportProvenanceFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PairingCode", + table: "Litters", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "RawImportData", + table: "Gerbils", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PairingCode", + table: "Litters"); + + migrationBuilder.DropColumn( + name: "RawImportData", + table: "Gerbils"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 2116917..b27de05 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -650,6 +650,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("OriginContactId") .HasColumnType("uuid"); + b.Property("RawImportData") + .HasColumnType("text"); + b.Property("ReceiverContactId") .HasColumnType("uuid"); @@ -759,6 +762,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("Notes") .HasColumnType("text"); + b.Property("PairingCode") + .HasColumnType("text"); + b.Property("TotalBorn") .HasColumnType("integer"); diff --git a/GerbilManagerWebAPI/Models/Gerbil.cs b/GerbilManagerWebAPI/Models/Gerbil.cs index 0839591..5d96bce 100644 --- a/GerbilManagerWebAPI/Models/Gerbil.cs +++ b/GerbilManagerWebAPI/Models/Gerbil.cs @@ -46,5 +46,9 @@ namespace GerbilManagerWebAPI.Models // Provenance (for the FEAT-8 spreadsheet import). public string? ImportSource { get; set; } public string? ExternalRef { 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/GerbilManagerWebAPI/Models/Litter.cs b/GerbilManagerWebAPI/Models/Litter.cs index 89b68af..954c4c4 100644 --- a/GerbilManagerWebAPI/Models/Litter.cs +++ b/GerbilManagerWebAPI/Models/Litter.cs @@ -21,5 +21,9 @@ namespace GerbilManagerWebAPI.Models /// Computed ~35 days after Date by default; editable. public DateOnly? ExpectedGoHomeDate { get; set; } public string? Notes { get; set; } + + /// Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code; + /// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import. + public string? PairingCode { get; set; } } } diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 6c28261..335275e 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -67,5 +67,6 @@ app.MapWeightRecordEndpoints(); app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapSaleAdEndpoints(); +app.MapImportEndpoints(); app.Run(); diff --git a/tools/import/extract.py b/tools/import/extract.py index 9221b8c..4cfabcf 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -554,10 +554,14 @@ def dedup(animals): "photos": sorted(set(photos)), "sourceFiles": sorted(files), "mentions": len(grp), + # FEAT-8c: machine-readable quarantine marker so the API loader can skip + # conflicting records without parsing the German review report. + "conflict": False, } merged.append(out) # conflict: same animal, disagreeing genotype or farbschlag or death if len(genos) > 1 or len(farb) > 1 or len(deaths) > 1: + out["conflict"] = True conflicts.append({ "id": out["id"], "name": base["name"], "dob": out["dob"], "genotypes": sorted(genos), "farbschlaege": sorted(farb),