diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index f814b70e18..cbeac60344 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -76,7 +76,7 @@ public interface IInstanceDataAccessor /// directly in the expression evaluator. /// /// - internal LayoutEvaluatorState? GetLayoutEvaluatorState(); + internal LayoutEvaluatorState GetLayoutEvaluatorState(); /// /// Set the authentication method used when reading and writing data of the given data type. diff --git a/src/Altinn.App.Core/Internal/Data/CleanInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CleanInstanceDataAccessor.cs index 7ff465cf1b..5f502909de 100644 --- a/src/Altinn.App.Core/Internal/Data/CleanInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CleanInstanceDataAccessor.cs @@ -144,7 +144,7 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) return _dataAccessor.GetDataElement(dataElementIdentifier); } - public LayoutEvaluatorState? GetLayoutEvaluatorState() + public LayoutEvaluatorState GetLayoutEvaluatorState() { throw new NotImplementedException( "GetLayoutEvaluatorState is not implemented in CleanInstanceDataAccessor, because LayoutEvaluatorState will be deprecated." diff --git a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs index 737a8a4fcf..2c1a4b30f8 100644 --- a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs +++ b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs @@ -181,19 +181,15 @@ public IInstanceDataAccessor GetPreviousDataAccessor() private LayoutEvaluatorState? _layoutEvaluatorStateCache; - public LayoutEvaluatorState? GetLayoutEvaluatorState() + public LayoutEvaluatorState GetLayoutEvaluatorState() { - if (TaskId is null) - { - return null; - } if (_layoutEvaluatorStateCache is not null) { return _layoutEvaluatorStateCache; } // Could use a double lock here, but a deadlock is more problematic than creating the state twice - var layouts = _appResources.GetLayoutModelForTask(TaskId); + var layouts = TaskId is null ? null : _appResources.GetLayoutModelForTask(TaskId); _layoutEvaluatorStateCache = new LayoutEvaluatorState( this, diff --git a/src/Altinn.App.Core/Internal/Data/PreviousDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/PreviousDataAccessor.cs index e2f8eae169..df9f67e893 100644 --- a/src/Altinn.App.Core/Internal/Data/PreviousDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/PreviousDataAccessor.cs @@ -19,7 +19,7 @@ internal class PreviousDataAccessor : IInstanceDataAccessor private readonly FrontEndSettings _frontEndSettings; private readonly ITranslationService _translationService; private readonly Telemetry? _telemetry; - private readonly Lazy _layoutEvaluatorState; + private readonly Lazy _layoutEvaluatorState; private readonly ConcurrentDictionary> _previousDataCache = new(); @@ -41,7 +41,7 @@ public PreviousDataAccessor( _layoutEvaluatorState = new(() => { var originalState = _dataAccessor.GetLayoutEvaluatorState(); - return originalState?.WithDataAccessor(this); + return originalState.WithDataAccessor(this); }); } @@ -106,7 +106,7 @@ public IInstanceDataAccessor GetPreviousDataAccessor() return this; } - public LayoutEvaluatorState? GetLayoutEvaluatorState() + public LayoutEvaluatorState GetLayoutEvaluatorState() { return _layoutEvaluatorState.Value; } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index ec766380f3..e06f0b8dbc 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -445,7 +445,7 @@ public async Task TranslateText(string textKey, ComponentContext context return _componentModel?.DefaultDataType; } - internal LayoutEvaluatorState? WithDataAccessor(IInstanceDataAccessor dataAccessor) + internal LayoutEvaluatorState WithDataAccessor(IInstanceDataAccessor dataAccessor) { return new LayoutEvaluatorState( dataAccessor, diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 8e07cc1df9..c64712acae 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -115,7 +115,7 @@ public IInstanceDataAccessor GetPreviousDataAccessor() throw new NotSupportedException("Legacy single data accessor does not implement GetPreviousDataAccessor"); } - public LayoutEvaluatorState? GetLayoutEvaluatorState() + public LayoutEvaluatorState GetLayoutEvaluatorState() { return new LayoutEvaluatorState( this, diff --git a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs index 613638cf94..67cca960d4 100644 --- a/src/Altinn.App.Core/Internal/Pdf/PdfService.cs +++ b/src/Altinn.App.Core/Internal/Pdf/PdfService.cs @@ -1,9 +1,12 @@ using System.Globalization; +using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Auth; using Altinn.App.Core.Helpers.Extensions; +using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; @@ -21,6 +24,14 @@ namespace Altinn.App.Core.Internal.Pdf; /// public class PdfService : IPdfService { + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + private readonly IDataClient _dataClient; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IPdfGeneratorClient _pdfGeneratorClient; @@ -29,6 +40,7 @@ public class PdfService : IPdfService private readonly IAuthenticationContext _authenticationContext; private readonly ITranslationService _translationService; private readonly GeneralSettings _generalSettings; + private readonly IAppResources _resources; private readonly InstanceDataUnitOfWorkInitializer? _instanceDataUnitOfWorkInitializer; private readonly Telemetry? _telemetry; internal const string PdfElementType = "ref-data-as-pdf"; @@ -46,6 +58,7 @@ public PdfService( ILogger logger, IAuthenticationContext authenticationContext, ITranslationService translationService, + IAppResources resources, IServiceProvider? serviceProvider = null, Telemetry? telemetry = null ) @@ -58,6 +71,7 @@ public PdfService( _logger = logger; _authenticationContext = authenticationContext; _translationService = translationService; + _resources = resources; _instanceDataUnitOfWorkInitializer = serviceProvider?.GetService(); _telemetry = telemetry; } @@ -206,7 +220,7 @@ CancellationToken ct } else if (displayFooter) { - footerContent = await GetFooterContent(instance, language); + footerContent = await GetFooterContent(instance, taskId, language); } Stream pdfContent = await _pdfGeneratorClient.GeneratePdf(uri, footerContent, ct); @@ -338,7 +352,7 @@ private async Task GetPreviewFooter(string language) "; } - private async Task GetFooterContent(Instance instance, string? language) + private async Task GetFooterContent(Instance instance, string taskId, string? language) { TimeZoneInfo timeZone = TimeZoneInfo.Utc; try @@ -353,15 +367,19 @@ private async Task GetFooterContent(Instance instance, string? language) DateTimeOffset now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); - string title = await _translationService.TranslateTextKey("appName", language) ?? "Altinn"; + bool hideAppName = await GetHideAppNameInPdf(instance, taskId, language); string dateGenerated = now.ToString("dd.MM.yyyy HH:mm", new CultureInfo("nb-NO")); string altinnReferenceId = instance.Id.Split("/")[1].Split("-")[4]; + string title = hideAppName + ? string.Empty + : $"{await _translationService.TranslateTextKey("appName", language) ?? "Altinn"}"; + string footerTemplate = $@"
- {title} + {title}
GetFooterContent(Instance instance, string? language) return footerTemplate; } + private async Task GetHideAppNameInPdf(Instance instance, string taskId, string? language) + { + string? layoutSets = _resources.GetLayoutSets(); + if (string.IsNullOrEmpty(layoutSets)) + return false; + + try + { + using var jsonDoc = JsonDocument.Parse( + layoutSets, + new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip } + ); + var root = jsonDoc.RootElement; + + if ( + !root.TryGetProperty("uiSettings", out var uiSettings) + || !uiSettings.TryGetProperty("hideAppNameInPdf", out var hideAppName) + ) + return false; + + if (hideAppName.ValueKind == JsonValueKind.True) + return true; + if (hideAppName.ValueKind == JsonValueKind.False) + return false; + + if (_instanceDataUnitOfWorkInitializer is null) + { + _logger.LogWarning( + "Cannot evaluate hideAppNameInPdf expression: InstanceDataUnitOfWorkInitializer is not available" + ); + return false; + } + + var expression = hideAppName.Deserialize(_jsonSerializerOptions); + var dataAccessor = await _instanceDataUnitOfWorkInitializer.Init(instance, taskId, language); + var state = dataAccessor.GetLayoutEvaluatorState(); + + var layoutSet = _resources.GetLayoutSetForTask(taskId); + DataElementIdentifier? dataElement = layoutSet?.DataType is { } dataType + ? instance.Data?.Find(d => d.DataType == dataType) + : null; + + var componentContext = new ComponentContext( + dataAccessor, + component: null, + rowIndices: null, + dataElementIdentifier: dataElement + ); + var result = await ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); + return result is true; + } + catch (JsonException e) + { + _logger.LogWarning(e, "Failed to evaluate hideAppNameInPdf, defaulting to showing app name"); + return false; + } + catch (InvalidOperationException e) + { + _logger.LogWarning(e, "Failed to evaluate hideAppNameInPdf, defaulting to showing app name"); + return false; + } + } + private static List> CreateAutoPdfTaskIdsQueryParams( List? autoGeneratePdfForTaskIds ) diff --git a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs index 6435b42b3f..bbf2270c10 100644 --- a/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/PdfControllerTests.cs @@ -6,6 +6,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Language; using Altinn.App.Core.Internal.Pdf; @@ -89,7 +90,8 @@ IOptions generalSettingsOptions generalSettingsOptions, _logger.Object, _authenticationContext.Object, - _translationService.Object + _translationService.Object, + _appResources.Object ); return pdfService; } diff --git a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs index dccdc27328..155fd3baec 100644 --- a/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Pdf/PdfServiceTests.cs @@ -6,6 +6,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Language; using Altinn.App.Core.Internal.Pdf; @@ -475,6 +476,198 @@ public async Task GenerateAndStorePdf_WithCustomFileNameIncludingPdfExtension_Sh ); } + [Fact] + public async Task GenerateAndStorePdf_WithDisplayFooter_HideAppNameInPdfExpression_EvaluatesToTrue_FooterShouldNotContainAppName() + { + // Arrange + _appResources + .Setup(s => s.GetLayoutSets()) + .Returns("""{"sets":[],"uiSettings":{"hideAppNameInPdf":["equals","a","a"]}}"""); + + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); + _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; + + var target = SetupPdfService( + pdfGeneratorClient: _pdfGeneratorClient, + generalSettingsOptions: _generalSettingsOptions, + pdfGeneratorSettingsOptions: Options.Create(new PdfGeneratorSettings { DisplayFooter = true }) + ); + + Instance instance = new() + { + Id = $"509378/{Guid.NewGuid()}", + AppId = "digdir/not-really-an-app", + Org = "digdir", + Data = [], + }; + + // Act + await target.GenerateAndStorePdf(instance, "Task_1", CancellationToken.None); + + _pdfGeneratorClient.Verify( + s => + s.GeneratePdf( + It.IsAny(), + It.Is(footer => footer != null && !footer.Contains("not-really-an-app")), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task GenerateAndStorePdf_WithDisplayFooter_HideAppNameInPdfTrue_FooterShouldNotContainAppName() + { + // Arrange + _appResources.Setup(s => s.GetLayoutSets()).Returns("""{"sets":[],"uiSettings":{"hideAppNameInPdf":true}}"""); + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); + _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; + + var target = SetupPdfService( + pdfGeneratorClient: _pdfGeneratorClient, + generalSettingsOptions: _generalSettingsOptions, + pdfGeneratorSettingsOptions: Options.Create(new PdfGeneratorSettings { DisplayFooter = true }) + ); + + Instance instance = new() + { + Id = $"509378/{Guid.NewGuid()}", + AppId = "digdir/not-really-an-app", + Org = "digdir", + }; + + // Act + await target.GenerateAndStorePdf(instance, "Task_1", CancellationToken.None); + + // Assert + _pdfGeneratorClient.Verify( + s => + s.GeneratePdf( + It.IsAny(), + It.Is(footer => footer != null && !footer.Contains("not-really-an-app")), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task GenerateAndStorePdf_WithDisplayFooter_HideAppNameInPdfFalse_FooterShouldContainAppName() + { + // Arrange + _appResources.Setup(s => s.GetLayoutSets()).Returns("""{"sets":[],"uiSettings":{"hideAppNameInPdf":false}}"""); + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); + _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; + + var target = SetupPdfService( + pdfGeneratorClient: _pdfGeneratorClient, + generalSettingsOptions: _generalSettingsOptions, + pdfGeneratorSettingsOptions: Options.Create(new PdfGeneratorSettings { DisplayFooter = true }) + ); + + Instance instance = new() + { + Id = $"509378/{Guid.NewGuid()}", + AppId = "digdir/not-really-an-app", + Org = "digdir", + }; + + // Act + await target.GenerateAndStorePdf(instance, "Task_1", CancellationToken.None); + + // Assert + _pdfGeneratorClient.Verify( + s => + s.GeneratePdf( + It.IsAny(), + It.Is(footer => footer != null && footer.Contains("not-really-an-app")), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task GenerateAndStorePdf_WithDisplayFooter_NoUiSettings_FooterShouldContainAppName() + { + // Arrange + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); + _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; + + var target = SetupPdfService( + pdfGeneratorClient: _pdfGeneratorClient, + generalSettingsOptions: _generalSettingsOptions, + pdfGeneratorSettingsOptions: Options.Create(new PdfGeneratorSettings { DisplayFooter = true }) + ); + + Instance instance = new() + { + Id = $"509378/{Guid.NewGuid()}", + AppId = "digdir/not-really-an-app", + Org = "digdir", + }; + + // Act + await target.GenerateAndStorePdf(instance, "Task_1", CancellationToken.None); + + // Assert + _pdfGeneratorClient.Verify( + s => + s.GeneratePdf( + It.IsAny(), + It.Is(footer => footer != null && footer.Contains("not-really-an-app")), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task GenerateAndStorePdf_WithDisplayFooter_MalformedLayoutSets_ShouldStillGeneratePdfWithAppName() + { + // Arrange + _appResources.Setup(s => s.GetLayoutSets()).Returns("{invalid json"); + _pdfGeneratorClient.Setup(s => + s.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny()) + ); + _generalSettingsOptions.Value.ExternalAppBaseUrl = "https://{org}.apps.{hostName}/{org}/{app}"; + + var target = SetupPdfService( + pdfGeneratorClient: _pdfGeneratorClient, + generalSettingsOptions: _generalSettingsOptions, + pdfGeneratorSettingsOptions: Options.Create(new PdfGeneratorSettings { DisplayFooter = true }) + ); + + Instance instance = new() + { + Id = $"509378/{Guid.NewGuid()}", + AppId = "digdir/not-really-an-app", + Org = "digdir", + }; + + // Act + await target.GenerateAndStorePdf(instance, "Task_1", CancellationToken.None); + + // Assert + _pdfGeneratorClient.Verify( + s => + s.GeneratePdf( + It.IsAny(), + It.Is(footer => footer != null && footer.Contains("not-really-an-app")), + It.IsAny() + ), + Times.Once + ); + } + private PdfService SetupPdfService( Mock? appResources = null, Mock? dataClient = null, @@ -533,6 +726,7 @@ private PdfService SetupPdfService( appResources?.Object ?? _appResources.Object, FakeLoggerXunit.Get(_outputHelper) ), + appResources?.Object ?? _appResources.Object, mockServiceProvider.Object, telemetrySink?.Object ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index ea58ba0cd9..84f0be8403 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -115,7 +115,7 @@ public IInstanceDataAccessor GetPreviousDataAccessor() ); } - public LayoutEvaluatorState? GetLayoutEvaluatorState() + public LayoutEvaluatorState GetLayoutEvaluatorState() { ArgumentNullException.ThrowIfNull(_translationService); ArgumentNullException.ThrowIfNull(_frontEndSettings); diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 848004bd37..ec6c882930 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3415,7 +3415,7 @@ namespace Altinn.App.Core.Internal.Pdf } public class PdfService : Altinn.App.Core.Internal.Pdf.IPdfService { - public PdfService(Altinn.App.Core.Internal.Data.IDataClient dataClient, Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor, Altinn.App.Core.Internal.Pdf.IPdfGeneratorClient pdfGeneratorClient, Microsoft.Extensions.Options.IOptions pdfGeneratorSettings, Microsoft.Extensions.Options.IOptions generalSettings, Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Features.Auth.IAuthenticationContext authenticationContext, Altinn.App.Core.Internal.Texts.ITranslationService translationService, System.IServiceProvider? serviceProvider = null, Altinn.App.Core.Features.Telemetry? telemetry = null) { } + public PdfService(Altinn.App.Core.Internal.Data.IDataClient dataClient, Microsoft.AspNetCore.Http.IHttpContextAccessor httpContextAccessor, Altinn.App.Core.Internal.Pdf.IPdfGeneratorClient pdfGeneratorClient, Microsoft.Extensions.Options.IOptions pdfGeneratorSettings, Microsoft.Extensions.Options.IOptions generalSettings, Microsoft.Extensions.Logging.ILogger logger, Altinn.App.Core.Features.Auth.IAuthenticationContext authenticationContext, Altinn.App.Core.Internal.Texts.ITranslationService translationService, Altinn.App.Core.Internal.App.IAppResources resources, System.IServiceProvider? serviceProvider = null, Altinn.App.Core.Features.Telemetry? telemetry = null) { } public System.Threading.Tasks.Task GenerateAndStorePdf(Altinn.Platform.Storage.Interface.Models.Instance instance, string taskId, System.Threading.CancellationToken ct) { } public System.Threading.Tasks.Task GenerateAndStorePdf(Altinn.Platform.Storage.Interface.Models.Instance instance, string taskId, string? customFileNameTextResourceKey, System.Collections.Generic.List? autoGeneratePdfForTaskIds = null, System.Threading.CancellationToken ct = default) { } public System.Threading.Tasks.Task GenerateAndStoreSubformPdf(Altinn.Platform.Storage.Interface.Models.Instance instance, string taskId, string? customFileNameTextResourceKey, Altinn.App.Core.Internal.Pdf.SubformPdfContext subformPdfContext, System.Threading.CancellationToken ct) { }