feat(ausstellungen): Ausstellungs-/Auszeichnungsergebnisse je Tier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal 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);
|
||||
}
|
||||
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal file
114
GerbilManagerWebAPI/Endpoints/ExhibitionEndpoints.cs
Normal 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 & 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);
|
||||
}
|
||||
}
|
||||
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201620_AddExhibitionResult.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal file
44
GerbilManagerWebAPI/Models/ExhibitionResult.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapExhibitionEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user