From 90042d72d6daed2b860c1406976d7f7fbc33f4f2 Mon Sep 17 00:00:00 2001 From: Magnus Revheim Martinsen Date: Tue, 1 Oct 2024 09:24:35 +0200 Subject: [PATCH] Add an optional footer for the generated PDF (#792) * add PDF footer * fix timezone, reference id and move footer check to PdfService * update mocks of GeneratePdf() * change FooterContet to camelCase * update styling according to new design * add logger and try catch for norwegian time zone * use explicit exception --- .../Clients/Pdf/PdfGeneratorClient.cs | 16 +++++- .../Internal/Pdf/IPdfGeneratorClient.cs | 6 +++ .../Internal/Pdf/PdfGeneratorSettings.cs | 5 ++ .../Internal/Pdf/PdfService.cs | 50 ++++++++++++++++++- .../Models/Pdf/PdfGeneratorRequestOptions.cs | 15 ++++++ .../InstancesController_PostNewInstance.cs | 4 +- .../Controllers/PdfControllerTests.cs | 16 ++++-- .../Controllers/ProcessControllerTests.cs | 8 ++- .../Internal/Pdf/PdfServiceTests.cs | 15 +++++- 9 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs index 8204b13c0..bd6f3ce39 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Pdf/PdfGeneratorClient.cs @@ -51,13 +51,27 @@ IHttpContextAccessor httpContextAccessor /// public async Task GeneratePdf(Uri uri, CancellationToken ct) + { + return await GeneratePdf(uri, null, ct); + } + + /// + public async Task GeneratePdf(Uri uri, string? footerContent, CancellationToken ct) { bool hasWaitForSelector = !string.IsNullOrWhiteSpace(_pdfGeneratorSettings.WaitForSelector); PdfGeneratorRequest generatorRequest = new() { Url = uri.AbsoluteUri, - WaitFor = hasWaitForSelector ? _pdfGeneratorSettings.WaitForSelector : _pdfGeneratorSettings.WaitForTime + WaitFor = hasWaitForSelector + ? _pdfGeneratorSettings.WaitForSelector + : _pdfGeneratorSettings.WaitForTime, + Options = + { + HeaderTemplate = "
", + FooterTemplate = footerContent ?? "
", + DisplayHeaderFooter = footerContent != null, + }, }; generatorRequest.Cookies.Add( diff --git a/src/Altinn.App.Core/Internal/Pdf/IPdfGeneratorClient.cs b/src/Altinn.App.Core/Internal/Pdf/IPdfGeneratorClient.cs index 5ebbcf8a8..2412a6317 100644 --- a/src/Altinn.App.Core/Internal/Pdf/IPdfGeneratorClient.cs +++ b/src/Altinn.App.Core/Internal/Pdf/IPdfGeneratorClient.cs @@ -10,4 +10,10 @@ public interface IPdfGeneratorClient /// /// A stream with the binary content of the generated PDF Task GeneratePdf(Uri uri, CancellationToken ct); + + /// + /// Generates a PDF. + /// + /// A stream with the binary content of the generated PDF with a footer + Task GeneratePdf(Uri uri, string? footerContent, CancellationToken ct); } diff --git a/src/Altinn.App.Core/Internal/Pdf/PdfGeneratorSettings.cs b/src/Altinn.App.Core/Internal/Pdf/PdfGeneratorSettings.cs index 5da9f96ae..a20c79439 100644 --- a/src/Altinn.App.Core/Internal/Pdf/PdfGeneratorSettings.cs +++ b/src/Altinn.App.Core/Internal/Pdf/PdfGeneratorSettings.cs @@ -24,4 +24,9 @@ public class PdfGeneratorSettings /// /// This will be ignored if has been assigned a value. public int WaitForTime { get; set; } = 5000; + + /// + /// Shows a footer on each page in the PDF with the date, altinn-referance, page number and total pages. + /// + public bool DisplayFooter { get; set; } } diff --git a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs index e8ac114e2..4c1319acc 100644 --- a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs +++ b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using Altinn.App.Core.Configuration; using Altinn.App.Core.Extensions; @@ -11,6 +12,7 @@ using Altinn.Platform.Profile.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -28,6 +30,7 @@ public class PdfService : IPdfService private readonly IPdfGeneratorClient _pdfGeneratorClient; private readonly PdfGeneratorSettings _pdfGeneratorSettings; + private readonly ILogger _logger; private readonly GeneralSettings _generalSettings; private readonly Telemetry? _telemetry; private const string PdfElementType = "ref-data-as-pdf"; @@ -43,6 +46,7 @@ public class PdfService : IPdfService /// PDF generator client for the experimental PDF generator service /// PDF generator related settings. /// The app general settings. + /// The logger. /// Telemetry for metrics and traces. public PdfService( IAppResources appResources, @@ -52,6 +56,7 @@ public PdfService( IPdfGeneratorClient pdfGeneratorClient, IOptions pdfGeneratorSettings, IOptions generalSettings, + ILogger logger, Telemetry? telemetry = null ) { @@ -62,6 +67,7 @@ public PdfService( _pdfGeneratorClient = pdfGeneratorClient; _pdfGeneratorSettings = pdfGeneratorSettings.Value; _generalSettings = generalSettings.Value; + _logger = logger; _telemetry = telemetry; } @@ -113,7 +119,10 @@ CancellationToken ct Uri uri = BuildUri(baseUrl, pagePath, language); - Stream pdfContent = await _pdfGeneratorClient.GeneratePdf(uri, ct); + bool displayFooter = _pdfGeneratorSettings.DisplayFooter; + string? footerContent = displayFooter ? GetFooterContent(instance) : null; + + Stream pdfContent = await _pdfGeneratorClient.GeneratePdf(uri, footerContent, ct); return pdfContent; } @@ -225,4 +234,43 @@ private static string GetValidFileName(string fileName) fileName = Uri.EscapeDataString(fileName.AsFileName(false)); return fileName; } + + private string GetFooterContent(Instance instance) + { + TimeZoneInfo timeZone = TimeZoneInfo.Utc; + try + { + // attempt to set timezone to norwegian + timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Oslo"); + } + catch (TimeZoneNotFoundException e) + { + _logger.LogWarning($"Could not find timezone Europe/Oslo. Defaulting to UTC. {e.Message}"); + } + + DateTimeOffset now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); + + string dateGenerated = now.ToString("G", new CultureInfo("nb-NO")); + string altinnReferenceId = instance.Id.Split("/")[1].Split("-")[4]; + + string footerTemplate = + $@"
+
+ +
+ {dateGenerated} + ID:{altinnReferenceId} +
+
+
+ + / + +
+
"; + return footerTemplate; + } } diff --git a/src/Altinn.App.Core/Models/Pdf/PdfGeneratorRequestOptions.cs b/src/Altinn.App.Core/Models/Pdf/PdfGeneratorRequestOptions.cs index 184078fe7..ddb51abec 100644 --- a/src/Altinn.App.Core/Models/Pdf/PdfGeneratorRequestOptions.cs +++ b/src/Altinn.App.Core/Models/Pdf/PdfGeneratorRequestOptions.cs @@ -10,6 +10,21 @@ internal class PdfGeneratorRequestOptions /// public bool DisplayHeaderFooter { get; set; } = false; + /// + /// HTML template for the print header. Should be valid HTML with the following classes used to inject values into them: + /// * `date` formatted print date + /// * `title` document title + /// * `url` document location + /// * `pageNumber` current page number + /// * `totalPages` total pages in the document + /// + public string HeaderTemplate { get; set; } = string.Empty; + + /// + /// HTML template for the print footer. Has the same constraints and support for special classes as HeaderTemplate. + /// + public string FooterTemplate { get; set; } = string.Empty; + /// /// Indicate wheter the background should be included. /// diff --git a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstance.cs b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstance.cs index 01f8e5ded..c4a32508f 100644 --- a/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstance.cs +++ b/test/Altinn.App.Api.Tests/Controllers/InstancesController_PostNewInstance.cs @@ -269,7 +269,9 @@ public async Task InstationAllowedByOrg_Returns_Ok_For_User_When_Copying_Simplif { var pdfMock = new Mock(MockBehavior.Strict); using var pdfReturnStream = new MemoryStream(); - pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + pdfMock + .Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(pdfReturnStream); // Setup test data string org = "tdd"; diff --git a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs index 6755f9302..030d5a09f 100644 --- a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs @@ -10,10 +10,12 @@ using Altinn.App.Core.Internal.Pdf; using Altinn.App.Core.Internal.Profile; using Altinn.Platform.Storage.Interface.Models; +using Castle.Core.Logging; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Moq.Protected; @@ -42,6 +44,8 @@ public class PdfControllerTests new() { } ); + private readonly Mock> _logger = new(); + public PdfControllerTests() { _instanceClient @@ -92,7 +96,8 @@ public async Task Request_In_Prod_Should_Be_Blocked() _profile.Object, pdfGeneratorClient, _pdfGeneratorSettingsOptions, - generalSettingsOptions + generalSettingsOptions, + _logger.Object ); var pdfController = new PdfController( _instanceClient.Object, @@ -146,7 +151,8 @@ public async Task Request_In_Dev_Should_Generate() _profile.Object, pdfGeneratorClient, _pdfGeneratorSettingsOptions, - generalSettingsOptions + generalSettingsOptions, + _logger.Object ); var pdfController = new PdfController( _instanceClient.Object, @@ -225,7 +231,8 @@ public async Task Request_In_Dev_Should_Include_Frontend_Version() _profile.Object, pdfGeneratorClient, _pdfGeneratorSettingsOptions, - generalSettingsOptions + generalSettingsOptions, + _logger.Object ); var pdfController = new PdfController( _instanceClient.Object, @@ -306,7 +313,8 @@ public async Task Request_In_TT02_Should_Ignore_Frontend_Version() _profile.Object, pdfGeneratorClient, _pdfGeneratorSettingsOptions, - generalSettingsOptions + generalSettingsOptions, + _logger.Object ); var pdfController = new PdfController( _instanceClient.Object, diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs index 421bfc166..e0626e3f7 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs @@ -341,7 +341,9 @@ public async Task RunProcessNext_NonErrorValidations_ReturnsOk() ); var pdfMock = new Mock(MockBehavior.Strict); using var pdfReturnStream = new MemoryStream(); - pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + pdfMock + .Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(pdfReturnStream); OverrideServicesForThisTest = (services) => { services.AddSingleton(dataValidator.Object); @@ -366,7 +368,9 @@ public async Task RunCompleteTask_GoesToEndEvent() { var pdfMock = new Mock(MockBehavior.Strict); using var pdfReturnStream = new MemoryStream(); - pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + pdfMock + .Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(pdfReturnStream); OverrideServicesForThisTest = (services) => { services.AddSingleton(pdfMock.Object); diff --git a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs index 6cc21dcaf..5dcdfee76 100644 --- a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs @@ -14,8 +14,10 @@ using Altinn.Platform.Profile.Models; using Altinn.Platform.Storage.Interface.Models; using AltinnCore.Authentication.Constants; +using Castle.Core.Logging; using FluentAssertions; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Moq; @@ -43,6 +45,8 @@ public class PdfServiceTests private readonly Mock _userTokenProvider; + private readonly Mock> _logger = new(); + public PdfServiceTests() { var resource = new TextResource() @@ -134,7 +138,9 @@ public async Task GenerateAndStorePdf() { // Arrange TelemetrySink telemetrySink = new(); - _pdfGeneratorClient.Setup(s => s.GeneratePdf(It.IsAny(), It.IsAny())); + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; var target = SetupPdfService( @@ -164,6 +170,7 @@ public async Task GenerateAndStorePdf() && u.AbsoluteUri.Contains(instance.AppId) && u.AbsoluteUri.Contains(instance.Id) ), + It.Is(s => s == null), It.IsAny() ), Times.Once @@ -189,7 +196,9 @@ public async Task GenerateAndStorePdf() public async Task GenerateAndStorePdf_with_generatedFrom() { // Arrange - _pdfGeneratorClient.Setup(s => s.GeneratePdf(It.IsAny(), It.IsAny())); + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; @@ -228,6 +237,7 @@ public async Task GenerateAndStorePdf_with_generatedFrom() && u.AbsoluteUri.Contains(instance.AppId) && u.AbsoluteUri.Contains(instance.Id) ), + It.Is(s => s == null), It.IsAny() ), Times.Once @@ -394,6 +404,7 @@ private PdfService SetupPdfService( pdfGeneratorClient?.Object ?? _pdfGeneratorClient.Object, pdfGeneratorSettingsOptions ?? _pdfGeneratorSettingsOptions, generalSettingsOptions ?? _generalSettingsOptions, + _logger.Object, telemetrySink?.Object ); }