diff --git a/GerbilManager.Tests/EnclosureEndpointTests.cs b/GerbilManager.Tests/EnclosureEndpointTests.cs new file mode 100644 index 0000000..5b422de --- /dev/null +++ b/GerbilManager.Tests/EnclosureEndpointTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace GerbilManager.Tests; + +/// +/// Gehege-Reinigungszyklus (RennmausPro becken_tb): Enclosure trägt Maße (Size), +/// Kapazität (Capacity), letzte Reinigung (LastCleanedDate) und Reinigungsintervall +/// (CleaningCycleDays). NextCleaningDate = LastCleanedDate + CleaningCycleDays wird +/// berechnet ausgegeben. "mark-cleaned" setzt die letzte Reinigung auf heute. +/// +public class EnclosureEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public EnclosureEndpointTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Post_persists_cleaning_fields_and_computes_next_cleaning() + { + var client = _factory.CreateClient(); + + var resp = await client.PostAsJsonAsync("/enclosures", new + { + name = "Reinigungs-Becken", + notes = "Test", + size = "120×50 cm", + capacity = 6, + lastCleanedDate = "2026-06-01", + cleaningCycleDays = 14, + }); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("120×50 cm", dto.GetProperty("size").GetString()); + Assert.Equal(6, dto.GetProperty("capacity").GetInt32()); + Assert.Equal("2026-06-01", dto.GetProperty("lastCleanedDate").GetString()); + Assert.Equal(14, dto.GetProperty("cleaningCycleDays").GetInt32()); + // 2026-06-01 + 14 Tage = 2026-06-15 + Assert.Equal("2026-06-15", dto.GetProperty("nextCleaningDate").GetString()); + } + + [Fact] + public async Task NextCleaning_is_null_without_cycle_or_lastCleaned() + { + var client = _factory.CreateClient(); + + // Nur letzte Reinigung, kein Zyklus -> keine nächste fällige Reinigung. + var resp = await client.PostAsJsonAsync("/enclosures", new + { + name = "Becken ohne Zyklus", + lastCleanedDate = "2026-06-01", + }); + var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(JsonValueKind.Null, dto.GetProperty("nextCleaningDate").ValueKind); + Assert.Equal(JsonValueKind.Null, dto.GetProperty("cleaningCycleDays").ValueKind); + } + + [Fact] + public async Task Put_updates_cleaning_fields() + { + var client = _factory.CreateClient(); + + var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new + { + name = "Becken-Edit", + cleaningCycleDays = 7, + })).Content.ReadAsStringAsync()).RootElement; + var id = created.GetProperty("id").GetString(); + + var put = await client.PutAsJsonAsync($"/enclosures/{id}", new + { + name = "Becken-Edit", + size = "80×40 cm", + capacity = 4, + lastCleanedDate = "2026-05-20", + cleaningCycleDays = 10, + }); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var dto = JsonDocument.Parse(await client.GetStringAsync($"/enclosures/{id}")).RootElement; + Assert.Equal("80×40 cm", dto.GetProperty("size").GetString()); + Assert.Equal(4, dto.GetProperty("capacity").GetInt32()); + Assert.Equal("2026-05-20", dto.GetProperty("lastCleanedDate").GetString()); + Assert.Equal(10, dto.GetProperty("cleaningCycleDays").GetInt32()); + Assert.Equal("2026-05-30", dto.GetProperty("nextCleaningDate").GetString()); + } + + [Fact] + public async Task MarkCleaned_sets_lastCleaned_to_today() + { + var client = _factory.CreateClient(); + + var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new + { + name = "Becken-Mark", + cleaningCycleDays = 21, + lastCleanedDate = "2020-01-01", + })).Content.ReadAsStringAsync()).RootElement; + var id = created.GetProperty("id").GetString(); + + var resp = await client.PostAsync($"/enclosures/{id}/mark-cleaned", null); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var today = DateOnly.FromDateTime(DateTime.Today); + Assert.Equal(today.ToString("yyyy-MM-dd"), dto.GetProperty("lastCleanedDate").GetString()); + Assert.Equal(today.AddDays(21).ToString("yyyy-MM-dd"), dto.GetProperty("nextCleaningDate").GetString()); + } + + [Fact] + public async Task MarkCleaned_unknown_id_returns_404() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsync($"/enclosures/{Guid.NewGuid()}/mark-cleaned", null); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } +} diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs index 399186e..1f0d36c 100644 --- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -49,7 +49,10 @@ namespace GerbilManagerWebAPI.Dtos public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance); - public record EnclosureDto(Guid Id, string Name, string? Notes); + public record EnclosureDto( + Guid Id, string Name, string? Notes, + string? Size, int? Capacity, + DateOnly? LastCleanedDate, int? CleaningCycleDays, DateOnly? NextCleaningDate); public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder); @@ -101,7 +104,10 @@ namespace GerbilManagerWebAPI.Dtos public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix); - public record EnclosureInput(string Name, string? Notes); + public record EnclosureInput( + string Name, string? Notes, + string? Size, int? Capacity, + DateOnly? LastCleanedDate, int? CleaningCycleDays); public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder); diff --git a/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs b/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs index 47f79c7..7a6c940 100644 --- a/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs @@ -25,6 +25,7 @@ namespace GerbilManagerWebAPI.Endpoints group.MapPost("/", async (EnclosureInput input, ApplicationContext db) => { var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes }; + Apply(e, input); db.Enclosures.Add(e); await db.SaveChangesAsync(); return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e)); @@ -35,10 +36,21 @@ namespace GerbilManagerWebAPI.Endpoints var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id); if (e is null) return TypedResults.NotFound(); e.Name = input.Name; e.Notes = input.Notes; + Apply(e, input); await db.SaveChangesAsync(); return TypedResults.NoContent(); }); + // Reinigung dokumentieren: setzt LastCleanedDate auf heute (NextCleaningDate folgt aus dem Zyklus). + group.MapPost("/{id:guid}/mark-cleaned", async Task, NotFound>> (Guid id, ApplicationContext db) => + { + var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id); + if (e is null) return TypedResults.NotFound(); + e.LastCleanedDate = DateOnly.FromDateTime(DateTime.Today); + await db.SaveChangesAsync(); + return TypedResults.Ok(ToDto(e)); + }); + // 409 if the enclosure still houses gerbils. group.MapDelete("/{id:guid}", async Task>> (Guid id, ApplicationContext db) => { @@ -54,6 +66,16 @@ namespace GerbilManagerWebAPI.Endpoints return app; } - private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes); + private static void Apply(Enclosure e, EnclosureInput input) + { + e.Size = input.Size; + e.Capacity = input.Capacity; + e.LastCleanedDate = input.LastCleanedDate; + e.CleaningCycleDays = input.CleaningCycleDays; + } + + private static EnclosureDto ToDto(Enclosure e) => + new(e.Id, e.Name, e.Notes, e.Size, e.Capacity, + e.LastCleanedDate, e.CleaningCycleDays, e.NextCleaningDate); } } diff --git a/GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs new file mode 100644 index 0000000..e89100a --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.Designer.cs @@ -0,0 +1,1560 @@ +// +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("20260622201422_AddEnclosureCleaning")] + partial class AddEnclosureCleaning + { + /// + 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.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CleaningCycleDays") + .HasColumnType("integer"); + + b.Property("LastCleanedDate") + .HasColumnType("date"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Size") + .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("ContactId") + .HasColumnType("uuid"); + + 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("Provenance") + .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/20260622201422_AddEnclosureCleaning.cs b/GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.cs new file mode 100644 index 0000000..33f4e17 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201422_AddEnclosureCleaning.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddEnclosureCleaning : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Capacity", + table: "Enclosures", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CleaningCycleDays", + table: "Enclosures", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastCleanedDate", + table: "Enclosures", + type: "date", + nullable: true); + + migrationBuilder.AddColumn( + name: "Size", + table: "Enclosures", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Capacity", + table: "Enclosures"); + + migrationBuilder.DropColumn( + name: "CleaningCycleDays", + table: "Enclosures"); + + migrationBuilder.DropColumn( + name: "LastCleanedDate", + table: "Enclosures"); + + migrationBuilder.DropColumn( + name: "Size", + table: "Enclosures"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 725b04b..9a4cf3b 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -755,6 +755,15 @@ namespace GerbilManagerWebAPI.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CleaningCycleDays") + .HasColumnType("integer"); + + b.Property("LastCleanedDate") + .HasColumnType("date"); + b.Property("Name") .IsRequired() .HasColumnType("text"); @@ -762,6 +771,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("Notes") .HasColumnType("text"); + b.Property("Size") + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("Enclosures"); diff --git a/GerbilManagerWebAPI/Models/Enclosure.cs b/GerbilManagerWebAPI/Models/Enclosure.cs index 825eb68..565a8d4 100644 --- a/GerbilManagerWebAPI/Models/Enclosure.cs +++ b/GerbilManagerWebAPI/Models/Enclosure.cs @@ -10,6 +10,22 @@ namespace GerbilManagerWebAPI.Models public required string Name { get; set; } public string? Notes { get; set; } + // Reinigungszyklus (aus RennmausPro becken_tb: _SIZE/_MENGE/_CLEANED/_CYCLUS). + /// Maße als Freitext (z. B. "120×50 cm"). + public string? Size { get; set; } + /// Empfohlene/maximale Tieranzahl. + public int? Capacity { get; set; } + /// Datum der letzten Reinigung. + public DateOnly? LastCleanedDate { get; set; } + /// Reinigungsintervall in Tagen. + public int? CleaningCycleDays { get; set; } + + /// Nächste fällige Reinigung = LastCleanedDate + CleaningCycleDays (berechnet, nicht persistiert). + public DateOnly? NextCleaningDate => + LastCleanedDate is { } last && CleaningCycleDays is { } cycle and > 0 + ? last.AddDays(cycle) + : null; + public ICollection Gerbils { get; } = new List(); } } diff --git a/gerbil-manager-web/e2e/becken-kontakte.spec.ts b/gerbil-manager-web/e2e/becken-kontakte.spec.ts index 90a7e1f..1e075ed 100644 --- a/gerbil-manager-web/e2e/becken-kontakte.spec.ts +++ b/gerbil-manager-web/e2e/becken-kontakte.spec.ts @@ -25,6 +25,35 @@ test.describe('Becken', () => { await expect(page.getByText(tb.delete.conflict)).toBeVisible() }) + test('Reinigungszyklus: überfälliges Becken zeigt Hinweis + Als gereinigt markieren', async ({ page }) => { + skipUnlessMock() + await page.goto('/gehege') + await page.getByRole('link', { name: /Großbecken/ }).click() + await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible() + // Maße + Kapazität werden angezeigt + await expect(page.getByText('120×50 cm')).toBeVisible() + // Reinigung ist überfällig (nextCleaningDate in der Vergangenheit) -> Warn-Hinweis + await expect(page.getByText(/Reinigung fällig/)).toBeVisible() + // Als gereinigt markieren -> letzte Reinigung = heute, Hinweis verschwindet + await page.getByRole('button', { name: tb.cleaning.markCleaned }).click() + await expect(page.getByText(/Reinigung fällig/)).toBeHidden() + }) + + test('Reinigungszyklus: Becken mit Zyklus anlegen zeigt nächste Reinigung', async ({ page }) => { + await gotoSection(page, de.nav.enclosures) + await page.getByRole('link', { name: tb.newButton }).click() + const name = uniqueName('Zyklusbecken') + await page.getByLabel(`${tb.fields.name} *`).fill(name) + await page.getByLabel(tb.fields.size).fill('60×40 cm') + await page.getByLabel(tb.fields.capacity).fill('4') + await page.getByLabel(tb.fields.lastCleanedDate).fill('2026-06-01') + await page.getByLabel(tb.fields.cleaningCycleDays).fill('14') + await page.getByRole('button', { name: tb.form.save, exact: true }).click() + await expect(page.getByRole('heading', { name })).toBeVisible() + // nächste Reinigung = 2026-06-01 + 14 Tage = 15.06.2026 (in der def-list-Zeile) + await expect(page.getByText('15.06.2026', { exact: true })).toBeVisible() + }) + test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => { await gotoSection(page, de.nav.enclosures) await page.getByRole('link', { name: tb.newButton }).click() diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 78faa7a..a99e87b 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -261,6 +261,22 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204) + // Gehege-Reinigungszyklus: nächste fällige Reinigung = letzte Reinigung + Zyklus (Tage). + const nextCleaning = (lastCleaned?: string | null, cycleDays?: number | null): string | null => { + if (!lastCleaned || cycleDays == null || cycleDays <= 0) return null + const d = new Date(`${lastCleaned}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + cycleDays) + return d.toISOString().slice(0, 10) + } + // "Als gereinigt markieren": setzt letzte Reinigung auf heute (wie das echte Backend). + m = path.match(/^\/enclosures\/([^/]+)\/mark-cleaned$/) + if (m && method === 'POST') { + const enc = db.enclosures.find((x) => x.id === m![1]) + if (!enc) return json(route, 404, { title: 'Not Found' }) + enc.lastCleanedDate = new Date().toISOString().slice(0, 10) + enc.nextCleaningDate = nextCleaning(enc.lastCleanedDate, enc.cleaningCycleDays) + return json(route, 200, enc) + } m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/) if (m) { const isKruemel = m[1] === 'kruemel' @@ -427,6 +443,13 @@ export async function installMockApi(page: Page): Promise { if (method === 'POST') { const body = request.postDataJSON() as Row const created = { id: newId(col.idPrefix), ...body } + // Gehege: berechnetes Feld nextCleaningDate wie das echte Backend ableiten. + if (m[1] === 'enclosures') { + created.nextCleaningDate = nextCleaning( + created.lastCleanedDate as string | null, + created.cleaningCycleDays as number | null, + ) + } col.rows.push(created) return json(route, 201, created) } @@ -440,6 +463,12 @@ export async function installMockApi(page: Page): Promise { if (method === 'PUT') { if (idx < 0) return json(route, 404, { title: 'Not Found' }) Object.assign(col.rows[idx], request.postDataJSON() as Row) + if (m[1] === 'enclosures') { + col.rows[idx].nextCleaningDate = nextCleaning( + col.rows[idx].lastCleanedDate as string | null, + col.rows[idx].cleaningCycleDays as number | null, + ) + } return json(route, 200, col.rows[idx]) } if (method === 'DELETE') { diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index b07cefe..5153296 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -232,8 +232,28 @@ export function seedDb(): MockDb { ] const enclosures: Enclosure[] = [ - { id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' }, - { id: 'enc-leer', name: 'Quarantänebecken', notes: null }, + // Reinigung überfällig: letzte Reinigung lange her + Zyklus -> nextCleaningDate in der Vergangenheit. + { + id: 'enc-gross', + name: 'Großbecken', + notes: null, + size: '120×50 cm', + capacity: 6, + lastCleanedDate: '2020-01-01', + cleaningCycleDays: 14, + nextCleaningDate: '2020-01-15', + }, + // Kein Reinigungszyklus hinterlegt. + { + id: 'enc-leer', + name: 'Quarantänebecken', + notes: null, + size: null, + capacity: null, + lastCleanedDate: null, + cleaningCycleDays: null, + nextCleaningDate: null, + }, ] // FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt. diff --git a/gerbil-manager-web/src/api/enclosures.ts b/gerbil-manager-web/src/api/enclosures.ts index abd372a..4a3af86 100644 --- a/gerbil-manager-web/src/api/enclosures.ts +++ b/gerbil-manager-web/src/api/enclosures.ts @@ -7,6 +7,14 @@ import type { Enclosure, Paged } from './types' export interface CreateEnclosure { name: string notes?: string | null + /** Maße als Freitext, z. B. "120×50 cm". */ + size?: string | null + /** Empfohlene/maximale Tieranzahl. */ + capacity?: number | null + /** Datum der letzten Reinigung (ISO yyyy-MM-dd). */ + lastCleanedDate?: string | null + /** Reinigungsintervall in Tagen. */ + cleaningCycleDays?: number | null } /** Payload for PUT /enclosures/{id}. */ @@ -31,3 +39,8 @@ export function updateEnclosure(id: string, body: UpdateEnclosure): Promise { return api.delete(`${resources.enclosures}/${id}`) } + +/** Reinigung dokumentieren: setzt die letzte Reinigung auf heute. */ +export function markEnclosureCleaned(id: string): Promise { + return api.post(`${resources.enclosures}/${id}/mark-cleaned`, {}) +} diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts index 725cd89..1d22dba 100644 --- a/gerbil-manager-web/src/api/types.ts +++ b/gerbil-manager-web/src/api/types.ts @@ -143,6 +143,16 @@ export interface Enclosure { id: string name: string notes: string | null + /** Maße als Freitext, z. B. "120×50 cm". */ + size: string | null + /** Empfohlene/maximale Tieranzahl. */ + capacity: number | null + /** Datum der letzten Reinigung (ISO yyyy-MM-dd). */ + lastCleanedDate: string | null + /** Reinigungsintervall in Tagen. */ + cleaningCycleDays: number | null + /** Berechnet: lastCleanedDate + cleaningCycleDays (ISO yyyy-MM-dd). */ + nextCleaningDate: string | null } export interface Contact { diff --git a/gerbil-manager-web/src/pages/BeckenDetailPage.tsx b/gerbil-manager-web/src/pages/BeckenDetailPage.tsx index a1b4039..67f46d3 100644 --- a/gerbil-manager-web/src/pages/BeckenDetailPage.tsx +++ b/gerbil-manager-web/src/pages/BeckenDetailPage.tsx @@ -7,17 +7,27 @@ import { useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' -import { deleteEnclosure, getEnclosure } from '../api/enclosures' +import { deleteEnclosure, getEnclosure, markEnclosureCleaned } from '../api/enclosures' import { listGerbils } from '../api/gerbils' import { listColorVarieties } from '../api/lookups' import { condition } from '../api/gridify' import { useApi, useMutation } from '../hooks/useApi' +import { formatDate } from '../format/labels' +import { useToast } from '../components/toast' import EnclosurePhotosSection from '../components/EnclosurePhotosSection' +/** true, wenn die nächste fällige Reinigung am/vor heute liegt. */ +function isCleaningDue(nextCleaningDate: string | null): boolean { + if (!nextCleaningDate) return false + const today = new Date().toISOString().slice(0, 10) + return nextCleaningDate <= today +} + export default function BeckenDetailPage() { const t = de.pages.becken const { id = '' } = useParams() const navigate = useNavigate() + const toast = useToast() const [deleteError, setDeleteError] = useState(null) const enclosure = useApi(() => getEnclosure(id), [id]) @@ -39,6 +49,17 @@ export default function BeckenDetailPage() { ) const removal = useMutation(() => deleteEnclosure(id)) + const cleaning = useMutation(() => markEnclosureCleaned(id)) + + async function onMarkCleaned() { + const result = await cleaning.run() + if (result.ok) { + toast.success(t.cleaning.marked) + enclosure.reload() + } else { + toast.error(result.error) + } + } async function onDelete() { if (!window.confirm(t.delete.confirmMessage)) return @@ -101,15 +122,62 @@ export default function BeckenDetailPage() { {deleteError &&
{deleteError}
} - {e.notes && ( + {(e.notes || e.size || e.capacity != null) && (
-
-
{t.fields.notes}
-
{e.notes}
-
+ {e.notes && ( +
+
{t.fields.notes}
+
{e.notes}
+
+ )} + {e.size && ( +
+
{t.fields.size}
+
{e.size}
+
+ )} + {e.capacity != null && ( +
+
{t.fields.capacity}
+
{t.cleaning.capacityUnit(e.capacity)}
+
+ )}
)} +

{t.cleaning.title}

+ {isCleaningDue(e.nextCleaningDate) && ( +
+ {e.nextCleaningDate + ? t.cleaning.dueSince(formatDate(e.nextCleaningDate)) + : t.cleaning.due} +
+ )} +
+
+
{t.fields.lastCleanedDate}
+
{e.lastCleanedDate ? formatDate(e.lastCleanedDate) : t.cleaning.neverCleaned}
+
+
+
{t.fields.cleaningCycleDays}
+
{e.cleaningCycleDays != null ? t.cleaning.cycleUnit(e.cleaningCycleDays) : t.cleaning.noCycle}
+
+ {e.nextCleaningDate && ( +
+
{t.fields.nextCleaningDate}
+
{formatDate(e.nextCleaningDate)}
+
+ )} +
+ +

{t.photosTitle}

diff --git a/gerbil-manager-web/src/pages/BeckenFormPage.tsx b/gerbil-manager-web/src/pages/BeckenFormPage.tsx index ea5e0a2..81b4ccf 100644 --- a/gerbil-manager-web/src/pages/BeckenFormPage.tsx +++ b/gerbil-manager-web/src/pages/BeckenFormPage.tsx @@ -9,13 +9,32 @@ import { useToast } from '../components/toast' interface FormState { name: string notes: string + size: string + capacity: string + lastCleanedDate: string + cleaningCycleDays: string } -const EMPTY: FormState = { name: '', notes: '' } +const EMPTY: FormState = { + name: '', + notes: '', + size: '', + capacity: '', + lastCleanedDate: '', + cleaningCycleDays: '', +} /** "" -> null, sonst der Wert. */ const nn = (s: string): string | null => (s.trim() === '' ? null : s) +/** "" -> null, sonst die geparste Ganzzahl (NaN -> null). */ +const ni = (s: string): number | null => { + const t = s.trim() + if (t === '') return null + const n = Number.parseInt(t, 10) + return Number.isNaN(n) ? null : n +} + export default function BeckenFormPage() { const t = de.pages.becken const navigate = useNavigate() @@ -32,7 +51,14 @@ export default function BeckenFormPage() { // Vorbefüllen im Bearbeiten-Modus (adjust-state-during-render, wie FEAT-1). if (existing.data && initializedFor !== existing.data.id) { setInitializedFor(existing.data.id) - setForm({ name: existing.data.name, notes: existing.data.notes ?? '' }) + setForm({ + name: existing.data.name, + notes: existing.data.notes ?? '', + size: existing.data.size ?? '', + capacity: existing.data.capacity?.toString() ?? '', + lastCleanedDate: existing.data.lastCleanedDate ?? '', + cleaningCycleDays: existing.data.cleaningCycleDays?.toString() ?? '', + }) } const mutation = useMutation((body: CreateEnclosure) => @@ -46,7 +72,14 @@ export default function BeckenFormPage() { return } setErrors({}) - const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) }) + const result = await mutation.run({ + name: form.name.trim(), + notes: nn(form.notes), + size: nn(form.size), + capacity: ni(form.capacity), + lastCleanedDate: nn(form.lastCleanedDate), + cleaningCycleDays: ni(form.cleaningCycleDays), + }) if (result.ok) { toast.success(de.common.saved) navigate(`/gehege/${result.value.id}`) @@ -82,6 +115,48 @@ export default function BeckenFormPage() { /> + + + + + + + + {mutation.error &&
{mutation.error}
}
diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index a146723..87d8a5d 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -368,6 +368,26 @@ export const de = { fields: { name: 'Name', notes: 'Notizen', + // Reinigungszyklus (RennmausPro becken_tb). + size: 'Maße', + capacity: 'Empf. Tieranzahl', + lastCleanedDate: 'Zuletzt gereinigt', + cleaningCycleDays: 'Reinigungszyklus (Tage)', + nextCleaningDate: 'Nächste Reinigung', + }, + // Reinigungszyklus: Hinweise + Aktion auf der Detailseite. + cleaning: { + title: 'Reinigung', + due: 'Reinigung fällig', + dueSince: (date: string) => `Reinigung fällig (seit ${date})`, + nextOn: (date: string) => `Nächste Reinigung am ${date}`, + noCycle: 'Kein Reinigungszyklus hinterlegt.', + neverCleaned: 'Noch nie als gereinigt vermerkt.', + markCleaned: 'Als gereinigt markieren', + marking: 'Wird gespeichert …', + marked: 'Reinigung vermerkt.', + capacityUnit: (n: number) => `${n} ${n === 1 ? 'Tier' : 'Tiere'}`, + cycleUnit: (n: number) => `alle ${n} Tage`, }, // Bilder-Sektion auf der Gehege-Detailseite. photosTitle: 'Bilder',