Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.
Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
(Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.
Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.
Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
10 KiB
C#
205 lines
10 KiB
C#
using System.IO.Compression;
|
|
using GerbilManagerWebAPI.Dtos;
|
|
using GerbilManagerWebAPI.Import;
|
|
using GerbilManagerWebAPI.Import.Rpro3;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
|
|
namespace GerbilManagerWebAPI.Endpoints
|
|
{
|
|
/// <summary>
|
|
/// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output.
|
|
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
|
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
|
/// against Julian's DB under god-supervision after he approves the dry-run.
|
|
///
|
|
/// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"):
|
|
/// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren;
|
|
/// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen.
|
|
/// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs).
|
|
/// </summary>
|
|
public static class ImportEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/import").WithTags("Import");
|
|
|
|
group.MapPost("/dry-run", async Task<Ok<ImportReport>> (
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var report = await new ImportService(db, config, env).RunAsync(execute: false);
|
|
return TypedResults.Ok(report);
|
|
});
|
|
|
|
group.MapPost("/execute", async Task<Ok<ImportReport>> (
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var report = await new ImportService(db, config, env).RunAsync(execute: true);
|
|
return TypedResults.Ok(report);
|
|
});
|
|
|
|
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
|
return TypedResults.Ok(result);
|
|
});
|
|
|
|
// Upload-Variante: lädt eine resolved_import.json (+ optional Fotos-ZIP) hoch, legt sie in
|
|
// dem Verzeichnis ab, aus dem der Ingest liest, und ingested direkt. Damit lässt sich der
|
|
// Import gegen PROD fahren, OHNE die Datei per SSH/docker cp in den Container zu kopieren
|
|
// (der lokal generierte Payload geht per HTTP an die Prod-API). Abwärtskompatibel — der
|
|
// bestehende /ingest-resolved-Endpoint bleibt unverändert.
|
|
group.MapPost("/ingest-resolved/upload", async Task<Results<Ok<string>, BadRequest<string>>> (
|
|
IFormFile resolved, IFormFile? photos,
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
if (resolved.Length == 0)
|
|
return TypedResults.BadRequest("resolved_import.json ist leer.");
|
|
|
|
var sourceDir = IngestResolvedService.ResolveSourceDir(config, env);
|
|
Directory.CreateDirectory(sourceDir);
|
|
|
|
// resolved_import.json an den erwarteten Ort schreiben.
|
|
var destPath = Path.Combine(sourceDir, "resolved_import.json");
|
|
await using (var fs = File.Create(destPath))
|
|
await resolved.CopyToAsync(fs);
|
|
|
|
// Optionale Fotos-ZIP in den Source-Dir entpacken (Einträge relativ), damit der
|
|
// Foto-Kopierschritt (Path.Combine(sourceDir, relPath)) neue Fotos findet.
|
|
if (photos is not null)
|
|
{
|
|
await using var zs = photos.OpenReadStream();
|
|
using var seekable = await ToSeekable(zs);
|
|
ExtractZipInto(seekable, sourceDir);
|
|
}
|
|
|
|
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
|
return TypedResults.Ok(result);
|
|
}).WithTags("Import").DisableAntiforgery();
|
|
|
|
// ── RPRO3 ──────────────────────────────────────────────────────────
|
|
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
|
IFormFile backup, IFormFile? images,
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
try
|
|
{
|
|
var reader = new Rpro3Reader();
|
|
await using var bs = backup.OpenReadStream();
|
|
using var seekable = await ToSeekable(bs);
|
|
var data = reader.ReadFromBackup(seekable, out var workDir);
|
|
try
|
|
{
|
|
IReadOnlySet<string>? photoFiles = null;
|
|
if (images is not null)
|
|
{
|
|
await using var imgStream = images.OpenReadStream();
|
|
using var imgSeekable = await ToSeekable(imgStream);
|
|
photoFiles = ListZipImageNames(imgSeekable);
|
|
}
|
|
var service = new Rpro3ImportService(db, config, env);
|
|
var result = await service.AnalyzeAsync(data, images is not null, photoFiles);
|
|
return TypedResults.Ok(result);
|
|
}
|
|
finally { TryDeleteDir(workDir); }
|
|
}
|
|
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
|
}).WithTags("Import").DisableAntiforgery();
|
|
|
|
group.MapPost("/rpro3/execute", async Task<Results<Ok<Rpro3ExecuteResult>, BadRequest<string>>> (
|
|
IFormFile backup, IFormFile? images,
|
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
try
|
|
{
|
|
var reader = new Rpro3Reader();
|
|
await using var bs = backup.OpenReadStream();
|
|
using var seekable = await ToSeekable(bs);
|
|
var data = reader.ReadFromBackup(seekable, out var workDir);
|
|
try
|
|
{
|
|
Dictionary<string, string>? photoPaths = null;
|
|
if (images is not null)
|
|
{
|
|
await using var imgStream = images.OpenReadStream();
|
|
using var imgSeekable = await ToSeekable(imgStream);
|
|
photoPaths = ExtractZipImages(imgSeekable, workDir);
|
|
}
|
|
var service = new Rpro3ImportService(db, config, env);
|
|
var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths);
|
|
return TypedResults.Ok(result);
|
|
}
|
|
finally { TryDeleteDir(workDir); }
|
|
}
|
|
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
|
}).WithTags("Import").DisableAntiforgery();
|
|
|
|
return app;
|
|
}
|
|
|
|
// IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren.
|
|
private static async Task<MemoryStream> ToSeekable(Stream s)
|
|
{
|
|
var ms = new MemoryStream();
|
|
await s.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
return ms;
|
|
}
|
|
|
|
private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
|
|
|
private static IReadOnlySet<string> ListZipImageNames(Stream zip)
|
|
{
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
|
foreach (var e in archive.Entries)
|
|
if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
|
set.Add(e.Name);
|
|
return set;
|
|
}
|
|
|
|
// Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad.
|
|
private static Dictionary<string, string> ExtractZipImages(Stream zip, string workDir)
|
|
{
|
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var imgDir = Path.Combine(workDir, "bilder");
|
|
Directory.CreateDirectory(imgDir);
|
|
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
|
foreach (var e in archive.Entries)
|
|
{
|
|
if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
|
continue;
|
|
var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name));
|
|
try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; }
|
|
catch { /* defektes Archiv-Entry überspringen */ }
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// Entpackt ein ZIP relativ in destDir (Einträge behalten ihren relativen Pfad). Schützt vor
|
|
// Zip-Slip (Einträge, die aus destDir ausbrechen wollen) und überspringt Verzeichnis-Einträge.
|
|
private static void ExtractZipInto(Stream zip, string destDir)
|
|
{
|
|
var root = Path.GetFullPath(destDir);
|
|
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
|
foreach (var e in archive.Entries)
|
|
{
|
|
if (string.IsNullOrEmpty(e.Name)) continue; // Verzeichnis-Eintrag
|
|
var target = Path.GetFullPath(Path.Combine(root, e.FullName));
|
|
if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
|
&& !string.Equals(target, root, StringComparison.Ordinal))
|
|
continue; // Zip-Slip abwehren
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
|
try { e.ExtractToFile(target, overwrite: true); }
|
|
catch { /* defektes Archiv-Entry überspringen */ }
|
|
}
|
|
}
|
|
|
|
private static void TryDeleteDir(string dir)
|
|
{
|
|
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
|
catch { /* best effort */ }
|
|
}
|
|
}
|
|
}
|