Compare commits
15 Commits
feature/ge
...
efbf24ca39
| Author | SHA1 | Date | |
|---|---|---|---|
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d |
@@ -49,7 +49,14 @@ jobs:
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: dotnet-ef Tool installieren
|
||||
run: dotnet tool install --global dotnet-ef --version 10.0.*
|
||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||
run: |
|
||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
@@ -59,7 +66,7 @@ jobs:
|
||||
dotnet ef migrations has-pending-model-changes
|
||||
--project GerbilManagerWebAPI
|
||||
--startup-project GerbilManagerWebAPI
|
||||
--no-build -c Release
|
||||
--no-build --configuration Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||
{
|
||||
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
// ExternalRef is set on created litters
|
||||
var litters = await db.Litters.ToListAsync();
|
||||
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||
|
||||
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
|
||||
@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||
// support custom collation names and would fail EnsureCreated on the test host.
|
||||
const string DeIcu = "de-x-icu";
|
||||
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
|
||||
modelBuilder.Entity<Gerbil>(e =>
|
||||
{
|
||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
@@ -95,6 +110,12 @@ public class ApplicationContext : DbContext
|
||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(l => l.Mother).WithMany()
|
||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// DB-5: ExternalRef = source litter id from extract.py.
|
||||
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
@@ -176,6 +197,15 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
modelBuilder.Entity<ColorVariety>()
|
||||
.Property(v => v.Name).UseCollation(DeIcu);
|
||||
modelBuilder.Entity<Contact>()
|
||||
.Property(c => c.Name).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
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.
|
||||
// existing rows (idempotency).
|
||||
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
|
||||
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterData
|
||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||
var existingLitterExtRefSet = existingLitterData
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
|
||||
// colour-variety name -> id (case-insensitive)
|
||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||
@@ -94,10 +98,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
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) ----
|
||||
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||
// so skip them early — they can never be created or linked to animals.
|
||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
@@ -107,7 +111,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||
{ littersExisting++; continue; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
@@ -122,6 +129,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8)
|
||||
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGermanCollation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalRef",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters",
|
||||
column: "ExternalRef",
|
||||
unique: true,
|
||||
filter: "\"ExternalRef\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterExternalRef : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
@@ -689,7 +690,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
@@ -777,16 +779,19 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSearch")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OriginBreeder")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
@@ -894,6 +899,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ExternalRef")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -915,6 +923,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalRef")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
@@ -25,5 +25,10 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||
public string? PairingCode { get; set; }
|
||||
|
||||
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
||||
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||
public string? ExternalRef { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||
|
||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||
|---|---|---|---|
|
||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||
| ~~A1~~ | ✅ **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
|
||||
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
|
||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
||||
@@ -126,10 +126,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
||||
| Tier | Konkreter Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
||||
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
||||
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
||||
|
||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# GerbilManager Produktionskonfiguration
|
||||
# GerbilManager Produktionskonfiguration
|
||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||
|
||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||
@@ -26,4 +26,4 @@ BACKUP_KEEP_DAYS=7
|
||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||
AI__BaseUrl=
|
||||
AI__ApiKey=
|
||||
AI__Model=gemini-2.0-flash
|
||||
AI__Model=gemini-flash-latest
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — TrueNAS Custom Application
|
||||
# GerbilManager — TrueNAS Custom Application
|
||||
# ==============================================
|
||||
# Vor dem ersten Start:
|
||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||
AI__ApiKey: "${AI__ApiKey:-}"
|
||||
AI__Model: "${AI__Model:-gemini-2.0-flash}"
|
||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||
|
||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
||||
|
||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||
|---|---|---|---|
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
||||
```powershell
|
||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||
$env:AI__ApiKey = '<schlüssel>'
|
||||
$env:AI__Model = 'gemini-2.0-flash'
|
||||
$env:AI__Model = 'gemini-flash-latest'
|
||||
```
|
||||
|
||||
oder per user-secrets im API-Projekt:
|
||||
@@ -46,5 +46,5 @@ services:
|
||||
environment:
|
||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||
AI__Model: gemini-2.0-flash
|
||||
AI__Model: gemini-flash-latest
|
||||
```
|
||||
|
||||
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
|
||||
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||
*
|
||||
* Abgedeckte Änderungen:
|
||||
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
|
||||
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
|
||||
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
|
||||
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
|
||||
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
|
||||
*
|
||||
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
|
||||
*/
|
||||
import { de, expect, skipUnlessLive, test } from './fixtures'
|
||||
|
||||
const API = 'http://localhost:5179'
|
||||
|
||||
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
|
||||
|
||||
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
|
||||
// eef = e/ef het → E>e>ef Rank → ee[f]
|
||||
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
|
||||
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
|
||||
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
|
||||
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
|
||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||
// Rohes 'eef' darf nicht mehr im Code-Block stehen
|
||||
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
|
||||
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
|
||||
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
|
||||
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
|
||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
|
||||
// Ccchm → Cc[chm]; eef → ee[f]
|
||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
|
||||
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
|
||||
const genoCode = page.locator('code')
|
||||
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
|
||||
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
|
||||
// Rohes Storage-Format darf nicht sichtbar sein
|
||||
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
|
||||
})
|
||||
|
||||
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
|
||||
|
||||
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
|
||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
|
||||
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
|
||||
await expect(originInput).toBeVisible()
|
||||
await expect(originInput).toHaveValue('Kleinen Chaoten')
|
||||
|
||||
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
|
||||
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
|
||||
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
|
||||
await expect(deafLabel).toBeVisible()
|
||||
// Dropdown-Select im Label-Kontext
|
||||
const deafSelect = deafLabel.locator('select')
|
||||
await expect(deafSelect).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
|
||||
|
||||
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Snapshot VORHER
|
||||
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||
const snap = await before.json()
|
||||
expect(snap.originBreeder).toBe('Kleinen Chaoten')
|
||||
expect(snap.genotype).toBeTruthy()
|
||||
|
||||
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
|
||||
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
|
||||
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
|
||||
data: {
|
||||
name: snap.name,
|
||||
gender: snap.gender,
|
||||
status: snap.status,
|
||||
isResident: snap.isResident,
|
||||
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
|
||||
},
|
||||
})
|
||||
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
|
||||
expect(res.status()).toBe(204)
|
||||
|
||||
// Re-Fetch und vergleichen
|
||||
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||
const updated = await afterRes.json()
|
||||
|
||||
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
|
||||
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
|
||||
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
|
||||
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
|
||||
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
|
||||
|
||||
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
|
||||
})
|
||||
|
||||
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
|
||||
|
||||
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
|
||||
await page.goto('/rennmaeuse')
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
|
||||
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
|
||||
const placeholder = page.getByText('(ohne Namen)')
|
||||
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
|
||||
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
|
||||
if (await toggle.isVisible()) await toggle.click()
|
||||
}
|
||||
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Stammbaum eines Tiers mit namenlosem Elternteil
|
||||
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
|
||||
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
|
||||
const placeholder = page.getByText('(ohne Namen)')
|
||||
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
|
||||
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
|
||||
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
if (visible) {
|
||||
await expect(placeholder.first()).toBeVisible()
|
||||
}
|
||||
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
|
||||
})
|
||||
|
||||
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
|
||||
|
||||
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/becken')
|
||||
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
|
||||
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
|
||||
await expect(
|
||||
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -84,6 +84,27 @@
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Little Runner's Big Ben",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||
"dob": "10.04.2022",
|
||||
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Skarlett von den Kleinen Chaoten",
|
||||
"dob": "14.07.2013",
|
||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||
"dateOfDeath": "17.04.2016",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user