Merge feature/export-1: Datenexport (zip JSON+German CSVs) on /einstellungen + CI flake-cap [god-QA pending result-gate]

# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/src/App.tsx
#	gerbil-manager-web/src/components/AppShell.tsx
#	gerbil-manager-web/src/pages/EinstellungenPage.tsx
This commit is contained in:
2026-06-06 08:06:38 +02:00
10 changed files with 488 additions and 3 deletions

View File

@@ -0,0 +1,36 @@
using GerbilManagerWebAPI.Export;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
/// </summary>
public static class ExportEndpoints
{
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
{
var data = new ExportService.ExportData(
await db.Gerbils.AsNoTracking().ToListAsync(ct),
await db.Litters.AsNoTracking().ToListAsync(ct),
await db.Contacts.AsNoTracking().ToListAsync(ct),
await db.Enclosures.AsNoTracking().ToListAsync(ct),
await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
await db.HealthRecords.AsNoTracking().ToListAsync(ct),
await db.WeightRecords.AsNoTracking().ToListAsync(ct),
await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
var today = DateOnly.FromDateTime(DateTime.Now);
var bytes = ExportService.BuildZip(data, today);
return Results.File(bytes, "application/zip",
$"rennmaus-export-{today:yyyy-MM-dd}.zip");
})
.WithTags("Export");
return app;
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Export
{
/// <summary>
/// EXPORT-1: user-facing Datenexport — builds a zip containing
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
///
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
/// and escaping tests run without a database.
/// </summary>
public static class ExportService
{
public sealed record ExportData(
List<Gerbil> Gerbils,
List<Litter> Litters,
List<Contact> Contacts,
List<Enclosure> Enclosures,
List<ColorVariety> ColorVarieties,
List<HealthRecord> HealthRecords,
List<WeightRecord> WeightRecords,
List<GerbilPhoto> Photos);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
// German labels for enum values (CSV is the wife-facing backup format;
// the JSON keeps the enum member names for lossless re-import).
private static readonly Dictionary<Gender, string> GenderDe = new()
{
[Gender.unknown] = "Unbekannt",
[Gender.male] = "Männlich",
[Gender.female] = "Weiblich",
};
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
{
[HealthRecordType.Examination] = "Untersuchung",
[HealthRecordType.Treatment] = "Behandlung",
[HealthRecordType.Injury] = "Verletzung",
[HealthRecordType.Vaccination] = "Impfung",
[HealthRecordType.Other] = "Sonstiges",
};
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
{
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
key is null ? null : map.GetValueOrDefault(key.Value);
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
AddText(zip, "export.json", JsonSerializer.Serialize(new
{
exportedAt = exportDate.ToString("yyyy-MM-dd"),
data.Gerbils,
data.Litters,
data.Contacts,
data.Enclosures,
data.ColorVarieties,
data.HealthRecords,
data.WeightRecords,
data.Photos,
}, JsonOptions), bom: false);
AddCsv(zip, "tiere.csv",
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
"Genotyp", "Notizen"],
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
{
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
g.Genotype, g.Notes,
}));
AddCsv(zip, "wuerfe.csv",
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
"Voraussichtliches Abgabedatum", "Notizen"],
data.Litters.OrderBy(l => l.Date).Select(l => new[]
{
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
De(l.ExpectedGoHomeDate), l.Notes,
}));
AddCsv(zip, "kontakte.csv",
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
{
c.Name, c.Email, c.Phone, c.Address, c.Notes,
}));
AddCsv(zip, "gesundheit.csv",
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
{
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
}));
AddCsv(zip, "gewichte.csv",
["Tier", "Datum", "Gewicht (g)", "Notizen"],
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
{
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
}));
AddText(zip, "LIESMICH.txt", $"""
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
Enthalten:
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
- wuerfe.csv : alle Würfe.
- kontakte.csv : alle Kontakte.
- gesundheit.csv: alle Gesundheitseinträge.
- gewichte.csv : alle Gewichtseinträge.
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
steht in export.json (Abschnitt "photos").
""", bom: false);
}
return stream.ToArray();
}
// ── CSV building blocks ────────────────────────────────────────────
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
private static string? De(DateOnly? date) =>
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
private static string De(DateOnly date) =>
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
/// <summary>
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
/// they contain separator/quote/newline; quotes doubled.
/// </summary>
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
{
var sb = new StringBuilder();
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
foreach (var row in rows)
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
return sb.ToString();
}
internal static string Escape(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
? $"\"{field.Replace("\"", "\"\"")}\""
: field;
}
private static void AddCsv(ZipArchive zip, string name, string[] header,
IEnumerable<string?[]> rows) =>
AddText(zip, name, BuildCsv(header, rows), bom: true);
private static void AddText(ZipArchive zip, string name, string content, bool bom)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var entryStream = entry.Open();
if (bom)
{
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
entryStream.Write(Encoding.UTF8.GetPreamble());
}
var bytes = Encoding.UTF8.GetBytes(content);
entryStream.Write(bytes);
}
}
}

View File

@@ -75,6 +75,7 @@ app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.Run();