diff --git a/src/IIIFPresentation/API/Helpers/PathGenerator.cs b/src/IIIFPresentation/API/Helpers/PathGenerator.cs
index 58a97147..ae874b3b 100644
--- a/src/IIIFPresentation/API/Helpers/PathGenerator.cs
+++ b/src/IIIFPresentation/API/Helpers/PathGenerator.cs
@@ -1,5 +1,6 @@
using API.Infrastructure.Requests;
using DLCS;
+using DLCS.Models;
using Microsoft.Extensions.Options;
using Models.Database.Collections;
using Models.Database.General;
@@ -92,12 +93,21 @@ public string GenerateCanvasId(CanvasPainting canvasPainting)
public Uri? GenerateAssetUri(CanvasPainting canvasPainting)
{
if (string.IsNullOrEmpty(canvasPainting.AssetId)) return null;
- var assetId = canvasPainting.AssetId.Split('/');
- if (assetId.Length != 3) return null;
+
+ AssetId assetId;
+
+ try
+ {
+ assetId = AssetId.FromString(canvasPainting.AssetId);
+ }
+ catch // swallow error as it's not needed
+ {
+ return null;
+ }
var uriBuilder = new UriBuilder(dlcsSettings.ApiUri)
{
- Path = $"/customers/{assetId[0]}/spaces/{assetId[1]}/images/{assetId[2]}",
+ Path = $"/customers/{assetId.Customer}/spaces/{assetId.Space}/images/{assetId.Asset}",
};
return uriBuilder.Uri;
}
diff --git a/src/IIIFPresentation/AWS/Settings/AWSSettings.cs b/src/IIIFPresentation/AWS/Settings/AWSSettings.cs
index fce00432..9c4b0fa5 100644
--- a/src/IIIFPresentation/AWS/Settings/AWSSettings.cs
+++ b/src/IIIFPresentation/AWS/Settings/AWSSettings.cs
@@ -2,6 +2,8 @@
public class AWSSettings
{
+ public const string SettingsName = "AWS";
+
///
/// If true, service will use LocalStack and custom ServiceUrl
///
@@ -16,4 +18,4 @@ public class AWSSettings
/// SQS Settings
///
public SQSSettings SQS { get; set; } = new();
-}
\ No newline at end of file
+}
diff --git a/src/IIIFPresentation/AWS/Settings/SQSSettings.cs b/src/IIIFPresentation/AWS/Settings/SQSSettings.cs
index b23e5d10..20b9ec98 100644
--- a/src/IIIFPresentation/AWS/Settings/SQSSettings.cs
+++ b/src/IIIFPresentation/AWS/Settings/SQSSettings.cs
@@ -7,6 +7,11 @@ public class SQSSettings
///
public string? CustomerCreatedQueueName { get; set; }
+ ///
+ /// Name of queue that will receive notifications when a batch is completed
+ ///
+ public string? BatchCompletionQueueName { get; set; }
+
///
/// The duration (in seconds) for which the call waits for a message to arrive in the queue before returning
///
@@ -21,4 +26,4 @@ public class SQSSettings
/// Service root for SQS. Ignored if not running LocalStack
///
public string ServiceUrl { get; set; } = "http://localhost:4566/";
-}
\ No newline at end of file
+}
diff --git a/src/IIIFPresentation/BackgroundHandler.Tests/BatchCompletion/BatchCompletionMessageHandlerTests.cs b/src/IIIFPresentation/BackgroundHandler.Tests/BatchCompletion/BatchCompletionMessageHandlerTests.cs
new file mode 100644
index 00000000..a4fef2ca
--- /dev/null
+++ b/src/IIIFPresentation/BackgroundHandler.Tests/BatchCompletion/BatchCompletionMessageHandlerTests.cs
@@ -0,0 +1,6 @@
+namespace BackgroundHandler.Tests.BatchCompletion;
+
+public class BatchCompletionMessageHandlerTests
+{
+
+}
diff --git a/src/IIIFPresentation/BackgroundHandler/BackgroundHandler.csproj b/src/IIIFPresentation/BackgroundHandler/BackgroundHandler.csproj
index a304dab5..67071d95 100644
--- a/src/IIIFPresentation/BackgroundHandler/BackgroundHandler.csproj
+++ b/src/IIIFPresentation/BackgroundHandler/BackgroundHandler.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessage.cs b/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessage.cs
new file mode 100644
index 00000000..f2247029
--- /dev/null
+++ b/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessage.cs
@@ -0,0 +1,11 @@
+namespace BackgroundHandler.BatchCompletion;
+
+public record BatchCompletionMessage(
+ int Id,
+ int CustomerId,
+ int Total,
+ int Success,
+ int Errors,
+ bool Superseded,
+ DateTime Started,
+ DateTime Finished);
diff --git a/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessageHandler.cs b/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessageHandler.cs
new file mode 100644
index 00000000..af3af218
--- /dev/null
+++ b/src/IIIFPresentation/BackgroundHandler/BatchCompletion/BatchCompletionMessageHandler.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using AWS.SQS;
+using BackgroundHandler.Helpers;
+using Core.Helpers;
+using DLCS;
+using DLCS.API;
+using DLCS.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Models.Database.General;
+using Repository;
+using Batch = Models.Database.General.Batch;
+
+namespace BackgroundHandler.BatchCompletion;
+
+public class BatchCompletionMessageHandler(
+ PresentationContext dbContext,
+ IDlcsApiClient dlcsApiClient,
+ IOptions dlcsOptions,
+ ILogger logger)
+ : IMessageHandler
+{
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly DlcsSettings dlcsSettings = dlcsOptions.Value;
+
+ public async Task HandleMessage(QueueMessage message, CancellationToken cancellationToken)
+ {
+ using (LogContextHelpers.SetServiceName(nameof(BatchCompletionMessageHandler)))
+ {
+ try
+ {
+ var batchCompletionMessage = DeserializeMessage(message);
+
+ await UpdateAssetsIfRequired(batchCompletionMessage, cancellationToken);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error handling batch-completion message {MessageId}", message.MessageId);
+ }
+ }
+
+ return false;
+ }
+
+ private async Task UpdateAssetsIfRequired(BatchCompletionMessage batchCompletionMessage, CancellationToken cancellationToken)
+ {
+ var batch = await dbContext.Batches.Include(b => b.Manifest)
+ .ThenInclude(m => m.CanvasPaintings)
+ .FirstOrDefaultAsync(b => b.Id == batchCompletionMessage.Id, cancellationToken);
+
+ // batch isn't tracked by presentation, so nothing to do
+ if (batch == null) return;
+
+ // Other batches haven't completed, so no point populating items until all are complete
+ if (await dbContext.Batches.AnyAsync(b => b.ManifestId == batch.ManifestId && b.Status != BatchStatus.Completed,
+ cancellationToken))
+ {
+ return;
+ }
+
+ logger.LogInformation(
+ "Attempting to complete assets in batch {BatchId} for customer {CustomerId} with the manifest {ManifestId}",
+ batch.Id, batch.CustomerId, batch.ManifestId);
+
+ var assets = await RetrieveImages(batch, cancellationToken);
+
+ UpdateCanvasPaintings(assets, batch);
+ CompleteBatch(batch);
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ logger.LogTrace("updating batch {BatchId} has been completed", batch.Id);
+ }
+
+ private void CompleteBatch(Batch batch)
+ {
+ batch.Finished = DateTime.UtcNow; //todo: change to actual batch completion time?
+ batch.Status = BatchStatus.Completed;
+ }
+
+ private void UpdateCanvasPaintings(HydraCollection assets, Batch batch)
+ {
+ if (batch.Manifest?.CanvasPaintings == null) return;
+
+ foreach (var canvasPainting in batch.Manifest.CanvasPaintings)
+ {
+ if (canvasPainting.CanvasOriginalId == null) // Trying to figure out an asset that hasn't been updated
+ {
+ var assetId = AssetId.FromString(canvasPainting.AssetId!);
+
+ var asset = assets.Members.FirstOrDefault(a => a.ResourceId!.Contains($"{assetId.Space}/images/{assetId.Asset}"));
+ if (asset == null || asset.Ingesting) continue;
+
+ canvasPainting.CanvasOriginalId =
+ new Uri(
+ $"{dlcsSettings.OrchestratorUri}/iiif-img/{assetId.Customer}/{assetId.Space}/{assetId.Asset}/full/max/0/default.jpg"); //todo: do we need this? Supposed to be null for an asset really
+ canvasPainting.Thumbnail =
+ new Uri(
+ $"{dlcsSettings.OrchestratorUri}/thumbs/{assetId.Customer}/{assetId.Space}/{assetId.Asset}/100,/max/0/default.jpg"); //todo: move this to class
+ canvasPainting.Modified = DateTime.UtcNow;
+ canvasPainting.StaticHeight = asset.Height;
+ canvasPainting.StaticWidth = asset.Width;
+ }
+ }
+ }
+
+ private async Task> RetrieveImages(Batch batch, CancellationToken cancellationToken)
+ {
+ var assetsRequest =
+ batch.Manifest?.CanvasPaintings?.Where(c => c.AssetId != null).Select(c => c.AssetId!).ToList() ?? [];
+
+ return await dlcsApiClient.RetrieveAllImages(batch.CustomerId, assetsRequest, cancellationToken);
+ }
+
+ private static BatchCompletionMessage DeserializeMessage(QueueMessage message)
+ {
+ var deserialized = JsonSerializer.Deserialize(message.Body, JsonSerializerOptions);
+ return deserialized.ThrowIfNull(nameof(deserialized));
+ }
+}
diff --git a/src/IIIFPresentation/BackgroundHandler/Infrastructure/ServiceCollectionX.cs b/src/IIIFPresentation/BackgroundHandler/Infrastructure/ServiceCollectionX.cs
index 6f436f18..a6e1568c 100644
--- a/src/IIIFPresentation/BackgroundHandler/Infrastructure/ServiceCollectionX.cs
+++ b/src/IIIFPresentation/BackgroundHandler/Infrastructure/ServiceCollectionX.cs
@@ -1,6 +1,7 @@
using AWS.Configuration;
using AWS.Settings;
using AWS.SQS;
+using BackgroundHandler.BatchCompletion;
using BackgroundHandler.CustomerCreation;
using BackgroundHandler.Listener;
using Repository;
@@ -33,6 +34,14 @@ public static IServiceCollection AddBackgroundServices(this IServiceCollection s
ActivatorUtilities.CreateInstance>(sp, aws.SQS.CustomerCreatedQueueName))
.AddScoped();
}
+
+ if (!string.IsNullOrEmpty(aws.SQS.BatchCompletionQueueName))
+ {
+ services
+ .AddHostedService(sp =>
+ ActivatorUtilities.CreateInstance>(sp, aws.SQS.BatchCompletionQueueName))
+ .AddScoped();
+ }
return services;
}
@@ -45,4 +54,4 @@ public static IServiceCollection AddDataAccess(this IServiceCollection services,
return services
.AddPresentationContext(configuration);
}
-}
\ No newline at end of file
+}
diff --git a/src/IIIFPresentation/BackgroundHandler/Program.cs b/src/IIIFPresentation/BackgroundHandler/Program.cs
index 331b1d8c..ef50064e 100644
--- a/src/IIIFPresentation/BackgroundHandler/Program.cs
+++ b/src/IIIFPresentation/BackgroundHandler/Program.cs
@@ -1,6 +1,7 @@
using AWS.Settings;
using BackgroundHandler.Infrastructure;
using BackgroundHandler.Settings;
+using DLCS;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -15,12 +16,17 @@
builder.Services.AddOptions()
.BindConfiguration(string.Empty);
-var aws = builder.Configuration.GetSection("AWS").Get() ?? new AWSSettings();
+var aws = builder.Configuration.GetSection(AWSSettings.SettingsName).Get() ?? new AWSSettings();
+var dlcsSettings = builder.Configuration.GetSection(DlcsSettings.SettingsName);
+var dlcs = dlcsSettings.Get()!;
-builder.Services.AddAws(builder.Configuration, builder.Environment);
-builder.Services.AddDataAccess(builder.Configuration);
-builder.Services.AddBackgroundServices(aws);
+builder.Services.AddAws(builder.Configuration, builder.Environment)
+ .AddDataAccess(builder.Configuration)
+ .AddHttpContextAccessor()
+ .AddDlcsClientWithLocalAuth(dlcs)
+ .AddBackgroundServices(aws)
+ .Configure(dlcsSettings);
var app = builder.Build();
-app.Run();
\ No newline at end of file
+app.Run();
diff --git a/src/IIIFPresentation/DLCS/API/DlcsApiClient.cs b/src/IIIFPresentation/DLCS/API/DlcsApiClient.cs
index 3e263321..1cf9f547 100644
--- a/src/IIIFPresentation/DLCS/API/DlcsApiClient.cs
+++ b/src/IIIFPresentation/DLCS/API/DlcsApiClient.cs
@@ -6,6 +6,7 @@
using DLCS.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Newtonsoft.Json.Linq;
namespace DLCS.API;
@@ -26,6 +27,12 @@ public interface IDlcsApiClient
///
public Task> IngestAssets(int customerId, List images,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieve a list of assets from the DLCS
+ ///
+ public Task> RetrieveAllImages(int customerId, List assets,
+ CancellationToken cancellationToken = default);
}
///
@@ -85,6 +92,20 @@ public async Task> IngestAssets(int customerId, List assets, C
return batches.ToList();
}
+ public async Task> RetrieveAllImages(int customerId, List assets,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogTrace("performing an all images call for customer {CustomerId}", customerId);
+ var allImagesPath = $"/customers/{customerId}/allImages";
+
+ var allImagesRequest = new AllImages(assets);
+
+ var asset =
+ await CallDlcsApi>(HttpMethod.Post, allImagesPath, allImagesRequest, cancellationToken);
+
+ return asset ?? throw new DlcsException("Failed to retrieve all images");
+ }
+
private async Task CallDlcsApi(HttpMethod httpMethod, string path, object payload,
CancellationToken cancellationToken)
{
diff --git a/src/IIIFPresentation/DLCS/DlcsSettings.cs b/src/IIIFPresentation/DLCS/DlcsSettings.cs
index f571229d..bf84f466 100644
--- a/src/IIIFPresentation/DLCS/DlcsSettings.cs
+++ b/src/IIIFPresentation/DLCS/DlcsSettings.cs
@@ -8,6 +8,11 @@ public class DlcsSettings
/// URL root of DLCS API
///
public required Uri ApiUri { get; set; }
+
+ ///
+ /// URL root of DLCS API
+ ///
+ public Uri? OrchestratorUri { get; set; }
///
/// Default timeout (in ms) use for HttpClient.Timeout.
@@ -18,4 +23,9 @@ public class DlcsSettings
/// The maximum size of an individual batch request
///
public int MaxBatchSize { get; set; } = 100;
+
+ ///
+ /// Used to authenticate requests that do not go via the HttpContextAccessor
+ ///
+ public string ApiLocalAuth { get; set; } //Todo: is this right? is there a better way of setting auth
}
diff --git a/src/IIIFPresentation/DLCS/Handlers/AmbientAuthHandler.cs b/src/IIIFPresentation/DLCS/Handlers/AmbientAuthHandler.cs
index a0adf678..b3454bb2 100644
--- a/src/IIIFPresentation/DLCS/Handlers/AmbientAuthHandler.cs
+++ b/src/IIIFPresentation/DLCS/Handlers/AmbientAuthHandler.cs
@@ -25,4 +25,4 @@ protected override Task SendAsync(HttpRequestMessage reques
request.Headers.Authorization = authHeader;
return base.SendAsync(request, cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/src/IIIFPresentation/DLCS/Handlers/AmbientAuthLocalHandler.cs b/src/IIIFPresentation/DLCS/Handlers/AmbientAuthLocalHandler.cs
new file mode 100644
index 00000000..afe38b8e
--- /dev/null
+++ b/src/IIIFPresentation/DLCS/Handlers/AmbientAuthLocalHandler.cs
@@ -0,0 +1,21 @@
+using System.Net.Http.Headers;
+using Microsoft.Extensions.Options;
+
+namespace DLCS.Handlers;
+
+public class AmbientAuthLocalHandler() : DelegatingHandler
+{
+ private readonly DlcsSettings dlcsSettings;
+
+ public AmbientAuthLocalHandler(IOptions dlcsOptions) : this()
+ {
+ dlcsSettings = dlcsOptions.Value;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", dlcsSettings.ApiLocalAuth);
+ return base.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/src/IIIFPresentation/DLCS/Models/AllImages.cs b/src/IIIFPresentation/DLCS/Models/AllImages.cs
new file mode 100644
index 00000000..c9da21fd
--- /dev/null
+++ b/src/IIIFPresentation/DLCS/Models/AllImages.cs
@@ -0,0 +1,23 @@
+using System.Text.Json.Serialization;
+
+namespace DLCS.Models;
+
+public class AllImages : JsonLdBase
+{
+ public AllImages(List members)
+ {
+ Members = members.Select(m => new AllImagesMember(m)).ToList();
+ Type = "Collection" ;
+ }
+
+ [JsonPropertyOrder(3)]
+ [JsonPropertyName("member")]
+ public List Members { get; set; }
+}
+
+public class AllImagesMember(string id)
+{
+ public string Id { get; set; } = id;
+}
+
+
diff --git a/src/IIIFPresentation/DLCS/Models/Asset.cs b/src/IIIFPresentation/DLCS/Models/Asset.cs
new file mode 100644
index 00000000..86d13dd3
--- /dev/null
+++ b/src/IIIFPresentation/DLCS/Models/Asset.cs
@@ -0,0 +1,10 @@
+namespace DLCS.Models;
+
+public class Asset : JsonLdBase
+{
+ public int? Width { get; set; }
+
+ public int? Height { get; set; }
+
+ public bool Ingesting { get; set; }
+}
diff --git a/src/IIIFPresentation/DLCS/Models/AssetId.cs b/src/IIIFPresentation/DLCS/Models/AssetId.cs
new file mode 100644
index 00000000..5b150e2e
--- /dev/null
+++ b/src/IIIFPresentation/DLCS/Models/AssetId.cs
@@ -0,0 +1,51 @@
+namespace DLCS.Models;
+
+
+///
+/// A record that represents an identifier for a DLCS Asset.
+///
+public class AssetId
+{
+ /// Id of customer
+ public int Customer { get; }
+
+ /// Id of space
+ public int Space { get; }
+
+ /// Id of asset
+ public string Asset { get; }
+
+ ///
+ /// A record that represents an identifier for a DLCS Asset.
+ ///
+ public AssetId(int customer, int space, string asset)
+ {
+ Customer = customer;
+ Space = space;
+ Asset = asset;
+ }
+
+ public override string ToString() => $"{Customer}/{Space}/{Asset}";
+
+ ///
+ /// Create a new AssetId from string in format customer/space/image
+ ///
+ public static AssetId FromString(string assetImageId)
+ {
+ var parts = assetImageId.Split("/", StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 3)
+ {
+ throw new ArgumentException($"AssetId '{assetImageId}' is invalid. Must be in format customer/space/asset");
+ }
+
+ try
+ {
+ return new AssetId(int.Parse(parts[0]), int.Parse(parts[1]), parts[2]);
+ }
+ catch (FormatException fmEx)
+ {
+ throw new ArgumentException($"AssetId '{assetImageId}' is invalid. Must be in format customer/space/asset",
+ fmEx);
+ }
+ }
+}
diff --git a/src/IIIFPresentation/DLCS/ServiceCollectionX.cs b/src/IIIFPresentation/DLCS/ServiceCollectionX.cs
index cda11aab..629eb5e6 100644
--- a/src/IIIFPresentation/DLCS/ServiceCollectionX.cs
+++ b/src/IIIFPresentation/DLCS/ServiceCollectionX.cs
@@ -27,4 +27,23 @@ public static IServiceCollection AddDlcsClient(this IServiceCollection services,
return services;
}
-}
\ No newline at end of file
+
+ ///
+ /// Add to services collection.
+ ///
+ public static IServiceCollection AddDlcsClientWithLocalAuth(this IServiceCollection services,
+ DlcsSettings dlcsSettings)
+ {
+ services
+ .AddScoped()
+ .AddTransient()
+ .AddHttpClient(client =>
+ {
+ client.BaseAddress = dlcsSettings.ApiUri;
+ client.Timeout = TimeSpan.FromMilliseconds(dlcsSettings.DefaultTimeoutMs);
+ }).AddHttpMessageHandler()
+ .AddHttpMessageHandler();
+
+ return services;
+ }
+}
diff --git a/src/IIIFPresentation/Models/Database/General/Batch.cs b/src/IIIFPresentation/Models/Database/General/Batch.cs
index 8187dc1f..c897e1ef 100644
--- a/src/IIIFPresentation/Models/Database/General/Batch.cs
+++ b/src/IIIFPresentation/Models/Database/General/Batch.cs
@@ -24,6 +24,11 @@ public class Batch
///
public DateTime Submitted { get; set; }
+ ///
+ /// When the batch was added to the DLCS
+ ///
+ public DateTime? Finished { get; set; }
+
///
/// Id of related manifest
///
diff --git a/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.Designer.cs b/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.Designer.cs
new file mode 100644
index 00000000..3edc62e3
--- /dev/null
+++ b/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.Designer.cs
@@ -0,0 +1,404 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Repository;
+
+#nullable disable
+
+namespace Repository.Migrations
+{
+ [DbContext(typeof(PresentationContext))]
+ [Migration("20241217155051_addFinishedToBatch")]
+ partial class addFinishedToBatch
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Models.Database.CanvasPainting", b =>
+ {
+ b.Property("CanvasPaintingId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("canvas_painting_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CanvasPaintingId"));
+
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("CanvasLabel")
+ .HasColumnType("text")
+ .HasColumnName("canvas_label");
+
+ b.Property("CanvasOrder")
+ .HasColumnType("integer")
+ .HasColumnName("canvas_order");
+
+ b.Property("CanvasOriginalId")
+ .HasColumnType("text")
+ .HasColumnName("canvas_original_id");
+
+ b.Property("ChoiceOrder")
+ .IsRequired()
+ .HasColumnType("integer")
+ .HasColumnName("choice_order");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Id")
+ .HasColumnType("text")
+ .HasColumnName("canvas_id");
+
+ b.Property("Label")
+ .HasColumnType("jsonb")
+ .HasColumnName("label");
+
+ b.Property("ManifestId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("manifest_id");
+
+ b.Property("Modified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified")
+ .HasDefaultValueSql("now()");
+
+ b.Property("StaticHeight")
+ .HasColumnType("integer")
+ .HasColumnName("static_height");
+
+ b.Property("StaticWidth")
+ .HasColumnType("integer")
+ .HasColumnName("static_width");
+
+ b.Property("Target")
+ .HasColumnType("text")
+ .HasColumnName("target");
+
+ b.Property("Thumbnail")
+ .HasColumnType("text")
+ .HasColumnName("thumbnail");
+
+ b.HasKey("CanvasPaintingId")
+ .HasName("pk_canvas_paintings");
+
+ b.HasIndex("ManifestId", "CustomerId")
+ .HasDatabaseName("ix_canvas_paintings_manifest_id_customer_id");
+
+ b.HasIndex("Id", "CustomerId", "ManifestId", "AssetId", "CanvasOrder", "ChoiceOrder")
+ .IsUnique()
+ .HasDatabaseName("ix_canvas_paintings_canvas_id_customer_id_manifest_id_asset_id")
+ .HasFilter("canvas_original_id is null");
+
+ b.HasIndex("Id", "CustomerId", "ManifestId", "CanvasOriginalId", "CanvasOrder", "ChoiceOrder")
+ .IsUnique()
+ .HasDatabaseName("ix_canvas_paintings_canvas_id_customer_id_manifest_id_canvas_o")
+ .HasFilter("asset_id is null");
+
+ b.ToTable("canvas_paintings", (string)null);
+ });
+
+ modelBuilder.Entity("Models.Database.Collections.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("CreatedBy")
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("IsPublic")
+ .HasColumnType("boolean")
+ .HasColumnName("is_public");
+
+ b.Property("IsStorageCollection")
+ .HasColumnType("boolean")
+ .HasColumnName("is_storage_collection");
+
+ b.Property("Label")
+ .HasColumnType("jsonb")
+ .HasColumnName("label");
+
+ b.Property("LockedBy")
+ .HasColumnType("text")
+ .HasColumnName("locked_by");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified");
+
+ b.Property("ModifiedBy")
+ .HasColumnType("text")
+ .HasColumnName("modified_by");
+
+ b.Property("Tags")
+ .HasColumnType("text")
+ .HasColumnName("tags");
+
+ b.Property("Thumbnail")
+ .HasColumnType("text")
+ .HasColumnName("thumbnail");
+
+ b.Property("UsePath")
+ .HasColumnType("boolean")
+ .HasColumnName("use_path");
+
+ b.HasKey("Id", "CustomerId")
+ .HasName("pk_collections");
+
+ b.ToTable("collections", (string)null);
+ });
+
+ modelBuilder.Entity("Models.Database.Collections.Manifest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("Label")
+ .HasColumnType("text")
+ .HasColumnName("label");
+
+ b.Property("Modified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified")
+ .HasDefaultValueSql("now()");
+
+ b.Property("ModifiedBy")
+ .HasColumnType("text")
+ .HasColumnName("modified_by");
+
+ b.Property("SpaceId")
+ .HasColumnType("integer")
+ .HasColumnName("space_id");
+
+ b.HasKey("Id", "CustomerId")
+ .HasName("pk_manifests");
+
+ b.ToTable("manifests", (string)null);
+ });
+
+ modelBuilder.Entity("Models.Database.General.Batch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Finished")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished");
+
+ b.Property("ManifestId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("manifest_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("Submitted")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("submitted");
+
+ b.HasKey("Id")
+ .HasName("pk_batches");
+
+ b.HasIndex("ManifestId", "CustomerId")
+ .HasDatabaseName("ix_batches_manifest_id_customer_id");
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("Models.Database.General.Hierarchy", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Canonical")
+ .HasColumnType("boolean")
+ .HasColumnName("canonical");
+
+ b.Property("CollectionId")
+ .HasColumnType("text")
+ .HasColumnName("collection_id");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("ItemsOrder")
+ .HasColumnType("integer")
+ .HasColumnName("items_order");
+
+ b.Property("ManifestId")
+ .HasColumnType("text")
+ .HasColumnName("manifest_id");
+
+ b.Property("Parent")
+ .HasColumnType("text")
+ .HasColumnName("parent");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("citext")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_hierarchy");
+
+ b.HasIndex("Parent", "CustomerId")
+ .HasDatabaseName("ix_hierarchy_parent_customer_id");
+
+ b.HasIndex("CollectionId", "CustomerId", "Canonical")
+ .IsUnique()
+ .HasDatabaseName("ix_hierarchy_collection_id_customer_id_canonical")
+ .HasFilter("canonical is true");
+
+ b.HasIndex("CustomerId", "Slug", "Parent")
+ .IsUnique()
+ .HasDatabaseName("ix_hierarchy_customer_id_slug_parent");
+
+ b.HasIndex("ManifestId", "CustomerId", "Canonical")
+ .IsUnique()
+ .HasDatabaseName("ix_hierarchy_manifest_id_customer_id_canonical")
+ .HasFilter("canonical is true");
+
+ b.ToTable("hierarchy", null, t =>
+ {
+ t.HasCheckConstraint("stop_collection_and_manifest_in_same_record", "num_nonnulls(manifest_id, collection_id) = 1");
+ });
+ });
+
+ modelBuilder.Entity("Models.Database.CanvasPainting", b =>
+ {
+ b.HasOne("Models.Database.Collections.Manifest", "Manifest")
+ .WithMany("CanvasPaintings")
+ .HasForeignKey("ManifestId", "CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_canvas_paintings_manifests_manifest_id_customer_id");
+
+ b.Navigation("Manifest");
+ });
+
+ modelBuilder.Entity("Models.Database.General.Batch", b =>
+ {
+ b.HasOne("Models.Database.Collections.Manifest", "Manifest")
+ .WithMany("Batches")
+ .HasForeignKey("ManifestId", "CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_batches_manifests_manifest_id_customer_id");
+
+ b.Navigation("Manifest");
+ });
+
+ modelBuilder.Entity("Models.Database.General.Hierarchy", b =>
+ {
+ b.HasOne("Models.Database.Collections.Collection", "Collection")
+ .WithMany("Hierarchy")
+ .HasForeignKey("CollectionId", "CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_hierarchy_collections_collection_id_customer_id");
+
+ b.HasOne("Models.Database.Collections.Manifest", "Manifest")
+ .WithMany("Hierarchy")
+ .HasForeignKey("ManifestId", "CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("fk_hierarchy_manifests_manifest_id_customer_id");
+
+ b.HasOne("Models.Database.Collections.Collection", "ParentCollection")
+ .WithMany("Children")
+ .HasForeignKey("Parent", "CustomerId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .HasConstraintName("fk_hierarchy_collections_parent_customer_id");
+
+ b.Navigation("Collection");
+
+ b.Navigation("Manifest");
+
+ b.Navigation("ParentCollection");
+ });
+
+ modelBuilder.Entity("Models.Database.Collections.Collection", b =>
+ {
+ b.Navigation("Children");
+
+ b.Navigation("Hierarchy");
+ });
+
+ modelBuilder.Entity("Models.Database.Collections.Manifest", b =>
+ {
+ b.Navigation("Batches");
+
+ b.Navigation("CanvasPaintings");
+
+ b.Navigation("Hierarchy");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.cs b/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.cs
new file mode 100644
index 00000000..1250c059
--- /dev/null
+++ b/src/IIIFPresentation/Repository/Migrations/20241217155051_addFinishedToBatch.cs
@@ -0,0 +1,29 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Repository.Migrations
+{
+ ///
+ public partial class addFinishedToBatch : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "finished",
+ table: "batches",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "finished",
+ table: "batches");
+ }
+ }
+}
diff --git a/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs b/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs
index 02ebdfb8..c5c48d5b 100644
--- a/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs
+++ b/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs
@@ -234,6 +234,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("integer")
.HasColumnName("customer_id");
+ b.Property("Finished")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished");
+
b.Property("ManifestId")
.IsRequired()
.HasColumnType("text")