feat(ausstellungen): Ausstellungs-/Auszeichnungsergebnisse je Tier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:19 +02:00
parent 5e14124322
commit d380a0c2ef
16 changed files with 2520 additions and 2 deletions

View File

@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -212,6 +213,14 @@ public class ApplicationContext : DbContext
e.HasIndex(f => f.CreatedAt);
});
// EXHIBITION: deliberately relationship-free (same pattern as Feedback). GerbilId is a
// plain nullable Guid column (no navigation property → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils never cascades into or breaks exhibition rows.
modelBuilder.Entity<ExhibitionResult>(e =>
{
e.HasIndex(x => x.GerbilId);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{

View File

@@ -0,0 +1,34 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>EXHIBITION: payload for POST /exhibitions.</summary>
public record ExhibitionResultInput(
Guid? GerbilId,
string? EntityName,
string EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note);
/// <summary>EXHIBITION: payload for PUT /exhibitions/{id} (all fields optional/partial).</summary>
public record ExhibitionResultUpdate(
Guid? GerbilId,
string? EntityName,
string? EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note);
/// <summary>EXHIBITION: response DTO for a stored exhibition result.</summary>
public record ExhibitionResultDto(
Guid Id,
Guid? GerbilId,
string? EntityName,
string EventName,
DateTime? Date,
string? Placement,
string? Award,
string? Note,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,114 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXHIBITION: show/exhibition results &amp; awards per animal (RennmausPro `ausz_tb`).
/// GET /exhibitions[?gerbilId=…] -> list (optionally filtered to one animal), newest first.
/// POST /exhibitions -> create a result, returns 201.
/// PUT /exhibitions/{id} -> edit a result (partial), 404 on missing id.
/// DELETE /exhibitions/{id} -> remove a result, 404 on missing id.
/// Decoupled from gerbils (loose nullable GerbilId, no FK) so rows survive the import re-ingest wipe.
/// </summary>
public static class ExhibitionEndpoints
{
public static IEndpointRouteBuilder MapExhibitionEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/exhibitions").WithTags("Exhibitions");
group.MapGet("/", async (ApplicationContext db, Guid? gerbilId) =>
{
// Filter in the query; order in memory (SQLite test host cannot ORDER BY a
// DateTimeOffset column — mirrors FeedbackEndpoints).
var query = db.ExhibitionResults.AsNoTracking();
if (gerbilId is { } gid)
query = query.Where(x => x.GerbilId == gid);
var rows = await query.ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(x => x.Date ?? DateTime.MinValue)
.ThenByDescending(x => x.CreatedAt)
.Select(ToDto)
.ToList());
});
group.MapGet("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var r = await db.ExhibitionResults.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
group.MapPost("/", async Task<Results<Created<ExhibitionResultDto>, BadRequest<string>>> (
ExhibitionResultInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.EventName))
return TypedResults.BadRequest("EventName darf nicht leer sein.");
var entity = new ExhibitionResult
{
Id = Guid.NewGuid(),
GerbilId = input.GerbilId,
EntityName = Trim(input.EntityName),
EventName = input.EventName.Trim(),
Date = input.Date,
Placement = Trim(input.Placement),
Award = Trim(input.Award),
Note = Trim(input.Note),
CreatedAt = DateTimeOffset.UtcNow,
};
db.ExhibitionResults.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/exhibitions/{entity.Id}", ToDto(entity));
});
group.MapPut("/{id:guid}", async Task<Results<Ok<ExhibitionResultDto>, NotFound, BadRequest<string>>> (
Guid id, ExhibitionResultUpdate input, ApplicationContext db) =>
{
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
if (entity is null)
return TypedResults.NotFound();
if (input.EventName is not null)
{
if (string.IsNullOrWhiteSpace(input.EventName))
return TypedResults.BadRequest("EventName darf nicht leer sein.");
entity.EventName = input.EventName.Trim();
}
if (input.GerbilId is not null) entity.GerbilId = input.GerbilId;
if (input.EntityName is not null) entity.EntityName = Trim(input.EntityName);
if (input.Date is not null) entity.Date = input.Date;
if (input.Placement is not null) entity.Placement = Trim(input.Placement);
if (input.Award is not null) entity.Award = Trim(input.Award);
if (input.Note is not null) entity.Note = Trim(input.Note);
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.ExhibitionResults.FirstOrDefaultAsync(x => x.Id == id);
if (entity is null)
return TypedResults.NotFound();
db.ExhibitionResults.Remove(entity);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
// Empty/whitespace optional strings normalise to null.
private static string? Trim(string? s) =>
string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static ExhibitionResultDto ToDto(ExhibitionResult r) =>
new(r.Id, r.GerbilId, r.EntityName, r.EventName, r.Date,
r.Placement, r.Award, r.Note, r.CreatedAt);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddExhibitionResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExhibitionResults",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
EventName = table.Column<string>(type: "text", nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Placement = table.Column<string>(type: "text", nullable: true),
Award = table.Column<string>(type: "text", nullable: true),
Note = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExhibitionResults", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExhibitionResults_GerbilId",
table: "ExhibitionResults",
column: "GerbilId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExhibitionResults");
}
}
}

View File

@@ -796,6 +796,44 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("EnclosurePhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Award")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<string>("EventName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<string>("Placement")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("ExhibitionResults");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// EXHIBITION: a show/exhibition result or award for an animal (RennmausPro `ausz_tb` +
/// `_AUSZ` flag — present in RPRO3 but unused by this breeder; implemented fully anyway).
///
/// Deliberately decoupled from the rest of the model, exactly like <see cref="Feedback"/>:
/// GerbilId is a plain nullable Guid column (NOT an enforced foreign key, no navigation
/// property), so the import re-ingest wipe (IngestResolvedService) can delete/recreate
/// gerbils without deleting or breaking exhibition rows. Captured EntityName keeps the
/// record human-readable even after the referenced animal is gone.
/// </summary>
public class ExhibitionResult
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the gerbil this result belongs to, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Captured name of the referenced animal (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>Name of the show/event (Veranstaltung), e.g. "Nationale Rennmausschau 2026".</summary>
public required string EventName { get; set; }
/// <summary>Date of the event, if known.</summary>
public DateTime? Date { get; set; }
/// <summary>Placement / ranking, e.g. "1. Platz", "BOB".</summary>
public string? Placement { get; set; }
/// <summary>Award / title (Auszeichnung), e.g. "Best in Show", "V1".</summary>
public string? Award { get; set; }
/// <summary>Free-text note (Bewertung / Bemerkung).</summary>
public string? Note { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapExhibitionEndpoints();
app.Run();