diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f48eb76e7..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,74 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -registries: - learninghubfeed: - type: nuget-feed - url: https://pkgs.dev.azure.com/e-LfH/_packaging/LearningHubFeed/nuget/v3/index.json - username: "kevin.whittaker" - password: ${{ secrets.AZURE_DEVOPS_PAT }} - nuget.org: - type: nuget-feed - url: "https://api.nuget.org/v3/index.json" -updates: - - package-ecosystem: "nuget" - directory: "/" # Location of package manifests - schedule: - interval: "daily" - open-pull-requests-limit: 5 - registries: - - learninghubfeed - - nuget.org - target-branch: "Automatic_version_update_dependabot" - ignore: - # Ignore updates to packages that start with 'Wildcards' - - dependency-name: "Microsoft.FeatureManagement.AspNetCore*" - - dependency-name: "LearningHub.Nhs.Models*" - - dependency-name: "LearningHub.Nhs.Caching*" - - dependency-name: "elfhHub.Nhs.Models*" - - dependency-name: "UK.NHS.CookieBanner" - - dependency-name: "GDS.MultiPageFormData" - - dependency-name: "linqtotwitter*" - # Ignore some updates to the package - - dependency-name: "Azure.Storage.Files.Shares" - versions: [">12.11.0"] - - dependency-name: "FluentAssertions" - versions: [">6.12.0"] - - dependency-name: "HtmlSanitizer" - versions: [">6.0.453"] - - dependency-name: "xunit" - versions: [">2.4.1"] - - dependency-name: "xunit.runner.visualstudio" - versions: [">2.4.3"] - - dependency-name: "Selenium.WebDriver.ChromeDriver" - versions: ">=113.0.5672.1278" # Recommended version - # For all packages, ignore all patch updates - #- dependency-name: "*" - # update-types: ["version-update:semver-patch"] - - # Configuration for npm WebUI - - package-ecosystem: "npm" - directory: "LearningHub.Nhs.WebUI/" # Location of package manifests - schedule: - interval: "daily" - target-branch: "Automatic_version_update_dependabot" - # - "dependencies" - open-pull-requests-limit: 10 - ignore: - - dependency-name: "*" - update-types: ["version-update:semver-major"] - - # Configuration for npm AdminUI - - package-ecosystem: "npm" - directory: "AdminUI/LearningHub.Nhs.AdminUI/" # Location of package manifests - schedule: - interval: "daily" - target-branch: "Automatic_version_update_dependabot" - # - "dependencies" - open-pull-requests-limit: 10 - ignore: - - dependency-name: "*" - update-types: ["version-update:semver-major"] diff --git a/.gitignore b/.gitignore index 386443c95..57a872f00 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,5 @@ obj /LearningHub.Nhs.WebUI.slnLaunch.user /LearningHub.Nhs.WebUI.BlazorClient/LearningHub.Nhs.WebUI.BlazorClient.csproj.user /LearningHub.Nhs.WebUI.BlazorClient/wwwroot/appsettings.Development.json -/LearningHub.Nhs.WebUI.BlazorClient/Properties/launchSettings.json \ No newline at end of file +/LearningHub.Nhs.WebUI.BlazorClient/Properties/launchSettings.json +/.github/dependabot.yml diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Configuration/AzureSearchConfig.cs b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/AzureSearchConfig.cs new file mode 100644 index 000000000..d40d2135d --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Configuration/AzureSearchConfig.cs @@ -0,0 +1,33 @@ +namespace LearningHub.Nhs.AdminUI.Configuration +{ + /// + /// Configuration settings for Azure AI Search. + /// + public class AzureSearchConfig + { + /// + /// Gets or sets the Azure Search service endpoint URL. + /// + public string ServiceEndpoint { get; set; } + + /// + /// Gets or sets the admin API key for managing indexes and indexers. + /// + public string AdminApiKey { get; set; } + + /// + /// Gets or sets the query API key for search operations. + /// + public string QueryApiKey { get; set; } + + /// + /// Gets or sets the name of the search index. + /// + public string IndexName { get; set; } + + /// + /// Gets or sets the name of the indexer. + /// + public string IndexerName { get; set; } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/AzureSearchAdmin.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/AzureSearchAdmin.cs new file mode 100644 index 000000000..5e75160ea --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/AzureSearchAdmin.cs @@ -0,0 +1,119 @@ +namespace LearningHub.Nhs.AdminUI.Controllers +{ + using System.Threading.Tasks; + using LearningHub.Nhs.AdminUI.Helpers; + using LearningHub.Nhs.AdminUI.Interfaces; + using LearningHub.Nhs.AdminUI.Models; + using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Logging; + using Microsoft.FeatureManagement; + + /// + /// Controller for Azure Search administration. + /// + public class AzureSearchAdminController : BaseController + { + private readonly IAzureSearchAdminService azureSearchAdminService; + private readonly IFeatureManager featureManager; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The hosting environment. + /// The Azure Search admin service. + /// The feature manager. + /// The logger. + public AzureSearchAdminController( + IWebHostEnvironment hostingEnvironment, + IAzureSearchAdminService azureSearchAdminService, + IFeatureManager featureManager, + ILogger logger) + : base(hostingEnvironment) + { + this.azureSearchAdminService = azureSearchAdminService; + this.featureManager = featureManager; + this.logger = logger; + } + + /// + /// Displays the Azure Search Admin dashboard. + /// + /// The view. + [HttpGet] + public async Task Index() + { + if (!await this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)) + { + return this.NotFound(); + } + + var viewModel = new AzureSearchAdminViewModel + { + Indexers = await this.azureSearchAdminService.GetIndexersStatusAsync(), + Indexes = await this.azureSearchAdminService.GetIndexesStatusAsync(), + }; + + return this.View(viewModel); + } + + /// + /// Triggers an indexer run. + /// + /// The name of the indexer to run. + /// Redirects to Index with status message. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RunIndexer(string indexerName) + { + if (!await this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)) + { + return this.NotFound(); + } + + if (string.IsNullOrEmpty(indexerName)) + { + return this.BadRequest("Indexer name is required."); + } + + var success = await this.azureSearchAdminService.RunIndexerAsync(indexerName); + + this.TempData["Message"] = success + ? $"Indexer '{indexerName}' has been triggered successfully." + : $"Failed to trigger indexer '{indexerName}'."; + this.TempData["IsError"] = !success; + + return this.RedirectToAction("Index"); + } + + /// + /// Resets an indexer. + /// + /// The name of the indexer to reset. + /// Redirects to Index with status message. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ResetIndexer(string indexerName) + { + if (!await this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)) + { + return this.NotFound(); + } + + if (string.IsNullOrEmpty(indexerName)) + { + return this.BadRequest("Indexer name is required."); + } + + var success = await this.azureSearchAdminService.ResetIndexerAsync(indexerName); + + this.TempData["Message"] = success + ? $"Indexer '{indexerName}' has been reset successfully. You may now run it to perform a full re-index." + : $"Failed to reset indexer '{indexerName}'."; + this.TempData["IsError"] = !success; + + return this.RedirectToAction("Index"); + } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs index 24e9e3be9..a304e49d6 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Helpers/FeatureFlags.cs @@ -14,5 +14,10 @@ public static class FeatureFlags /// The DisplayAudioVideo. /// public const string DisplayAudioVideo = "DisplayAudioVideo"; + + /// + /// The AzureSearch. + /// + public const string AzureSearch = "AzureSearch"; } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IAzureSearchAdminService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IAzureSearchAdminService.cs new file mode 100644 index 000000000..f8f17d9c9 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IAzureSearchAdminService.cs @@ -0,0 +1,38 @@ +namespace LearningHub.Nhs.AdminUI.Interfaces +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using LearningHub.Nhs.AdminUI.Models; + + /// + /// Interface for Azure Search administration operations. + /// + public interface IAzureSearchAdminService + { + /// + /// Gets the status of all indexers. + /// + /// A list of indexer statuses. + Task> GetIndexersStatusAsync(); + + /// + /// Gets the status of all indexes. + /// + /// A list of index statuses. + Task> GetIndexesStatusAsync(); + + /// + /// Runs an indexer manually. + /// + /// The name of the indexer to run. + /// True if successful, false otherwise. + Task RunIndexerAsync(string indexerName); + + /// + /// Resets an indexer (clears state and allows full reindex). + /// + /// The name of the indexer to reset. + /// True if successful, false otherwise. + Task ResetIndexerAsync(string indexerName); + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj index 39e4b25da..a42f95f98 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj +++ b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj @@ -89,7 +89,7 @@ - + diff --git a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj.user b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj.user index b17387f00..75a932fca 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj.user +++ b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj.user @@ -4,6 +4,10 @@ ProjectDebugger - IIS Local + Local IIS + MvcControllerEmptyScaffolder + root/Common/MVC/Controller + RazorViewEmptyScaffolder + root/Common/MVC/View \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Models/AzureSearchAdminViewModel.cs b/AdminUI/LearningHub.Nhs.AdminUI/Models/AzureSearchAdminViewModel.cs new file mode 100644 index 000000000..767a769d1 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Models/AzureSearchAdminViewModel.cs @@ -0,0 +1,30 @@ +namespace LearningHub.Nhs.AdminUI.Models +{ + using System.Collections.Generic; + + /// + /// View model for Azure Search Admin page. + /// + public class AzureSearchAdminViewModel + { + /// + /// Gets or sets the list of indexer statuses. + /// + public List Indexers { get; set; } = new List(); + + /// + /// Gets or sets the list of index statuses. + /// + public List Indexes { get; set; } = new List(); + + /// + /// Gets or sets a message to display to the user. + /// + public string Message { get; set; } + + /// + /// Gets or sets a value indicating whether the message is an error. + /// + public bool IsError { get; set; } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexStatusViewModel.cs b/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexStatusViewModel.cs new file mode 100644 index 000000000..41e27953d --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexStatusViewModel.cs @@ -0,0 +1,23 @@ +namespace LearningHub.Nhs.AdminUI.Models +{ + /// + /// View model for Azure Search index status. + /// + public class IndexStatusViewModel + { + /// + /// Gets or sets the name of the index. + /// + public string Name { get; set; } + + /// + /// Gets or sets the document count in the index. + /// + public long? DocumentCount { get; set; } + + /// + /// Gets or sets the storage size in bytes. + /// + public long? StorageSize { get; set; } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexerStatusViewModel.cs b/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexerStatusViewModel.cs new file mode 100644 index 000000000..8cc027eb9 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Models/IndexerStatusViewModel.cs @@ -0,0 +1,45 @@ +namespace LearningHub.Nhs.AdminUI.Models +{ + using System; + + /// + /// View model for Azure Search indexer status. + /// + public class IndexerStatusViewModel + { + /// + /// Gets or sets the name of the indexer. + /// + public string Name { get; set; } + + /// + /// Gets or sets the current status of the indexer. + /// + public string Status { get; set; } + + /// + /// Gets or sets the last run time of the indexer. + /// + public DateTimeOffset? LastRunTime { get; set; } + + /// + /// Gets or sets the last run status (success/failed/inProgress). + /// + public string LastRunStatus { get; set; } + + /// + /// Gets or sets the error message if the last run failed. + /// + public string LastRunErrorMessage { get; set; } + + /// + /// Gets or sets the number of documents indexed in the last run. + /// + public int? ItemsProcessed { get; set; } + + /// + /// Gets or sets the number of documents that failed in the last run. + /// + public int? ItemsFailed { get; set; } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs index 4f95fe9fb..9137a55e7 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs @@ -108,6 +108,11 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur services.AddScoped(); services.AddScoped(); + // Configure Azure Search + services.Configure(configuration.GetSection("AzureSearch")); + services.AddHttpClient("AzureSearch"); + services.AddScoped(); + // web settings binding var webSettings = new WebSettings(); configuration.Bind("WebSettings", webSettings); @@ -160,12 +165,12 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur }).AddCookie( "Cookies", options => - { - options.AccessDeniedPath = "/Authorisation/AccessDenied"; - options.ExpireTimeSpan = TimeSpan.FromMinutes(webSettings.AuthTimeout); - options.SlidingExpiration = true; - options.EventsType = typeof(CookieEventHandler); - }).AddOpenIdConnect( + { + options.AccessDeniedPath = "/Authorisation/AccessDenied"; + options.ExpireTimeSpan = TimeSpan.FromMinutes(webSettings.AuthTimeout); + options.SlidingExpiration = true; + options.EventsType = typeof(CookieEventHandler); + }).AddOpenIdConnect( "oidc", options => { @@ -185,12 +190,12 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur options.GetClaimsFromUserInfoEndpoint = true; options.Events.OnRemoteFailure = async context => - { - context.Response.Redirect("/"); // If login cancelled return to home page - context.HandleResponse(); + { + context.Response.Redirect("/"); // If login cancelled return to home page + context.HandleResponse(); - await Task.CompletedTask; - }; + await Task.CompletedTask; + }; options.ClaimActions.MapUniqueJsonKey("role", "role"); options.ClaimActions.MapUniqueJsonKey("name", "elfh_userName"); @@ -223,11 +228,11 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur services.Configure( options => - { - options.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor; - options.KnownNetworks.Clear(); - options.KnownProxies.Clear(); - }); + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + }); services.AddControllersWithViews(options => { diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/AzureSearchAdminService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/AzureSearchAdminService.cs new file mode 100644 index 000000000..3d4406d7e --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/AzureSearchAdminService.cs @@ -0,0 +1,299 @@ +namespace LearningHub.Nhs.AdminUI.Services +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Text.Json; + using System.Threading.Tasks; + using LearningHub.Nhs.AdminUI.Configuration; + using LearningHub.Nhs.AdminUI.Interfaces; + using LearningHub.Nhs.AdminUI.Models; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Service for Azure Search administration operations. + /// + public class AzureSearchAdminService : IAzureSearchAdminService + { + /// + /// The Azure Search REST API version. + /// + private const string ApiVersion = "2024-07-01"; + + private readonly AzureSearchConfig config; + private readonly ILogger logger; + private readonly HttpClient httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// The Azure Search configuration. + /// The logger. + /// The HTTP client factory. + public AzureSearchAdminService( + IOptions config, + ILogger logger, + IHttpClientFactory httpClientFactory) + { + this.config = config.Value; + this.logger = logger; + this.httpClient = httpClientFactory.CreateClient("AzureSearch"); + this.ConfigureHttpClient(); + } + + /// + public async Task> GetIndexersStatusAsync() + { + var result = new List(); + + try + { + if (string.IsNullOrEmpty(this.config.ServiceEndpoint) || string.IsNullOrEmpty(this.config.AdminApiKey)) + { + this.logger.LogWarning("Azure Search configuration is not set"); + return result; + } + + // Get list of indexers + var indexersResponse = await this.httpClient.GetAsync($"indexers?api-version={ApiVersion}"); + if (!indexersResponse.IsSuccessStatusCode) + { + this.logger.LogError("Failed to get indexers list: {StatusCode}", indexersResponse.StatusCode); + return result; + } + + var indexersJson = await indexersResponse.Content.ReadAsStringAsync(); + using var indexersDoc = JsonDocument.Parse(indexersJson); + + if (indexersDoc.RootElement.TryGetProperty("value", out var indexersArray)) + { + foreach (var indexer in indexersArray.EnumerateArray()) + { + var indexerName = indexer.GetProperty("name").GetString(); + var encodedIndexerName = Uri.EscapeDataString(indexerName); + + // Get status for each indexer + var statusResponse = await this.httpClient.GetAsync($"indexers/{encodedIndexerName}/status?api-version={ApiVersion}"); + if (statusResponse.IsSuccessStatusCode) + { + var statusJson = await statusResponse.Content.ReadAsStringAsync(); + var statusViewModel = this.ParseIndexerStatus(indexerName, statusJson); + result.Add(statusViewModel); + } + else + { + result.Add(new IndexerStatusViewModel + { + Name = indexerName, + Status = "Unknown", + LastRunStatus = "Error retrieving status", + }); + } + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting indexers status"); + } + + return result; + } + + /// + public async Task> GetIndexesStatusAsync() + { + var result = new List(); + + try + { + if (string.IsNullOrEmpty(this.config.ServiceEndpoint) || string.IsNullOrEmpty(this.config.AdminApiKey)) + { + this.logger.LogWarning("Azure Search configuration is not set"); + return result; + } + + // Get list of indexes + var indexesResponse = await this.httpClient.GetAsync($"indexes?api-version={ApiVersion}"); + if (!indexesResponse.IsSuccessStatusCode) + { + this.logger.LogError("Failed to get indexes list: {StatusCode}", indexesResponse.StatusCode); + return result; + } + + var indexesJson = await indexesResponse.Content.ReadAsStringAsync(); + using var indexesDoc = JsonDocument.Parse(indexesJson); + + if (indexesDoc.RootElement.TryGetProperty("value", out var indexesArray)) + { + foreach (var index in indexesArray.EnumerateArray()) + { + var indexName = index.GetProperty("name").GetString(); + var encodedIndexName = Uri.EscapeDataString(indexName); + + // Get statistics for each index + var statsResponse = await this.httpClient.GetAsync($"indexes/{encodedIndexName}/stats?api-version={ApiVersion}"); + if (statsResponse.IsSuccessStatusCode) + { + var statsJson = await statsResponse.Content.ReadAsStringAsync(); + using var statsDoc = JsonDocument.Parse(statsJson); + + result.Add(new IndexStatusViewModel + { + Name = indexName, + DocumentCount = statsDoc.RootElement.TryGetProperty("documentCount", out var docCount) ? docCount.GetInt64() : null, + StorageSize = statsDoc.RootElement.TryGetProperty("storageSize", out var storageSize) ? storageSize.GetInt64() : null, + }); + } + else + { + result.Add(new IndexStatusViewModel + { + Name = indexName, + DocumentCount = null, + StorageSize = null, + }); + } + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error getting indexes status"); + } + + return result; + } + + /// + public async Task RunIndexerAsync(string indexerName) + { + try + { + if (string.IsNullOrEmpty(this.config.ServiceEndpoint) || string.IsNullOrEmpty(this.config.AdminApiKey)) + { + this.logger.LogWarning("Azure Search configuration is not set"); + return false; + } + + var encodedIndexerName = Uri.EscapeDataString(indexerName); + var response = await this.httpClient.PostAsync($"indexers/{encodedIndexerName}/run?api-version={ApiVersion}", null); + + if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.Accepted) + { + this.logger.LogInformation("Successfully triggered indexer: {IndexerName}", indexerName); + return true; + } + + this.logger.LogError("Failed to run indexer {IndexerName}: {StatusCode}", indexerName, response.StatusCode); + return false; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error running indexer {IndexerName}", indexerName); + return false; + } + } + + /// + public async Task ResetIndexerAsync(string indexerName) + { + try + { + if (string.IsNullOrEmpty(this.config.ServiceEndpoint) || string.IsNullOrEmpty(this.config.AdminApiKey)) + { + this.logger.LogWarning("Azure Search configuration is not set"); + return false; + } + + var encodedIndexerName = Uri.EscapeDataString(indexerName); + var response = await this.httpClient.PostAsync($"indexers/{encodedIndexerName}/reset?api-version={ApiVersion}", null); + + if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + this.logger.LogInformation("Successfully reset indexer: {IndexerName}", indexerName); + return true; + } + + this.logger.LogError("Failed to reset indexer {IndexerName}: {StatusCode}", indexerName, response.StatusCode); + return false; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error resetting indexer {IndexerName}", indexerName); + return false; + } + } + + private void ConfigureHttpClient() + { + if (!string.IsNullOrEmpty(this.config.ServiceEndpoint)) + { + this.httpClient.BaseAddress = new Uri(this.config.ServiceEndpoint.TrimEnd('/') + "/"); + } + + if (!string.IsNullOrEmpty(this.config.AdminApiKey)) + { + this.httpClient.DefaultRequestHeaders.Add("api-key", this.config.AdminApiKey); + } + + this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + private IndexerStatusViewModel ParseIndexerStatus(string indexerName, string statusJson) + { + var viewModel = new IndexerStatusViewModel + { + Name = indexerName, + }; + + try + { + using var doc = JsonDocument.Parse(statusJson); + var root = doc.RootElement; + + if (root.TryGetProperty("status", out var status)) + { + viewModel.Status = status.GetString(); + } + + if (root.TryGetProperty("lastResult", out var lastResult)) + { + if (lastResult.TryGetProperty("status", out var lastStatus)) + { + viewModel.LastRunStatus = lastStatus.GetString(); + } + + if (lastResult.TryGetProperty("endTime", out var endTime)) + { + viewModel.LastRunTime = endTime.GetDateTimeOffset(); + } + + if (lastResult.TryGetProperty("errorMessage", out var errorMessage)) + { + viewModel.LastRunErrorMessage = errorMessage.GetString(); + } + + if (lastResult.TryGetProperty("itemsProcessed", out var itemsProcessed)) + { + viewModel.ItemsProcessed = itemsProcessed.GetInt32(); + } + + if (lastResult.TryGetProperty("itemsFailed", out var itemsFailed)) + { + viewModel.ItemsFailed = itemsFailed.GetInt32(); + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error parsing indexer status for {IndexerName}", indexerName); + viewModel.Status = "Error parsing status"; + } + + return viewModel; + } + } +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/UserService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/UserService.cs index 5f329c61a..815eb3c5e 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/UserService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/UserService.cs @@ -337,6 +337,7 @@ public async Task SendAdminPasswordResetEmail(int u public async Task ClearUserCachedPermissions(int userId) { await this.cacheService.RemoveAsync($"{userId}:AllRolesWithPermissions"); + await this.cacheService.RemoveAsync($"{userId}:DatabricksReporter"); await this.cacheService.RemoveAsync($"{userId}:UserHasPublishedResources"); return new LearningHubValidationResult(true); } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/AzureSearchAdmin/Index.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/AzureSearchAdmin/Index.cshtml new file mode 100644 index 000000000..98065c271 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/AzureSearchAdmin/Index.cshtml @@ -0,0 +1,222 @@ +@model LearningHub.Nhs.AdminUI.Models.AzureSearchAdminViewModel +@{ + ViewData["Title"] = "Azure AI Search Management"; + var message = TempData["Message"] as string; + var isError = TempData["IsError"] as bool? ?? false; +} + +@section SideMenu { + @{ + await Html.RenderPartialAsync("_NavSection"); + } +} + +
+
+ Azure AI Search Management +
+ +
+ @if (!string.IsNullOrEmpty(message)) + { +
+

@message

+
+ } + + +
+

Search Indexes

+ @if (Model.Indexes.Any()) + { +
+ + + + + + + + + + @foreach (var index in Model.Indexes) + { + + + + + + } + +
Index NameDocument CountStorage Size
@index.Name@(index.DocumentCount?.ToString("N0") ?? "N/A")@(index.StorageSize.HasValue? FormatBytes(index.StorageSize.Value) : "N/A")
+
+ } + else + { +

No indexes found or Azure Search is not configured.

+ } +
+ + +
+

Indexers

+ @if (Model.Indexers.Any()) + { +
+ + + + + + + + + + + + + + @foreach (var indexer in Model.Indexers) + { + + + + + + + + + + } + +
Indexer NameStatusLast RunLast Run StatusItems ProcessedItems FailedActions
@indexer.Name + @indexer.Status + @(indexer.LastRunTime?.ToString("dd/MM/yyyy HH:mm:ss") ?? "N/A") + @(indexer.LastRunStatus ?? "N/A") + @if (!string.IsNullOrEmpty(indexer.LastRunErrorMessage)) + { + + } + @(indexer.ItemsProcessed?.ToString("N0") ?? "N/A") + @if (indexer.ItemsFailed > 0) + { + @indexer.ItemsFailed?.ToString("N0") + } + else + { + @(indexer.ItemsFailed?.ToString("N0") ?? "N/A") + } + +
+ @Html.AntiForgeryToken() + + +
+ +
+
+ } + else + { +

No indexers found or Azure Search is not configured.

+ } +
+ + +
+
+ + + + +@functions { + string FormatBytes(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len /= 1024; + } + return $"{len:0.##} {sizes[order]}"; + } + + string GetStatusBadgeClass(string status) + { + return status?.ToLower() switch + { + "running" => "badge-primary", + "error" => "badge-danger", + _ => "badge-secondary" + }; + } + + string GetLastRunStatusBadgeClass(string status) + { + return status?.ToLower() switch + { + "success" => "badge-success", + "transientfailure" or "transientFailure" => "badge-warning", + "persistentfailure" or "persistentFailure" => "badge-danger", + "reset" => "badge-info", + "inprogress" or "inProgress" => "badge-primary", + _ => "badge-secondary" + }; + } +} + +@section Scripts { + +} \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Shared/_NavPartial.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Shared/_NavPartial.cshtml index 191e87560..6a6e66a26 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Shared/_NavPartial.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Shared/_NavPartial.cshtml @@ -1,6 +1,9 @@ -@using Microsoft.Extensions.Options; +@using Microsoft.Extensions.Options; @using LearningHub.Nhs.AdminUI.Configuration; +@using LearningHub.Nhs.AdminUI.Helpers; +@using Microsoft.FeatureManagement; @inject IOptions webSettings +@inject IFeatureManager featureManager @{ var mainMenu = "Home"; @@ -13,7 +16,7 @@ case "externalsystem": case "log": case "roadmap": - case "cms": + case "cms": case "release": case "cache": mainMenu = "Settings"; @@ -30,12 +33,17 @@ case "resourcesync": mainMenu = "ResourceSync"; break; + case "azuresearchadmin": + mainMenu = "AzureSearch"; + break; } string IsActive(string itemCheck) { return mainMenu == itemCheck ? "active" : "inactive"; } + + var azureSearchEnabled = await featureManager.IsEnabledAsync(FeatureFlags.AzureSearch); }
@@ -44,7 +52,6 @@
-
@@ -81,9 +88,18 @@ - + @if (!azureSearchEnabled) + { + + } + else + { + + } }
- - + \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/appsettings.json b/AdminUI/LearningHub.Nhs.AdminUI/appsettings.json index 17b29013c..a4ffbecb3 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/appsettings.json +++ b/AdminUI/LearningHub.Nhs.AdminUI/appsettings.json @@ -70,9 +70,17 @@ "NLogDb": "", "LearningHubRedis": "" }, + "AzureSearch": { + "ServiceEndpoint": "", + "AdminApiKey": "", + "QueryApiKey": "", + "IndexName": "", + "IndexerName": "" + }, "APPINSIGHTS_INSTRUMENTATIONKEY": "", "FeatureManagement": { "AddAudioVideo": true, - "DisplayAudioVideo": true + "DisplayAudioVideo": true, + "AzureSearch": false } } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/package.json b/AdminUI/LearningHub.Nhs.AdminUI/package.json index 2117d7501..0c7f82eca 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/package.json +++ b/AdminUI/LearningHub.Nhs.AdminUI/package.json @@ -43,7 +43,7 @@ "vue-carousel": "^0.18.0", "vue-clamp": "0.4.1", "vue-click-outside": "1.1.0", - "vue-ctk-date-time-picker": "^2.5.0", + "vue-ctk-date-time-picker": "2.5.0", "vue-router": "^3.6.5", "vue-simple-progress": "^1.1.1", "vue-typeahead": "^2.3.2", diff --git a/LearningHub.Nhs.WebUI.AutomatedUiTests/LearningHub.Nhs.WebUI.AutomatedUiTests.csproj b/LearningHub.Nhs.WebUI.AutomatedUiTests/LearningHub.Nhs.WebUI.AutomatedUiTests.csproj index 695e93e2d..4c85e0949 100644 --- a/LearningHub.Nhs.WebUI.AutomatedUiTests/LearningHub.Nhs.WebUI.AutomatedUiTests.csproj +++ b/LearningHub.Nhs.WebUI.AutomatedUiTests/LearningHub.Nhs.WebUI.AutomatedUiTests.csproj @@ -9,10 +9,11 @@ True + - + diff --git a/LearningHub.Nhs.WebUI/Configuration/Settings.cs b/LearningHub.Nhs.WebUI/Configuration/Settings.cs index e300e80e3..b6eb94279 100644 --- a/LearningHub.Nhs.WebUI/Configuration/Settings.cs +++ b/LearningHub.Nhs.WebUI/Configuration/Settings.cs @@ -265,5 +265,10 @@ public Settings() /// Gets or sets AllCataloguePageSize. /// public int AllCataloguePageSize { get; set; } + + /// + /// Gets or sets the StatMandId. + /// + public int StatMandId { get; set; } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Controllers/AccountController.cs b/LearningHub.Nhs.WebUI/Controllers/AccountController.cs index d22a8485e..1309ff694 100644 --- a/LearningHub.Nhs.WebUI/Controllers/AccountController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/AccountController.cs @@ -1167,6 +1167,16 @@ public IActionResult InvalidUserAccount() return this.View(); } + /// + /// The user does not have permissions to access a section of the Learning Hub. + /// + /// The . + [HttpGet] + public IActionResult AccessRestricted() + { + return this.View(); + } + /// /// The user already has an already active session. Then prevent concurrent access to the Learning Hub. /// diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/ScormController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/ScormController.cs index 6a8f7d699..690094c16 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/ScormController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/ScormController.cs @@ -261,6 +261,9 @@ private async Task Commit(SCO scoObject) // Persist update. await this.activityService.UpdateScormActivityAsync(scoObject); + // Create Activity Complete event. (TODO process event using service bus queue - perform any longer running async status re-calc). + await this.activityService.ScormCompleteActivity(scoObject); + return true; } catch (Exception ex) diff --git a/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs b/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs index d9ececfed..a91718384 100644 --- a/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs @@ -1398,7 +1398,7 @@ await this.userService.UpdateUserEmployment( } else { - if (!searchSubmission) + if (string.IsNullOrWhiteSpace(viewModel.FilterText)) { viewModel.SelectedWorkPlaceId = profile.LocationId.ToString(); viewModel.FilterText = profile.LocationName; diff --git a/LearningHub.Nhs.WebUI/Controllers/ReportsController.cs b/LearningHub.Nhs.WebUI/Controllers/ReportsController.cs new file mode 100644 index 000000000..4cda20cb7 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Controllers/ReportsController.cs @@ -0,0 +1,482 @@ +namespace LearningHub.Nhs.WebUI.Controllers +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Threading.Tasks; + using GDS.MultiPageFormData; + using GDS.MultiPageFormData.Enums; + using LearningHub.Nhs.Caching; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.Paging; + using LearningHub.Nhs.WebUI.Configuration; + using LearningHub.Nhs.WebUI.Filters; + using LearningHub.Nhs.WebUI.Helpers; + using LearningHub.Nhs.WebUI.Interfaces; + using LearningHub.Nhs.WebUI.Models.Learning; + using LearningHub.Nhs.WebUI.Models.Report; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// Defines the . + /// + [ServiceFilter(typeof(LoginWizardFilter))] + [ServiceFilter(typeof(ReporterPermissionFilter))] + [Authorize] + [Route("Reports")] + public class ReportsController : BaseController + { + private const int ReportPageSize = 10; + private readonly ICacheService cacheService; + private readonly ICategoryService categoryService; + private readonly IMultiPageFormService multiPageFormService; + private readonly IReportService reportService; + private readonly IFileService fileService; + + /// + /// Initializes a new instance of the class. + /// + /// httpClientFactory. + /// cacheService. + /// multiPageFormService. + /// reportService. + /// categoryService. + /// fileService. + /// The hostingEnvironment. + /// The logger. + /// settings. + public ReportsController(IHttpClientFactory httpClientFactory, IWebHostEnvironment hostingEnvironment, ILogger logger, IOptions settings, ICacheService cacheService, IMultiPageFormService multiPageFormService, IReportService reportService, ICategoryService categoryService, IFileService fileService) + : base(hostingEnvironment, httpClientFactory, logger, settings.Value) + { + this.cacheService = cacheService; + this.multiPageFormService = multiPageFormService; + this.reportService = reportService; + this.categoryService = categoryService; + this.fileService = fileService; + } + + /// + /// The Report landing page. + /// + /// reportHistoryViewModel. + /// The . + [ResponseCache(CacheProfileName = "Never")] + public async Task Index(ReportHistoryViewModel reportHistoryViewModel = null) + { + int page = 1; + this.TempData.Clear(); + var newReport = new DatabricksRequestModel { Take = ReportPageSize, Skip = 0 }; + + await this.multiPageFormService.SetMultiPageFormData( + newReport, + MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), + this.TempData); + + var historyRequest = new PagingRequestModel + { + PageSize = ReportPageSize, + }; + + switch (reportHistoryViewModel.ReportFormActionType) + { + case ReportFormActionTypeEnum.NextPageChange: + reportHistoryViewModel.CurrentPageIndex += 1; + break; + + case ReportFormActionTypeEnum.PreviousPageChange: + reportHistoryViewModel.CurrentPageIndex -= 1; + break; + default: + reportHistoryViewModel.CurrentPageIndex = 0; + break; + } + + page = page + reportHistoryViewModel.CurrentPageIndex; + historyRequest.Page = page; + + // get a list of report history and send to view + var result = await this.reportService.GetReportHistory(historyRequest); + if (result != null) + { + reportHistoryViewModel.TotalCount = result.TotalItemCount; + reportHistoryViewModel.ReportHistoryModels = result.Items; + } + + this.ViewData["AllCourses"] = await this.GetCoursesAsync(); + reportHistoryViewModel.ReportPaging = new ReportPagingModel() { CurrentPage = reportHistoryViewModel.CurrentPageIndex, PageSize = ReportPageSize, TotalItems = reportHistoryViewModel.TotalCount, HasItems = reportHistoryViewModel.TotalCount > 0 }; + return this.View(reportHistoryViewModel); + } + + /// + /// CreateReportCourseSelection. + /// + /// searchText. + /// returnUrl. + /// A representing the result of the asynchronous operation. + [Route("CreateReportCourseSelection")] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task CreateReportCourseSelection(string searchText = "", string returnUrl = "") + { + this.ViewBag.ReturnUrl = returnUrl; + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + var coursevm = new ReportCreationCourseSelection { SearchText = searchText, Courses = reportCreation.Courses != null ? reportCreation.Courses : new List() }; + var getCourses = await this.GetCoursesAsync(); + if (!string.IsNullOrWhiteSpace(searchText)) + { + getCourses = getCourses.Where(x => x.Value.ToLower().Contains(searchText.ToLower())).ToList(); + } + + if (coursevm.Courses.Count == 0 && !string.IsNullOrWhiteSpace(reportCreation.TimePeriod)) + { + coursevm.Courses = new List { "all" }; + } + + coursevm.BuildCourses(getCourses); + return this.View(coursevm); + } + + /// + /// CreateReportCourseSelection. + /// + /// courseSelection. + /// returnurl. + /// A representing the result of the asynchronous operation. + [HttpPost] + [Route("CreateReportCourseSelection")] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task CreateReportCourseSelection(ReportCreationCourseSelection courseSelection, string returnUrl = "") + { + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + + if (courseSelection.Courses != null) + { + if (courseSelection.Courses.Any()) + { + reportCreation.Courses = courseSelection.Courses.Contains("all") ? new List() : courseSelection.Courses; + await this.multiPageFormService.SetMultiPageFormData(reportCreation, MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + if (!string.IsNullOrEmpty(returnUrl)) + { + return this.Redirect(returnUrl); + } + + return this.RedirectToAction("CreateReportDateSelection"); + } + } + + this.ModelState.AddModelError("Courses", CommonValidationErrorMessages.CourseRequired); + + courseSelection.BuildCourses(await this.GetCoursesAsync()); + courseSelection.Courses = reportCreation.Courses; + this.ViewBag.ReturnUrl = returnUrl; + return this.View("CreateReportCourseSelection", courseSelection); + } + + /// + /// CreateReportDateSelection. + /// + /// returnUrl. + /// A representing the result of the asynchronous operation. + [Route("CreateReportDateSelection")] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task CreateReportDateSelection(string returnUrl = "") + { + this.ViewBag.ReturnUrl = returnUrl; + { + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + var dateVM = new ReportCreationDateSelection(); + dateVM.TimePeriod = reportCreation.TimePeriod; + if (reportCreation.StartDate.HasValue && reportCreation.TimePeriod == "Custom") + { + dateVM.StartDay = reportCreation.StartDate.HasValue ? reportCreation.StartDate.GetValueOrDefault().Day : 0; + dateVM.StartMonth = reportCreation.StartDate.HasValue ? reportCreation.StartDate.GetValueOrDefault().Month : 0; + dateVM.StartYear = reportCreation.StartDate.HasValue ? reportCreation.StartDate.GetValueOrDefault().Year : 0; + dateVM.EndDay = reportCreation.EndDate.HasValue ? reportCreation.EndDate.GetValueOrDefault().Day : 0; + dateVM.EndMonth = reportCreation.EndDate.HasValue ? reportCreation.EndDate.GetValueOrDefault().Month : 0; + dateVM.EndYear = reportCreation.EndDate.HasValue ? reportCreation.EndDate.GetValueOrDefault().Year : 0; + } + + var minDate = await this.GetMinDate(); + + dateVM.DataStart = minDate.DataStart; + dateVM.HintText = minDate.HintText; + + return this.View(dateVM); + } + } + + /// + /// CreateReportDateSelection. + /// + /// reportCreationDate. + /// returnurl. + /// A representing the result of the asynchronous operation. + [Route("CreateReportSummary")] + [HttpPost] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task CreateReportSummary(ReportCreationDateSelection reportCreationDate, string returnUrl = "") + { + // validate date + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + reportCreation.TimePeriod = reportCreationDate.TimePeriod; + + if (reportCreation.TimePeriod == null) + { + var minDate = await this.GetMinDate(); + reportCreationDate.DataStart = minDate.DataStart; + reportCreationDate.HintText = minDate.HintText; + this.ModelState.AddModelError("TimePeriod", CommonValidationErrorMessages.ReportingPeriodRequired); + this.ViewBag.ReturnUrl = returnUrl; + return this.View("CreateReportDateSelection", reportCreationDate); + } + + if (!this.ModelState.IsValid) + { + var minDate = await this.GetMinDate(); + reportCreationDate.DataStart = minDate.DataStart; + reportCreationDate.HintText = minDate.HintText; + this.ViewBag.ReturnUrl = returnUrl; + return this.View("CreateReportDateSelection", reportCreationDate); + } + + reportCreation.StartDate = reportCreation.TimePeriod == "Custom" ? reportCreationDate.GetStartDate() : null; + reportCreation.EndDate = reportCreation.TimePeriod == "Custom" ? reportCreationDate.GetEndDate() : null; + await this.multiPageFormService.SetMultiPageFormData(reportCreation, MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + return this.RedirectToAction("CourseProgressReport"); + } + + /// + /// ViewReport. + /// + /// reportHistoryId. + /// A representing the result of the asynchronous operation. + [Route("ViewReport/{reportHistoryId}")] + [HttpGet] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task ViewReport(int reportHistoryId) + { + this.TempData.Clear(); + var report = await this.reportService.GetReportHistoryById(reportHistoryId); + if (report == null) + { + return this.RedirectToAction("Index"); + } + + var reportRequest = new DatabricksRequestModel { Take = ReportPageSize, Skip = 0, ReportHistoryId = reportHistoryId }; + var periodCheck = int.TryParse(report.PeriodDays.ToString(), out int numberOfDays); + if (report.PeriodDays > 0 && periodCheck) + { + reportRequest.TimePeriod = report.PeriodDays.ToString(); + reportRequest.StartDate = DateTime.Now.AddDays(-numberOfDays); + reportRequest.EndDate = DateTime.Now; + } + else + { + reportRequest.TimePeriod = "Custom"; + reportRequest.StartDate = report.StartDate; + reportRequest.EndDate = report.EndDate; + } + + if (report.CourseFilter == "all") + { + report.CourseFilter = string.Empty; + } + + reportRequest.Courses = report.CourseFilter.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(f => f.Trim()).ToList(); + + await this.multiPageFormService.SetMultiPageFormData(reportRequest, MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + return this.RedirectToAction("CourseProgressReport"); + } + + /// + /// DownloadReport. + /// + /// reportHistoryId. + /// A representing the result of the asynchronous operation. + [Route("DownloadReport/{reportHistoryId}")] + [HttpGet] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task DownloadReport(int reportHistoryId) + { + var report = await this.reportService.DownloadReport(reportHistoryId); + if (report == null) + { + return this.RedirectToAction("Index"); + } + + var result = await this.fileService.DownloadBlobFileAsync(report.FilePath); + return this.File(result.Stream, result.ContentType, result.FileName); + } + + /// + /// ViewReport. + /// + /// reportHistoryId. + /// A representing the result of the asynchronous operation. + [Route("QueueReportDownload")] + [HttpPost] + [ResponseCache(CacheProfileName = "Never")] + public async Task QueueReportDownload(int reportHistoryId) + { + await this.reportService.QueueReportDownload(reportHistoryId); + return this.RedirectToAction("CourseProgressReport"); + } + + /// + /// CourseCompletionReport. + /// + /// courseCompletion. + /// A representing the result of the asynchronous operation. + [Route("CourseProgressReport")] + [HttpGet] + [ResponseCache(CacheProfileName = "Never")] + [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { "ReportWizardCWF" })] + public async Task CourseProgressReport(CourseCompletionViewModel courseCompletion = null) + { + int page = 1; + + // validate date + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + + if (reportCreation.Courses == null) + { + return this.RedirectToAction("Index"); + } + + switch (courseCompletion.ReportFormActionType) + { + case ReportFormActionTypeEnum.NextPageChange: + courseCompletion.CurrentPageIndex += 1; + page = page + courseCompletion.CurrentPageIndex; + reportCreation.Skip = courseCompletion.CurrentPageIndex * ReportPageSize; + break; + + case ReportFormActionTypeEnum.PreviousPageChange: + courseCompletion.CurrentPageIndex -= 1; + page = page + courseCompletion.CurrentPageIndex; + reportCreation.Skip = courseCompletion.CurrentPageIndex * ReportPageSize; + break; + default: + courseCompletion.CurrentPageIndex = 0; + reportCreation.Skip = 0; + break; + } + + DateTimeOffset today = DateTimeOffset.Now.Date; + DateTimeOffset? startDate = null; + DateTimeOffset? endDate = null; + + if (int.TryParse(reportCreation.TimePeriod, out int days)) + { + startDate = today.AddDays(-days); + endDate = today; + } + else if (reportCreation.TimePeriod == "Custom") + { + startDate = reportCreation.StartDate; + endDate = reportCreation.EndDate; + } + + var result = await this.reportService.GetCourseCompletionReport(new DatabricksRequestModel + { + StartDate = startDate, + EndDate = endDate, + TimePeriod = reportCreation.TimePeriod, + Courses = reportCreation.Courses, + ReportHistoryId = reportCreation.ReportHistoryId, + Take = reportCreation.Take, + Skip = page, + }); + + var response = new CourseCompletionViewModel(reportCreation); + + if (result != null) + { + response.TotalCount = result.TotalCount; + response.CourseCompletionRecords = result.CourseCompletionRecords; + response.ReportHistoryModel = await this.reportService.GetReportHistoryById(result.ReportHistoryId); + response.ReportHistoryId = result.ReportHistoryId; + reportCreation.ReportHistoryId = result.ReportHistoryId; + } + + await this.multiPageFormService.SetMultiPageFormData(reportCreation, MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + + var allCourses = await this.GetCoursesAsync(); + + List matchedCourseNames; + + if (reportCreation.Courses.Count == 0) + { + matchedCourseNames = new List { "All courses" }; + } + else + { + matchedCourseNames = allCourses + .Where(course => reportCreation.Courses.Contains(course.Key)) + .Select(course => course.Value) + .ToList(); + } + + this.ViewData["matchedCourseNames"] = matchedCourseNames; + response.ReportPaging = new ReportPagingModel() { CurrentPage = courseCompletion.CurrentPageIndex, PageSize = ReportPageSize, TotalItems = response.TotalCount, HasItems = response.TotalCount > 0 }; + return this.View(response); + } + + private async Task>> GetCoursesAsync() + { + int categoryId = this.Settings.StatMandId; + var courses = new List>(); + var subCategories = await this.categoryService.GetCoursesByCategoryIdAsync(categoryId); + + foreach (var subCategory in subCategories.Courses) + { + courses.Add(new KeyValuePair(subCategory.Id.ToString(), UtilityHelper.ConvertToSentenceCase(subCategory.Displayname))); + } + + return courses; + } + + private async Task GetMinDate() + { + var dateVM = new ReportCreationDateSelection(); + var reportCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddCustomWebForm("ReportWizardCWF"), this.TempData); + + var result = await this.reportService.GetCourseCompletionReport(new DatabricksRequestModel + { + StartDate = null, + EndDate = null, + TimePeriod = reportCreation.TimePeriod, + Courses = reportCreation.Courses, + ReportHistoryId = 0, + Take = 1, + Skip = 1, + }); + + if (result != null) + { + var validDate = DateTime.TryParse(result.MinValidDate, out DateTime startDate); + dateVM.DataStart = validDate ? startDate : null; + dateVM.HintText = validDate + ? $"For example, {startDate.Day} {startDate.Month} {startDate.Year}" + : $"For example, {DateTime.Now.Day} {DateTime.Now.Month} {DateTime.Now.Year}"; + } + else + { + dateVM.DataStart = null; + dateVM.HintText = $"For example, {DateTime.Now.Day} {DateTime.Now.Month} {DateTime.Now.Year}"; + } + + return dateVM; + } + } +} diff --git a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs index a7b53fb11..074bc39ab 100644 --- a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs @@ -8,6 +8,7 @@ namespace LearningHub.Nhs.WebUI.Controllers using LearningHub.Nhs.Models.Search; using LearningHub.Nhs.Models.Search.SearchClick; using LearningHub.Nhs.WebUI.Filters; + using LearningHub.Nhs.WebUI.Helpers; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models.Search; using Microsoft.AspNetCore.Authorization; @@ -16,6 +17,7 @@ namespace LearningHub.Nhs.WebUI.Controllers using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; + using Microsoft.FeatureManagement; using Settings = LearningHub.Nhs.WebUI.Configuration.Settings; /// @@ -28,6 +30,7 @@ public class SearchController : BaseController { private readonly ISearchService searchService; private readonly IFileService fileService; + private readonly IFeatureManager featureManager; /// /// Initializes a new instance of the class. @@ -38,17 +41,20 @@ public class SearchController : BaseController /// The searchService. /// The logger. /// The fileService. + /// The Feature flag manager. public SearchController( IHttpClientFactory httpClientFactory, IWebHostEnvironment hostingEnvironment, IOptions settings, ISearchService searchService, ILogger logger, - IFileService fileService) + IFileService fileService, + IFeatureManager featureManager) : base(hostingEnvironment, httpClientFactory, logger, settings.Value) { this.searchService = searchService; this.fileService = fileService; + this.featureManager = featureManager; } /// @@ -65,7 +71,18 @@ public async Task Index(SearchRequestViewModel search, bool noSor search.SearchId ??= 0; search.GroupId = !string.IsNullOrWhiteSpace(search.GroupId) && Guid.TryParse(search.GroupId, out Guid groupId) ? groupId.ToString() : Guid.NewGuid().ToString(); - var searchResult = await this.searchService.PerformSearch(this.User, search); + // Fix: Ensure an instance of IFeatureManager is injected and used + var azureSearchEnabled = Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)).Result; + SearchResultViewModel searchResult = new SearchResultViewModel(); + + if (azureSearchEnabled) + { + searchResult = await this.searchService.PerformSearch(this.User, search); + } + else + { + searchResult = await this.searchService.PerformSearchInFindwise(this.User, search); + } if (search.SearchId == 0 && searchResult.ResourceSearchResult != null) { @@ -73,10 +90,13 @@ public async Task Index(SearchRequestViewModel search, bool noSor search, SearchFormActionTypeEnum.BasicSearch, searchResult.ResourceSearchResult.TotalHits, - searchResult.CatalogueSearchResult.TotalHits); + searchResult.CatalogueSearchResult != null ? searchResult.CatalogueSearchResult.TotalHits : 0); searchResult.ResourceSearchResult.SearchId = searchId; - searchResult.CatalogueSearchResult.SearchId = searchId; + if (searchResult.CatalogueSearchResult != null) + { + searchResult.CatalogueSearchResult.SearchId = searchId; + } } if (filterApplied) @@ -109,10 +129,11 @@ public async Task Index(SearchRequestViewModel search, bool noSor /// The search group id. /// The search id. /// The action type. + /// The show filter. /// The feedback. /// The actionResult. [HttpPost("results")] - public async Task IndexPost([FromQuery] SearchRequestViewModel search, int resourceCount, [FromForm] IEnumerable filters, [FromForm] int? resourceAccessLevelId, [FromForm] IEnumerable providerfilters, [FromForm] int? sortby, [FromForm] string groupId, [FromForm] int searchId, [FromQuery] string actionType, string feedback) + public async Task IndexPost([FromQuery] SearchRequestViewModel search, int resourceCount, [FromForm] IEnumerable filters, [FromForm] int? resourceAccessLevelId, [FromForm] IEnumerable providerfilters, [FromForm] int? sortby, [FromForm] string groupId, [FromForm] int searchId, [FromQuery] string actionType, [FromForm] IEnumerable resourceCollectionFilter, string feedback) { if (actionType == "feedback") { @@ -144,14 +165,17 @@ public async Task IndexPost([FromQuery] SearchRequestViewModel se var existingProviderFilters = (search.ProviderFilters ?? new List()).OrderBy(t => t); var newProviderFilters = providerfilters.OrderBy(t => t); var filterProviderUpdated = !newProviderFilters.SequenceEqual(existingProviderFilters); + var existingResourceCollectionFilter = (search.ResourceCollectionFilter ?? new List()).OrderBy(t => t); + var newResourceCollectionFilter = resourceCollectionFilter.OrderBy(t => t); + var filterResourceCollectionUpdated = !newResourceCollectionFilter.SequenceEqual(existingResourceCollectionFilter); - // No sort or resource type filter updated or resource access level filter updated or provider filter applied - if ((search.Sortby ?? 0) == sortby && !filterUpdated && !resourceAccessLevelFilterUpdated && !filterProviderUpdated) + // No sort or resource type filter updated or resource access level filter updated or provider filter applied or resource collection filter applied + if ((search.Sortby ?? 0) == sortby && !filterUpdated && !resourceAccessLevelFilterUpdated && !filterProviderUpdated && !filterResourceCollectionUpdated) { return await this.Index(search, noSortFilterError: true); } - if (search.ResourcePageIndex > 0 && (filterUpdated || resourceAccessLevelFilterUpdated || filterProviderUpdated)) + if (search.ResourcePageIndex > 0 && (filterUpdated || resourceAccessLevelFilterUpdated || filterProviderUpdated || filterResourceCollectionUpdated)) { search.ResourcePageIndex = null; } @@ -163,6 +187,7 @@ public async Task IndexPost([FromQuery] SearchRequestViewModel se search.GroupId = groupId; search.SearchId = searchId; search.ResourceAccessLevelId = resourceAccessLevelId; + search.ResourceCollectionFilter = resourceCollectionFilter; var routeValues = new RouteValueDictionary(search) { @@ -239,6 +264,45 @@ public void RecordResourceClick(string url, int nodePathId, int itemIndex, int p this.Response.Redirect(url); } + /// + /// The RecordClickedSearchResult. + /// + /// The url. + /// The nodePathId. + /// The itemIndex. + /// The page index. + /// The totalNumberOfHits. + /// The searchText. + /// The resourceReferenceId. + /// The groupdId. + /// The search id. + /// time of search. + /// user query. + /// search query. + /// the title. + [HttpGet("record-course-click")] + public void RecordCourseClick(string url, int nodePathId, int itemIndex, int pageIndex, int totalNumberOfHits, string searchText, int resourceReferenceId, Guid groupId, string searchId, long timeOfSearch, string userQuery, string query, string title) + { + var searchActionResourceModel = new SearchActionResourceModel + { + NodePathId = nodePathId, + ItemIndex = itemIndex, + NumberOfHits = pageIndex * this.Settings.FindwiseSettings.ResourceSearchPageSize, + TotalNumberOfHits = totalNumberOfHits, + SearchText = searchText, + ResourceReferenceId = resourceReferenceId, + GroupId = groupId, + SearchId = searchId, + TimeOfSearch = timeOfSearch, + UserQuery = userQuery, + Query = query, + Title = title, + }; + + this.searchService.CreateResourceSearchActionAsync(searchActionResourceModel); + this.Response.Redirect(url); + } + /// /// The RecordClickedCatalogueSearchResult. /// @@ -313,7 +377,14 @@ public async Task GetAutoSuggestion(string term) var autoSuggestions = await this.searchService.GetAutoSuggestionList(term); - return this.PartialView("_AutoComplete", autoSuggestions); + var azureSearchEnabled = Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)).Result; + + if (!azureSearchEnabled) + { + return this.PartialView("_AutoComplete", autoSuggestions); + } + + return this.PartialView("_AutoSuggest", autoSuggestions); } /// diff --git a/LearningHub.Nhs.WebUI/Filters/ReporterPermissionFilter.cs b/LearningHub.Nhs.WebUI/Filters/ReporterPermissionFilter.cs new file mode 100644 index 000000000..f121ea1a8 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Filters/ReporterPermissionFilter.cs @@ -0,0 +1,60 @@ +namespace LearningHub.Nhs.WebUI.Filters +{ + using System.Threading.Tasks; + using LearningHub.Nhs.WebUI.Helpers; + using LearningHub.Nhs.WebUI.Interfaces; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using Microsoft.AspNetCore.Routing; + using Microsoft.FeatureManagement; + + /// + /// Defines the . + /// + public class ReporterPermissionFilter : ActionFilterAttribute + { + private readonly IReportService reportService; + private IFeatureManager featureManager; + + /// + /// Initializes a new instance of the class. + /// + /// reportService. + /// featureManager. + public ReporterPermissionFilter(IReportService reportService, IFeatureManager featureManager) + { + this.reportService = reportService; + this.featureManager = featureManager; + } + + /// + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var controller = context.ActionDescriptor.RouteValues["Controller"]; + var action = context.ActionDescriptor.RouteValues["Action"]; + + // Check if in-platform report is active. + var reportActive = Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.InPlatformReport)).Result; + + // Run your cached permission check + var hasPermission = await this.reportService.GetReporterPermission(); + + // If user does NOT have permission and they're not in safe routes + if (!hasPermission && !reportActive + && !(controller == "Account" && action == "AccessRestricted") + && !(controller == "Home" && action == "Logout")) + { + context.Result = new RedirectToRouteResult( + new RouteValueDictionary + { + { "Controller", "Account" }, + { "Action", "AccessRestricted" }, + }); + + return; + } + + await next(); + } + } +} diff --git a/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs b/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs index e41b794f1..ddd4b54f4 100644 --- a/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs +++ b/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs @@ -75,6 +75,11 @@ public static class CommonValidationErrorMessages /// public const string StartDate = "Enter a start date containing a day, month and a year"; + /// + /// Start date Required. + /// + public const string EndDate = "Enter an end date containing a day, month and a year"; + /// /// Workplace Required. /// @@ -259,5 +264,15 @@ public static class CommonValidationErrorMessages /// Security question Required. /// public const string SecurityQuestionRequired = "Please select a security question"; + + /// + /// Course Required. + /// + public const string CourseRequired = "Select a course"; + + /// + /// Course Required. + /// + public const string ReportingPeriodRequired = "Select a reporting period"; } } diff --git a/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs b/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs index 7f019e336..8c3e9bd76 100644 --- a/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs +++ b/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs @@ -19,5 +19,15 @@ public static class FeatureFlags /// The EnableMoodle. /// public const string EnableMoodle = "EnableMoodle"; + + /// + /// The AzureSearch. + /// + public const string AzureSearch = "AzureSearch"; + + /// + /// The InPlatformReport. + /// + public const string InPlatformReport = "InPlatformReport"; } } diff --git a/LearningHub.Nhs.WebUI/Helpers/UtilityHelper.cs b/LearningHub.Nhs.WebUI/Helpers/UtilityHelper.cs index 762695dd9..4c444cbcf 100644 --- a/LearningHub.Nhs.WebUI/Helpers/UtilityHelper.cs +++ b/LearningHub.Nhs.WebUI/Helpers/UtilityHelper.cs @@ -25,13 +25,17 @@ public static class UtilityHelper { "article", ResourceTypeEnum.Article }, { "case", ResourceTypeEnum.Case }, { "weblink", ResourceTypeEnum.WebLink }, + { "web link", ResourceTypeEnum.WebLink }, { "audio", ResourceTypeEnum.Audio }, { "scorm", ResourceTypeEnum.Scorm }, + { "scorm e-learning resource", ResourceTypeEnum.Scorm }, { "assessment", ResourceTypeEnum.Assessment }, { "genericfile", ResourceTypeEnum.GenericFile }, + { "file", ResourceTypeEnum.GenericFile }, { "image", ResourceTypeEnum.Image }, { "html", ResourceTypeEnum.Html }, { "moodle", ResourceTypeEnum.Moodle }, + { "catalogue", ResourceTypeEnum.Catalogue }, }; /// @@ -146,6 +150,8 @@ public static string GetPrettifiedResourceTypeName(ResourceTypeEnum resourceType return "HTML"; case ResourceTypeEnum.Moodle: return "Course"; + case ResourceTypeEnum.Catalogue: + return "Catalogue"; default: return "File"; } @@ -186,6 +192,8 @@ public static string GetPrettifiedResourceTypeName(ResourceTypeEnum resourceType return "HTML"; case ResourceTypeEnum.Moodle: return "Course"; + case ResourceTypeEnum.Catalogue: + return "Catalogue"; default: return "File"; } @@ -377,6 +385,48 @@ public static string GetAuthoredDate(int? day, int? month, int? year) return authoredDate; } + /// + /// Formats an authored date string into a human-readable date if possible. + /// + /// If the input does not match any of the supported date formats, the method returns the + /// original input string unchanged. This method does not validate whether the date is a valid calendar date + /// beyond format matching. + /// The date string to format. Must be in one of the supported date formats, such as "yyyy-MM-dd", "dd/MM/yyyy", + /// "MM/dd/yyyy", "yyyyMMdd", "yyyy-MM-ddTHH:mm:ss", or "yyyy-MM-ddTHH:mm:ssZ". If null, empty, or whitespace, + /// an empty string is returned. + /// A formatted date string in the form "dd MMM yyyy" if parsing succeeds; otherwise, the original input string + /// or an empty string if the input is null, empty, or whitespace. + public static string GetFormattedAuthoredDate(string authoredDate) + { + string[] dateFormats = + { + "dd/MM/yyyy", + "dd/MM/yyyy HH:mm:ss", + "yyyy-MM-dd", + "yyyy-MM-dd HH:mm:ss", + "MM/dd/yyyy", + "MM/dd/yyyy HH:mm:ss", + "yyyyMMdd", + "yyyyMMdd HH:mm:ss", + "M/d/yyyy", + "M/d/yyyy h:mm:ss tt", + "M/d/yyyy h:mm tt", + }; + + if (string.IsNullOrWhiteSpace(authoredDate)) + { + return string.Empty; + } + + if (DateTime.TryParse(authoredDate, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var date) || + DateTime.TryParseExact(authoredDate, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out date)) + { + return date.ToString("dd MMM yyyy"); + } + + return authoredDate; + } + /// /// Gets the text to display on a generic file download button according to the file extension. /// @@ -522,5 +572,21 @@ public static string GetPillColour(string filename) return breadcrumbs; } + + /// + /// Returns sentence case of input string. + /// + /// input. + /// A sentence case string corresponding to the input string. + public static string ConvertToSentenceCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + input = input.ToLower(); + return char.ToUpper(input[0]) + input.Substring(1); + } } } diff --git a/LearningHub.Nhs.WebUI/Helpers/ViewActivityHelper.cs b/LearningHub.Nhs.WebUI/Helpers/ViewActivityHelper.cs index a357fa371..da1cb5781 100644 --- a/LearningHub.Nhs.WebUI/Helpers/ViewActivityHelper.cs +++ b/LearningHub.Nhs.WebUI/Helpers/ViewActivityHelper.cs @@ -116,6 +116,9 @@ public static string GetResourceTypeDesc(ResourceTypeEnum resourceType) case ResourceTypeEnum.Moodle: typeText = "Course"; break; + case ResourceTypeEnum.Catalogue: + typeText = "Catalogue"; + break; default: typeText = string.Empty; break; @@ -524,5 +527,20 @@ public static bool CanDownloadCertificate(this ActivityDetailedItemViewModel act return false; } + + /// + /// GetReportStatusDisplayText. + /// + /// The status. + /// The string. + public static string GetReportStatusDisplayText(string status) + { + if (status == "Not completed") + { + return "In progress"; + } + + return status; + } } } diff --git a/LearningHub.Nhs.WebUI/Interfaces/IActivityService.cs b/LearningHub.Nhs.WebUI/Interfaces/IActivityService.cs index 8dff7ae97..38e3bef61 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IActivityService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IActivityService.cs @@ -66,6 +66,13 @@ public interface IActivityService /// The . Task CompleteScormActivity(ScormActivityViewModel scormActivityViewModel); + /// + /// The CompleteScormActivity. + /// + /// The updateScormActivityViewModel. + /// The . + Task ScormCompleteActivity(ScormActivityViewModel scormActivityViewModel); + /// /// The ResolveScormActivity. /// diff --git a/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs b/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs index eb94d0185..8ab50935a 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs @@ -53,5 +53,12 @@ public interface IFileService /// . /// The . Task PurgeResourceFile(ResourceVersionExtendedViewModel vm = null, List filePaths = null); + + /// + /// The DownloadBlobFile. + /// + /// uri. + /// The . + Task<(Stream Stream, string FileName, string ContentType)> DownloadBlobFileAsync(string uri); } } diff --git a/LearningHub.Nhs.WebUI/Interfaces/IReportService.cs b/LearningHub.Nhs.WebUI/Interfaces/IReportService.cs new file mode 100644 index 000000000..8afa2cbc7 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Interfaces/IReportService.cs @@ -0,0 +1,57 @@ +namespace LearningHub.Nhs.WebUI.Interfaces +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using elfhHub.Nhs.Models.Common; + using LearningHub.Nhs.Models.Common; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.Paging; + + /// + /// Defines the . + /// + public interface IReportService + { + /// + /// The GetReporterPermission. + /// + /// A representing the result of the asynchronous operation. + Task GetReporterPermission(); + + /// + /// The GetCourseCompletionReport. + /// + /// The requestModel.. + /// The . + Task GetCourseCompletionReport(DatabricksRequestModel requestModel); + + /// + /// The GetReportHistory. + /// + /// The requestModel.. + /// The . + Task> GetReportHistory(PagingRequestModel requestModel); + + /// + /// The GetReportHistory. + /// + /// The reportHistoryId.. + /// The . + Task GetReportHistoryById(int reportHistoryId); + + /// + /// The QueueReportDownload. + /// + /// The reportHistoryId.. + /// The . + Task QueueReportDownload(int reportHistoryId); + + /// + /// The DownloadReport. + /// + /// reportHistoryId. + /// The . + Task DownloadReport(int reportHistoryId); + } +} diff --git a/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs b/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs index 3f083bc38..7c9d7b59f 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs @@ -20,6 +20,15 @@ public interface ISearchService /// A representing the result of the asynchronous operation. Task PerformSearch(IPrincipal user, SearchRequestViewModel searchRequest); + /// + /// Performs a search - either a combined resource and catalogue search, or just a resource search if + /// searching within a catalogue. + /// + /// User. + /// The SearchRequestViewModel. + /// A representing the result of the asynchronous operation. + Task PerformSearchInFindwise(IPrincipal user, SearchRequestViewModel searchRequest); + /// /// Records the analytics events associated with a search. /// diff --git a/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj b/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj index 39df64b2a..c8016ba86 100644 --- a/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj +++ b/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj @@ -28,7 +28,6 @@ - @@ -113,7 +112,7 @@ - + diff --git a/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxItemViewModel.cs b/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxItemViewModel.cs new file mode 100644 index 000000000..7b1c2759b --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxItemViewModel.cs @@ -0,0 +1,33 @@ +namespace LearningHub.Nhs.WebUI.Models.DynamicCheckbox +{ + /// + /// DynamicCheckboxItemViewModel. + /// + public class DynamicCheckboxItemViewModel + { + /// + /// Gets or sets a value. + /// + public string Value { get; set; } + + /// + /// Gets or sets a Label. + /// + public string Label { get; set; } + + /// + /// Gets or sets a HintText. + /// + public string? HintText { get; set; } + + /// + /// Gets or sets a value indicating whether gets or sets a selected. + /// + public bool Selected { get; set; } + + /// + /// Gets or sets a value indicating whether the option is exclusive. + /// + public bool Exclusive { get; set; } = false; + } +} diff --git a/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxesViewModel.cs b/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxesViewModel.cs new file mode 100644 index 000000000..85738837a --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/DynamicCheckbox/DynamicCheckboxesViewModel.cs @@ -0,0 +1,45 @@ +namespace LearningHub.Nhs.WebUI.Models.DynamicCheckbox +{ + using System.Collections.Generic; + + /// + /// DynamicCheckboxesViewModel. + /// + public class DynamicCheckboxesViewModel + { + /// + /// Gets or sets a Label. + /// + public string Label { get; set; } + + /// + /// Gets or sets a HintText. + /// + public string HintText { get; set; } + + /// + /// Gets or sets a ErrorMessage. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets a value indicating whether gets or sets a Required. + /// + public bool Required { get; set; } + + /// + /// Gets or sets a CssClass. + /// + public string CssClass { get; set; } + + /// + /// Gets or sets SelectedValues. + /// + public List SelectedValues { get; set; } = []; + + /// + /// Gets or sets a Checkboxes. + /// + public List Checkboxes { get; set; } = []; + } +} diff --git a/LearningHub.Nhs.WebUI/Models/NavigationModel.cs b/LearningHub.Nhs.WebUI/Models/NavigationModel.cs index 25b51671c..934564960 100644 --- a/LearningHub.Nhs.WebUI/Models/NavigationModel.cs +++ b/LearningHub.Nhs.WebUI/Models/NavigationModel.cs @@ -75,6 +75,11 @@ public class NavigationModel /// public bool ShowBrowseCatalogues { get; set; } + /// + /// Gets or sets a value indicating whether to show reports. + /// + public bool ShowReports { get; set; } + /// /// Gets or sets a value indicating whether ShowHome. /// diff --git a/LearningHub.Nhs.WebUI/Models/Report/CourseCompletionViewModel.cs b/LearningHub.Nhs.WebUI/Models/Report/CourseCompletionViewModel.cs new file mode 100644 index 000000000..3e183c63e --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/CourseCompletionViewModel.cs @@ -0,0 +1,66 @@ +namespace LearningHub.Nhs.WebUI.Models.Report +{ + using System.Collections.Generic; + using System.Reflection; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.MyLearning; + using LearningHub.Nhs.Models.Paging; + using LearningHub.Nhs.WebUI.Models.Learning; + + /// + /// CourseCompletionViewModel. + /// + public class CourseCompletionViewModel : DatabricksRequestModel + { + /// + /// Initializes a new instance of the class. + /// + public CourseCompletionViewModel() + { + this.CourseCompletionRecords = new List(); + } + + /// + /// Initializes a new instance of the class. + /// + /// DatabricksRequestModel. + public CourseCompletionViewModel(DatabricksRequestModel requestModel) + { + this.CourseCompletionRecords = new List(); + foreach (PropertyInfo prop in requestModel.GetType().GetProperties()) + { + this.GetType().GetProperty(prop.Name).SetValue(this, prop.GetValue(requestModel, null), null); + } + } + + /// + /// Gets or sets the CurrentPageIndex. + /// + public int CurrentPageIndex { get; set; } = 0; + + /// + /// Gets or sets the TotalCount. + /// + public int TotalCount { get; set; } + + /// + /// Gets or sets the report result paging. + /// + public PagingViewModel ReportPaging { get; set; } + + /// + /// Gets or sets the ReportFormActionTypeEnum. + /// + public ReportFormActionTypeEnum ReportFormActionType { get; set; } + + /// + /// Gets or sets the CourseCompletionRecords. + /// + public List CourseCompletionRecords { get; set; } + + /// + /// Gets or sets the ReportHistoryModel. + /// + public ReportHistoryModel ReportHistoryModel { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Report/ReportCreationCourseSelection.cs b/LearningHub.Nhs.WebUI/Models/Report/ReportCreationCourseSelection.cs new file mode 100644 index 000000000..ae3adea12 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/ReportCreationCourseSelection.cs @@ -0,0 +1,47 @@ +namespace LearningHub.Nhs.WebUI.Models.Report +{ + using System; + using System.Collections.Generic; + using System.Data; + using System.Linq; + using LearningHub.Nhs.WebUI.Helpers; + using LearningHub.Nhs.WebUI.Models.DynamicCheckbox; + using NHSUKViewComponents.Web.ViewModels; + + /// + /// CourseSelection. + /// + public class ReportCreationCourseSelection + { + /// + /// Gets or sets the list of courses. + /// + public List Courses { get; set; } + + /// + /// Gets or sets the list of all courses. + /// + public List AllCources { get; set; } + + /// + /// Gets or sets the list of SearchText. + /// + public string SearchText { get; set; } + + /// + /// BuildCourses. + /// + /// The all Courses. + /// The . + public List BuildCourses(List> allCourses) + { + this.AllCources = allCourses.Select(r => new DynamicCheckboxItemViewModel + { + Value = r.Key.ToString(), + Label = r.Value, + }).ToList(); + this.AllCources.Insert(0, new DynamicCheckboxItemViewModel { Value = "all", Label = "All courses", Exclusive = true }); + return this.AllCources; + } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Report/ReportCreationDateSelection.cs b/LearningHub.Nhs.WebUI/Models/Report/ReportCreationDateSelection.cs new file mode 100644 index 000000000..0dc0e4c8f --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/ReportCreationDateSelection.cs @@ -0,0 +1,238 @@ +namespace LearningHub.Nhs.WebUI.Models.Report +{ + using System; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.Linq; + using LearningHub.Nhs.WebUI.Helpers; + using NHSUKViewComponents.Web.ViewModels; + + /// + /// ReportCreationDateSelection. + /// + public class ReportCreationDateSelection : IValidatableObject + { + /// + /// Gets or sets the start date to define on the search. + /// + public string TimePeriod { get; set; } + + /// + /// Gets or sets the start date to define on the search. + /// + /// + /// Gets or sets the Day. + /// + public int? StartDay { get; set; } + + /// + /// Gets or sets the end Day. + /// + public int? EndDay { get; set; } + + /// + /// Gets or sets the Country. + /// + public int? StartMonth { get; set; } + + /// + /// Gets or sets the Country. + /// + public int? EndMonth { get; set; } + + /// + /// Gets or sets the Year. + /// + public int? StartYear { get; set; } + + /// + /// Gets or sets the Year. + /// + public int? EndYear { get; set; } + + /// + /// Gets or sets the Year. + /// + public DateTime? DataStart { get; set; } + + /// + /// Gets or sets a value indicating whether gets or sets the EndDate. + /// + public bool EndDate { get; set; } + + /// + /// Gets or sets a value indicating whether gets or sets the HintText. + /// + public string HintText { get; set; } + + /// + /// Gets or sets the GetDate. + /// + /// DateTime. + public DateTime? GetStartDate() + { + return (this.StartDay.HasValue && this.StartMonth.HasValue && this.StartYear.HasValue) ? new DateTime(this.StartYear!.Value, this.StartMonth!.Value, this.StartDay!.Value) : (DateTime?)null; + } + + /// + /// Gets or sets the GetDate. + /// + /// DateTime. + public DateTime? GetEndDate() + { + return (this.EndDay.HasValue && this.EndMonth.HasValue && this.EndYear.HasValue) ? new DateTime(this.EndYear!.Value, this.EndMonth!.Value, this.EndDay!.Value) : (DateTime?)null; + } + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + var validationResults = new List(); + if (this.TimePeriod == "Custom") + { + this.ValidateStartDate(validationResults); + this.ValidateEndDate(validationResults); + } + + return validationResults; + } + + /// + /// Gets or sets the GetValidatedStartDate. + /// + /// DateTime. + public DateTime GetValidatedStartDate() + { + return new DateTime(this.StartYear!.Value, this.StartMonth!.Value, this.StartDay!.Value); + } + + /// + /// Gets or sets the GetValidatedEndDate. + /// + /// DateTime. + public DateTime? GetValidatedEndDate() + { + return this.EndDate + ? new DateTime(this.EndYear!.Value, this.EndMonth!.Value, this.EndDay!.Value) + : (DateTime?)null; + } + + /// + /// sets the list of radio region. + /// + /// The . + public List PopulateDateRange() + { + var radios = new List() + { + new RadiosItemViewModel("7", "7 days", false, null), + new RadiosItemViewModel("30", "30 days", false, null), + new RadiosItemViewModel("90", "90 days", false, null), + new RadiosItemViewModel("Custom", "Custom date range", false, null), + }; + + // if (string.IsNullOrWhiteSpace(this.TimePeriod)) + // { + // this.TimePeriod = "Custom"; + // } + return radios; + } + + /// + // public IEnumerable Validate(ValidationContext validationContext) + // { + // var results = new List(); + // if (this.TimePeriod == "dateRange") + // { + // var startDateValidation = DateValidator.ValidateDate(this.StartDay, this.StartMonth, this.StartYear, "valid start date") + // .ToValidationResultList(nameof(this.StartDay), nameof(this.StartMonth), nameof(this.StartYear)); + // if (startDateValidation.Any()) + // { + // results.AddRange(startDateValidation); + // } + // var endDateValidation = DateValidator.ValidateDate(this.EndDay, this.EndMonth, this.EndYear, "valid end date") + // .ToValidationResultList(nameof(this.EndDay), nameof(this.EndMonth), nameof(this.EndYear)); + // if (endDateValidation.Any()) + // { + // results.AddRange(endDateValidation); + // } + // } + // return results; + // } + private void ValidateStartDate(List validationResults) + { + var startDateValidationResults = DateValidator.ValidateDate( + this.StartDay, + this.StartMonth, + this.StartYear, + "Start date", + true, + false, + true) + .ToValidationResultList(nameof(this.StartDay), nameof(this.StartMonth), nameof(this.StartYear)); + + if (!startDateValidationResults.Any()) + { + this.ValidateStartDateIsAfterDataStart(startDateValidationResults); + } + + validationResults.AddRange(startDateValidationResults); + } + + private void ValidateStartDateIsAfterDataStart(List startDateValidationResults) + { + var startDate = this.GetValidatedStartDate(); + + if (startDate.AddDays(1) < this.DataStart) + { + startDateValidationResults.Add( + new ValidationResult( + $"No data is available for the selected date range. Select a date after {this.DataStart.Value.Day} {this.DataStart.Value.Month} {this.DataStart.Value.Year}", new[] { nameof(this.StartDay), })); + startDateValidationResults.Add( + new ValidationResult( + string.Empty, + new[] { nameof(this.StartMonth), nameof(this.StartYear), })); + } + + if (startDate.Date > DateTime.Now.Date) + { + startDateValidationResults.Add( + new ValidationResult( + "The start date cannot be in the future", new[] { nameof(this.StartDay), })); + startDateValidationResults.Add( + new ValidationResult( + string.Empty, + new[] { nameof(this.StartMonth), nameof(this.StartYear), })); + } + } + + private void ValidateEndDate(List validationResults) + { + var endDateValidationResults = DateValidator.ValidateDate( + this.EndDay, + this.EndMonth, + this.EndYear, + "End date", + true, + false, + true) + .ToValidationResultList(nameof(this.EndDay), nameof(this.EndMonth), nameof(this.EndYear)); + + this.ValidateEndDateIsAfterStartDate(endDateValidationResults); + + validationResults.AddRange(endDateValidationResults); + } + + private void ValidateEndDateIsAfterStartDate(List endDateValidationResults) + { + if (this.StartYear > this.EndYear + || (this.StartYear == this.EndYear && this.StartMonth > this.EndMonth) + || (this.StartYear == this.EndYear && this.StartMonth == this.EndMonth && this.StartDay > this.EndDay)) + { + endDateValidationResults.Add( + new ValidationResult("Enter an end date after the start date", new[] { nameof(this.EndDay), })); + endDateValidationResults.Add( + new ValidationResult(string.Empty, new[] { nameof(this.EndMonth), nameof(this.EndYear), })); + } + } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Report/ReportFormActionTypeEnum.cs b/LearningHub.Nhs.WebUI/Models/Report/ReportFormActionTypeEnum.cs new file mode 100644 index 000000000..2e38defc1 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/ReportFormActionTypeEnum.cs @@ -0,0 +1,23 @@ +namespace LearningHub.Nhs.WebUI.Models.Learning +{ + /// + /// Defines the ReportFormActionTypeEnum. + /// + public enum ReportFormActionTypeEnum + { + /// + /// Defines the basic search for mylearning + /// + BasicSearch = 0, + + /// + /// Previoous page change. + /// + PreviousPageChange = 1, + + /// + /// Next page change. + /// + NextPageChange = 2, + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Report/ReportHistoryViewModel.cs b/LearningHub.Nhs.WebUI/Models/Report/ReportHistoryViewModel.cs new file mode 100644 index 000000000..a958c39f7 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/ReportHistoryViewModel.cs @@ -0,0 +1,38 @@ +namespace LearningHub.Nhs.WebUI.Models.Report +{ + using System.Collections.Generic; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.Paging; + using LearningHub.Nhs.WebUI.Models.Learning; + + /// + /// ReportHistoryViewModel. + /// + public class ReportHistoryViewModel + { + /// + /// Gets or sets the CurrentPageIndex. + /// + public int CurrentPageIndex { get; set; } = 0; + + /// + /// Gets or sets the TotalCount. + /// + public int TotalCount { get; set; } + + /// + /// Gets or sets the report result paging. + /// + public PagingViewModel ReportPaging { get; set; } + + /// + /// Gets or sets the ReportFormActionTypeEnum. + /// + public ReportFormActionTypeEnum ReportFormActionType { get; set; } + + /// + /// Gets or sets the ReportHistoryModels. + /// + public List ReportHistoryModels { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Report/ReportPagingModel.cs b/LearningHub.Nhs.WebUI/Models/Report/ReportPagingModel.cs new file mode 100644 index 000000000..5468bab7b --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Report/ReportPagingModel.cs @@ -0,0 +1,20 @@ +namespace LearningHub.Nhs.WebUI.Models.Learning +{ + using LearningHub.Nhs.Models.Paging; + + /// + /// Defines the . + /// + public class ReportPagingModel : PagingViewModel + { + /// + /// Gets or sets the page previous action value. + /// + public int PreviousActionValue { get; set; } + + /// + /// Gets or sets the page next action value. + /// + public int NextActionValue { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchRequestViewModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchRequestViewModel.cs index 8b4d5de04..ebe9174ba 100644 --- a/LearningHub.Nhs.WebUI/Models/Search/SearchRequestViewModel.cs +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchRequestViewModel.cs @@ -75,5 +75,10 @@ public class SearchRequestViewModel /// [FromQuery] public IEnumerable ProviderFilters { get; set; } + + /// + /// Gets or sets the show filter (all, catalogues, courses, resources). + /// + public IEnumerable ResourceCollectionFilter { get; set; } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs index 543a50e5f..336be2e17 100644 --- a/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs @@ -1,6 +1,7 @@ namespace LearningHub.Nhs.WebUI.Models.Search { using System; + using System.Collections.Generic; using LearningHub.Nhs.Models.Paging; using LearningHub.Nhs.Models.Search; @@ -93,5 +94,10 @@ public class SearchResultViewModel /// Gets or sets Suggested Resource name. /// public string SuggestedResource { get; set; } + + /// + /// Gets or sets the show filter (all, catalogues, courses, resources). + /// + public List ResourceCollectionFilter { get; set; } } } diff --git a/LearningHub.Nhs.WebUI/Program.cs b/LearningHub.Nhs.WebUI/Program.cs index c24d9057c..dda754ec6 100644 --- a/LearningHub.Nhs.WebUI/Program.cs +++ b/LearningHub.Nhs.WebUI/Program.cs @@ -35,6 +35,19 @@ builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); builder.Host.UseNLog(); + string corsMoodleUrl = builder.Configuration.GetValue("MoodleAPIConfig:BaseUrl"); + + builder.Services.AddCors(options => + { + options.AddPolicy("MoodleCORS", builder => + { + builder.WithOrigins(corsMoodleUrl.TrimEnd('/')) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + builder.Services.AddHostedService(); builder.Services.ConfigureServices(builder.Configuration, builder.Environment); @@ -80,6 +93,8 @@ app.UseRouting(); + app.UseCors("MoodleCORS"); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notification.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notification.vue index 84fb2eb0c..5ba47c945 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notification.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notification.vue @@ -198,6 +198,8 @@ return [{ text: 'Action required', className: 'fa-solid fa-triangle-exclamation text-warning pt-1' }]; case NotificationType.AccessRequest: return [{ text: 'Access request', className: 'fa-solid fa-lock text-dark pt-1' }]; + case NotificationType.ReportProcessed: + return [{ text: 'Report', className: 'fa-solid fa-circle-check text-success pt-1' }]; default: return [{ text: 'unknown', className: '' }]; } diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notificationModel.ts b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notificationModel.ts index 3bfc1bc9f..334fc62af 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notificationModel.ts +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notificationModel.ts @@ -19,6 +19,7 @@ export enum NotificationType { UserPermission = 6, PublishFailed = 7, AccessRequest = 8, + ReportProcessed = 9, } export enum NotificationPriority { diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notifications.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notifications.vue index 30adc00e0..d550e1e27 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notifications.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/notification/notifications.vue @@ -180,6 +180,8 @@ return [{ text: 'Action required', className: 'fa-solid fa-triangle-exclamation text-warning' }]; case NotificationType.AccessRequest: return [{ text: 'Access request', className: 'fas fa-lock-alt text-dark pt-1' }]; + case NotificationType.ReportProcessed: + return [{ text: 'Report', className: 'fa-solid fa-circle-check text-success' }]; default: return [{ text: 'unknown', className: '' }]; } diff --git a/LearningHub.Nhs.WebUI/Services/ActivityService.cs b/LearningHub.Nhs.WebUI/Services/ActivityService.cs index 185dc3a2c..9f1e43f3d 100644 --- a/LearningHub.Nhs.WebUI/Services/ActivityService.cs +++ b/LearningHub.Nhs.WebUI/Services/ActivityService.cs @@ -294,6 +294,39 @@ public async Task CompleteScormActivity(ScormActivi return validationResult; } + /// + /// The CompleteScormActivity. + /// + /// The updateScormActivityViewModel. + /// The . + public async Task ScormCompleteActivity(ScormActivityViewModel scormActivityViewModel) + { + var json = JsonConvert.SerializeObject(scormActivityViewModel); + var stringContent = new StringContent(json, Encoding.UTF8, "application/json"); + + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Activity/ScormCompleteActivity"; + var response = await client.PostAsync(request, stringContent).ConfigureAwait(false); + + LearningHubValidationResult validationResult; + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + validationResult = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + else + { + throw new Exception("Complete Scorm Activity failed!"); + } + + return validationResult; + } + /// /// The ResolveScormActivity. /// diff --git a/LearningHub.Nhs.WebUI/Services/FileService.cs b/LearningHub.Nhs.WebUI/Services/FileService.cs index a09195d62..fda4a0444 100644 --- a/LearningHub.Nhs.WebUI/Services/FileService.cs +++ b/LearningHub.Nhs.WebUI/Services/FileService.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; + using Azure.Storage.Blobs; using Azure.Storage.Files.Shares; using Azure.Storage.Files.Shares.Models; using Azure.Storage.Sas; @@ -12,6 +13,7 @@ using LearningHub.Nhs.WebUI.Configuration; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models; + using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; @@ -249,6 +251,26 @@ public async Task PurgeResourceFile(ResourceVersionExtendedViewModel vm = null, } } + /// + /// The DownloadBlobFile. + /// + /// url. + /// The . + public async Task<(Stream Stream, string FileName, string ContentType)> DownloadBlobFileAsync(string url) + { + var uri = new Uri(url); + string containerName = uri.Segments[1].TrimEnd('/'); + string blobName = string.Join(string.Empty, uri.Segments, 2, uri.Segments.Length - 2); + BlobClient blobClient = new BlobClient(this.settings.AzureBlobSettings.ConnectionString, containerName, blobName); + + var properties = await blobClient.GetPropertiesAsync(); + string contentType = properties.Value.ContentType ?? "application/octet-stream"; + string fileName = Path.GetFileName(blobClient.Name); + var stream = await blobClient.OpenReadAsync(); + + return (stream, fileName, contentType); + } + private static async Task WaitForCopyAsync(ShareFileClient fileClient) { // Wait for the copy operation to complete diff --git a/LearningHub.Nhs.WebUI/Services/NavigationPermissionService.cs b/LearningHub.Nhs.WebUI/Services/NavigationPermissionService.cs index 7c357ada6..ce88580c2 100644 --- a/LearningHub.Nhs.WebUI/Services/NavigationPermissionService.cs +++ b/LearningHub.Nhs.WebUI/Services/NavigationPermissionService.cs @@ -2,8 +2,10 @@ { using System.Security.Principal; using System.Threading.Tasks; + using LearningHub.Nhs.WebUI.Helpers; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models; + using Microsoft.FeatureManagement; /// /// Defines the . @@ -12,18 +14,26 @@ public class NavigationPermissionService : INavigationPermissionService { private readonly IResourceService resourceService; private readonly IUserGroupService userGroupService; + private readonly IReportService reportService; + private IFeatureManager featureManager; /// /// Initializes a new instance of the class. /// /// Resource service. /// UserGroup service. + /// Report Service. + /// Feature Manager. public NavigationPermissionService( IResourceService resourceService, - IUserGroupService userGroupService) + IUserGroupService userGroupService, + IReportService reportService, + IFeatureManager featureManager) { this.resourceService = resourceService; this.userGroupService = userGroupService; + this.reportService = reportService; + this.featureManager = featureManager; } /// @@ -45,7 +55,7 @@ public async Task GetNavigationModelAsync(IPrincipal user, bool } else if (user.IsInRole("Administrator")) { - return this.AuthenticatedAdministrator(controllerName); + return await this.AuthenticatedAdministrator(controllerName); } else if (user.IsInRole("ReadOnly")) { @@ -87,6 +97,7 @@ public NavigationModel NotAuthenticated() ShowSignOut = false, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } @@ -95,7 +106,7 @@ public NavigationModel NotAuthenticated() /// /// The controller name. /// The . - private NavigationModel AuthenticatedAdministrator(string controllerName) + private async Task AuthenticatedAdministrator(string controllerName) { return new NavigationModel() { @@ -113,6 +124,7 @@ private NavigationModel AuthenticatedAdministrator(string controllerName) ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.DisplayReportMenu() ? await this.reportService.GetReporterPermission() : false, }; } @@ -139,6 +151,7 @@ private async Task AuthenticatedBlueUser(string controllerName) ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.DisplayReportMenu() ? await this.reportService.GetReporterPermission() : false, }; } @@ -164,6 +177,7 @@ private NavigationModel AuthenticatedGuest() ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } @@ -190,6 +204,7 @@ private async Task AuthenticatedReadOnly(string controllerName) ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = true, + ShowReports = this.DisplayReportMenu() ? await this.reportService.GetReporterPermission() : false, }; } @@ -215,6 +230,7 @@ private async Task AuthenticatedBasicUserOnly() ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.DisplayReportMenu() ? await this.reportService.GetReporterPermission() : false, }; } @@ -240,7 +256,13 @@ private NavigationModel InLoginWizard() ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } + + private bool DisplayReportMenu() + { + return Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.InPlatformReport)).Result; + } } } diff --git a/LearningHub.Nhs.WebUI/Services/ReportService.cs b/LearningHub.Nhs.WebUI/Services/ReportService.cs new file mode 100644 index 000000000..2a0dadf44 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Services/ReportService.cs @@ -0,0 +1,208 @@ +namespace LearningHub.Nhs.WebUI.Services +{ + using System; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + using LearningHub.Nhs.Caching; + using LearningHub.Nhs.Models.Common; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.Extensions; + using LearningHub.Nhs.Models.Paging; + using LearningHub.Nhs.WebUI.Interfaces; + using Microsoft.AspNetCore.Http; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + + /// + /// Defines the . + /// + public class ReportService : BaseService, IReportService + { + private readonly ICacheService cacheService; + private readonly IHttpContextAccessor contextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The cache service. + /// The contextAccessor. + /// The Web Api Http Client. + /// The Open Api Http Client. + /// logger. + public ReportService(ICacheService cacheService, IHttpContextAccessor contextAccessor, ILearningHubHttpClient learningHubHttpClient, IOpenApiHttpClient openApiHttpClient, ILogger logger) + : base(learningHubHttpClient, openApiHttpClient, logger) + { + this.cacheService = cacheService; + this.contextAccessor = contextAccessor; + } + + /// + /// The GetAllAsync. + /// + /// The . + public async Task GetReporterPermission() + { + bool response = false; + var cacheKey = $"{this.contextAccessor.HttpContext.User.Identity.GetCurrentUserId()}:DatabricksReporter"; + response = await this.cacheService.GetOrFetchAsync(cacheKey, this.FetchReporterPermission); + return response; + } + + /// + /// The GetCourseCompletionReport. + /// + /// The requestModel.. + /// The . + public async Task GetCourseCompletionReport(DatabricksRequestModel requestModel) + { + DatabricksDetailedViewModel apiResponse = null; + var json = JsonConvert.SerializeObject(requestModel); + var stringContent = new StringContent(json, UnicodeEncoding.UTF8, "application/json"); + + var client = await this.OpenApiHttpClient.GetClientAsync(); + + var request = $"Report/GetCourseProgressReport"; + var response = await client.PostAsync(request, stringContent).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + apiResponse = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return apiResponse; + } + + /// + /// The GetReportHistory. + /// + /// The requestModel.. + /// The . + public async Task> GetReportHistory(PagingRequestModel requestModel) + { + PagedResultSet apiResponse = null; + var json = JsonConvert.SerializeObject(requestModel); + var stringContent = new StringContent(json, UnicodeEncoding.UTF8, "application/json"); + + var client = await this.OpenApiHttpClient.GetClientAsync(); + + var request = $"Report/GetReportHistory"; + var response = await client.PostAsync(request, stringContent).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + apiResponse = JsonConvert.DeserializeObject>(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return apiResponse; + } + + /// + /// The GetReportHistory. + /// + /// The reportHistoryId.. + /// The . + public async Task GetReportHistoryById(int reportHistoryId) + { + ReportHistoryModel apiResponse = null; + var client = await this.OpenApiHttpClient.GetClientAsync(); + var request = $"Report/GetReportHistoryById/{reportHistoryId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + apiResponse = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return apiResponse; + } + + /// + /// The QueueReportDownload. + /// + /// The reportHistoryId.. + /// The . + public async Task QueueReportDownload(int reportHistoryId) + { + bool apiResponse = false; + var client = await this.OpenApiHttpClient.GetClientAsync(); + var request = $"Report/QueueReportDownload/{reportHistoryId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + apiResponse = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return apiResponse; + } + + /// + /// The DownloadReport. + /// + /// reportHistoryId. + /// The . + public async Task DownloadReport(int reportHistoryId) + { + ReportHistoryModel apiResponse = null; + var client = await this.OpenApiHttpClient.GetClientAsync(); + var request = $"Report/DownloadReport/{reportHistoryId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + apiResponse = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return apiResponse; + } + + private async Task FetchReporterPermission() + { + bool viewmodel = false; + var client = await this.OpenApiHttpClient.GetClientAsync(); + + var request = $"Report/GetReporterPermission"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + viewmodel = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized + || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return viewmodel; + } + } +} diff --git a/LearningHub.Nhs.WebUI/Services/SearchService.cs b/LearningHub.Nhs.WebUI/Services/SearchService.cs index 333848a30..f259f2c4e 100644 --- a/LearningHub.Nhs.WebUI/Services/SearchService.cs +++ b/LearningHub.Nhs.WebUI/Services/SearchService.cs @@ -1,4 +1,4 @@ -namespace LearningHub.Nhs.WebUI.Services +namespace LearningHub.Nhs.WebUI.Services { using System; using System.Collections.Generic; @@ -75,7 +75,226 @@ public async Task PerformSearch(IPrincipal user, SearchRe { SearchId = searchRequest.SearchId.Value, SearchText = searchString, - FilterText = searchRequest.Filters?.Any() == true ? $"&resource_type={string.Join("&resource_type=", searchRequest.Filters)}" : string.Empty, + FilterText = this.BuildFilterText(searchRequest.Filters, searchRequest.ResourceCollectionFilter), + ProviderFilterText = searchRequest.ProviderFilters?.Any() == true ? $"&provider_ids={string.Join("&provider_ids=", searchRequest.ProviderFilters)}" : string.Empty, + SortColumn = selectedSortItem.Value, + SortDirection = selectedSortItem?.SortDirection, + PageIndex = searchRequest.ResourcePageIndex ?? 0, + PageSize = resourceSearchPageSize, + GroupId = groupId, + CatalogueId = searchRequest.CatalogueId, + ResourceAccessLevelFilterText = searchRequest.ResourceAccessLevelId.HasValue && searchRequest.ResourceAccessLevelId != (int)ResourceAccessibilityEnum.None ? $"&resource_access_level={searchRequest.ResourceAccessLevelId.Value}" : string.Empty, + }; + + SearchViewModel resourceResult = null; + var resourceCollectionFilter = new List(); + + if (searchString != string.Empty) + { + var resourceResultTask = this.GetSearchResultAsync(resourceSearchRequestModel); + + if (searchRequest.CatalogueId.HasValue) + { + // Search within a catalogue - resources only. + await resourceResultTask; + resourceResult = resourceResultTask.Result; + } + else + { + await Task.WhenAll(resourceResultTask); + + resourceResult = resourceResultTask.Result; + + // Did you mean suggestion when no hits found + if (resourceResult?.TotalHits == 0 && (resourceResult?.Spell?.Suggestions?.Count > 0)) + { + didYouMeanEnabled = true; + + // pass the spell suggestion as new search text - resources + if (resourceResult?.Spell?.Suggestions?.Count > 0) + { + resourceSearchRequestModel.SearchText = Regex.Replace(resourceResult?.Spell?.Suggestions?.FirstOrDefault().ToString(), "<.*?>", string.Empty); + suggestedResource = resourceSearchRequestModel.SearchText; + + // calling findwise endpoint with new search text - resources + resourceResultTask = this.GetSearchResultAsync(resourceSearchRequestModel); + } + + await Task.WhenAll(resourceResultTask); + + resourceResult = resourceResultTask.Result; + } + } + + var searchfilters = new List(); + var resourceAccessLevelFilters = new List(); + + var providerfilters = new List(); + + if (resourceResult != null && resourceResult.Facets != null && resourceResult.Facets.Length > 0) + { + var filters = resourceResult.Facets.Where(x => x.Id == "resource_type").First().Filters; + + foreach (var filteritem in filters.Select(x => x.DisplayName.ToLower()).Distinct()) + { + var filter = filters.Where(x => x.DisplayName == filteritem).FirstOrDefault(); + + if (filter != null && UtilityHelper.FindwiseResourceTypeDict.ContainsKey(filter.DisplayName)) + { + var resourceTypeEnum = UtilityHelper.FindwiseResourceTypeDict[filter.DisplayName]; + var searchfilter = new SearchFilterModel() { DisplayName = UtilityHelper.GetPrettifiedResourceTypeName(resourceTypeEnum), Count = filter.Count, Value = filteritem, Selected = searchRequest.Filters?.Contains(filter.DisplayName) ?? false }; + searchfilters.Add(searchfilter); + } + } + + if (user.IsInRole("BasicUser")) + { + var accessLevelFilters = resourceResult.Facets.Where(x => x.Id == "resource_access_level").First().Filters; + + var basicUserAudienceFilterItem = accessLevelFilters.Where(x => x.DisplayName == "2").FirstOrDefault(); // GeneralAccess + var basicResourceAccesslevelCount = basicUserAudienceFilterItem?.Count ?? 0; + var basicUserAudienceFilter = new SearchFilterModel() { DisplayName = ResourceAccessLevelHelper.GetPrettifiedResourceAccessLevelOptionDisplayName(ResourceAccessibilityEnum.GeneralAccess), Count = basicResourceAccesslevelCount, Value = "2", Selected = (searchRequest.ResourceAccessLevelId ?? 0) == 2 }; + resourceAccessLevelFilters.Add(basicUserAudienceFilter); + } + + filters = resourceResult.Facets.Where(x => x.Id == "provider_ids").First().Filters; + + if (filters.Length > 0) + { + var providers = await this.providerService.GetProviders(); + var provider_ids = providers.Select(n => n.Id).ToList(); + + foreach (var filteritem in filters.Select(x => x.DisplayName.ToLower()).Distinct()) + { + var filter = filters.Where(x => x.DisplayName == filteritem).FirstOrDefault(); + + if (filter != null && provider_ids.Contains(Convert.ToInt32(filter.DisplayName))) + { + var provider = providers.Where(n => n.Id == Convert.ToInt32(filter.DisplayName)).FirstOrDefault(); + + var searchfilter = new SearchFilterModel() { DisplayName = provider.Name, Count = filter.Count, Value = filteritem, Selected = searchRequest.ProviderFilters?.Contains(filter.DisplayName) ?? false }; + providerfilters.Add(searchfilter); + } + } + } + + // Process resource_collection facets + var collectionFacet = resourceResult.Facets.FirstOrDefault(x => x.Id == "resource_collection"); + if (collectionFacet != null && collectionFacet.Filters != null) + { + foreach (var filteritem in collectionFacet.Filters.Select(x => x.DisplayName).Distinct()) + { + var filter = collectionFacet.Filters.Where(x => x.DisplayName == filteritem).FirstOrDefault(); + + if (filter != null && !string.IsNullOrEmpty(filter.DisplayName)) + { + var searchfilter = new SearchFilterModel() + { + DisplayName = filter.DisplayName, + Count = filter.Count, + Value = filter.DisplayName.ToLower(), + Selected = searchRequest.ResourceCollectionFilter?.Contains(filter.DisplayName, StringComparer.OrdinalIgnoreCase) ?? false, + }; + resourceCollectionFilter.Add(searchfilter); + } + } + + if (resourceCollectionFilter.Any()) + { + // Sum of all counts + var allCount = resourceCollectionFilter.Sum(x => x.Count); + + // If none selected ➜ All is selected + // If ALL child filters are selected → All is selected + var allSelected = + !resourceCollectionFilter.Any(x => x.Selected) || + resourceCollectionFilter.All(x => x.Selected); + + // Create the ALL filter + var allFilter = new SearchFilterModel + { + DisplayName = "All", + Value = "all", + Count = allCount, + Selected = allSelected, + }; + + // Insert at top + resourceCollectionFilter.Insert(0, allFilter); + } + } + } + + resourceResult.SortItemList = searchSortItemList; + resourceResult.SortItemSelected = selectedSortItem; + resourceResult.SearchFilters = searchfilters; + resourceResult.SearchResourceAccessLevelFilters = resourceAccessLevelFilters; + resourceResult.SearchProviderFilters = providerfilters; + } + + var searchResultViewModel = new SearchResultViewModel + { + SearchString = searchString, + GroupId = groupId, + FeedbackSubmitted = searchRequest.FeedbackSubmitted ?? false, + ResourceCurrentPageIndex = searchRequest.ResourcePageIndex ?? 0, + CatalogueCurrentPageIndex = searchRequest.CataloguePageIndex ?? 0, + ResourceSearchResult = resourceResult, + + // SearchCollectionFilters = resourceCollectionFilters, + ResourceResultPaging = new SearchResultPagingModel + { + CurrentPage = searchRequest.ResourcePageIndex ?? 0, + PageSize = resourceSearchPageSize, + TotalItems = resourceResult?.TotalHits ?? 0, + }, + + CatalogueResultPaging = new SearchResultPagingModel + { + CurrentPage = searchRequest.CataloguePageIndex ?? 0, + PageSize = catalogueSearchPageSize, + }, + DidYouMeanEnabled = didYouMeanEnabled, + SuggestedCatalogue = suggestedCatalogue, + SuggestedResource = suggestedResource, + }; + + searchResultViewModel.ResourceCollectionFilter = resourceCollectionFilter; + + return searchResultViewModel; + } + + /// + /// Performs a search - either a combined resource and catalogue search, or just a resource search if + /// searching within a catalogue. + /// + /// user. + /// The SearchRequestViewModel. + /// A representing the result of the asynchronous operation. + public async Task PerformSearchInFindwise(IPrincipal user, SearchRequestViewModel searchRequest) + { + var searchSortType = 0; + if (searchRequest.Sortby.HasValue && Enum.IsDefined(typeof(SearchSortTypeEnum), searchRequest.Sortby)) + { + searchSortType = searchRequest.Sortby.Value; + } + + var searchString = searchRequest.Term?.Trim() ?? string.Empty; + var searchSortItemList = SearchHelper.GetSearchSortList(); + var selectedSortItem = searchSortItemList.Where(x => x.SearchSortType == (SearchSortTypeEnum)searchSortType).FirstOrDefault(); + var groupId = Guid.Parse(searchRequest.GroupId); + bool didYouMeanEnabled = false; + var suggestedCatalogue = string.Empty; + var suggestedResource = string.Empty; + + var resourceSearchPageSize = this.settings.FindwiseSettings.ResourceSearchPageSize; + var catalogueSearchPageSize = this.settings.FindwiseSettings.CatalogueSearchPageSize; + + var resourceSearchRequestModel = new SearchRequestModel + { + SearchId = searchRequest.SearchId.Value, + SearchText = searchString, + FilterText = this.BuildFilterText(searchRequest.Filters, searchRequest.ResourceCollectionFilter), ProviderFilterText = searchRequest.ProviderFilters?.Any() == true ? $"&provider_ids={string.Join("&provider_ids=", searchRequest.ProviderFilters)}" : string.Empty, SortColumn = selectedSortItem.Value, SortDirection = selectedSortItem?.SortDirection, @@ -98,6 +317,7 @@ public async Task PerformSearch(IPrincipal user, SearchRe SearchViewModel resourceResult = null; SearchCatalogueViewModel catalogueResult = null; + var resourceCollectionFilter = new List(); if (searchString != string.Empty) { @@ -203,6 +423,28 @@ public async Task PerformSearch(IPrincipal user, SearchRe } } } + + // Process resource_collection facets + var collectionFacet = resourceResult.Facets.FirstOrDefault(x => x.Id == "resource_collection"); + if (collectionFacet != null && collectionFacet.Filters != null) + { + foreach (var filteritem in collectionFacet.Filters.Select(x => x.DisplayName).Distinct()) + { + var filter = collectionFacet.Filters.Where(x => x.DisplayName == filteritem).FirstOrDefault(); + + if (filter != null && !string.IsNullOrEmpty(filter.DisplayName)) + { + var searchfilter = new SearchFilterModel() + { + DisplayName = filter.DisplayName, + Count = filter.Count, + Value = filter.DisplayName.ToLower(), + Selected = searchRequest.ResourceCollectionFilter?.Contains(filter.DisplayName, StringComparer.OrdinalIgnoreCase) ?? false, + }; + resourceCollectionFilter.Add(searchfilter); + } + } + } } resourceResult.SortItemList = searchSortItemList; @@ -222,6 +464,7 @@ public async Task PerformSearch(IPrincipal user, SearchRe ResourceSearchResult = resourceResult, CatalogueSearchResult = catalogueResult, + // SearchCollectionFilters = resourceCollectionFilters, ResourceResultPaging = new SearchResultPagingModel { CurrentPage = searchRequest.ResourcePageIndex ?? 0, @@ -240,6 +483,8 @@ public async Task PerformSearch(IPrincipal user, SearchRe SuggestedResource = suggestedResource, }; + searchResultViewModel.ResourceCollectionFilter = resourceCollectionFilter; + return searchResultViewModel; } @@ -271,7 +516,7 @@ public async Task RegisterSearchEventsAsync(SearchRequestViewModel search, GroupId = Guid.Parse(search.GroupId), SortDirection = "descending", SortColumn = sortBy.ToString(), - FilterText = search.Filters?.Any() == true ? string.Join(",", search.Filters) : null, + FilterText = this.BuildAnalyticsFilterText(search.Filters, search.ResourceCollectionFilter), ResourceAccessLevelFilterText = search.ResourceAccessLevelId.HasValue ? search.ResourceAccessLevelId.Value.ToString() : null, ProviderFilterText = search.ProviderFilters?.Any() == true ? string.Join(",", allproviders.Where(n => search.ProviderFilters.Contains(n.Id.ToString())).Select(x => x.Name).ToList()) : null, }; @@ -737,6 +982,52 @@ public async Task SendAutoSuggestionClickActionAsync(AutoSuggestionClickPayloadM } } + /// + /// Builds the filter text combining resource type and resource collection filters. + /// + /// The resource type filters. + /// The resource collection filters. + /// The combined filter text. + private string BuildFilterText(IEnumerable resourceTypeFilter, IEnumerable resourceCollectionFilter) + { + var filterParts = new List(); + + if (resourceTypeFilter?.Any() == true) + { + filterParts.Add($"&resource_type={string.Join("&resource_type=", resourceTypeFilter)}"); + } + + if (resourceCollectionFilter?.Any() == true) + { + filterParts.Add($"&resource_collection={string.Join("&resource_collection=", resourceCollectionFilter)}"); + } + + return string.Join(string.Empty, filterParts); + } + + /// + /// Builds the analytics filter text combining resource type and resource collection filters for logging. + /// + /// The resource type filters. + /// The resource collection filters. + /// The combined filter text for analytics. + private string BuildAnalyticsFilterText(IEnumerable resourceTypeFilter, IEnumerable resourceCollectionFilter) + { + var filterParts = new List(); + + if (resourceTypeFilter?.Any() == true) + { + filterParts.AddRange(resourceTypeFilter); + } + + if (resourceCollectionFilter?.Any() == true) + { + filterParts.AddRange(resourceCollectionFilter.Select(f => $"collection:{f}")); + } + + return filterParts.Any() ? string.Join(",", filterParts) : null; + } + /// /// The RemoveHtmlTags. /// diff --git a/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs b/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs index 6ed258354..60ff5f634 100644 --- a/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs +++ b/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs @@ -81,6 +81,7 @@ public static void AddLearningHubMappings(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -119,6 +120,7 @@ public static void AddLearningHubMappings(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } } diff --git a/LearningHub.Nhs.WebUI/Styles/nhsuk/layout.scss b/LearningHub.Nhs.WebUI/Styles/nhsuk/layout.scss index 67cdb0895..1e4cfee04 100644 --- a/LearningHub.Nhs.WebUI/Styles/nhsuk/layout.scss +++ b/LearningHub.Nhs.WebUI/Styles/nhsuk/layout.scss @@ -246,7 +246,7 @@ button[data-toggle="modal"] { } .autosuggestion-menu { - padding: 16px 16px 0px 16px !important; + padding: 0; background-color: $color_nhsuk-white; border-bottom: 1px solid $color_nhsuk-grey-4; border-radius: 0px 0px 4px 4px; @@ -263,12 +263,11 @@ button[data-toggle="modal"] { } .autosuggestion-option { - margin-bottom: 0; + margin-bottom: 0 !important; border-bottom: 1px solid $color_nhsuk-grey-4; color: $color_nhsuk-blue; cursor: pointer; font-size: 16px; - padding-bottom: 12px; text-align: left; text-decoration: none; } @@ -288,7 +287,7 @@ button[data-toggle="modal"] { } .autosuggestion-option .autosuggestion-link { - display: flex; + display: block; margin-bottom: 0px; text-decoration: underline; } @@ -297,6 +296,32 @@ li.autosuggestion-option:last-of-type { border-bottom: none !important; } +.autosuggestion-option a { + display: block; + width: 100%; + padding: 8px 16px; +} + +.autosuggestion-option a:hover, +.autosuggestion-option a:focus-visible { + background-color: #ffeb3b; + box-shadow: 0 4px 0 0 #212b32; + z-index: 5; + outline: none !important; + text-decoration: none; + padding-bottom: 10px +} + +.autosuggestion-option a:focus .autosuggestion-icon, .autosuggestion-option a:hover .autosuggestion-icon { + fill: #212b32 !important; +} + +.autosuggestion-option a:focus .autosuggestion-link, .autosuggestion-option a:hover .autosuggestion-link { + font-weight: 700; + color: #212b32; + text-decoration: none +} + /* side navigation styles */ .side-nav__list { @@ -313,7 +338,17 @@ li.autosuggestion-option:last-of-type { } - +.search-catalogue-badge { + position: absolute; + top: 0; + right: 0; + background-color: #0056b3; + color: white; + padding: 5px 15px; + border-top-right-radius: 8px; + border-bottom-left-radius: 8px; + font-size: 0.8em; +} .side-nav__item:last-child { border-bottom: none; diff --git a/LearningHub.Nhs.WebUI/Styles/nhsuk/pages/reporting.scss b/LearningHub.Nhs.WebUI/Styles/nhsuk/pages/reporting.scss new file mode 100644 index 000000000..f294a7ecc --- /dev/null +++ b/LearningHub.Nhs.WebUI/Styles/nhsuk/pages/reporting.scss @@ -0,0 +1,102 @@ +@use "../../abstracts/all" as *; + + +.user-report { + .nhsuk-details__summary { + display: flex; + justify-content: space-between; + align-items: center; + } + + .override-summary-color { + color: black !important; + font-weight: normal; + text-decoration: none; + } + + .nhsuk-summary-list__key--tight { + flex: 0 0 20%; + width: auto; + } + + .nhsuk-date-inline { + display: flex; + flex-direction: row; + /* align-items: center;*/ + gap: 0.5rem; + } + + .date-range-container .nhsuk-date-inline .nhsuk-error-message { + max-width: 400px; + word-wrap: break-word; + white-space: normal; + } + + + .align-label-component { + display: flex; + align-items: flex-start; + gap: 0.5rem; + } + + .nhsuk-date-inline .nhsuk-label { + margin-bottom: 0; + white-space: nowrap; + } + + .nhsuk-button--with-border { + border: 2px solid #005eb8; + background-color: #ffffff; + color: #005eb8; + } + + .nhsuk-button--with-border:hover { + background-color: #f0f8ff; + border-color: #003087; + } + + .date-range-container { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + .date-range-item { + flex: 0 1 auto; + } + + @media (max-width: 767px) { + .date-range-container .nhsuk-label { + min-width: 50px; + } + + .nhsuk-date-inline { + display: flex; + align-items: unset; + flex-direction: column; + gap: 0.5rem; + } + } + + .nhsuk-radios__divider { + text-align: center; + width: 40px; + } + + .nhsuk-hint { + margin-bottom: 0.5rem; + } + + .date-range-container .nhsuk-hint { + display: block; + max-height: 0; + overflow: hidden; + margin: 0; + padding: 0; + visibility: hidden; + } + + .nhsuk-checkboxes__item:first-child .nhsuk-checkboxes__label { + font-weight: 900; + } +} diff --git a/LearningHub.Nhs.WebUI/ViewComponents/DynamicCheckboxesViewComponent.cs b/LearningHub.Nhs.WebUI/ViewComponents/DynamicCheckboxesViewComponent.cs new file mode 100644 index 000000000..1884d3788 --- /dev/null +++ b/LearningHub.Nhs.WebUI/ViewComponents/DynamicCheckboxesViewComponent.cs @@ -0,0 +1,59 @@ +namespace LearningHub.Nhs.WebUI.ViewComponents +{ + using System.Collections.Generic; + using System.Linq; + using LearningHub.Nhs.WebUI.Models.DynamicCheckbox; + using Microsoft.AspNetCore.Mvc; + + /// + /// Defines the . + /// + public class DynamicCheckboxesViewComponent : ViewComponent + { + /// + /// The Invoke. + /// + /// label. + /// checkboxes. + /// required. + /// errorMessage. + /// hintText. + /// cssClass. + /// selectedValues. + /// propertyName. + /// A representing the result of the synchronous operation. + public IViewComponentResult Invoke( + string label, + IEnumerable checkboxes, + bool required = false, + string? errorMessage = null, + string? hintText = null, + string? cssClass = null, + IEnumerable? selectedValues = null, + string propertyName = "SelectedValues") + { + var selectedList = selectedValues?.ToList() ?? new List(); + + var viewModel = new DynamicCheckboxesViewModel + { + Label = label, + HintText = string.IsNullOrWhiteSpace(hintText) ? null : hintText, + ErrorMessage = errorMessage, + Required = required, + CssClass = string.IsNullOrWhiteSpace(cssClass) ? null : cssClass, + SelectedValues = selectedList, + Checkboxes = checkboxes.Select(cb => new DynamicCheckboxItemViewModel + { + Value = cb.Value, + Label = cb.Label, + HintText = cb.HintText, + Exclusive = cb.Exclusive, + Selected = selectedList.Contains(cb.Value), + }).ToList(), + }; + + this.ViewData["PropertyName"] = propertyName; + return this.View(viewModel); + } + } +} diff --git a/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml b/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml new file mode 100644 index 000000000..ec8385d39 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Account/AccessRestricted.cshtml @@ -0,0 +1,32 @@ +@{ + ViewData["Title"] = "You do not have the required permissions to access this part of the Learning Hub"; + Layout = ViewData["Layout"].ToString(); +} + +@section styles{ + +} + +
+
+
+
+

Access restricted

+
+

You do not have the required permissions to access this part of the Learning Hub yet.

+
+
+
+
+
+
+
+

If you think you should have access, please contact the support team.

+
+
+
+
+
+
+ + diff --git a/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml b/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml index 1b37e2979..3105eef83 100644 --- a/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Policies/AcceptableUsePolicy.cshtml @@ -68,7 +68,7 @@

4.5.2.2 which requires a subscription or payment to gain access to such content;

4.5.2.3 in which the user has a commercial interest;

4.5.2.4 which promotes a business name and/or logo;

-

4.5.2.5 which contains a link to an app via iOS or Google Play; or

+

4.5.2.5 which contains a link to an app via iOS, Google Play or similar; or

4.5.2.6 which has as its purpose or effect the collection and sharing of personal data;

4.5.3 be irrelevant to the purpose or aims of the Platform or while addressing relevant subject matter, contain an irrelevant, unsuitable or inappropriate slant (for example relating to potentially controversial opinions or beliefs of any kind intended to influence others);

4.5.4 be defamatory of any person;

@@ -92,8 +92,10 @@

4.5.22 contain harmful material;

4.5.23 give the impression that the Contribution emanates from us, if this is not the case; or

4.5.24 disclose any third party’s confidential information, identity, personally identifiable information or personal data (including data concerning health).

-

4.6 You acknowledge and accept that, when using the Platform and accessing the Content, some Content that has been uploaded by third parties may be factually inaccurate, or the topics addressed by the Content may be offensive, indecent, or objectionable in nature. We are not responsible (legally or otherwise) for any claim you may have in relation to the Content.

-

4.7 When producing Content to upload to the Platform, we encourage you to implement NICE guideline recommendations and ensure that sources of evidence are valid (for example, by peer review).

+

4.5.25 contain or request any material, the provision of which is not compliant with NHS England Information Governance guidance[https://www.england.nhs.uk/ig/ig-resources/].

+

4.6 Posts must be made with consideration of the NHS People Promise[https://www.england.nhs.uk/our-nhs-people/online-version/lfaop/our-nhs-people-promise/].

+

4.7 You acknowledge and accept that, when using the Platform and accessing the Content, some Content that has been uploaded by third parties may be factually inaccurate, or the topics addressed by the Content may be offensive,indecent, or objectionable in nature. We are not responsible (legally or otherwise) for any claim you may have in relation to the Content.

+

4.8 When producing Content to upload to the Platform, we encourage you to implement NICE guideline recommendations and ensure that sources of evidence are valid (for example, by peer review).

5 Metadata

When making any Contribution, you must where prompted include a sufficient description of the Content so that other users can understand the description, source, and age of the Content. For example, if Content has been quality assured, then the relevant information should be posted in the appropriate field. All metadata fields on the Platform must be completed appropriately before initiating upload. Including the correct information is important in order to help other users locate the Content (otherwise the Content may not appear in search results for others to select).

6 Updates

@@ -121,13 +123,13 @@

10.4 legal proceedings against you for reimbursement of all costs on an indemnity basis (including, but not limited to, reasonable administrative and legal costs) resulting from the breach, and/or further legal action against you;

10.5 disclosure of such information to law enforcement authorities as we reasonably feel is necessary or as required by law; and/or

10.6 any other action we reasonably deem appropriate.

-

Moderation of Contributions

+

11 Moderation of Contributions

11.1 Course Manager means a person authorised by NHS England who is responsible for creating and managing courses and learning resources on the Learning Hub, including the moderation of learner's Contributions within social learning environments of an online course, such as discussion forums.

11.2 Course Managers shall be responsible for monitoring local forum activity and ensuring compliance of all Contributions with the Learning Hub’s Acceptable Use Policy. Inappropriate Contributions shall be addressed promptly and escalated where necessary to the Learning Hub, who will review the user’s access to the Platform and the relevant forum.

11.3 Course Managers shall also take responsibility for the following:

-

11.3.1 Clear Expectations: Set clear local forum rules within their course at the outset, compliantly with this Acceptable Use Policy, which includes expected behaviour, response times and moderation practices.

-

11.3.2 Inclusive Practice: Encourage participation from all learners/users and foster a respectful, inclusive environment that values diverse perspectives. This should involve reminding local forum users of the appropriate measures of this Acceptable Use Policy and potentially removing Contributions that infringe the rules of this Acceptable Use Policy including under paragraph 4.5 above.

-

11.3.3 Data Protection and Safety: Ensure that no sensitive or personal data i shared, requested or stored in local forums, including by promptly removing any inclusions or requests for such material and notifying NHS England promptly of any potential breaches by contacting NHS England’s Data Protection Officer team via england.dpo@nhs.net; and ensure that risks are actively managed and the local forums are maintained as a safe space for discussion.

+

11.3.1 Clear Expectations: Set clear local forum rules within their course at the outset, compliantly with this Acceptable Use Policy, which includes expected behaviour, response times and moderation practices.

+

11.3.2 Inclusive Practice: Encourage participation from all learners/users and foster a respectful, inclusive environment that values diverse perspectives. This should involve reminding local forum users of the appropriate measures of this Acceptable Use Policy and potentially removing Contributions that infringe the rules of this Acceptable Use Policy including under paragraph 4.5 above.

+

11.3.3 Data Protection and Safety: Ensure that no sensitive or personal data is shared, requested or stored in local forums, including by promptly removing any inclusions or requests for such material and notifying NHS England promptly of any potential breaches by contacting NHS England’s Data Protection Officer team via england.dpo@nhs.net; and ensure that risks are actively managed and the local forums are maintained as a safe space for discussion.

11.4 All users must comply with this Acceptable Use Policy. Breaches may result in removal of Contributions and/or withdrawal or suspension of user access without notice.

diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml new file mode 100644 index 000000000..ea0660794 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/CourseProgressReport.cshtml @@ -0,0 +1,166 @@ +@using LearningHub.Nhs.Models.Enums +@using LearningHub.Nhs.Models.Enums.Report +@using LearningHub.Nhs.WebUI.Helpers +@using LearningHub.Nhs.WebUI.Models +@using LearningHub.Nhs.WebUI.Models.Learning +@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel + +@{ + ViewData["Title"] = "Course progress report"; + var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}"; + + + var pagingModel = Model.ReportPaging; + int currentPage = pagingModel.CurrentPage; + int pageSize = pagingModel.PageSize; + int totalRows = pagingModel.TotalItems; + + int startRow = (currentPage * pageSize) + 1; + int endRow = Math.Min(startRow + pageSize - 1, totalRows); + var distinctCourses = this.ViewData["matchedCourseNames"] as List; + +} + +@section styles { + +} + +
+
+
+ + +
+ +
+ Home + + Reports +
+ +

Course progress report

+
+
+
+ Course@(distinctCourses.Count() > 1 ? "s" : "") +
+
+ @if (distinctCourses.Count() > 1) + { +
    + @foreach (var entry in distinctCourses) + { +
  • @entry
  • + } +
+ } + else + { +

@distinctCourses.FirstOrDefault()

+ } + + +
+ +
+ + Change Course + + +
+ +
+
+
+ Reporting period +
+
+ @{ + if (Model.TimePeriod == "Custom" && Model.StartDate.HasValue && Model.EndDate.HasValue) + { + @Model.StartDate.Value.ToString("dd MMMM yyyy") to @Model.EndDate.Value.ToString("dd MMMM yyyy") + } + else + { + @Model.TimePeriod days + } + } + +
+ +
+ + Change Reporting period + + +
+ +
+
+ +
+ + + @if (Model.TotalCount > 0) + { +

Displaying @startRow to @endRow of @Model.TotalCount filtered row@(Model.TotalCount > 1 ? "s" : "")

+ + + @if (Model.ReportHistoryModel != null && Model.ReportHistoryModel.DownloadRequest == null) + { +

+ Request to download this report in a spreadsheet (.xls) format. You will be notified + when the report is ready. +

+
+ + +
+ } + else if (Model.ReportHistoryModel != null && Model.ReportHistoryModel.DownloadRequest == true && Model.ReportHistoryModel.ReportStatusId == ((int)Status.Pending)) + { +
+ Information: +

We’re getting your report ready

+

+ You will be notified once it’s ready to download and you will be able to access it in the + Reports section. +

+
+ } + } + else + { +

Displaying no results

+ } + + @if (Model.CourseCompletionRecords.Any()) + { + @await Html.PartialAsync("_ReportTable", Model) + } + else + { +
    +
  • +
    +
    +

    + No information is available +

    +

    + Please adjust your filters +

    +
    +
    +
  • +
+ } + +
+
+
+ @await Html.PartialAsync("_ReportPaging", Model) +
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml new file mode 100644 index 000000000..71ad7a7ff --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportCourseSelection.cshtml @@ -0,0 +1,79 @@ +@using LearningHub.Nhs.Models.Enums +@using LearningHub.Nhs.WebUI.Helpers +@using LearningHub.Nhs.WebUI.Models +@using LearningHub.Nhs.WebUI.Models.Learning +@using NHSUKViewComponents.Web.ViewModels +@model LearningHub.Nhs.WebUI.Models.Report.ReportCreationCourseSelection; +@{ + ViewData["Title"] = "Select Course"; + var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}"; + var errorHasOccurred = !ViewData.ModelState.IsValid; +} + +@section styles { + +} + +
+
+
+ + +
+ @if (string.IsNullOrWhiteSpace(ViewBag.ReturnUrl)) + { + + } + else + { + + } +
+ Create a course progress report +
+ @if (errorHasOccurred) + { + + } +

+ Select course(s) +

+
+ + + +
+ +
+ @await Component.InvokeAsync("DynamicCheckboxes", new + { + label = "", + checkboxes = Model.AllCources, + required = false, + errorMessage = "Select a course", + selectedValues = Model.Courses, + propertyName = nameof(Model.Courses) + }) +
+
+ @if (string.IsNullOrWhiteSpace(ViewBag.ReturnUrl)) + { + + } + else + { + + } + +
+
+ +
+ + +
+
+
+
+ + diff --git a/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml new file mode 100644 index 000000000..04c840a73 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/CreateReportDateSelection.cshtml @@ -0,0 +1,164 @@ +@using LearningHub.Nhs.Models.Enums +@using LearningHub.Nhs.WebUI.Helpers +@using LearningHub.Nhs.WebUI.Models +@using LearningHub.Nhs.WebUI.Models.Learning +@using NHSUKViewComponents.Web.ViewModels +@model LearningHub.Nhs.WebUI.Models.Report.ReportCreationDateSelection; +@{ + ViewData["Title"] = "Select Date"; + var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}"; + var errorHasOccurred = !ViewData.ModelState.IsValid; + var customId = $"TimePeriod-{Model.PopulateDateRange().Count()}"; + var hintTextLines = new List { $"For example, {Model.StartDay} {Model.StartMonth} {Model.StartYear}" }; + var hintTextLine = new List { $" " }; +} + +@section styles { + +} + + +
+
+
+ + +
+ + @if (string.IsNullOrWhiteSpace(ViewBag.ReturnUrl)) + { + + } + else + { + + } +
+ Create a course progress report +
+ @if (errorHasOccurred) + { + + } +

+ Reporting period +

+
+ + +
+ + @Html.HiddenFor(x=>x.HintText) + @Html.HiddenFor(x =>x.DataStart) +
+
+
+ For the last: +
+ + @if (errorHasOccurred && string.IsNullOrWhiteSpace(Model.TimePeriod)) + { +
+ + + Error: Select a reporting period + +
+ } + +
+ @foreach (var (radio, index) in Model.PopulateDateRange().Select((r, i) => (r, i))) + { + var radioId = index == 0 ? "TimePeriod" : $"TimePeriod-{index}"; + @if (radio.Value != "Custom") + { + +
+ + + @if (radio.HintText != null) + { +
+ @radio.HintText +
+ } +
+ } + else + { +
or
+
+
+ + +
+ @Model.HintText +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ } + + } + + + +
+
+
+
+ + +
+ + +
+
+
+ +
+ + + +
+ + +
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml new file mode 100644 index 000000000..5f03ecbe6 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/Index.cshtml @@ -0,0 +1,215 @@ +@using LearningHub.Nhs.Models.Enums +@using LearningHub.Nhs.Models.Enums.Report +@using LearningHub.Nhs.WebUI.Helpers +@using LearningHub.Nhs.WebUI.Models +@using LearningHub.Nhs.WebUI.Models.Learning +@model LearningHub.Nhs.WebUI.Models.Report.ReportHistoryViewModel +; +@{ + ViewData["Title"] = "Reports"; + var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}"; + var allCourses = this.ViewData["AllCourses"] as List>; +} + +@section styles { + +} + +@section NavBreadcrumbs { + +
+
+
+
+ +
+ Home + +
+ +
+

Reports

+

View and manage your reports

+
+ +
+
+
+
+} +
+
+
+ + +
+
+

+ This page lists all reports you can access or have created. Use the Create a course progress report button to generate a new report. +

+ Create a course progress report +
+ +
+

Previously run reports

+ + @if (Model.ReportHistoryModels.Any()) + { +

+ Reports are stored for 30 days from the date they’re generated. If you need to keep a copy, make sure you download it before it expires. +

+
+ @foreach (var entry in Model.ReportHistoryModels) + { + var matchedCourseNames = string.Empty; + var matchedCourseNamesDetails = new List(); + + if (string.IsNullOrWhiteSpace(entry.CourseFilter)) + { + matchedCourseNames = "All courses"; + matchedCourseNamesDetails = new List { "All courses" }; + } + else + { + var matched = allCourses + .Where(c => entry.CourseFilter.Contains(c.Key)) + .Select(c => c.Value) + .ToList(); + + matchedCourseNamesDetails = matched; + + if (matched.Count == 1) + { + matchedCourseNames = matched[0]; + } + else + { + matchedCourseNames = $"{matched[0]} and {matched.Count - 1} other{((matched.Count - 1) > 1 ? "s" : "")}"; + } + matchedCourseNames = UtilityHelper.ConvertToSentenceCase(matchedCourseNames); + } + + + string datePeriod = entry.PeriodDays > 0 ? $"{entry.PeriodDays} days" : $"{entry.StartDate.GetValueOrDefault().ToString("dd MMM yyyy")} to {entry.EndDate.GetValueOrDefault().ToString("dd MMM yyyy")}"; + bool downloadCheck = entry.DownloadRequest != null && (bool)entry.DownloadRequest; + string expiryDate = entry.LastRun.AddDays(30).ToString("dd MMM yyyy"); + +
+ + + Course progress for @matchedCourseNames + + @if (downloadCheck) + { + + @if (entry.ReportStatusId == ((int)Status.Ready) && DateTime.Now.Date < entry.LastRun.AddDays(30).Date) + { + + + Expires on @expiryDate + + + Ready to download + } + else if (entry.ReportStatusId == ((int)Status.Pending)) + { + Getting it ready + } + else + { + Expired + } + + } + +
+
+
+
Date requested:
+
@entry.FirstRun.Date.ToString("dd MMM yyyy")
+
+
+
Date period:
+
@datePeriod
+
+
+
Type:
+
Course progress
+
+
+
Reporting on:
+
+ + @if (matchedCourseNamesDetails.Count > 1) + { +
    + @foreach (var item in matchedCourseNamesDetails) + { +
  • @item
  • + } +
+ } + else + { + @matchedCourseNamesDetails.FirstOrDefault() + } + + +
+
+
+ +
    +
  • + + View report + +
  • + + @if (downloadCheck) + { + @if (entry.ReportStatusId == ((int)Status.Ready) && DateTime.Now.Date < entry.LastRun.AddDays(30).Date) + { +
  • + Download report +
  • + } + } +
+
+
+
+ } + + } + else + { +
    +
  • +
    +
    +

    + No reports available yet +

    +

    + You haven’t run any reports yet, so this section is currently empty. + Once you generate a report, it will appear here for you to view or download. +

    +
    +
    +
  • +
+ + } + +
+
+
+ @await Html.PartialAsync("_ReportHistoryPaging", Model) +
+
+
+
diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml new file mode 100644 index 000000000..2e4d43245 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportHistoryPaging.cshtml @@ -0,0 +1,76 @@ +@using System.Web; +@using LearningHub.Nhs.WebUI.Models.Learning +@using LearningHub.Nhs.WebUI.Models.Search; +@model LearningHub.Nhs.WebUI.Models.Report.ReportHistoryViewModel + + +@{ + var pagingModel = Model.ReportPaging; + var showPaging = pagingModel.CurrentPage >= 0 && pagingModel.CurrentPage <= pagingModel.TotalPages - 1; + var previousMessage = $"{pagingModel.CurrentPage} of {pagingModel.TotalPages}"; + int CurrentPageNumber = pagingModel.CurrentPage + 1; + var nextMessage = string.Empty; + if (CurrentPageNumber <= pagingModel.TotalPages) + { + nextMessage = $"{CurrentPageNumber + 1} of {pagingModel.TotalPages}"; + } + else + { + previousMessage = $"{CurrentPageNumber - 1} of {pagingModel.TotalPages}"; + nextMessage = $"{CurrentPageNumber} of {pagingModel.TotalPages}"; + } + + var routeData = new Dictionary(); + routeData["CurrentPageIndex"] = pagingModel.CurrentPage.ToString(); + var nextRouteData = new Dictionary(routeData); + var previousRouteData = new Dictionary(routeData); + nextRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.NextPageChange.ToString(); + previousRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.PreviousPageChange.ToString(); +} + +@if (pagingModel.TotalPages > 1) +{ + +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml new file mode 100644 index 000000000..37af21817 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportPaging.cshtml @@ -0,0 +1,76 @@ +@using System.Web; +@using LearningHub.Nhs.WebUI.Models.Learning +@using LearningHub.Nhs.WebUI.Models.Search; +@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel + + +@{ + var pagingModel = Model.ReportPaging; + var showPaging = pagingModel.CurrentPage >= 0 && pagingModel.CurrentPage <= pagingModel.TotalPages - 1; + var previousMessage = $"{pagingModel.CurrentPage} of {pagingModel.TotalPages}"; + int CurrentPageNumber = pagingModel.CurrentPage + 1; + var nextMessage = string.Empty; + if (CurrentPageNumber <= pagingModel.TotalPages) + { + nextMessage = $"{CurrentPageNumber + 1} of {pagingModel.TotalPages}"; + } + else + { + previousMessage = $"{CurrentPageNumber - 1} of {pagingModel.TotalPages}"; + nextMessage = $"{CurrentPageNumber} of {pagingModel.TotalPages}"; + } + + var routeData = new Dictionary(); + routeData["CurrentPageIndex"] = pagingModel.CurrentPage.ToString(); + var nextRouteData = new Dictionary(routeData); + var previousRouteData = new Dictionary(routeData); + nextRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.NextPageChange.ToString(); + previousRouteData["ReportFormActionType"] = ReportFormActionTypeEnum.PreviousPageChange.ToString(); +} + +@if (pagingModel.TotalPages > 1) +{ + +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml b/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml new file mode 100644 index 000000000..733d9a72b --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Reports/_ReportTable.cshtml @@ -0,0 +1,153 @@ +@model LearningHub.Nhs.WebUI.Models.Report.CourseCompletionViewModel + +@{ + var returnUrl = $"{Context.Request.Path}{Context.Request.QueryString}"; +} + + +@if (Model.TotalCount > 0) +{ +
+ + + + + + + + + + + + + + + + + + + @foreach (var entry in Model.CourseCompletionRecords) + { + + + + + + + + + + + + + + + + + } + + +
+ Username + + First name + + Last name + + Email address + + Medical council no + + Medical council name + + Role + + Grade + + Location + + Programme name + + Course learning path name + + Status +
+ Username + + @entry.UserName + + + First name + @entry.FirstName + + + Last name + @entry.LastName + + + Email address + @entry.Email + + + Medical council no + @entry.MedicalCouncilNo + + + Medical council name + @entry.MedicalCouncilName + + + Role + @entry.Role + + + Grade + @entry.Grade + + + Location + @entry.Location + + + Programme name + @entry.Programme + + + Course learning path name + @entry.Course + + + Status + @ViewActivityHelper.GetReportStatusDisplayText(entry.CourseStatus) + +
+
+} diff --git a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml index 4b8e0e6e3..9783090ba 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml @@ -1,19 +1,22 @@ -@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel +@using Microsoft.FeatureManagement +@inject IFeatureManager FeatureManager +@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel @{ ViewData["Title"] = "Search"; + var azureSearchEnabled = await FeatureManager.IsEnabledAsync("AzureSearch"); } -@section styles{ +@section styles { }
-
+
@if (Model.DidYouMeanEnabled) {

- No results were found for @(Model.SearchString) please change your search term or explore the suggestions below + No results were found for @(Model.SearchString) please change your search term or explore the suggestions below

} else @@ -22,14 +25,14 @@ Search results @(!string.IsNullOrEmpty(Model.SearchString) ? "for " + Model.SearchString : string.Empty) } - +
@await Html.PartialAsync("_SearchBar", @Model.SearchString)
- @if (Model.CatalogueSearchResult?.TotalHits > 0) + @if (Model.CatalogueSearchResult?.TotalHits > 0 && !azureSearchEnabled) { @if (Model.DidYouMeanEnabled) { @@ -52,18 +55,32 @@ Showing results for @(Model.SuggestedResource)

} + + @* @if (azureSearchEnabled) // [BY] Use this when we create quick filters for Azure Search + { + @await Html.PartialAsync("_SearchFilter", Model) + } *@ + @await Html.PartialAsync("_ResourceFilter", Model) +
- @await Html.PartialAsync("_ResourceSearchResult", Model) + @if (azureSearchEnabled) + { + @await Html.PartialAsync("_SearchResult", Model) + } + else + { + @await Html.PartialAsync("_ResourceSearchResult", Model) + } @await Html.PartialAsync("_ResourcePagination", Model)
} - + @if ((Model.CatalogueSearchResult?.TotalHits ?? 0) == 0 && (Model.ResourceSearchResult?.TotalHits ?? 0) == 0) {
diff --git a/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml new file mode 100644 index 000000000..3b5582254 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Search/_AutoSuggest.cshtml @@ -0,0 +1,81 @@ +@model LearningHub.Nhs.Models.Search.AutoSuggestionModel; +@inject LearningHub.Nhs.WebUI.Interfaces.IMoodleApiService moodleApiService; + +@using LearningHub.Nhs.Models.Search.SearchClick; +@using Microsoft.AspNetCore.WebUtilities +@using System.Web +@{ + var counter = 0; + var counter_res = 0; + string GetUrl(string term, string searchType, AutoSuggestionClickPayloadModel payload, string reference, int? resourceReferenceId) + { + + var url = string.Empty; + if (!string.IsNullOrEmpty(reference) && searchType == "catalogue") + { + url = "/Catalogue/" + reference; + } + else if ((resourceReferenceId > 0) && searchType == "resource") + { + url = "/Resource/" + resourceReferenceId; + } + else if ((resourceReferenceId > 0) && searchType == "course") + { + if (resourceReferenceId != null) + return moodleApiService.GetCourseUrl(resourceReferenceId.Value); + } + else if (!string.IsNullOrEmpty(term) && searchType == "Concepts") + { + url = "/Search/results?term=" + term; + } + else + { + url = "/Search/results?term=" + term; + } + + return $@"/search/record-autosuggestion-click?term={term}&url={url}&clickTargetUrl={payload.ClickTargetUrl}&itemIndex={payload?.HitNumber}&&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits} +&containerId={payload?.ContainerId}&name={payload?.DocumentFields.Name}&query={payload?.SearchSignal?.Query}&userQuery={HttpUtility.UrlEncode(payload?.SearchSignal?.UserQuery)} +&searchId={payload?.SearchSignal?.SearchId}&timeOfSearch={payload?.SearchSignal?.TimeOfSearch}&title={payload?.DocumentFields?.Title}"; + } +} +@if (Model != null) +{ + @if (Model.ConceptDocument != null) + { + @foreach (var item in Model.ConceptDocument.ConceptDocumentList) + { + counter++; +
  • + + + + + + +
  • + } + } + @if (Model.ResourceCollectionDocument != null) + { + @foreach (var item in Model.ResourceCollectionDocument.DocumentList) + { + counter_res++; +
  • + + +

    + Type: @* + @(item.ResourceType == "resource" ? "Learning resource" : item.ResourceType) *@ + + @(item.ResourceType == "resource" + ? "Learning resource" + : !string.IsNullOrEmpty(item.ResourceType) + ? char.ToUpper(item.ResourceType[0]) + item.ResourceType.Substring(1) + : string.Empty) + +

    +
    +
  • + } + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml index 429eae962..51fc9223e 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/_ResourceFilter.cshtml @@ -4,12 +4,11 @@ @{ var resourceResult = Model.ResourceSearchResult; var filtersApplied = resourceResult.SortItemSelected.Value != string.Empty - || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected) - || resourceResult.SearchProviderFilters.Any(f =>f.Selected); + || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected); var queryParams = QueryHelpers.ParseQuery(Context.Request.QueryString.ToString().ToLower()); queryParams["actiontype"] = "sort-filter"; - var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl +"/search" : "/search/results"; + var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl + "/search" : "/search/results"; var actionUrl = QueryHelpers.AddQueryString(pageUrl, queryParams); var pageFragment = "#search-filters"; @@ -25,17 +24,10 @@ .Select(f => $"{char.ToUpper(f.DisplayName[0])}{f.DisplayName[1..]}"); if (resourceAccessLevelFilters.Any()) - { + { summary += $" and Filtered by Audience access level {string.Join(" ", resourceAccessLevelFilters)}"; } - - - var providerfilters = resourceResult.SearchProviderFilters.Where(f => f.Selected).Select(f => $"{f.DisplayName}"); - if (providerfilters.Any()) - { - summary += $" and Filtered by Provider {string.Join(" ", providerfilters)}"; - } if (filters.Any()) { @@ -46,7 +38,7 @@ }

    - @($"{resourceResult.TotalHits} resource{(resourceResult.TotalHits == 1 ? string.Empty : "s")}") + @($"Showing {resourceResult.TotalHits} result{(resourceResult.TotalHits == 1 ? string.Empty : "s")}")

    @if (resourceResult.TotalHits > 0) @@ -128,7 +120,7 @@
    + value="@filter.Value" checked="@filter.Selected" class="@(filter.Count > 0 ? "" : "disabled")"> @@ -140,35 +132,6 @@
    } - @if (resourceResult.SearchProviderFilters.Count > 0) - { -
    - -
    -
    - -

    Filter by provider:

    -
    - -
    - @foreach (var filter in resourceResult.SearchProviderFilters) - { -
    - -
    - - -
    -
    - } -
    -
    -
    - } -
    diff --git a/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml index 50c8df8f4..91a16273d 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/_ResourceSearchResult.cshtml @@ -15,11 +15,11 @@ var pagingModel = Model.ResourceResultPaging; var index = pagingModel.CurrentPage * pagingModel.PageSize; var searchString = HttpUtility.UrlEncode(Model.SearchString); + string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString()); string GetUrl(int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload) { - var searchSignal = payload?.SearchSignal; - string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString()); + var searchSignal = payload?.SearchSignal; string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); return $@"/search/record-resource-click?url=/Resource/{resourceReferenceId}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} @@ -28,7 +28,7 @@ &query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}"; } - string GetMoodleCourseUrl(string courseIdWithPrefix) + string GetMoodleCourseUrl(string courseIdWithPrefix, int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload) { const string prefix = "M"; @@ -46,7 +46,15 @@ if (int.TryParse(courseIdPart, out int courseId)) { - return moodleApiService.GetCourseUrl(courseId); + var searchSignal = payload?.SearchSignal; + + string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); + string url = moodleApiService.GetCourseUrl(courseId); + + return $@"/search/record-course-click?url={url}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} + &pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId} + &groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)} + &query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}"; } else { @@ -66,7 +74,7 @@

    @if (item.ResourceType == "moodle") { - @item.Title + @item.Title } else { diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml index da4a475e9..7c1a5705d 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchBar.cshtml @@ -19,6 +19,7 @@ var searchInput = document.getElementById("sub-search-field"); var suggestionsList = document.getElementById("sub-search-field_listbox"); var minLengthAutoComplete = 3; + let currentIndex = -1; function fetchSuggestions(term) { var xhr = new XMLHttpRequest(); @@ -66,12 +67,50 @@ } } }); + + searchInput.addEventListener("focus", function () { + currentIndex = -1; + }); + searchInput.addEventListener("keydown", handleArrowKeys); + suggestionsList.addEventListener("keydown", handleArrowKeys); } } + function handleArrowKeys(e) { + const items = suggestionsList.querySelectorAll('.autosuggestion-option > a'); + if (!items.length) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + currentIndex = (currentIndex + 1) % items.length; + items[currentIndex].focus(); + break; + + case "ArrowUp": + e.preventDefault(); + currentIndex = (currentIndex - 1 + items.length) % items.length; + items[currentIndex].focus(); + break; + + case "Tab": + if (currentIndex >= items.length - 1) { + closeAllLists(); + currentIndex = -1; + } + else { + e.preventDefault(); + currentIndex++; + items[currentIndex].focus(); + } + break; + } + } + function closeAllLists() { suggestionsList.innerHTML = ''; suggestionsList.style.display = "none"; + currentIndex = -1; } autocomplete(searchInput, minLengthAutoComplete); diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml new file mode 100644 index 000000000..86a7784c1 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml @@ -0,0 +1,45 @@ +@using System.Web; +@using LearningHub.Nhs.WebUI.Extensions +@using LearningHub.Nhs.Models.Search.SearchClick; + +@model (LearningHub.Nhs.Models.Search.Document document, string groupId, int currentPage) + +@{ + var item = Model.document; + var searchString = string.Empty;// HttpUtility.UrlEncode(Model.se); + var suggestedSearchString = string.Empty; /* Model.DidYouMeanEnabled ? HttpUtility.UrlEncode(Model.SuggestedCatalogue) : HttpUtility.UrlEncode (Model.SearchString);*/ + + string GetUrl(string catalogueUrl, int? nodePathId, int itemIndex, int catalogueId, SearchClickPayloadModel payload) + { + + var searchSignal = payload?.SearchSignal; + var pagingModel = Model.currentPage; + string encodedCatalogueUrl = HttpUtility.UrlEncode("/Catalogue/" + catalogueUrl); + string groupId = Model.groupId;// HttpUtility.UrlEncode(Model.GroupId.ToString()); + string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); + + var url = $@"/search/record-catalogue-click?url={encodedCatalogueUrl}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} +&pageIndex={pagingModel}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={suggestedSearchString}&catalogueId={catalogueId} +&GroupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal?.UserQuery)} +&query={searchSignalQueryEncoded}&name={payload?.DocumentFields?.Name}"; + return url; + } +} + +
    + +
    + +
    Catalogue
    + +

    + @item.Title + +

    + +

    + @item.Description +

    + +
    +
    \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml new file mode 100644 index 000000000..03a1518a9 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchFilter.cshtml @@ -0,0 +1,227 @@ +@using Microsoft.AspNetCore.WebUtilities +@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel + +@{ + var resourceResult = Model.ResourceSearchResult; + var filtersApplied = resourceResult.SortItemSelected.Value != string.Empty + || resourceResult.SearchFilters.Any(f => f.Selected) || resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected); + + var queryParams = QueryHelpers.ParseQuery(Context.Request.QueryString.ToString().ToLower()); + queryParams["actiontype"] = "sort-filter"; + var pageUrl = Model.CatalogueId > 0 ? "/catalogue/" + Model.CatalogueUrl + "/search" : "/search/results"; + var actionUrl = QueryHelpers.AddQueryString(pageUrl, queryParams); + var pageFragment = "#search-filters"; + + string FilterSummary() + { + string summary = $"Sorted by {resourceResult.SortItemSelected.Name}"; + var filters = resourceResult.SearchFilters + .Where(f => f.Selected) + .Select(f => $"{f.DisplayName}"); + + var resourceAccessLevelFilters = resourceResult.SearchResourceAccessLevelFilters + .Where(f => f.Selected) + .Select(f => $"{char.ToUpper(f.DisplayName[0])}{f.DisplayName[1..]}"); + + if (resourceAccessLevelFilters.Any()) + { + summary += $" and Filtered by Audience access level {string.Join(" ", resourceAccessLevelFilters)}"; + } + + if (filters.Any()) + { + summary += $" and Filtered by Type {string.Join(" ", filters)}"; + } + return summary; + } +} + +

    + @($"Showing {resourceResult.TotalHits} result{(resourceResult.TotalHits == 1 ? string.Empty : "s")}") +

    + +@if (resourceResult.TotalHits > 0) +{ +
    +
    + + + + +
    +
    + +

    Show

    +
    + + @{ + // Get MULTIPLE selected values + var selectedResourceCollections = Model.ResourceCollectionFilter + .Where(x => x.Selected) + .Select(x => x.Value) + .ToList(); + } + +
    + @foreach (var option in new[] + { + new { Id = "all", Label = "All" }, + new { Id = "catalogue", Label = "Catalogues" }, + new { Id = "course", Label = "Courses" }, + new { Id = "resource", Label = "Learning Resources" } + }) + { + // Look up count from your real model + var filter = Model.ResourceCollectionFilter + .FirstOrDefault(x => x.Value == option.Id); + + var count = filter?.Count ?? 0; + +
    +
    + + + + + +
    +
    + } +
    +
    +
    + +
    + + +
    + + Sort and filter results + + + Sorted by: @resourceResult.SortItemSelected.Name + +
    + +
    + +
    +
    + @Html.Raw(FilterSummary()) +
    + @if (filtersApplied) + { + + } +
    +
    + +
    + + @if (this.ViewBag.SelectFilterError == true) + { + + Error: You must update the sort or filter before applying changes + + } + +
    +
    + +

    Sort by:

    +
    + +
    + @foreach (var sortItem in resourceResult.SortItemList) + { +
    +
    + + +
    +
    + } + +
    +
    +
    + + @if (resourceResult.SearchResourceAccessLevelFilters != null && resourceResult.SearchResourceAccessLevelFilters.Any()) + { +
    + +
    +
    + +

    Filter by audience access level:

    +
    + +
    + @foreach (var filter in resourceResult.SearchResourceAccessLevelFilters) + { +
    +
    + + +
    +
    + } +
    +
    +
    + } + +
    + +
    +
    + +

    Filter by:

    +
    + +
    + @foreach (var filter in resourceResult.SearchFilters) + { +
    + +
    + + +
    +
    + } + +
    +
    +
    + + +
    + +
    +
    +
    +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml new file mode 100644 index 000000000..e251ebca8 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml @@ -0,0 +1,155 @@ +@model LearningHub.Nhs.WebUI.Models.Search.SearchResultViewModel +@inject LearningHub.Nhs.WebUI.Interfaces.IMoodleApiService moodleApiService; + +@using System.Linq; +@using System.Web; +@using LearningHub.Nhs.WebUI.Helpers; +@using LearningHub.Nhs.Models.Search; +@using LearningHub.Nhs.Models.Search.SearchFeedback; +@using LearningHub.Nhs.Models.Enums; +@using LearningHub.Nhs.WebUI.Models.Search; +@using LearningHub.Nhs.Models.Search.SearchClick; + +@{ + var resourceResult = Model.ResourceSearchResult; + var pagingModel = Model.ResourceResultPaging; + var index = pagingModel.CurrentPage * pagingModel.PageSize; + var searchString = HttpUtility.UrlEncode(Model.SearchString); + string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString()); + + string GetUrl(int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload) + { + var searchSignal = payload?.SearchSignal; + + string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); + + return $@"/search/record-resource-click?url=/Resource/{resourceReferenceId}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} +&pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId} +&groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)} +&query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}"; + } + + string GetMoodleCourseUrl(string courseIdWithPrefix, int resourceReferenceId,int itemIndex, int nodePathId, SearchClickPayloadModel payload) + { + + if (int.TryParse(courseIdWithPrefix, out int courseId)) + { + var searchSignal = payload?.SearchSignal; + + string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); + string url = moodleApiService.GetCourseUrl(courseId); + + return $@"/search/record-course-click?url={url}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} + &pageIndex={pagingModel.CurrentPage}&totalNumberOfHits={payload?.SearchSignal?.Stats?.TotalHits}&searchText={searchString}&resourceReferenceId={resourceReferenceId} + &groupId={groupId}&searchId={searchSignal?.SearchId}&timeOfSearch={searchSignal?.TimeOfSearch}&userQuery={HttpUtility.UrlEncode(searchSignal.UserQuery)} + &query={searchSignalQueryEncoded}&title={payload?.DocumentFields?.Title}"; + + // return moodleApiService.GetCourseUrl(courseId); + } + else + { + return string.Empty; + } + } + + bool showCatalogueFieldsInResources = ViewBag.ShowCatalogueFieldsInResources == null || ViewBag.ShowCatalogueFieldsInResources == true; + bool resourceAccessLevelFilterSelected = resourceResult.SearchResourceAccessLevelFilters.Any(f => f.Selected); +} + +@foreach (var item in resourceResult.DocumentModel) +{ + + @if (item.ResourceType == "catalogue") + { + @await Html.PartialAsync("_SearchCatalogueResult", (item, groupId, pagingModel.CurrentPage)) + } + else + { + var provider = item.Providers?.FirstOrDefault(); + +
    +

    + @if (item.ResourceType == "moodle") + { + @item.Title + } + else + { + @item.Title + } +

    + + @if (provider != null) + { +
    +
    + + @ProviderHelper.GetProviderString(provider.Name) +
    +
    + } + @if (item.CatalogueRestrictedAccess && !Model.HideRestrictedBadge && showCatalogueFieldsInResources) + { +

    + @((item.CatalogueHasAccess || this.User.IsInRole("Administrator")) ? "Access Granted" : "Access restricted") +

    + } + + @if (!resourceAccessLevelFilterSelected) + { +
    +
    + Audience access level: + @ResourceAccessLevelHelper.GetResourceAccessLevelText((ResourceAccessibilityEnum)item.ResourceAccessLevel) +
    +
    + } + + @if (!string.IsNullOrEmpty(item.ResourceType)) + { +
    +
    + Type: + @UtilityHelper.GetPrettifiedResourceTypeName(UtilityHelper.ToEnum(item.ResourceType), 0) +
    +
    + @if (item.ResourceType != "moodle") + { + @await Html.PartialAsync("../Shared/_StarRating.cshtml", item.Rating) + } +
    +
    + } +

    + @item.Description +

    + +
    + @if (!string.IsNullOrWhiteSpace(item.CatalogueBadgeUrl) && showCatalogueFieldsInResources) + { + Provider's catalogue badge + } + + @if (!string.IsNullOrEmpty(item.CatalogueName) && !this.Model.CatalogueId.HasValue && showCatalogueFieldsInResources) + { + + } + +
    + @UtilityHelper.GetAttribution(item.Authors) + + @if (!string.IsNullOrWhiteSpace(item.AuthoredDate)) + { + string formattedAuthoredDate = @UtilityHelper.GetFormattedAuthoredDate(item.AuthoredDate); + @* Render helper text plus formatted date *@ + @UtilityHelper.GetInOn(formattedAuthoredDate) + @: @formattedAuthoredDate + } +
    +
    +
    + index++; + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml new file mode 100644 index 000000000..a8512bcf9 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/DynamicCheckboxes/Default.cshtml @@ -0,0 +1,49 @@ +@using LearningHub.Nhs.WebUI.Models.DynamicCheckbox +@model DynamicCheckboxesViewModel +@{ + var propertyName = ViewData["PropertyName"]?.ToString() ?? "SelectedValues"; + var exclusiveGroup = $"{propertyName}-list"; +} + +
    +
    + @if (!string.IsNullOrEmpty(Model.Label)) + { + + @Model.Label + + } + + @if (!ViewData.ModelState.IsValid && Model.SelectedValues.Count() == 0) + { +
    + + @Model.ErrorMessage + +
    + } + +
    + @for (int i = 0; i < Model.Checkboxes.Count; i++) + { + var checkbox = Model.Checkboxes[i]; + var inputId = i == 0 ? propertyName : $"{propertyName}_{i}"; + +
    + + + +
    + } +
    +
    +
    diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml index 43881e477..d98f78679 100644 --- a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Default.cshtml @@ -50,7 +50,15 @@ } + @if (Model.ShowReports) + { +
  • + + Reports + +
  • + } @if (Model.ShowMyBookmarks) {
  • @@ -61,6 +69,7 @@
  • } + @if (Context.Request.Path.Value != "/Home/Error" && !SystemOffline()) { @if (Model.ShowHelp) diff --git a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml index f3a69f8e6..b572b63f4 100644 --- a/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Shared/Components/NavigationItems/Searchbar.cshtml @@ -43,6 +43,7 @@ var searchInput = document.getElementById("search-field"); var suggestionsList = document.getElementById("search-field_listbox"); var minLengthAutoComplete = 3; + let currentIndex = -1; function fetchSuggestions(term) { var xhr = new XMLHttpRequest(); @@ -90,12 +91,50 @@ } } }); + + searchInput.addEventListener("focus", function () { + currentIndex = -1; + }); + searchInput.addEventListener("keydown", handleArrowKeys); + suggestionsList.addEventListener("keydown", handleArrowKeys); } } + function handleArrowKeys(e) { + const items = suggestionsList.querySelectorAll('.autosuggestion-option > a'); + if (!items.length) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + currentIndex = (currentIndex + 1) % items.length; + items[currentIndex].focus(); + break; + + case "ArrowUp": + e.preventDefault(); + currentIndex = (currentIndex - 1 + items.length) % items.length; + items[currentIndex].focus(); + break; + + case "Tab": + if (currentIndex >= items.length - 1) { + closeAllLists(); + currentIndex = -1; + } + else { + e.preventDefault(); + currentIndex++; + items[currentIndex].focus(); + } + break; + } + } + function closeAllLists() { suggestionsList.innerHTML = ''; suggestionsList.style.display = "none"; + currentIndex = -1; } autocomplete(searchInput, minLengthAutoComplete); diff --git a/LearningHub.Nhs.WebUI/appsettings.json b/LearningHub.Nhs.WebUI/appsettings.json index c1875cc2b..ab5887246 100644 --- a/LearningHub.Nhs.WebUI/appsettings.json +++ b/LearningHub.Nhs.WebUI/appsettings.json @@ -115,10 +115,11 @@ "MKPlayerLicence": "", "MediaKindStorageConnectionString": "" }, + "StatMandId": 0, "EnableTempDebugging": "false", "LimitScormToAdmin": "false" - }, + }, "LearningHubAuthServiceConfig": { "Authority": "", "ClientId": "", @@ -164,11 +165,13 @@ "ClientId": "", "ClientIdentityKey": "" }, - "FeatureManagement": { - "ContributeAudioVideoResource": true, - "DisplayAudioVideoResource": true, - "EnableMoodle": false - }, + "FeatureManagement": { + "ContributeAudioVideoResource": true, + "DisplayAudioVideoResource": true, + "EnableMoodle": false, + "AzureSearch": false, + "InPlatformReport": false + }, "IpRateLimiting": { "EnableEndpointRateLimiting": true, "StackBlockedRequests": false, diff --git a/LearningHub.Nhs.WebUI/package-lock.json b/LearningHub.Nhs.WebUI/package-lock.json index 17125bf85..1b09c4094 100644 --- a/LearningHub.Nhs.WebUI/package-lock.json +++ b/LearningHub.Nhs.WebUI/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "learninghubnhswebui", "version": "1.0.0", "license": "MIT", "dependencies": { diff --git a/LearningHub.Nhs.WebUI/package.json b/LearningHub.Nhs.WebUI/package.json index 1759001f8..19f91556a 100644 --- a/LearningHub.Nhs.WebUI/package.json +++ b/LearningHub.Nhs.WebUI/package.json @@ -47,7 +47,7 @@ "vue-carousel-3d": "^1.0.1", "vue-clamp": "0.4.1", "vue-click-outside": "1.1.0", - "vue-ctk-date-time-picker": "^2.5.0", + "vue-ctk-date-time-picker": "2.5.0", "vue-router": "^3.6.5", "vue-simple-progress": "^1.1.1", "vue-typeahead": "^2.3.2", diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs new file mode 100644 index 000000000..15f0921ee --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/AzureSearchConfig.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +namespace LearningHub.Nhs.OpenApi.Models.Configuration +{ + /// + /// The Azure AI Search configuration settings. + /// + public class AzureSearchConfig + { + /// + /// Gets or sets the Azure Search service endpoint URL. + /// + public string ServiceEndpoint { get; set; } = null!; + + /// + /// Gets or sets the Azure Search admin API key. + /// + public string AdminApiKey { get; set; } = null!; + + /// + /// Gets or sets the Azure Search query API key. + /// + public string QueryApiKey { get; set; } = null!; + + /// + /// Gets or sets the index name. + /// + public string IndexName { get; set; } = null!; + + /// + /// Gets or sets the default item limit for search results. + /// + public int DefaultItemLimitForSearch { get; set; } = 10; + + /// + /// Gets or sets the description length limit. + /// + public int DescriptionLengthLimit { get; set; } = 3000; + + /// + /// Gets or sets the maximum description length. + /// + public int MaximumDescriptionLength { get; set; } = 150; + + /// + /// Gets or sets the suggester name for auto-complete and suggestions. + /// + public string SuggesterName { get; set; } = null!; + + /// + /// Gets or sets the suggester size for auto-complete and suggestions. + /// + public int ConceptsSuggesterSize { get; set; } = 5; + + /// + /// Gets or sets the resource collection size (catalogue, course and resources) for auto-complete and suggestions. + /// + public int ResourceCollectionSuggesterSize { get; set; } = 5; + + /// + /// Gets or sets the search query type (semantic, full, or simple). + /// + public string SearchQueryType { get; set; } = "Semantic"; + + /// + /// Gets or sets the semantic result buffer size for post-processing sorts. + /// When sorting is applied to semantic search results, this many results are retrieved + /// before applying the sort and pagination. Default is 50. + /// + public int SemanticResultBufferSize { get; set; } = 55; + + /// + /// Gets or sets the scoring profile name used for boosting search results. + /// Default is "boostExactTitle". + /// + public string ScoringProfile { get; set; } = "boostExactTitle"; + + /// + /// Gets or sets the semantic configuration name for semantic search. + /// Default is "default". + /// + public string SemanticConfigurationName { get; set; } = "default"; + + /// + /// Gets or sets the facet fields to include in search results. + /// Default is ["resource_type", "resource_collection", "provider_ids"]. + /// + public List FacetFields { get; set; } = new List { "resource_type", "resource_collection", "provider_ids", "resource_access_level" }; + + /// + /// Gets or sets the field name for the deleted filter. + /// Default is "is_deleted". + /// + public string DeletedFilterField { get; set; } = "is_deleted"; + + /// + /// Gets or sets the value for the deleted filter. + /// Default is "false". + /// + public string DeletedFilterValue { get; set; } = "false"; + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs new file mode 100644 index 000000000..3483a2d60 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/DatabricksConfig.cs @@ -0,0 +1,60 @@ +namespace LearningHub.Nhs.OpenApi.Models.Configuration +{ + /// + /// DatabricksConfig + /// + public class DatabricksConfig + { + /// + /// Gets or sets the ResourceId for the databricks instance. + /// + public string ResourceId { get; set; } = null!; + + /// + /// Gets or sets the base url for the databricks instance. + /// + public string InstanceUrl { get; set; } = null!; + + /// + /// Gets or sets the warehouse id for databricks. + /// + public string WarehouseId { get; set; } = null!; + + /// + /// Gets or sets the job id for databricks. + /// + public string JobId { get; set; } = null!; + + /// + /// Gets or sets the tenant Id of the service pricncipl. + /// + public string TenantId { get; set; } = null!; + + /// + /// Gets or sets the client Id of the service pricncipl. + /// + public string ClientId { get; set; } = null!; + + /// + /// Gets or sets the client scret of the service pricncipl. + /// + public string ClientSecret { get; set; } = null!; + + /// + /// Gets or sets the endpoint to check user permission. + /// + public string UserPermissionEndpoint { get; set; } = null!; + + + /// + /// Gets or sets the endpoint for course completion record. + /// + public string CourseCompletionEndpoint { get; set; } = null!; + + /// + /// Gets or sets the token. + /// + public string Token { get; set; } = null!; + + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs new file mode 100644 index 000000000..4c3f3b8b4 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/FeatureFlagsConfig.cs @@ -0,0 +1,14 @@ +namespace LearningHub.Nhs.OpenApi.Models.Configuration +{ + /// + /// Defines the . + /// + public class FeatureFlagsConfig + { + + /// + /// The InPlatformReport. + /// + public string InPlatformReport { get; set; } = null!; + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs index 81b91aa65..9815f4828 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/LearningHubConfig.cs @@ -67,6 +67,11 @@ public class LearningHubConfig ///
    public string ContentManagementQueueName { get; set; } = null!; + /// + /// Gets or sets . + /// + public string DatabricksProcessingQueueName { get; set; } = null!; + /// /// Gets or sets . /// @@ -142,6 +147,15 @@ public class LearningHubConfig /// public string BrowseCataloguesUrl { get; set; } = null!; + /// + /// Gets or sets . + /// + public string ReportUrl { get; set; } = null!; + + /// + /// Gets or sets the StatMandId. + /// + public int StatMandId { get; set; } /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs index 7b9b7b5ba..a3ea9a1bf 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/Configuration/NotificationSetting.cs @@ -55,5 +55,15 @@ public class NotificationSetting /// Gets or sets the ResourceContributeAccess. /// public string ResourceContributeAccess { get; set; } = null!; + + /// + /// Gets or sets the report title notification content. + /// + public string ReportTitle { get; set; } = null!; + + /// + /// Gets or sets the report notification content. + /// + public string Report { get; set; } = null!; } } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj index cecf1b22a..829bba059 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/LearningHub.Nhs.OpenApi.Models.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -16,7 +16,8 @@ - + + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs new file mode 100644 index 000000000..d10ab70cb --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/CacheableFacetResult.cs @@ -0,0 +1,19 @@ +namespace LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch +{ + /// + /// A cacheable representation of Azure Search FacetResult. + /// This DTO is used to cache facet data without serialization issues with Azure SDK types. + /// + public class CacheableFacetResult + { + /// + /// Gets or sets the facet value. + /// + public object? Value { get; set; } + + /// + /// Gets or sets the count of documents matching this facet value. + /// + public long Count { get; set; } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs new file mode 100644 index 000000000..37b1b52d2 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ServiceModels/AzureSearch/SearchDocument.cs @@ -0,0 +1,175 @@ +namespace LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch +{ + using System; + using System.Collections.Generic; + using System.Text.Json.Serialization; + using System.Text.RegularExpressions; + using Azure.Search.Documents.Indexes; + + /// + /// Represents a search document for Azure AI Search integration. + /// + public class SearchDocument + { + private string _description = string.Empty; + + /// + /// Gets or sets the unique identifier. + /// + [JsonPropertyName("id")] + public string PrefixedId { get; set; } = string.Empty; + + /// + /// Gets the numeric ID extracted from the PrefixedId. + /// + [JsonIgnore] + public string Id + { + get + { + if (string.IsNullOrWhiteSpace(PrefixedId)) + return "0"; + + var parts = PrefixedId.Split('-'); + if (parts.Length != 2) + return "0"; + + return int.TryParse(parts[1], out int id) ? id.ToString() : "0"; + } + } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the normalised title. + /// + [JsonPropertyName("normalised_title")] + public string NormalisedTitle { get; set; } = string.Empty; + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => _description = StripParagraphTags(value); + } + + /// + /// Gets or sets the resource type. + /// + [JsonPropertyName("resource_collection")] + public string ResourceCollection { get; set; } = string.Empty; + + /// + /// gets or sets the catalogue identifier. + /// + [JsonPropertyName("catalogue_id")] + public string CatalogueId { get; set; } = string.Empty; + + [JsonPropertyName("resource_reference_id")] + public string ResourceReferenceId { get; set; } = string.Empty; + + /// + /// Gets or sets the manual tag JSON. + /// + [JsonPropertyName("manual_tag")] + public string ManualTagJson { get; set; } = string.Empty; + + /// + /// Gets or sets the manual tags. + /// + [JsonPropertyName("manualTags")] + public List ManualTags { get; set; } = new List(); + + /// + /// Gets or sets the content type. + /// + [JsonPropertyName("resource_type")] + public string? ResourceType { get; set; } = string.Empty; + + /// + /// Gets or sets the resource access level. + /// + [JsonPropertyName("resource_access_level")] + public string? ResourceAccessLevel { get; set; } + + /// + /// Gets or sets the date authored. + /// + [JsonPropertyName("date_authored")] + public DateTime? DateAuthored { get; set; } + + /// + /// Gets or sets the rating. + /// + [JsonPropertyName("rating")] + public double? Rating { get; set; } + + /// + /// Gets or sets the provider IDs. + /// + [JsonPropertyName("provider_ids")] + public string ProviderIds { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this is statutory mandatory. + /// + [JsonPropertyName("statutory_mandatory")] + public bool? StatutoryMandatory { get; set; } + + /// + /// Gets or sets the author. + /// + [JsonPropertyName("author")] + public string Author { get; set; } = string.Empty; + + /// + /// Gets or sets the url. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + // + /// Gets or sets the url. + /// + [JsonPropertyName("is_deleted")] + public bool IsDeleted { get; set; } + + /// + /// Strips paragraph tags from input string. + /// + /// The input string. + /// The cleaned string. + private static string StripParagraphTags(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + return Regex.Replace(input, @"<\/?p[^>]*>", string.Empty, RegexOptions.IgnoreCase); + } + + /// + /// Parses the ManualTagJson into ManualTags list. + /// + public void ParseManualTags() + { + if (!string.IsNullOrEmpty(ManualTagJson)) + { + try + { + ManualTags = System.Text.Json.JsonSerializer.Deserialize>(ManualTagJson) ?? new List(); + } + catch + { + ManualTags = new List(); + } + } + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs new file mode 100644 index 000000000..5ce62c6eb --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/DatabricksNotification.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; + +namespace LearningHub.Nhs.OpenApi.Models.ViewModels +{ + /// + /// DatabricksNotification + /// + public class DatabricksNotification + { + /// + /// Gets or sets . + /// + [JsonProperty("event_type")] + public string EventType { get; set; } + + /// + /// Gets or sets . + /// + [JsonProperty("run")] + public RunInfo Run { get; set; } + + /// + /// RunInfo + /// + public class RunInfo + { + /// + /// Gets or sets . + /// + [JsonProperty("run_id")] + public long RunId { get; set; } + + /// + /// Gets or sets . + /// + [JsonProperty("parent_run_id")] + public long ParentRunId { get; set; } + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs index 912d99cae..c7ccc7862 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Models/ViewModels/NavigationModel.cs @@ -75,6 +75,11 @@ public class NavigationModel /// public bool ShowBrowseCatalogues { get; set; } + /// + /// Gets or sets a value indicating whether to show reports. + /// + public bool ShowReports { get; set; } + /// /// Gets or sets a value indicating whether to show home. /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj index a52fd6bf1..cbac27a64 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/LearningHub.Nhs.OpenApi.Repositories.Interface.csproj @@ -17,7 +17,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs new file mode 100644 index 000000000..b2aabcb95 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories.Interface/Repositories/IReportHistoryRepository.cs @@ -0,0 +1,28 @@ +namespace LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using LearningHub.Nhs.Models.Entities; + using LearningHub.Nhs.Models.Entities.DatabricksReport; + + /// + /// The ProviderRepository interface. + /// + public interface IReportHistoryRepository : IGenericRepository + { + /// + /// The get by id async. + /// + /// The id. + /// The . + Task GetByIdAsync(int id); + + /// + /// The get by user id async. + /// + /// The userId. + /// The . + IQueryable GetByUserIdAsync(int userId); + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs index 1b8fe8783..80ec18a5c 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/LearningHubDbContext.cs @@ -6,6 +6,7 @@ namespace LearningHub.Nhs.OpenApi.Repositories.EntityFramework using LearningHub.Nhs.Models.Entities; using LearningHub.Nhs.Models.Entities.Activity; using LearningHub.Nhs.Models.Entities.Content; + using LearningHub.Nhs.Models.Entities.DatabricksReport; using LearningHub.Nhs.Models.Entities.External; using LearningHub.Nhs.Models.Entities.Hierarchy; using LearningHub.Nhs.Models.Entities.Messaging; @@ -750,6 +751,11 @@ public LearningHubDbContextOptions Options /// public virtual DbSet UserProvider { get; set; } + /// + /// Gets or sets Report History. + /// + public virtual DbSet ReportHistory { get; set; } + /// /// Gets or sets Resource Version Provider. /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs index a571d177a..197f95dcb 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/EntityFramework/ServiceMappings.cs @@ -184,6 +184,7 @@ public static void AddLearningHubMappings(this IServiceCollection services, ICon services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // External services.AddSingleton(); diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj index 46722121a..6a727dc60 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/LearningHub.Nhs.OpenApi.Repositories.csproj @@ -24,7 +24,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs new file mode 100644 index 000000000..3e398744c --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Map/ReportHistoryMap.cs @@ -0,0 +1,23 @@ +using LearningHub.Nhs.Models.Entities.DatabricksReport; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearningHub.Nhs.OpenApi.Repositories.Map +{ + /// + /// The ReportHistory Map. + /// + public class ReportHistoryMap : BaseEntityMap + { + /// + /// The internal map. + /// + /// + /// The model builder. + /// + protected override void InternalMap(EntityTypeBuilder modelBuilder) + { + modelBuilder.ToTable("ReportHistory", "Reports"); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs new file mode 100644 index 000000000..32bfd0458 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Repositories/ReportHistoryRepository.cs @@ -0,0 +1,57 @@ +namespace LearningHub.Nhs.OpenApi.Repositories.Repositories +{ + using System.Collections.Generic; + using System.Data; + using System.Linq; + using System.Threading.Tasks; + using LearningHub.Nhs.Models.Entities; + using LearningHub.Nhs.Models.Entities.DatabricksReport; + using LearningHub.Nhs.Models.Entities.Hierarchy; + using LearningHub.Nhs.Models.Entities.Resource; + using LearningHub.Nhs.OpenApi.Repositories.EntityFramework; + using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories; + using Microsoft.EntityFrameworkCore; + + /// + /// The provider repository. + /// + public class ReportHistoryRepository : GenericRepository, IReportHistoryRepository + { + /// + /// Initializes a new instance of the class. + /// + /// The db context. + /// The Timezone offset manager. + public ReportHistoryRepository(LearningHubDbContext dbContext, ITimezoneOffsetManager tzOffsetManager) + : base(dbContext, tzOffsetManager) + { + } + + /// + public async Task GetByIdAsync(int id) + { + return await DbContext.ReportHistory.AsNoTracking().FirstOrDefaultAsync(n => n.Id == id && !n.Deleted); + } + + /// + public IQueryable GetByUserIdAsync(int userId) + { + return DbContext.ReportHistory.AsNoTracking().Where(n => n.CreateUserId == userId && !n.Deleted); + } + + /// + /// The get by user id async. + /// + /// The user id. + /// The . + public IQueryable GetProvidersByUserIdAsync(int userId) + { + return DbContext.Set() + .Include(up => up.Provider) + .Where(up => up.UserId == userId && !up.Deleted).AsNoTracking() + .Select(up => up.Provider); + } + + + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs index 767d35d19..37d56292d 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Repositories/Startup.cs @@ -56,6 +56,7 @@ private static void AddRepositoryImplementations(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs new file mode 100644 index 000000000..f4226cd8f --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/HttpClients/IDatabricksApiHttpClient.cs @@ -0,0 +1,26 @@ +namespace LearningHub.Nhs.OpenApi.Services.Interface.HttpClients +{ + using System; + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// The Bookmark Http Client interface. + /// + public interface IDatabricksApiHttpClient : IDisposable + { + /// + /// GETs data from Databricks API. + /// + /// The URL to make a get call to. + /// Optional authorization header. + /// A representing the result of the asynchronous operation. + Task GetData(string requestUrl, string? authHeader); + + /// + /// The Get Client method. + /// + /// The . + HttpClient GetClient(); + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj index f6e92a8ce..175ac7fb3 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/LearningHub.Nhs.OpenApi.Services.Interface.csproj @@ -17,7 +17,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs new file mode 100644 index 000000000..10e0f358c --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/IDatabricksService.cs @@ -0,0 +1,77 @@ +using LearningHub.Nhs.Models.Common; +using LearningHub.Nhs.Models.Databricks; +using LearningHub.Nhs.OpenApi.Models.ViewModels; +using System.Threading.Tasks; + +namespace LearningHub.Nhs.OpenApi.Services.Interface.Services +{ + /// + /// IDatabricks service + /// + public interface IDatabricksService + { + /// + /// IsUserReporter. + /// + /// The userId. + /// A representing the result of the asynchronous operation. + Task IsUserReporter(int userId); + + /// + /// CourseCompletionReport. + /// + /// The userId. + /// The model. + /// A representing the result of the asynchronous operation. + Task CourseCompletionReport(int userId, DatabricksRequestModel model); + + /// + /// CourseCompletionReport. + /// + /// The userId. + /// The page. + /// The pageSize. + /// A representing the result of the asynchronous operation. + Task> GetPagedReportHistory(int userId, int page, int pageSize); + + /// + /// GetPagedReportHistoryById. + /// + /// The userId. + /// The reportHistoryId. + /// A representing the result of the asynchronous operation. + Task GetPagedReportHistoryById(int userId, int reportHistoryId); + + /// + /// QueueReportDownload + /// + /// + /// + /// + Task QueueReportDownload(int userId, int reportHistoryId); + + /// + /// DownloadReport + /// + /// + /// + /// + Task DownloadReport(int userId, int reportHistoryId); + + /// + /// DatabricksJobUpdate. + /// + /// + /// + /// + Task DatabricksJobUpdate(int userId, DatabricksNotification databricksNotification); + + /// + /// DatabricksJobUpdate. + /// + /// userId. + /// databricksUpdateRequest. + /// + Task UpdateDatabricksReport(int userId, DatabricksUpdateRequest databricksUpdateRequest); + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs index 0a0af21eb..217a77228 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/INotificationService.cs @@ -69,5 +69,14 @@ public interface INotificationService /// Error message. /// The . Task CreatePublishFailedNotificationAsync(int userId, string resourceTitle, string errorMessage = ""); + + /// + /// Creates report processed notification. + /// + /// The current user id. + /// Report Name. + /// Report Content. + /// The . + Task CreateReportNotificationAsync(int userId, string reportName, string reportContent); } } \ No newline at end of file diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs index 194264b15..2a238edf5 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/ISearchService.cs @@ -1,5 +1,6 @@ namespace LearningHub.Nhs.OpenApi.Services.Interface.Services { + using System.Threading; using System.Threading.Tasks; using LearningHub.Nhs.Models.Search; using LearningHub.Nhs.Models.Search.SearchClick; @@ -17,8 +18,9 @@ public interface ISearchService /// /// The catalog search request model. /// The user id. + /// Cancellation token. /// The . - Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId); + Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId, CancellationToken cancellationToken = default); /// /// The Get Catalogue Search Result Async method. @@ -29,10 +31,11 @@ public interface ISearchService /// /// The user id. /// + /// Cancellation token. /// /// The . /// - Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId); + Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId, CancellationToken cancellationToken = default); /// /// The create resource search action async. @@ -152,8 +155,9 @@ public interface ISearchService /// The Get Auto suggestion Results Async method. /// /// The term. + /// Cancellation token. /// The . - Task GetAutoSuggestionResultsAsync(string term); + Task GetAutoSuggestionResultsAsync(string term, CancellationToken cancellationToken = default); /// /// The Send AutoSuggestion Event Async. diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs index 794a55ae4..3e4a3f47f 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailSenderService.cs @@ -54,5 +54,13 @@ public interface IEmailSenderService /// The isUserRoleUpgrade. /// The task. Task SendEmailVerifiedEmail(int userId, SendEmailModel model, bool isUserRoleUpgrade); + + /// + /// Sends report generation completion email to user. + /// + /// The userId sending the email. + /// The model. + /// The task. + Task SendReportProcessedEmail(int userId, SendEmailModel model); } } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs index 7e75ae57b..9f676fcda 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services.Interface/Services/Messaging/IEmailTemplateService.cs @@ -56,5 +56,13 @@ public interface IEmailTemplateService /// The isUserRoleUpgrade. /// The subject and body. EmailDetails GetEmailVerificationEmail(SendEmailModel emailModel, bool isUserRoleUpgrade); + + + /// + /// The GetCatalogueAccessRequestFailure. + /// + /// The email model. + /// The subject and body. + EmailDetails GetReportProcessed(SendEmailModel emailModel); } } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs new file mode 100644 index 000000000..ad0faee6c --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/AzureSearchClientFactory.cs @@ -0,0 +1,47 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers +{ + using System; + using Azure; + using Azure.Search.Documents; + using LearningHub.Nhs.OpenApi.Models.Configuration; + + /// + /// Factory for creating Azure Search clients. + /// + public static class AzureSearchClientFactory + { + /// + /// Creates a SearchClient for querying the Azure Search index. + /// + /// The Azure Search configuration. + /// A configured SearchClient instance. + public static SearchClient CreateQueryClient(AzureSearchConfig config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + var credential = new AzureKeyCredential(config.QueryApiKey); + return new SearchClient( + new Uri(config.ServiceEndpoint), + config.IndexName, + credential); + } + + /// + /// Creates a SearchClient with admin credentials for indexing operations. + /// + /// The Azure Search configuration. + /// A configured SearchClient instance with admin credentials. + public static SearchClient CreateAdminClient(AzureSearchConfig config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + var adminCredential = new AzureKeyCredential(config.AdminApiKey); + return new SearchClient( + new Uri(config.ServiceEndpoint), + config.IndexName, + adminCredential); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs new file mode 100644 index 000000000..066c6aae3 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/AzureSearchFacetHelper.cs @@ -0,0 +1,172 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search +{ + using Azure.Search.Documents.Models; + using LearningHub.Nhs.Models.Search; + using LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + /// + /// Helper class for Azure Search facet operations including conversion and caching. + /// + public static class AzureSearchFacetHelper + { + private static readonly ConstructorInfo FacetResultCtor = + typeof(FacetResult) + .GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(c => + { + var parameters = c.GetParameters(); + return parameters.Length == 2 && + parameters[0].ParameterType == typeof(long?) && + typeof(IReadOnlyDictionary).IsAssignableFrom(parameters[1].ParameterType); + }); + + /// + /// Converts Azure Search FacetResult dictionary to cacheable DTO. + /// + /// The facets from Azure Search. + /// A cacheable dictionary of facets. + public static IDictionary> ConvertToCacheable( + IDictionary>? facets) + { + if (facets == null) + return new Dictionary>(); + + return facets.ToDictionary( + kvp => kvp.Key, + kvp => (IList)kvp.Value + .Select(f => new CacheableFacetResult + { + Value = f.Value, + Count = f.Count ?? 0 + }) + .ToList()); + } + + /// + /// Converts cacheable DTO back to Azure Search FacetResult dictionary. + /// + /// The cached facets. + /// A dictionary of FacetResult. + public static IDictionary> ConvertFromCacheable( + IDictionary>? cacheableFacets) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + if (cacheableFacets == null || FacetResultCtor == null) + return result; + + foreach (var kvp in cacheableFacets) + { + var facetResults = new List(); + + foreach (var cf in kvp.Value) + { + // Build the additionalProperties dictionary just like Azure returns in JSON + var additionalProps = new Dictionary + { + ["value"] = cf.Value ?? string.Empty + }; + + // Use the internal constructor via reflection + var facet = (FacetResult)FacetResultCtor.Invoke(new object?[] { cf.Count, additionalProps }); + + facetResults.Add(facet); + } + + result[kvp.Key] = facetResults; + } + + return result; + } + + + /// + /// Merges filtered and unfiltered facets to maintain visibility of all filter options. + /// + /// Facets from the filtered search. + /// Facets from the unfiltered search. + /// The currently applied filters. + /// An array of merged facets. + public static Facet[] MergeFacets( + IDictionary> filteredFacets, + IDictionary> unfilteredFacets, + Dictionary> appliedFilters) + { + if (unfilteredFacets == null || !unfilteredFacets.Any()) + { + return Array.Empty(); + } + + var facets = new Facet[unfilteredFacets.Count]; + var index = 0; + + foreach (var facetGroup in unfilteredFacets) + { + var facetKey = facetGroup.Key; + var hasAppliedFilter = appliedFilters?.ContainsKey(facetKey) == true; + var appliedValues = hasAppliedFilter ? appliedFilters[facetKey] : new List(); + + // Get filtered facet values if available + var filteredFacetValues = filteredFacets?.ContainsKey(facetKey) == true + ? filteredFacets[facetKey].ToDictionary(f => f.Value?.ToString()?.ToLower() ?? "", f => (int)f.Count) + : new Dictionary(); + + var filters = facetGroup.Value.Select(f => + { + var displayName = f.Value?.ToString()?.ToLower() ?? ""; + var isSelected = appliedValues.Any(av => av.Equals(f.Value?.ToString(), StringComparison.OrdinalIgnoreCase)); + + // Default to the unfiltered count. This is used when: + // - no filtered search has been performed, OR + // - the filter value is selected (keep count so the option remains visible for deselection), OR + // - this facet group itself has an applied filter (multi-select pattern: a group's own + // counts are not narrowed down by its own selections so the user can still pick other values). + // Only update counts using the filtered results when a filter from a DIFFERENT group + // is driving the narrowing of this group. + var count = (int)f.Count; + if (!isSelected && filteredFacets != null && !hasAppliedFilter) + { + count = filteredFacetValues.TryGetValue(displayName, out var filteredCount) ? filteredCount : 0; + } + + return new Filter + { + DisplayName = displayName, + Count = count, + Selected = isSelected + }; + }).ToList(); + + // Ensure all selected filter values remain visible even if absent from the unfiltered + // baseline (e.g. a resource selected under a resource-level filter that yields no results). + foreach (var selectedValue in appliedValues) + { + var alreadyPresent = filters.Any(f => + f.DisplayName.Equals(selectedValue, StringComparison.OrdinalIgnoreCase)); + + if (!alreadyPresent) + { + filters.Add(new Filter + { + DisplayName = selectedValue.ToLower(), + Count = 0, + Selected = true, + }); + } + } + + facets[index++] = new Facet + { + Id = facetKey, + Filters = filters.ToArray() + }; + } + + return facets; + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs new file mode 100644 index 000000000..529cbbb18 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/LuceneQueryBuilder.cs @@ -0,0 +1,44 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search +{ + using System; + using System.Linq; + using System.Text.RegularExpressions; + + /// + /// Helper class for building Lucene queries for Azure Search. + /// + public static class LuceneQueryBuilder + { + /// + /// Builds a Lucene query from the search text. + /// + /// The search text. + /// The Lucene query string. + public static string BuildLuceneQuery(string? searchText) + { + if (string.IsNullOrWhiteSpace(searchText)) + return "*"; + + var tokens = searchText + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(EscapeLuceneSpecialCharacters) + .Where(t => !string.IsNullOrWhiteSpace(t)); + + return string.Join(" AND ", tokens); + } + + /// + /// Escapes Lucene special characters in the input string. + /// + /// The input string. + /// The escaped string. + public static string EscapeLuceneSpecialCharacters(string? input) + { + if (string.IsNullOrEmpty(input)) + return input ?? string.Empty; + + var pattern = @"([+\-!(){}[\]^\""?~*:\\/])"; + return Regex.Replace(input, pattern, "\\$1"); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs new file mode 100644 index 000000000..7c16bc461 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchFilterBuilder.cs @@ -0,0 +1,196 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Web; + + /// + /// Helper class for building Azure Search filter expressions. + /// + public static class SearchFilterBuilder + { + + public static Dictionary> CombineAndNormaliseFilters(string requestTypeFilterText, string? providerFilterText, string? resourceAccessLevelFilterText) + { + var filters = new Dictionary> + { + // { "resource_collection", new List { "Resource" } } + }; + + // Parse and merge additional filters from query string + var requestTypeFilters = ParseQueryStringFilters(requestTypeFilterText); + var providerFilters = ParseQueryStringFilters(providerFilterText); + var resourceAccessLevelFilters = ParseQueryStringFilters(resourceAccessLevelFilterText); + + // Merge filters from both sources + MergeFilterDictionary(filters, requestTypeFilters); + MergeFilterDictionary(filters, providerFilters); + MergeFilterDictionary(filters, resourceAccessLevelFilters); + + //NormaliseFilters(filters); + + return filters; + } + + private static void MergeFilterDictionary(Dictionary> target, Dictionary> source) + { + foreach (var kvp in source) + { + if (!target.ContainsKey(kvp.Key)) + target[kvp.Key] = new List(); + + target[kvp.Key].AddRange(kvp.Value); + } + } + + /// + /// Builds a filter expression from a dictionary of filters. + /// + /// The filters to apply. + /// The filter expression string. + /// + /// Build an OData filter that supports multi-select values. + /// Pass a dictionary where key = field name, value = list of selected values. + /// If `collectionFields` contains a field name, that field will be treated as a collection and use any(...). + /// + public static string BuildFilterExpression( + Dictionary>? filters, + ISet? collectionFields = null) + { + if (filters == null || !filters.Any()) + return string.Empty; + + collectionFields ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + // Handle spacing, NBSP, escaping quotes + string Normalize(string v) + { + if (v == null) return string.Empty; + + // Replace NBSP + v = v.Replace('\u00A0', ' ').Trim(); + + // Collapse multiple spaces + v = System.Text.RegularExpressions.Regex.Replace(v, @"\s+", " "); + + // Escape single quotes for OData + v = v.Replace("'", "''"); + + return v; + } + + var expressions = new List(); + + foreach (var kvp in filters) + { + var field = kvp.Key; + var values = kvp.Value?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList(); + + if (values == null || values.Count == 0) + continue; + + // Normalize all values + var normalizedValues = values.Select(Normalize).Distinct().ToList(); + + // Single value → use eq + if (normalizedValues.Count == 1) + { + var v = normalizedValues[0]; + + if (collectionFields.Contains(field)) + { + expressions.Add($"{field}/any(t: t eq '{v}')"); + } + else if (v.Contains("false")) + { + expressions.Add($"{field} eq {v}"); + } + else + { + expressions.Add($"{field} eq '{v}'"); + } + + continue; + } + + // Multiple values → use OR conditions (ALWAYS works) + if (collectionFields.Contains(field)) + { + // collection field (array) → OR any(...) conditions + var ors = normalizedValues + .Select(v => $"{field}/any(t: t eq '{v}')"); + + expressions.Add("(" + string.Join(" or ", ors) + ")"); + } + else + { + // single string field → OR eq conditions + var ors = normalizedValues + .Select(v => $"{field} eq '{v}'"); + + expressions.Add("(" + string.Join(" or ", ors) + ")"); + } + } + + return expressions.Count > 0 + ? string.Join(" and ", expressions) + : string.Empty; + } + + + + /// + /// Parses filter parameters from a query string. + /// + /// The query string to parse. + /// A dictionary of filter names and their values. + public static Dictionary> ParseQueryStringFilters(string? queryString) + { + var filters = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(queryString)) + return filters; + + // Remove '?' if present + if (queryString.StartsWith("?")) + queryString = queryString.Substring(1); + + // Parse using HttpUtility + var parsed = HttpUtility.ParseQueryString(queryString); + + foreach (string? key in parsed.AllKeys) + { + if (key == null) + continue; // skip null keys + + var values = parsed.GetValues(key); + if (values != null) + { + // Add to dictionary (support multiple values per key) + if (!filters.ContainsKey(key)) + filters[key] = new List(); + + filters[key].AddRange(values); + } + } + + return filters; + } + + /// + /// Normalizes resource type and resource collection filter values by capitalizing the first letter. + /// + /// The filters dictionary to normalize. + public static void NormaliseFilters(Dictionary>? filters) + { + if (filters == null || !filters.ContainsKey("resource_type")) + return; + + filters["resource_type"] = filters["resource_type"] + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => char.ToUpper(v[0]) + v.Substring(1)) + .ToList(); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs new file mode 100644 index 000000000..40686cd46 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/Search/SearchOptionsBuilder.cs @@ -0,0 +1,246 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers.Search +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Azure.Search.Documents; + using Azure.Search.Documents.Models; + + /// + /// Helper class for building Azure Search options. + /// + public static class SearchOptionsBuilder + { + /// + /// Determines if a sort direction is descending. + /// + /// The sort direction string. + /// True if descending, false otherwise. + public static bool IsDescendingSort(string? sortDirection) + { + return sortDirection != null && + (sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase) || + sortDirection.Equals("descending", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Maps a UI sort column name to the corresponding Azure Search field name. + /// + /// The UI sort column name. + /// The Azure Search field name, or null if no mapping exists. + public static string? MapSortColumnToSearchField(string? uiSortColumn) + { + if (string.IsNullOrWhiteSpace(uiSortColumn)) + return null; + + return uiSortColumn.Trim().ToLowerInvariant() switch + { + "avgrating" => "rating", + "rating" => "rating", + "authored_date" => "date_authored", + "authoreddate" => "date_authored", + "authoredDate" => "date_authored", + "title" => "normalised_title", + "atoz" => "normalised_title", + "alphabetical" => "normalised_title", + "ztoa" => "normalised_title", + _ => null + }; + } + + /// + /// Maps a UI sort column name to the corresponding Document property name for in-memory sorting. + /// + /// The UI sort column name. + /// The Document property name. + public static string? MapSortColumnToDocumentProperty(string? uiSortColumn) + { + if (string.IsNullOrWhiteSpace(uiSortColumn)) + return null; + + return uiSortColumn.Trim().ToLowerInvariant() switch + { + "avgrating" => "rating", + "rating" => "rating", + "authored_date" => "authored_date", + "authoreddate" => "authored_date", + "authoredDate" => "authored_date", + "title" => "title", + "atoz" => "title", + "alphabetical" => "title", + "ztoa" => "title", + _ => "title" // Default to title + }; + } + + /// + /// Applies post-processing sort to a list of documents. + /// Used when semantic search is active with non-relevance sorting. + /// + /// The documents to sort. + /// The column to sort by (title, rating, authored_date). + /// The sort direction (ascending/descending). + /// The sorted list of documents. + public static List ApplyPostProcessingSort( + List documents, + string? sortColumn, + string? sortDirection) + { + if (documents == null || documents.Count == 0) + { + return documents; + } + + bool isDescending = IsDescendingSort(sortDirection); + string? mappedColumn = MapSortColumnToDocumentProperty(sortColumn); + + IOrderedEnumerable sortedDocuments = mappedColumn?.ToLowerInvariant() switch + { + "title" => isDescending + ? documents.OrderByDescending(d => d.Title, StringComparer.OrdinalIgnoreCase) + : documents.OrderBy(d => d.Title, StringComparer.OrdinalIgnoreCase), + "rating" => isDescending + ? documents.OrderByDescending(d => d.Rating) + : documents.OrderBy(d => d.Rating), + "authored_date" => isDescending + ? documents.OrderByDescending(d => + DateTime.TryParse(d.AuthoredDate, out var dt) ? dt : DateTime.MinValue) + : documents.OrderBy(d => + DateTime.TryParse(d.AuthoredDate, out var dt) ? dt : DateTime.MinValue), + _ => isDescending + ? documents.OrderByDescending(d => d.Title, StringComparer.OrdinalIgnoreCase) + : documents.OrderBy(d => d.Title, StringComparer.OrdinalIgnoreCase) + }; + + return sortedDocuments.ToList(); + } + + /// + /// Builds search options for Azure Search queries. + /// + /// The type of search query. + /// The number of results to skip. + /// The number of results to return. + /// The filters to apply. + /// The sort to apply. + /// Whether to include facets. + /// The Azure Search configuration. + /// The configured search options. + public static SearchOptions BuildSearchOptions( + SearchQueryType searchQueryType, + int offset, + int pageSize, + Dictionary>? filters, + Dictionary? sortBy, + bool includeFacets, + Models.Configuration.AzureSearchConfig config) + { + var searchOptions = new SearchOptions + { + Skip = offset, + Size = pageSize, + IncludeTotalCount = true, + ScoringProfile = config.ScoringProfile + }; + + string sortByFinal = GetSortOption(sortBy); + + // Configure query type + if (searchQueryType == SearchQueryType.Semantic) + { + searchOptions.QueryType = SearchQueryType.Semantic; + searchOptions.SemanticSearch = new SemanticSearchOptions + { + SemanticConfigurationName = config.SemanticConfigurationName + }; + } + else if (searchQueryType == SearchQueryType.Simple) + { + searchOptions.QueryType = SearchQueryType.Simple; + searchOptions.SearchMode = SearchMode.Any; + searchOptions.OrderBy.Add(sortByFinal); + } + else + { + searchOptions.QueryType = SearchQueryType.Full; + searchOptions.OrderBy.Add(sortByFinal); + } + + // Add facets + if (includeFacets && config.FacetFields != null) + { + foreach (var facet in config.FacetFields) + { + var facetValue = facet.Contains("count:") + ? facet: $"{facet},count:20"; + + searchOptions.Facets.Add(facetValue); + } + } + + // Add deleted filter + Dictionary> deleteFilter = new Dictionary> + { + { config.DeletedFilterField, new List { config.DeletedFilterValue } } + }; + filters = filters == null ? deleteFilter : filters.Concat(deleteFilter).ToDictionary(k => k.Key, v => v.Value); + + // Apply filters + if (filters?.Any() == true) + { + searchOptions.Filter = SearchFilterBuilder.BuildFilterExpression(filters); + } + + return searchOptions; + } + + private static string GetSortOption(Dictionary? sortBy) + { + // If null or empty → Azure Search will default to relevance + if (sortBy == null || sortBy.Count == 0) + return string.Empty; + + // Extract key/value (only first pair used) + string? uiSortKey = sortBy.Keys.FirstOrDefault(); + string? directionInput = sortBy.Values.FirstOrDefault(); + + // Handle empty key → no sorting + if (string.IsNullOrWhiteSpace(uiSortKey)) + return string.Empty; + + // Determine direction using shared helper + string sortDirection = IsDescendingSort(directionInput) ? "desc" : "asc"; + + // Map UI values to search fields using shared helper + string? sortColumn = MapSortColumnToSearchField(uiSortKey); + + // No valid mapping → fall back to relevance + if (string.IsNullOrWhiteSpace(sortColumn)) + return string.Empty; + + return $"{sortColumn} {sortDirection}"; + } + + /// + /// Parses the search query type from configuration string. + /// Parsing is case-insensitive. If the value is null, empty, or invalid, defaults to Semantic. + /// + /// The search query type string (semantic, full, or simple). + /// The parsed SearchQueryType enum value. + public static SearchQueryType ParseSearchQueryType(string searchQueryTypeString) + { + if (string.IsNullOrWhiteSpace(searchQueryTypeString)) + { + return SearchQueryType.Semantic; + } + + if (Enum.TryParse(searchQueryTypeString, ignoreCase: true, out var queryType) && + (queryType == SearchQueryType.Semantic || queryType == SearchQueryType.Full || queryType == SearchQueryType.Simple)) + { + return queryType; + } + + return SearchQueryType.Semantic; + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs new file mode 100644 index 000000000..415211a32 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Helpers/TextCasingHelper.cs @@ -0,0 +1,24 @@ +namespace LearningHub.Nhs.OpenApi.Services.Helpers +{ + /// + /// TextCasingHelper. + /// + public class TextCasingHelper + { + /// + /// Returns sentence case of input string. + /// + /// input. + /// A sentence case string corresponding to the input string. + public static string ConvertToSentenceCase(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + input = input.ToLower(); + return char.ToUpper(input[0]) + input.Substring(1); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs new file mode 100644 index 000000000..b5452240a --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/HttpClients/DatabricksApiHttpClient.cs @@ -0,0 +1,63 @@ +namespace LearningHub.Nhs.OpenApi.Services.HttpClients +{ + using System; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading.Tasks; + using IdentityModel.Client; + using LearningHub.Nhs.OpenApi.Models.Configuration; + using LearningHub.Nhs.OpenApi.Services.Interface.HttpClients; + using Microsoft.Extensions.Options; + using Microsoft.IdentityModel.Protocols.OpenIdConnect; + + /// + /// Http client for Databricks. + /// + public class DatabricksApiHttpClient : IDatabricksApiHttpClient + { + private readonly HttpClient httpClient; + private readonly IOptions databricksConfig; + + /// + /// Initializes a new instance of the class. + /// + /// Configuration details for the databricks. + public DatabricksApiHttpClient(IOptions databricksConfig) + { + this.databricksConfig = databricksConfig; + this.httpClient = new HttpClient { BaseAddress = new Uri(databricksConfig.Value.InstanceUrl) }; + this.httpClient.DefaultRequestHeaders.Accept.Clear(); + this.httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + } + + /// + public void Dispose() + { + this.httpClient.Dispose(); + } + + /// + /// The Get Client method. + /// + /// The . + public HttpClient GetClient() + { + string accessToken = this.databricksConfig.Value.Token; + this.httpClient.SetBearerToken(accessToken); + return this.httpClient; + } + + /// + public async Task GetData(string requestUrl, string? authHeader) + { + if (!string.IsNullOrEmpty(authHeader)) + { + this.httpClient.SetBearerToken(authHeader); + } + + var message = await this.httpClient.GetAsync(requestUrl).ConfigureAwait(false); + return message; + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj index e2e064738..36c6d8f5f 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/LearningHub.Nhs.OpenApi.Services.csproj @@ -23,6 +23,7 @@ + @@ -30,7 +31,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs new file mode 100644 index 000000000..25a98541e --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs @@ -0,0 +1,947 @@ +namespace LearningHub.Nhs.OpenApi.Services.Services.AzureSearch +{ + using AutoMapper; + using Azure.Search.Documents; + using Azure.Search.Documents.Models; + using LearningHub.Nhs.Models.Entities.Activity; + using LearningHub.Nhs.Models.Entities.Resource; + using LearningHub.Nhs.Models.Entities.Resource.Blocks; + using LearningHub.Nhs.Models.Enums; + using LearningHub.Nhs.Models.Search; + using LearningHub.Nhs.Models.Search.SearchClick; + using LearningHub.Nhs.Models.Validation; + using LearningHub.Nhs.Models.ViewModels.Helpers; + using LearningHub.Nhs.OpenApi.Models.Configuration; + using LearningHub.Nhs.OpenApi.Models.ServiceModels.AzureSearch; + using LearningHub.Nhs.OpenApi.Models.ServiceModels.Resource; + using LearningHub.Nhs.OpenApi.Models.ViewModels; + using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories; + using LearningHub.Nhs.OpenApi.Services.Helpers; + using LearningHub.Nhs.OpenApi.Services.Helpers.Search; + using LearningHub.Nhs.OpenApi.Services.Interface.Services; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using Newtonsoft.Json; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using static Microsoft.EntityFrameworkCore.DbLoggerCategory; + using Event = LearningHub.Nhs.Models.Entities.Analytics.Event; + + + /// + /// The Azure AI Search service implementation. + /// Provides search functionality with facet caching and parallel query execution. + /// + public class AzureSearchService : ISearchService + { + /// + /// Default cache expiration time in minutes for facet results. + /// + private const int DefaultFacetCacheExpirationMinutes = 5; + private readonly IEventService eventService; + private readonly ILearningHubService learningHubService; + private readonly IResourceRepository resourceRepository; + private readonly ICachingService cachingService; + private readonly SearchClient searchClient; + private readonly ILogger logger; + private readonly AzureSearchConfig azureSearchConfig; + private readonly IMapper mapper; + + /// + /// Initializes a new instance of the class. + /// + /// The learning hub service. + /// The event service. + /// The Azure Search configuration. + /// The resource repository. + /// The caching service. + /// The logger. + /// The mapper. + public AzureSearchService( + ILearningHubService learningHubService, + IEventService eventService, + IOptions azureSearchConfig, + IResourceRepository resourceRepository, + ICachingService cachingService, + ILogger logger, + IMapper mapper) + { + this.learningHubService = learningHubService; + this.eventService = eventService; + this.resourceRepository = resourceRepository; + this.cachingService = cachingService; + this.logger = logger; + this.mapper = mapper; + this.azureSearchConfig = azureSearchConfig.Value; + + this.searchClient = AzureSearchClientFactory.CreateQueryClient(this.azureSearchConfig); + } + + /// + public async Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId, CancellationToken cancellationToken = default) + { + var viewmodel = new SearchResultModel(); + + try + { + var searchQueryType = SearchOptionsBuilder.ParseSearchQueryType(this.azureSearchConfig.SearchQueryType); ; + var pageSize = searchRequestModel.PageSize; + var offset = searchRequestModel.PageIndex * pageSize; + + // Build query string + var query = searchQueryType == SearchQueryType.Full + ? LuceneQueryBuilder.BuildLuceneQuery(searchRequestModel.SearchText) + : searchRequestModel.SearchText; + + Dictionary sortBy = new Dictionary() + { + { searchRequestModel.SortColumn, searchRequestModel.SortDirection } + }; + + // Determine if we need to apply post-processing sort + bool needsPostProcessingSort = searchQueryType == SearchQueryType.Semantic && + !string.IsNullOrEmpty(searchRequestModel.SortColumn) && + searchRequestModel.SortColumn != "relevance"; + + // For semantic search with sorting, retrieve buffer size results for post-processing + int queryPageSize = pageSize; + int queryOffset = offset; + int semanticResultBufferSize = this.azureSearchConfig.SemanticResultBufferSize; + + if (needsPostProcessingSort) + { + // Retrieve semantic buffer size results from the starta nd add offset for post-processing sort and pagination + queryPageSize = semanticResultBufferSize + offset; + queryOffset = 0; + } + + // Normalize resource_type filter values + var filters = SearchFilterBuilder.CombineAndNormaliseFilters(searchRequestModel.FilterText, searchRequestModel.ProviderFilterText, searchRequestModel.ResourceAccessLevelFilterText); + + if (searchRequestModel.CatalogueId.HasValue) + { + Dictionary> catalogueIdFilter = new Dictionary> { { "catalogue_id", new List { searchRequestModel.CatalogueId.ToString() } } }; + filters = filters == null ? catalogueIdFilter : filters.Concat(catalogueIdFilter).ToDictionary(k => k.Key, v => v.Value); + } + + var searchOptions = SearchOptionsBuilder.BuildSearchOptions(searchQueryType, queryOffset, queryPageSize, filters, sortBy, true, this.azureSearchConfig); + SearchResults filteredResponse = await this.searchClient.SearchAsync(query, searchOptions, cancellationToken); + var count = Convert.ToInt32(filteredResponse.TotalCount); + + // Map documents + var documents = filteredResponse.GetResults() + .Select(result => + { + var doc = result.Document; + doc.ParseManualTags(); + + return new Document + { + Id = doc.Id, + Title = doc.Title, + Description = doc.Description, + ResourceType = MapToResourceType(doc.ResourceType), + ProviderIds = doc.ProviderIds?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(), + CatalogueIds = + doc.ResourceType == "catalogue" + ? new List { Convert.ToInt32(doc.Id) } + : ( + doc.CatalogueId? + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(id => int.TryParse(id, out var val) ? val : 0) + .ToList() + ?? new List() + ), + Rating = Convert.ToDecimal(doc.Rating), + ResourceAccessLevel = Convert.ToInt32(doc.ResourceAccessLevel), + Author = doc.Author, + Authors = doc.Author?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(a => a.Trim()).ToList(), + AuthoredDate = doc.DateAuthored?.ToString(), + ResourceReferenceId = int.TryParse(doc.ResourceReferenceId, out var id) ? id : 0, + Click = BuildSearchClickModel(doc.Id, doc.Title, searchRequestModel.PageIndex, searchRequestModel.SearchId, filters, query, count) + }; + }) + .ToList(); + + // Apply post-processing sort if needed + if (needsPostProcessingSort) + { + documents = SearchOptionsBuilder.ApplyPostProcessingSort(documents, searchRequestModel.SortColumn, searchRequestModel.SortDirection); + + // Apply pagination after sorting + documents = documents.Skip(offset).Take(pageSize).ToList(); + } + + viewmodel.DocumentList = new Documentlist + { + Documents = documents.ToArray() + }; + + var unfilteredFacets = await GetUnfilteredFacetsAsync( + searchRequestModel.SearchText, + filteredResponse.Facets, + searchRequestModel.ResourceAccessLevelFilterText, + cancellationToken); + + // Merge facets from filtered and unfiltered results + viewmodel.Facets = AzureSearchFacetHelper.MergeFacets(filteredResponse.Facets, unfilteredFacets, filters); + + + viewmodel.Stats = new Stats + { + TotalHits = count + }; + searchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits; + + return viewmodel; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Azure Search query failed for search text: {SearchText}", searchRequestModel.SearchText); + throw; + } + } + + /// + public async Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId, CancellationToken cancellationToken = default) + { + var viewmodel = new SearchCatalogueResultModel(); + + try + { + var offset = catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize; + + // Build filters for catalogue search + var filters = new Dictionary> + { + { "resource_collection", new List { "Catalogue" } } + }; + + var searchOptions = new SearchOptions + { + Skip = offset, + Size = catalogSearchRequestModel.PageSize, + IncludeTotalCount = true, + Filter = SearchFilterBuilder.BuildFilterExpression(filters) + }; + + SearchResults response = await this.searchClient.SearchAsync( + catalogSearchRequestModel.SearchText, + searchOptions, + cancellationToken); + var count = Convert.ToInt32(response.TotalCount); + + var documentList = new CatalogueDocumentList + { + Documents = response.GetResults() + .Select(result => + { + var doc = result.Document; + doc.ParseManualTags(); + + return new CatalogueDocument + { + Id = doc.Id, + Name = doc.Title, + Description = doc.Description, + Click = BuildSearchClickModel(doc.Id, doc.Title, catalogSearchRequestModel.PageIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count) + }; + }) + .ToArray() + }; + + viewmodel.DocumentList = documentList; + viewmodel.Stats = new Stats + { + TotalHits = count + }; + catalogSearchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits; + + var remainingItems = catalogSearchRequestModel.TotalNumberOfHits - offset; + var resultsPerPage = remainingItems >= catalogSearchRequestModel.PageSize ? catalogSearchRequestModel.PageSize : remainingItems; + var validationResult = await this.CreateCatalogueSearchTerm(catalogSearchRequestModel, resultsPerPage, userId); + + viewmodel.SearchId = validationResult.CreatedId ?? 0; + + return viewmodel; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Azure Search catalogue query failed for search text: {SearchText}", catalogSearchRequestModel.SearchText); + throw; + } + } + + /// + public async Task CreateResourceSearchActionAsync(SearchActionResourceModel searchActionResourceModel, int userId) + { + var jsonobj = new + { + searchActionResourceModel.SearchText, + searchActionResourceModel.NodePathId, + searchActionResourceModel.ItemIndex, + searchActionResourceModel.NumberOfHits, + searchActionResourceModel.TotalNumberOfHits, + searchActionResourceModel.ResourceReferenceId, + }; + + var json = JsonConvert.SerializeObject(jsonobj); + + var eventEntity = new Event + { + EventTypeEnum = EventTypeEnum.SearchLaunchResource, + JsonData = json, + UserId = userId, + GroupId = searchActionResourceModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task CreateCatalogueSearchActionAsync(SearchActionCatalogueModel searchActionCatalogueModel, int userId) + { + var jsonobj = new + { + searchActionCatalogueModel.SearchText, + searchActionCatalogueModel.NodePathId, + searchActionCatalogueModel.ItemIndex, + searchActionCatalogueModel.NumberOfHits, + searchActionCatalogueModel.TotalNumberOfHits, + searchActionCatalogueModel.CatalogueId, + }; + + var json = JsonConvert.SerializeObject(jsonobj); + + var eventEntity = new Event + { + EventTypeEnum = EventTypeEnum.SearchLaunchCatalogue, + JsonData = json, + UserId = userId, + GroupId = searchActionCatalogueModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task CreateCatalogueResourceSearchActionAsync(SearchActionResourceModel searchActionResourceModel, int userId) + { + var jsonobj = new + { + searchActionResourceModel.SearchText, + searchActionResourceModel.NodePathId, + searchActionResourceModel.ItemIndex, + searchActionResourceModel.ResourceReferenceId, + searchActionResourceModel.NumberOfHits, + searchActionResourceModel.TotalNumberOfHits, + }; + + var json = JsonConvert.SerializeObject(jsonobj); + + var eventEntity = new Event + { + EventTypeEnum = EventTypeEnum.LaunchCatalogueResource, + JsonData = json, + UserId = userId, + GroupId = searchActionResourceModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task SubmitFeedbackAsync(SearchFeedBackModel searchFeedbackModel, int userId) + { + var jsonobj = new + { + searchFeedbackModel.SearchText, + searchFeedbackModel.Feedback, + searchFeedbackModel.TotalNumberOfHits, + }; + + var json = JsonConvert.SerializeObject(jsonobj); + + var eventEntity = new Event + { + EventTypeEnum = EventTypeEnum.SearchSubmitFeedback, + JsonData = json, + UserId = userId, + GroupId = searchFeedbackModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task CreateSearchTermEvent(SearchRequestModel searchRequestModel, int userId) + { + var pageSize = searchRequestModel.PageSize; + var offset = searchRequestModel.PageIndex * pageSize; + + var remainingItems = searchRequestModel.TotalNumberOfHits - offset; + var resultsPerPage = remainingItems >= pageSize ? pageSize : remainingItems; + + var searchEventModel = this.mapper.Map(searchRequestModel); + searchEventModel.ItemsViewed = resultsPerPage; + var json = JsonConvert.SerializeObject(searchEventModel); + + var eventEntity = new Event + { + EventTypeEnum = searchRequestModel.EventTypeEnum, + JsonData = json, + UserId = userId, + GroupId = searchRequestModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task CreateCatalogueSearchTermEvent(CatalogueSearchRequestModel catalogueSearchRequestModel, int userId) + { + var offset = catalogueSearchRequestModel.PageIndex * catalogueSearchRequestModel.PageSize; + + var remainingItems = catalogueSearchRequestModel.TotalNumberOfHits - offset; + var resultsPerPage = remainingItems >= catalogueSearchRequestModel.PageSize ? catalogueSearchRequestModel.PageSize : remainingItems; + + var searchCatalogueEventModel = this.mapper.Map(catalogueSearchRequestModel); + searchCatalogueEventModel.ItemsViewed = resultsPerPage; + var json = JsonConvert.SerializeObject(searchCatalogueEventModel); + var eventEntity = new Event + { + EventTypeEnum = catalogueSearchRequestModel.EventTypeEnum, + JsonData = json, + UserId = userId, + GroupId = catalogueSearchRequestModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task SendResourceSearchEventClickAsync(SearchActionResourceModel searchActionResourceModel) + { + // Azure Search doesn't need click tracking like Findwise + // Log the event but return true + this.logger.LogInformation($"Search click event logged for resource {searchActionResourceModel.ResourceReferenceId}"); + return await Task.FromResult(true); + } + + /// + public async Task CreateCatalogueSearchTerm(CatalogueSearchRequestModel catalogueSearchRequestModel, int resultsPerPage, int userId) + { + var searchCatalogueEventModel = this.mapper.Map(catalogueSearchRequestModel); + searchCatalogueEventModel.ItemsViewed = resultsPerPage; + var json = JsonConvert.SerializeObject(searchCatalogueEventModel); + var eventEntity = new Event + { + EventTypeEnum = catalogueSearchRequestModel.EventTypeEnum, + JsonData = json, + UserId = userId, + GroupId = catalogueSearchRequestModel.GroupId, + }; + + return await this.eventService.CreateAsync(userId, eventEntity); + } + + /// + public async Task Search(ResourceSearchRequest query, int? currentUserId) + { + try + { + var searchOptions = new SearchOptions + { + Skip = query.Offset, + Size = query.Limit, + IncludeTotalCount = true, + }; + + SearchResults response = await this.searchClient.SearchAsync( + query.SearchText, + searchOptions); + + var documentsFound = response.GetResults().Select(r => r.Document).ToList(); + var findwiseResourceIds = documentsFound.Select(d => int.Parse(d.Id)).ToList(); + + if (!findwiseResourceIds.Any()) + { + return new ResourceSearchResultModel( + new List(), + LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Success, + 0); + } + + var resourceMetadataViewModels = await this.GetResourceMetadataViewModels(findwiseResourceIds, currentUserId); + + var totalHits = (int)(response.TotalCount ?? 0); + + return new ResourceSearchResultModel( + resourceMetadataViewModels, + LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Success, + totalHits); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Azure Search query failed"); + return ResourceSearchResultModel.FailedWithStatus( + LearningHub.Nhs.OpenApi.Models.ServiceModels.Findwise.FindwiseRequestStatus.Timeout); + } + } + + /// + public async Task RemoveResourceFromSearchAsync(int resourceId) + { + try + { + // We are not currently implementing delete in Azure Search, it is handled via data source indexer + // var adminClient = AzureSearchClientFactory.CreateAdminClient(this.azureSearchConfig); + + // await adminClient.DeleteDocumentsAsync("id", new[] { resourceId.ToString() }); + } + catch (Exception ex) + { + this.logger.LogError(ex, $"Failed to remove resource {resourceId} from Azure Search"); + throw new Exception($"Removal of resource from search failed: {resourceId} : {ex.Message}"); + } + } + + /// + public async Task SendResourceForSearchAsync(SearchResourceRequestModel searchResourceRequestModel, int userId, int? iterations) + { + try + { + // We are not currently implementing in Azure Search, it is handled via data source indexer + + // var adminClient = AzureSearchClientFactory.CreateAdminClient(this.azureSearchConfig); + // var document = new Models.ServiceModels.AzureSearch.SearchDocument{}; + // await adminClient.IndexDocumentsAsync(IndexDocumentsBatch.Upload(new[] { document })); + return true; + } + catch (Exception ex) + { + this.logger.LogError(ex, $"Failed to add resource {searchResourceRequestModel.Id} to Azure Search"); + throw new Exception($"Posting of resource to search failed: {searchResourceRequestModel.Id} : {ex.Message}"); + } + } + + /// + public async Task SendCatalogueSearchEventAsync(SearchActionCatalogueModel searchActionCatalogueModel) + { + // We are not currently implementing in Azure Search, it is handled via data source indexer + return await Task.FromResult(true); + } + + /// + public async Task GetAllCatalogueSearchResultsAsync(AllCatalogueSearchRequestModel catalogSearchRequestModel) + { + var viewmodel = new SearchAllCatalogueResultModel(); + CancellationToken cancellationToken = default; + try + { + var offset = catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize; + var filters = new Dictionary> + { + { "resource_collection", new List { "catalogue" } } + }; + + var searchOptions = new SearchOptions + { + Skip = offset, + Size = catalogSearchRequestModel.PageSize, + IncludeTotalCount = true, + Filter = SearchFilterBuilder.BuildFilterExpression(filters) + }; + + SearchResults response = await this.searchClient.SearchAsync( + catalogSearchRequestModel.SearchText, searchOptions, cancellationToken); + var count = Convert.ToInt32(response.TotalCount); + + var documentList = new CatalogueDocumentList + { + Documents = response.GetResults() + .Select(result => + { + var doc = result.Document; + doc.ParseManualTags(); + + return new CatalogueDocument + { + Id = doc.Id, + Name = doc.Title, + Description = doc.Description, + Click = BuildSearchClickModel(doc.Id, doc.Title, catalogSearchRequestModel.PageIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count) + }; + }) + .ToArray() + }; + + viewmodel.DocumentList = documentList; + viewmodel.Stats = new Stats + { + TotalHits = count + }; + + catalogSearchRequestModel.TotalNumberOfHits = viewmodel.Stats.TotalHits; + + return viewmodel; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Azure Search all catalogue query failed"); + throw; + } + } + + /// + public async Task GetAutoSuggestionResultsAsync(string term, CancellationToken cancellationToken = default) + { + var viewmodel = new AutoSuggestionModel(); + + try + { + var searchOptions = new SearchOptions + { + Size = 10, + }; + + var response = await this.searchClient.SearchAsync( + term, + searchOptions, + cancellationToken); + + var suggestOptions = new SuggestOptions + { + Size = 50, + UseFuzzyMatching = true + }; + suggestOptions.SearchFields.Add("title"); + suggestOptions.SearchFields.Add("description"); + suggestOptions.SearchFields.Add("manual_tag"); + suggestOptions.Select.Add("id"); + suggestOptions.Select.Add("title"); + suggestOptions.Select.Add("description"); + suggestOptions.Select.Add("manual_tag"); + suggestOptions.Select.Add("resource_type"); + suggestOptions.Select.Add("resource_collection"); + suggestOptions.Select.Add("url"); + suggestOptions.Select.Add("resource_reference_id"); + suggestOptions.Select.Add("is_deleted"); + + var autoOptions = new AutocompleteOptions + { + Mode = AutocompleteMode.OneTermWithContext, + Size = this.azureSearchConfig.ConceptsSuggesterSize, + Filter = "is_deleted eq false" + }; + + var searchText = LuceneQueryBuilder.EscapeLuceneSpecialCharacters(term); + var suggesterName = this.azureSearchConfig.SuggesterName; + + // Fire both requests in parallel for performance + var suggestTask = this.searchClient.SuggestAsync(searchText, suggesterName, suggestOptions, cancellationToken); + var autoTask = this.searchClient.AutocompleteAsync(searchText, suggesterName, autoOptions, cancellationToken); + + await Task.WhenAll(suggestTask, autoTask); + + var suggestResponse = await suggestTask; + var autoResponse = await autoTask; + + var suggestResults = suggestResponse.Value.Results + .Where(r => !string.IsNullOrEmpty(r.Document?.Title) && + r.Document?.IsDeleted == false) + .Select(r => new + { + Id = r.Document.Id, + Text = r.Document.Title.Trim(), + URL = r.Document.Url, + ResourceReferenceId = (r.Document.ResourceCollection == "resource") ? r.Document.ResourceReferenceId : r.Document.Id, + Type = r.Document.ResourceCollection ?? "Suggestion" + }); + + var autoResults = autoResponse.Value.Results + .Where(r => !string.IsNullOrWhiteSpace(r.Text)) + .Select((r, index) => new + { + Id = "A" + (index + 1), + Text = r.Text.Trim(), + URL = string.Empty, + ResourceReferenceId = (string?)null, + Type = "AutoComplete" + }); + + var combined = suggestResults + .Concat(autoResults) + .GroupBy(r => r.Text, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + + viewmodel.Stats = new Stats + { + TotalHits = combined.Count + }; + + var autoSuggestion = new AutoSuggestionResourceCollection + { + TotalHits = suggestResults.Count(), + DocumentList = suggestResults.Select(item => new AutoSuggestionResourceCollectionDocument + { + Id = item.Id, + ResourceType = item.Type, + URL = item.URL, + ResourceReferenceId = item.ResourceReferenceId != null && int.TryParse(item.ResourceReferenceId, out var refId) ? refId : 0, + Title = item.Text, + Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, suggestResults.Count()) + }) + .Take(this.azureSearchConfig.ResourceCollectionSuggesterSize) + .ToList() + }; + + // We couldn't pass null value so just etting to empty collection with 0 hits, as we are using one resouce colection in auto-suggest + var autoSuggestionCatalogue = new AutoSuggestionCatalogue + { + TotalHits = suggestResults.Count(), + CatalogueDocumentList = suggestResults + .Where(a => a.Type == "catalogue") + .Select(item => new AutoSuggestionCatalogueDocument + { + Id = item.Id, + Name = item.Text, + Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, suggestResults.Count()) + }) + .Take(0) + .ToList() + }; + + var autoSuggestionConcept = new AutoSuggestionConcept + { + TotalHits = autoResults.Count(), + ConceptDocumentList = autoResults + .Select(item => new AutoSuggestionConceptDocument + { + Id = item.Id, + Concept = item.Text, + Title = item.Text, + Click = BuildAutoSuggestClickModel(item.Id, item.Text, 0, 0, term, autoResults.Count()) + }) + .ToList() + }; + + viewmodel.ResourceCollectionDocument = autoSuggestion; + viewmodel.CatalogueDocument = autoSuggestionCatalogue; // We coundt pass null value so just etting to empty collection with 0 hits, as we are using one resouce colection in auto-suggest + viewmodel.ConceptDocument = autoSuggestionConcept; + + return viewmodel; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Azure Search auto-suggestion query failed for term: {Term}", term); + throw; + } + } + + /// + public async Task SendAutoSuggestionEventAsync(AutoSuggestionClickPayloadModel clickPayloadModel) + { + // We are not currently implementing in Azure Search, it is handled via data source indexer + return await Task.FromResult(true); + } + + private async Task> GetResourceMetadataViewModels( + List findwiseResourceIds, int? currentUserId) + { + List resourceActivities = new List(); + List resourceMetadataViewModels = new List(); + + if (!findwiseResourceIds.Any()) + { + return new List(); + } + + var resourcesFound = await this.resourceRepository.GetResourcesFromIds(findwiseResourceIds); + + if (currentUserId.HasValue) + { + List resourceIds = resourcesFound.Select(x => x.Id).ToList(); + List userIds = new List { currentUserId.Value }; + + resourceActivities = (await this.resourceRepository.GetResourceActivityPerResourceMajorVersion(resourceIds, userIds))?.ToList() ?? new List(); + } + + resourceMetadataViewModels = resourcesFound.Select(resource => this.MapToViewModel(resource, resourceActivities.Where(x => x.ResourceId == resource.Id).ToList())) + .OrderBySequence(findwiseResourceIds) + .ToList(); + + var unmatchedResources = findwiseResourceIds + .Except(resourceMetadataViewModels.Select(r => r.ResourceId)).ToList(); + + if (unmatchedResources.Any()) + { + var unmatchedResourcesIdsString = string.Join(", ", unmatchedResources); + this.logger.LogWarning( + "Azure Search returned documents that were not found in the database with IDs: " + + unmatchedResourcesIdsString); + } + + return resourceMetadataViewModels; + } + + + private static SearchClickModel BuildSearchClickModel(string targetUrl, string title, int hitNumber, long searchId, Dictionary> filters, string query, int count) + { + return new SearchClickModel + { + Payload = new SearchClickPayloadModel + { + ClickTargetUrl = targetUrl, + DocumentFields = new SearchClickDocumentModel() { Name = title, Title = title }, + HitNumber = hitNumber, + SearchSignal = new SearchClickSignalModel() + { + SearchId = Convert.ToString(searchId), + Query = query + "&" + filters, + UserQuery = query, + TimeOfSearch = DateTimeOffset.Now.ToUnixTimeMilliseconds(), + Stats = new SearchClickStatsModel() { TotalHits = count } + }, + } + }; + } + + private static AutoSuggestionClickModel BuildAutoSuggestClickModel(string targetUrl, string title, int hitNumber, long searchId, string query, int count) + { + return new AutoSuggestionClickModel + { + Payload = new AutoSuggestionClickPayloadModel + { + ClickTargetUrl = targetUrl, + DocumentFields = new SearchClickDocumentModel() { Name = title, Title = title }, + HitNumber = hitNumber, + SearchSignal = new SearchClickSignalModel() + { + SearchId = Convert.ToString(searchId), + Query = query, + UserQuery = query, + TimeOfSearch = DateTimeOffset.Now.ToUnixTimeMilliseconds(), + Stats = new SearchClickStatsModel() { TotalHits = count } + }, + } + }; + } + + private string MapToResourceType(string resourceType) + { + if (string.IsNullOrWhiteSpace(resourceType)) + return ResourceTypeEnum.Undefined.ToString(); + + string cleanedResourceType = resourceType + .Trim() + .ToLower() + .Replace(" ", "") + .Replace("_", "") + .Replace("-", ""); + + if (cleanedResourceType.StartsWith("scorm")) + cleanedResourceType = ResourceTypeEnum.Scorm.ToString(); + else if (cleanedResourceType.StartsWith("web")) + cleanedResourceType = ResourceTypeEnum.WebLink.ToString(); + else if (cleanedResourceType.Contains("file")) + cleanedResourceType = ResourceTypeEnum.GenericFile.ToString(); + + return cleanedResourceType; + } + + /// + /// Gets unfiltered facets for a search term, using caching. + /// + /// The search text. + /// The facet results. + /// The resource access level filter, if any, used to further differentiate cache entries. + /// Cancellation token. + /// The unfiltered facet results. + private async Task>> GetUnfilteredFacetsAsync( + string searchText, + IDictionary> facets, + string? resourceAccessLevel, + CancellationToken cancellationToken) + { + var normalizedSearch = searchText?.ToLowerInvariant() ?? "*"; + var accessLevelKey = resourceAccessLevel?.ToString() ?? "null"; + var cacheKey = $"Facets_{normalizedSearch}"; + if (!string.IsNullOrWhiteSpace(accessLevelKey)) + cacheKey += $"_{accessLevelKey.Replace("=", "_")}"; + + var cacheResponse = await this.cachingService.GetAsync>>(cacheKey); + + if (cacheResponse.ResponseEnum == CacheReadResponseEnum.Found && string.IsNullOrEmpty(accessLevelKey)) + { + // Convert cached DTO back to FacetResult dictionary + return AzureSearchFacetHelper.ConvertFromCacheable(cacheResponse.Item); + } + + if (facets != null) + { + // Convert to cacheable DTO before caching + var cacheableFacets = AzureSearchFacetHelper.ConvertToCacheable(facets); + await this.cachingService.SetAsync(cacheKey, cacheableFacets, DefaultFacetCacheExpirationMinutes, slidingExpiration: true); + } + + return facets ?? new Dictionary>(); + } + + private ResourceMetadataViewModel MapToViewModel(Resource resource, List resourceActivities) + { + var hasCurrentResourceVersion = resource.CurrentResourceVersion != null; + var hasRating = resource.CurrentResourceVersion?.ResourceVersionRatingSummary != null; + + List majorVersionIdActivityStatusDescription = new List(); + + if (resourceActivities != null && resourceActivities.Count != 0) + { + majorVersionIdActivityStatusDescription = ActivityStatusHelper.GetMajorVersionIdActivityStatusDescriptionLSPerResource(resource, resourceActivities) + .ToList(); + } + + if (!hasCurrentResourceVersion) + { + this.logger.LogInformation( + $"Resource with id {resource.Id} is missing a current resource version"); + } + + if (!hasRating) + { + this.logger.LogInformation( + $"Resource with id {resource.Id} is missing a ResourceVersionRatingSummary"); + } + + var resourceTypeNameOrEmpty = resource.GetResourceTypeNameOrEmpty(); + if (resourceTypeNameOrEmpty == string.Empty) + { + this.logger.LogError($"Resource has unrecognised type: {resource.ResourceTypeEnum}"); + } + + return new ResourceMetadataViewModel( + resource.Id, + resource.CurrentResourceVersion?.Title ?? ResourceHelpers.NoResourceVersionText, + resource.CurrentResourceVersion?.Description ?? string.Empty, + resource.ResourceReference.Select(this.GetResourceReferenceViewModel).ToList(), + resourceTypeNameOrEmpty, + resource.CurrentResourceVersion?.MajorVersion ?? 0, + resource.CurrentResourceVersion?.ResourceVersionRatingSummary?.AverageRating ?? 0.0m, + majorVersionIdActivityStatusDescription); + } + + private ResourceReferenceViewModel GetResourceReferenceViewModel( + ResourceReference resourceReference) + { + return new ResourceReferenceViewModel( + resourceReference.OriginalResourceReferenceId, + resourceReference.GetCatalogue(), + this.learningHubService.GetResourceLaunchUrl(resourceReference.OriginalResourceReferenceId)); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs index 612ae06ca..ac1eb38ba 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/BaseService.cs @@ -16,20 +16,20 @@ public abstract class BaseService /// private readonly ILogger logger; - /// - /// The Find Wise HTTP Client. - /// - private IFindwiseClient findwiseClient; + ///// + ///// The Find Wise HTTP Client. + ///// + //private IFindwiseClient findwiseClient; /// /// Initializes a new instance of the class. /// The base service. /// - /// The Find Wise http client. + ///// The Find Wise http client. /// The logger. - protected BaseService(IFindwiseClient findwiseClient, ILogger logger) + protected BaseService(ILogger logger) { - this.findwiseClient = findwiseClient; + //this.findwiseClient = findwiseClient; this.logger = logger; } @@ -44,9 +44,9 @@ protected ILogger Logger /// /// Gets the Find Wise HTTP Client. /// - protected IFindwiseClient FindwiseClient - { - get { return this.findwiseClient; } - } + //protected IFindwiseClient FindwiseClient + //{ + // get { return this.findwiseClient; } + //} } } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs new file mode 100644 index 000000000..e7037e78a --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksService.cs @@ -0,0 +1,517 @@ +using LearningHub.Nhs.OpenApi.Models.Configuration; +using LearningHub.Nhs.OpenApi.Services.HttpClients; +using LearningHub.Nhs.OpenApi.Services.Interface.Services; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using LearningHub.Nhs.Models.Databricks; +using System.Linq; +using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories; +using LearningHub.Nhs.Models.Entities.DatabricksReport; +using AutoMapper; +using LearningHub.Nhs.Models.Common; +using Microsoft.EntityFrameworkCore; +using LearningHub.Nhs.OpenApi.Models.ViewModels; +using LearningHub.Nhs.Models.Enums; +using System.Text.Json; +using LearningHub.Nhs.Models.Entities; +using LearningHub.Nhs.OpenApi.Services.Interface.Services.Messaging; +using LearningHub.Nhs.Models.Email.Models; +using LearningHub.Nhs.Models.Email; +using LearningHub.Nhs.OpenApi.Services.Helpers; +using System.Configuration; + +namespace LearningHub.Nhs.OpenApi.Services.Services +{ + /// + /// DatabricksService + /// + public class DatabricksService : IDatabricksService + { + private const string CacheKey = "DatabricksReporter"; + private readonly IOptions databricksConfig; + private readonly IOptions learningHubConfig; + private readonly IReportHistoryRepository reportHistoryRepository; + private readonly IQueueCommunicatorService queueCommunicatorService; + private readonly ICachingService cachingService; + private readonly INotificationService notificationService; + private readonly IEmailSenderService emailSenderService; + private readonly IUserNotificationService userNotificationService; + private readonly IMoodleApiService moodleApiService; + private readonly IUserProfileService userProfileService; + private readonly IMapper mapper; + + /// + /// Initializes a new instance of the class. + /// + /// databricksConfig. + /// learningHubConfig. + /// reportHistoryRepository. + /// mapper. + /// queueCommunicatorService. + /// cachingService. + /// notificationService. + /// userNotificationService. + /// moodleApiService. + /// emailSenderService. + /// userProfileService. + public DatabricksService(IOptions databricksConfig,IOptions learningHubConfig, IReportHistoryRepository reportHistoryRepository, IMapper mapper, IQueueCommunicatorService queueCommunicatorService, ICachingService cachingService, INotificationService notificationService, IUserNotificationService userNotificationService, IMoodleApiService moodleApiService, IEmailSenderService emailSenderService, IUserProfileService userProfileService) + { + this.databricksConfig = databricksConfig; + this.learningHubConfig = learningHubConfig; + this.reportHistoryRepository = reportHistoryRepository; + this.mapper = mapper; + this.queueCommunicatorService = queueCommunicatorService; + this.cachingService = cachingService; + this.notificationService = notificationService; + this.userNotificationService = userNotificationService; + this.moodleApiService = moodleApiService; + this.emailSenderService = emailSenderService; + this.userProfileService = userProfileService; + } + + /// + public async Task IsUserReporter(int userId) + { + bool isReporter = false; + string cacheKey = $"{userId}:{CacheKey}"; + try + { + var userReportPermission = await this.cachingService.GetAsync(cacheKey); + if (userReportPermission.ResponseEnum == CacheReadResponseEnum.Found) + { + return userReportPermission.Item; + } + + + DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig); + + var sqlText = $"CALL {this.databricksConfig.Value.UserPermissionEndpoint}({userId});"; + const string requestUrl = "/api/2.0/sql/statements"; + + var requestPayload = new + { + warehouse_id = this.databricksConfig.Value.WarehouseId, + statement = sqlText, + wait_timeout = "30s", + on_wait_timeout = "CANCEL" + }; + + var jsonBody = JsonConvert.SerializeObject(requestPayload); + using var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + var response = await databricksInstance.GetClient().PostAsync(requestUrl, content); + + var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content); + if (databricksResponse.StatusCode is not HttpStatusCode.OK) + { + //log failure + return false; + } + var responseResult = await databricksResponse.Content.ReadAsStringAsync(); + + responseResult = responseResult.Trim(); + var root = JsonDocument.Parse(responseResult).RootElement; + string data = root.GetProperty("result").GetProperty("data_array")[0][0].GetString(); + isReporter = data == "1"; + + await this.cachingService.SetAsync(cacheKey, isReporter); + return isReporter; + + } + catch + { + await this.cachingService.SetAsync(cacheKey, isReporter); + return isReporter; + } + } + + /// + public async Task CourseCompletionReport(int userId, DatabricksRequestModel model) + { + newEntry: + if (model.ReportHistoryId == 0 && model.Take > 1) + { + + bool timePeriodCheck = int.TryParse(model.TimePeriod, out int timePeriod); + var reportHistory = new ReportHistory { CourseFilter = string.Join(",", model.Courses) ,StartDate = model.StartDate,EndDate =model.EndDate, PeriodDays = timePeriodCheck ? timePeriod : 0 , + FirstRun = DateTimeOffset.Now, LastRun = DateTimeOffset.Now, ReportStatusId = 2}; + model.ReportHistoryId = await AddReportHistory(userId, reportHistory); + } + else if(model.ReportHistoryId > 0 && model.Take > 1) + { + //get the existing values and compare + var reportChecker = await GetPagedReportHistoryById(userId, model.ReportHistoryId); + if (reportChecker != null) + { + if(reportChecker.CourseFilter == "all") { reportChecker.CourseFilter = string.Empty; } + if(reportChecker.CourseFilter != string.Join(",", model.Courses) || reportChecker.StartDate.GetValueOrDefault().Date != model.StartDate.GetValueOrDefault().Date || reportChecker.EndDate.GetValueOrDefault().Date != model.EndDate.GetValueOrDefault().Date) + { + model.ReportHistoryId = 0; + goto newEntry; + } + } + await UpdateReportLastRunTime(userId, model.ReportHistoryId); + } + + DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig); + + const string requestUrl = "/api/2.0/sql/statements"; + + var sql = $@"CALL {this.databricksConfig.Value.CourseCompletionEndpoint}(:par_adminId, :par_completionFlag, :par_locationId, :par_catalogueId, :par_learnerId, :par_courseId, :par_PageSize, :par_PageNumber, :par_Date_from, :par_Date_to);"; + + + var parameters = new List> + { + new("par_adminId", userId), + new("par_completionFlag", -1), + new("par_locationId", -1), + new("par_catalogueId", -1), + new("par_learnerId", -1), + new("par_courseId", model.Courses.Count < 1 ? string.Empty : string.Join(",", model.Courses)), + new("par_PageSize", model.Take), + new("par_PageNumber", model.Skip), + new("par_Date_from", model.StartDate.HasValue ? model.StartDate.Value.ToString("yyyy-MM-dd"): string.Empty), + new("par_Date_to", model.EndDate.HasValue ? model.EndDate.Value.ToString("yyyy-MM-dd"): string.Empty), + }; + + var formattedParams = parameters.Select(p => new { name = p.Key, value = p.Value }); + + var body = new + { + warehouse_id = this.databricksConfig.Value.WarehouseId, + statement = sql, + parameters = formattedParams, + wait_timeout = "30s", + on_wait_timeout = "CANCEL" + }; + + var json = JsonConvert.SerializeObject(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await databricksInstance.GetClient().PostAsync(requestUrl, content); + + var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content); + if (databricksResponse.StatusCode is not HttpStatusCode.OK) + { + //log failure + return new DatabricksDetailedViewModel { ReportHistoryId = model.ReportHistoryId }; + } + var responseResult = await databricksResponse.Content.ReadAsStringAsync(); + + var result = JsonConvert.DeserializeObject(responseResult); + if (result != null && result.Result.DataArray != null) + { + var records = MapDataArrayToCourseCompletionRecords(result.Result.DataArray); + return new DatabricksDetailedViewModel { CourseCompletionRecords = records, ReportHistoryId = model.ReportHistoryId }; + + } + + return new DatabricksDetailedViewModel { CourseCompletionRecords= new List(), ReportHistoryId = model.ReportHistoryId }; + } + + /// + public async Task> GetPagedReportHistory(int userId,int page, int pageSize) + { + var result = new PagedResultSet(); + var query = this.reportHistoryRepository.GetByUserIdAsync(userId); + + // Execute async count + result.TotalItemCount = await query.CountAsync(); + try + { + // Execute async paging + var pagedItems = await query + .OrderByDescending(x => x.LastRun) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + result.Items = mapper.Map>(pagedItems); + } + catch(Exception e) + { + + } + + return result; + } + + /// + public async Task GetPagedReportHistoryById(int userId, int reportHistoryId) + { + var result = new ReportHistoryModel(); + + var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId); + if(reportHistory != null) + { + if(reportHistory.CreateUserId != userId) + { + throw new Exception("Invalid Id"); + } + } + result = mapper.Map(reportHistory); + if(result != null && string.IsNullOrWhiteSpace(result.CourseFilter)) + { + result.CourseFilter = "all"; + } + + return result; + + } + + /// + public async Task QueueReportDownload(int userId, int reportHistoryId) + { + var result = new ReportHistoryModel(); + + var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId); + if (reportHistory != null && reportHistory.DownloadRequest == null) + { + if (reportHistory.CreateUserId != userId) + { + throw new Exception("Invalid Id"); + } + reportHistory.DownloadRequest = true; + reportHistory.DownloadRequested = DateTimeOffset.Now; + } + else + { + throw new Exception("Invalid Id"); + } + //call the job + DatabricksApiHttpClient databricksInstance = new DatabricksApiHttpClient(this.databricksConfig); + + const string requestUrl = "/api/2.1/jobs/run-now"; + + var body = new + { + job_id = this.databricksConfig.Value.JobId, + notebook_params = new + { + par_adminId = userId, + par_completionFlag = -1, + par_locationId = -1, + par_catalogueId = -1, + par_learnerId = -1, + par_courseId = reportHistory.CourseFilter, + par_PageSize = 0, + par_PageNumber = 0, + par_Date_from = reportHistory.StartDate.GetValueOrDefault().ToString("yyyy-MM-dd"), + par_Date_to = reportHistory.EndDate.GetValueOrDefault().ToString("yyyy-MM-dd"), + par_reportId = reportHistoryId + } + }; + + var json = JsonConvert.SerializeObject(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var databricksResponse = await databricksInstance.GetClient().PostAsync(requestUrl, content); + if (databricksResponse.StatusCode is not HttpStatusCode.OK) + { + reportHistory.ProcessingMessage = databricksResponse.ReasonPhrase; + reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed; + await reportHistoryRepository.UpdateAsync(userId, reportHistory); + return false; + } + var responseResult = await databricksResponse.Content.ReadAsStringAsync(); + var responseData = JsonConvert.DeserializeObject(responseResult); + if (responseData != null) + { + reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Pending; + reportHistory.ParentJobRunId = (long)responseData.run_id; + await reportHistoryRepository.UpdateAsync(userId, reportHistory); + return true; + } + return false; + } + + /// + public async Task DownloadReport(int userId, int reportHistoryId) + { + var response = new ReportHistoryModel(); + + var reportHistory = await this.reportHistoryRepository.GetByIdAsync(reportHistoryId); + if (reportHistory != null) + { + if (reportHistory.CreateUserId != userId) + { + throw new Exception("Invalid Id"); + } + reportHistory.DownloadedDate = DateTimeOffset.Now; + await reportHistoryRepository.UpdateAsync(userId, reportHistory); + response = mapper.Map(reportHistory); + } + else + { + throw new Exception("Invalid Id"); + } + + return response; + } + + /// + /// DatabricksJobUpdate. + /// + /// + /// + /// + public async Task DatabricksJobUpdate(int userId, DatabricksNotification databricksNotification) + { + var reportHistory = await this.reportHistoryRepository.GetAll().FirstOrDefaultAsync(x=>x.ParentJobRunId == databricksNotification.Run.ParentRunId); + if (reportHistory == null) { return; } + reportHistory.JobRunId = databricksNotification.Run.RunId; + if (!databricksNotification.EventType.Contains("success")) + { + reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed; + reportHistoryRepository.Update(userId, reportHistory); + return; + } + reportHistoryRepository.Update(userId, reportHistory); + + await this.queueCommunicatorService.SendAsync(this.learningHubConfig.Value.DatabricksProcessingQueueName, databricksNotification.Run.RunId); + return; + } + + + /// + /// DatabricksJobUpdate. + /// + /// userId. + /// databricksUpdateRequest. + /// + public async Task UpdateDatabricksReport(int userId, DatabricksUpdateRequest databricksUpdateRequest) + { + var reportHistory = await this.reportHistoryRepository.GetAll().FirstOrDefaultAsync(x => x.JobRunId == databricksUpdateRequest.RunId); + if (reportHistory == null) { return; } + if(string.IsNullOrWhiteSpace(databricksUpdateRequest.ProcessingMessage)) + { + reportHistory.DownloadReady = DateTimeOffset.Now; + reportHistory.FilePath = databricksUpdateRequest.FilePath; + reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Ready; + //send notification + string firstCourse = string.Empty; + + var courses = await moodleApiService.GetCoursesByCategoryIdAsync(learningHubConfig.Value.StatMandId); + + if (string.IsNullOrWhiteSpace(reportHistory.CourseFilter)) + { + firstCourse = "All courses"; + } + else + { + var matched = courses.Courses + .Where(c => reportHistory.CourseFilter.Contains(c.Id.ToString())) + .Select(c => c.Displayname) + .ToList(); + + if (matched.Count == 1) + { + firstCourse = matched[0].ToLower(); + } + else + { + firstCourse = $"{matched[0].ToLower()} and {matched.Count - 1} other{((matched.Count - 1) > 1 ? "s" : "")}"; + + } + } + + firstCourse = TextCasingHelper.ConvertToSentenceCase(firstCourse); + + try + { + var notificationId = await this.notificationService.CreateReportNotificationAsync(userId, "Course progress", firstCourse); + + if (notificationId > 0) + { + await this.userNotificationService.CreateAsync(userId, new UserNotification { UserId = reportHistory.CreateUserId, NotificationId = notificationId }); + } + var user = await this.userProfileService.GetByIdAsync(reportHistory.CreateUserId); + var emailModel = new SendEmailModel( + new ReportSucessEmailModel + { + UserFirstName = user.FirstName, + ReportName = "Course progress", + ReportTitle = firstCourse, + ReportUrl = $"{this.learningHubConfig.Value.BaseUrl.TrimEnd('/')}/{this.learningHubConfig.Value.ReportUrl.TrimStart('/')}" + }); + emailModel.EmailAddress = user.EmailAddress; + + await this.emailSenderService.SendReportProcessedEmail(userId, emailModel); + } + catch { } + + } + else + { + reportHistory.ProcessingMessage = databricksUpdateRequest.ProcessingMessage; + reportHistory.ReportStatusId = (int)Nhs.Models.Enums.Report.Status.Failed; + } + + reportHistoryRepository.Update(userId, reportHistory); + return; + } + + + private async Task AddReportHistory(int userId,ReportHistory model) + { + return await reportHistoryRepository.CreateAsync(userId, model); + } + + private async Task UpdateReportLastRunTime(int userId, int reportHistoryId) + { + var entry = await reportHistoryRepository.GetByIdAsync(reportHistoryId); + entry.LastRun = DateTime.Now; + await reportHistoryRepository.UpdateAsync(userId, entry); + } + + /// + /// MapDataArrayToCourseCompletionRecords. + /// + /// + /// + public static List MapDataArrayToCourseCompletionRecords(List> dataArray) + { + var records = new List(); + + foreach (var row in dataArray) + { + if (row == null || row.Count < 19) continue; + + var record = new DatabricksDetailedItemViewModel + { + UserName = row[0]?.ToString(), + FirstName = row[1]?.ToString(), + LastName = row[2]?.ToString(), + Email = row[3]?.ToString(), + Programme = row[4]?.ToString(), + Course = row[5]?.ToString(), + CourseStatus = row[6]?.ToString(), + Location = row[7]?.ToString(), + Role = row[8]?.ToString(), + Grade = row[9]?.ToString(), + MedicalCouncilNo = row[10]?.ToString(), + MedicalCouncilName = row[11]?.ToString(), + LastAccess = row[12]?.ToString(), + CourseCompletionDate = row[13]?.ToString(), + ReferenceType = row[14]?.ToString(), + ReferenceValue = row[15]?.ToString(), + PermissionType = row[16]?.ToString(), + MinValidDate = row[17]?.ToString(), + TotalRows = row[18] != null && int.TryParse(row[18].ToString(), out int totalRows) ? totalRows : 0 + }; + + records.Add(record); + } + + return records; + } + + } + +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs new file mode 100644 index 000000000..72d373339 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/DatabricksServiceNoImplementation.cs @@ -0,0 +1,84 @@ +using LearningHub.Nhs.Models.Common; +using LearningHub.Nhs.Models.Databricks; +using LearningHub.Nhs.OpenApi.Models.ViewModels; +using LearningHub.Nhs.OpenApi.Services.Interface.Services; +using System.Collections.Generic; + +using System.Threading.Tasks; + +namespace LearningHub.Nhs.OpenApi.Services.Services +{ + /// + /// DatabricksServiceNoImplementation + /// + public class DatabricksServiceNoImplementation : IDatabricksService + { + /// + public Task IsUserReporter(int userId) + { + // Feature disabled → always return false + return Task.FromResult(false); + } + + /// + public Task CourseCompletionReport( + int userId, + DatabricksRequestModel model) + { + // Return an empty model to avoid null reference issues + return Task.FromResult(new DatabricksDetailedViewModel()); + } + + /// + public Task> GetPagedReportHistory( + int userId, + int page, + int pageSize) + { + // Return an empty paged result + return Task.FromResult(new PagedResultSet + { + Items = new List(), + TotalItemCount = 0 + }); + } + + /// + public Task GetPagedReportHistoryById( + int userId, + int reportHistoryId) + { + // Return an empty model + return Task.FromResult(new ReportHistoryModel()); + } + + /// + public Task QueueReportDownload(int userId, int reportHistoryId) + { + // Pretend the queue operation succeeded + return Task.FromResult(true); + } + + /// + public Task DownloadReport(int userId, int reportHistoryId) + { + // Return an empty model + return Task.FromResult(new ReportHistoryModel()); + } + + /// + public Task DatabricksJobUpdate(int userId, DatabricksNotification databricksNotification) + { + // No-op + return Task.CompletedTask; + } + + /// + public Task UpdateDatabricksReport(int userId, DatabricksUpdateRequest databricksUpdateRequest) + { + // No-op + return Task.CompletedTask; + } + } + +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Findwise/NullFindwiseApiFacade.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Findwise/NullFindwiseApiFacade.cs new file mode 100644 index 000000000..ef5f8d974 --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Findwise/NullFindwiseApiFacade.cs @@ -0,0 +1,59 @@ +namespace LearningHub.Nhs.OpenApi.Services.Services.Findwise +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using LearningHub.Nhs.Models.Search; + using LearningHub.Nhs.OpenApi.Services.Interface.Services; + using Microsoft.Extensions.Logging; + + /// + /// Null implementation of IFindwiseApiFacade for use when Azure Search is enabled. + /// This implementation performs no operations and is used to avoid Findwise calls when using Azure Search. + /// + public class NullFindwiseApiFacade : IFindwiseApiFacade + { + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public NullFindwiseApiFacade(ILogger logger) + { + this.logger = logger; + } + + /// + /// No-op implementation. Does not add or replace catalogues in Findwise. + /// + /// The catalogues to add/replace in the index. + /// The task. + public Task AddOrReplaceAsync(List catalogues) + { + this.logger.LogDebug("NullFindwiseApiFacade: Skipping AddOrReplaceAsync for {Count} catalogues (Azure Search is enabled)", catalogues?.Count ?? 0); + return Task.CompletedTask; + } + + /// + /// No-op implementation. Does not add or replace resources in Findwise. + /// + /// The resources to add/replace in the index. + /// The task. + public Task AddOrReplaceAsync(List resources) + { + this.logger.LogDebug("NullFindwiseApiFacade: Skipping AddOrReplaceAsync for {Count} resources (Azure Search is enabled)", resources?.Count ?? 0); + return Task.CompletedTask; + } + + /// + /// No-op implementation. Does not remove resources from Findwise. + /// + /// The resources to remove from Findwise. + /// The task. + public Task RemoveAsync(List resources) + { + this.logger.LogDebug("NullFindwiseApiFacade: Skipping RemoveAsync for {Count} resources (Azure Search is enabled)", resources?.Count ?? 0); + return Task.CompletedTask; + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailSenderService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailSenderService.cs index d79260330..614ffe2a3 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailSenderService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailSenderService.cs @@ -60,6 +60,19 @@ public async Task SendAccessRequestInviteEmail(int userId, SendEmailModel + /// Sends report generation completion email to user. + /// + /// The userId sending the email. + /// The model. + /// The task. + public async Task SendReportProcessedEmail(int userId, SendEmailModel model) + { + var template = emailTemplateService.GetReportProcessed(model); + await messageService.CreateEmailAsync(userId, template.Subject, template.Body, template.EmailAddress); + } + + /// /// Sends email change confirmation email. /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailTemplateService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailTemplateService.cs index dd4851149..ca048f34a 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailTemplateService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/EmailTemplateService.cs @@ -6,6 +6,7 @@ using LearningHub.Nhs.Models.Email; using LearningHub.Nhs.Models.Email.Models; using LearningHub.Nhs.Models.Enums; + using LearningHub.Nhs.OpenApi.Models.ViewModels; using LearningHub.Nhs.OpenApi.Repositories.Interface.Repositories.Messaging; using LearningHub.Nhs.OpenApi.Services.Interface.Services.Messaging; @@ -140,6 +141,32 @@ public EmailDetails GetCatalogueAccessInvitation(SendEmailModel + /// The GetCatalogueAccessRequestFailure. + /// + /// The email model. + /// The subject and body. + public EmailDetails GetReportProcessed(SendEmailModel emailModel) + { + var emailTemplate = emailTemplateRepository.GetTemplate((int)EmailTemplates.ReportProcessed); + var emailBody = this.Replace(emailTemplate.EmailTemplateLayout.Body, new Dictionary + { + ["Content"] = emailTemplate.Body, + }); + var model = emailModel.Model; + var replacementDict = new Dictionary + { + ["UserFirstName"] = model.UserFirstName, + ["ReportSection"] = model.ReportUrl, + ["ReportName"] = model.ReportName, + ["ReportContent"] = model.ReportTitle, + }; + + var subject = this.Replace(emailTemplate.Subject, replacementDict); + var body = Replace(emailBody, replacementDict); + return new EmailDetails { Body = body, Subject = subject, EmailAddress = emailModel.EmailAddress }; + } + /// /// The GetEmailChangeConfirmationEmail. /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/MessageService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/MessageService.cs index 815257e27..e6eb0a644 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/MessageService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/Messaging/MessageService.cs @@ -23,14 +23,13 @@ public class MessageService : BaseService, IMessageService /// /// Initializes a new instance of the class. /// - /// The findwiseHttpClient. + // /// The findwiseHttpClient. /// The logger. /// The message repository. - public MessageService( - IFindwiseClient findwiseClient, + public MessageService( ILogger logger, IMessageRepository messageRepository) - : base(findwiseClient, logger) + : base(logger) { this.messageRepository = messageRepository; } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NavigationPermissionService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NavigationPermissionService.cs index a8c6ceb78..d43382725 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NavigationPermissionService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NavigationPermissionService.cs @@ -2,9 +2,10 @@ { using System.Security.Principal; using System.Threading.Tasks; - using LearningHub.Nhs.Models.Extensions; + using LearningHub.Nhs.OpenApi.Models.Configuration; using LearningHub.Nhs.OpenApi.Models.ViewModels; using LearningHub.Nhs.OpenApi.Services.Interface.Services; + using Microsoft.Extensions.Options; /// /// Defines the . @@ -13,16 +14,22 @@ public class NavigationPermissionService : INavigationPermissionService { private readonly IResourceService resourceService; private readonly IUserGroupService userGroupService; + private readonly IDatabricksService databricksService; + private readonly IOptions featureFlagsConfig; /// /// Initializes a new instance of the class. /// /// Resource service. /// userGroup service. - public NavigationPermissionService(IResourceService resourceService, IUserGroupService userGroupService) + /// databricksService. + /// featureFlags + public NavigationPermissionService(IResourceService resourceService, IUserGroupService userGroupService, IDatabricksService databricksService, IOptions featureFlagsConfig) { this.resourceService = resourceService; this.userGroupService = userGroupService; + this.databricksService = databricksService; + this.featureFlagsConfig = featureFlagsConfig; } /// @@ -45,7 +52,7 @@ public async Task GetNavigationModelAsync(IPrincipal user, bool } else if (user.IsInRole("Administrator")) { - return AuthenticatedAdministrator(controllerName); + return await AuthenticatedAdministrator(controllerName, currentUserId); } else if (user.IsInRole("ReadOnly")) { @@ -86,6 +93,7 @@ public NavigationModel NotAuthenticated() ShowSignOut = false, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } @@ -93,8 +101,9 @@ public NavigationModel NotAuthenticated() /// The AuthenticatedAdministrator. /// /// The controller name. + /// userId. /// The . - private NavigationModel AuthenticatedAdministrator(string controllerName) + private async Task AuthenticatedAdministrator(string controllerName, int userId) { return new NavigationModel() { @@ -111,6 +120,7 @@ private NavigationModel AuthenticatedAdministrator(string controllerName) ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.IsInplatformReportActive() ? await this.databricksService.IsUserReporter(userId) : false, }; } @@ -137,6 +147,7 @@ private async Task AuthenticatedBlueUser(string controllerName, ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.IsInplatformReportActive() ? await this.databricksService.IsUserReporter(userId) : false, }; } @@ -161,6 +172,7 @@ private NavigationModel AuthenticatedGuest() ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } @@ -186,6 +198,7 @@ private async Task AuthenticatedReadOnly(string controllerName, ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = true, + ShowReports = this.IsInplatformReportActive() ? await this.databricksService.IsUserReporter(userId) : false, }; } @@ -210,6 +223,7 @@ private async Task AuthenticatedBasicUserOnly(int userId) ShowSignOut = true, ShowMyAccount = true, ShowBrowseCatalogues = true, + ShowReports = this.IsInplatformReportActive() ? await this.databricksService.IsUserReporter(userId) : false, }; } @@ -234,7 +248,18 @@ private NavigationModel InLoginWizard() ShowSignOut = true, ShowMyAccount = false, ShowBrowseCatalogues = false, + ShowReports = false, }; } + + private bool IsInplatformReportActive() + { + bool.TryParse(this.featureFlagsConfig.Value.InPlatformReport, out bool inPlatformReport); + if (inPlatformReport) + { + return true; + } + return false; + } } } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationService.cs index fe002695a..ec46526ef 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationService.cs @@ -195,6 +195,36 @@ public async Task CreateResourcePublishedNotificationAsync(int userId, stri } } + /// + /// Creates report processed notification. + /// + /// The current user id. + /// Report Name. + /// Report Content. + /// The . + public async Task CreateReportNotificationAsync(int userId, string reportName, string reportContent) + { + var title = learningHubConfig.Notifications.ReportTitle.Replace("[ReportName]", reportName).Replace("[ReportContent]", reportContent); + + var message = learningHubConfig.Notifications.Report.Replace("[ReportName]", reportName).Replace("[ReportContent]", reportContent); + + message = message.Replace("[ReportSection]", $"{this.learningHubConfig.BaseUrl.TrimEnd('/')}/{this.learningHubConfig.ReportUrl.TrimStart('/')}"); + + + var notification = await this.CreateAsync(userId, this.UserSpecificNotification( + title, message, NotificationTypeEnum.ReportProcessed, NotificationPriorityEnum.General)); + + if (notification.CreatedId.HasValue) + { + return notification.CreatedId.Value; + } + else + { + return 0; + } + } + + /// /// Creates resource publish failed notification. /// diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationTemplateService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationTemplateService.cs index 74e66bd44..fcd6548d7 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationTemplateService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/NotificationTemplateService.cs @@ -21,14 +21,14 @@ public class NotificationTemplateService : BaseService, IN /// /// Initializes a new instance of the class. /// - /// The findwise client. + ///// The findwise client. /// The logger. /// The notification template repository. public NotificationTemplateService( - IFindwiseClient fwClient, + // IFindwiseClient fwClient, ILogger logger, INotificationTemplateRepository notificationTemplateRepository) - : base(fwClient, logger) + : base(logger) { this.notificationTemplateRepository = notificationTemplateRepository; } diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/SearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/SearchService.cs index 82c9a52ca..d3eb98d67 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/SearchService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/SearchService.cs @@ -5,6 +5,7 @@ namespace LearningHub.Nhs.OpenApi.Services.Services using System.Linq; using System.Net.Http; using System.Text; + using System.Threading; using System.Threading.Tasks; using System.Web; using AutoMapper; @@ -92,8 +93,9 @@ public SearchService( /// /// The search request model. /// The user id. + /// Cancellation token. /// The . - public async Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId) + public async Task GetSearchResultAsync(SearchRequestModel searchRequestModel, int userId, CancellationToken cancellationToken = default) { SearchResultModel viewmodel = new SearchResultModel(); @@ -170,10 +172,11 @@ public async Task GetSearchResultAsync(SearchRequestModel sea /// /// The user id. /// + /// Cancellation token. /// /// The . /// - public async Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId) + public async Task GetCatalogueSearchResultAsync(CatalogueSearchRequestModel catalogSearchRequestModel, int userId, CancellationToken cancellationToken = default) { var viewmodel = new SearchCatalogueResultModel(); @@ -641,8 +644,9 @@ public async Task GetAllCatalogueSearchResultsAsy /// The Get Auto suggestion Results Async method. /// /// The term. + /// Cancellation token. /// The . - public async Task GetAutoSuggestionResultsAsync(string term) + public async Task GetAutoSuggestionResultsAsync(string term, CancellationToken cancellationToken = default) { var viewmodel = new AutoSuggestionModel(); diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Startup.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Startup.cs index 87c94f64e..73c887df7 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Startup.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Startup.cs @@ -6,9 +6,11 @@ namespace LearningHub.Nhs.OpenApi.Services using LearningHub.Nhs.OpenApi.Services.Interface.Services; using LearningHub.Nhs.OpenApi.Services.Interface.Services.Messaging; using LearningHub.Nhs.OpenApi.Services.Services; + using LearningHub.Nhs.OpenApi.Services.Services.AzureSearch; using LearningHub.Nhs.OpenApi.Services.Services.Findwise; using LearningHub.Nhs.OpenApi.Services.Services.Messaging; using LearningHub.Nhs.Services; + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; /// @@ -20,11 +22,24 @@ public static class Startup /// Registers the implementations in the project with ASP.NET DI. /// /// The IServiceCollection. - public static void AddServices(this IServiceCollection services) + /// The configuration. + public static void AddServices(this IServiceCollection services, IConfiguration configuration) { - services.AddScoped(); + // Register search service based on feature flag + var useAzureSearch = configuration.GetValue("FeatureFlags:UseAzureSearch", false); + + if (useAzureSearch) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + services.AddScoped(); + } + services.AddHttpClient(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -37,6 +52,16 @@ public static void AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); + var reportingEnabled = configuration.GetValue("FeatureFlags:InPlatformReport"); + if (reportingEnabled) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -53,7 +78,6 @@ public static void AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddTransient(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -67,6 +91,16 @@ public static void AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // Register IFindwiseApiFacade based on feature flag + if (useAzureSearch) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } } } } \ No newline at end of file diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Tests/LearningHub.Nhs.OpenApi.Tests.csproj b/OpenAPI/LearningHub.Nhs.OpenApi.Tests/LearningHub.Nhs.OpenApi.Tests.csproj index 35c780917..93461ce2b 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Tests/LearningHub.Nhs.OpenApi.Tests.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Tests/LearningHub.Nhs.OpenApi.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/Configuration/ConfigurationExtensions.cs b/OpenAPI/LearningHub.Nhs.OpenApi/Configuration/ConfigurationExtensions.cs index 7d5a61795..309eb8bd5 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/Configuration/ConfigurationExtensions.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi/Configuration/ConfigurationExtensions.cs @@ -25,6 +25,11 @@ public static class ConfigurationExtensions /// public const string FindwiseSectionName = "Findwise"; + /// + /// The AzureSearchSectionName. + /// + public const string AzureSearchSectionName = "AzureSearch"; + /// /// The LearningHubSectionName. /// @@ -45,6 +50,16 @@ public static class ConfigurationExtensions /// public const string MoodleSectionName = "Moodle"; + /// + /// The DatabricksSectionName. + /// + public const string DatabricksSectionName = "Databricks"; + + /// + /// The FeatureFlagsSectionName. + /// + public const string FeatureFlagsSectionName = "FeatureFlags"; + /// /// Adds config. /// @@ -58,6 +73,8 @@ public static void AddConfig(this IServiceCollection services, IConfiguration co services.AddOptions().Bind(config.GetSection(FindwiseSectionName)); + services.AddOptions().Bind(config.GetSection(AzureSearchSectionName)); + services.AddOptions().Bind(config.GetSection(LearningHubSectionName)); services.AddOptions().Bind(config.GetSection(LearningHubApiSectionName)); @@ -65,6 +82,10 @@ public static void AddConfig(this IServiceCollection services, IConfiguration co services.AddOptions().Bind(config.GetSection(AzureSectionName)); services.AddOptions().Bind(config.GetSection(MoodleSectionName)); + + services.AddOptions().Bind(config.GetSection(DatabricksSectionName)); + + services.AddOptions().Bind(config.GetSection(FeatureFlagsSectionName)); } private static OptionsBuilder RegisterPostConfigure(this OptionsBuilder builder) diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/ReportController.cs b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/ReportController.cs new file mode 100644 index 000000000..091b3db5d --- /dev/null +++ b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/ReportController.cs @@ -0,0 +1,129 @@ +namespace LearningHub.NHS.OpenAPI.Controllers +{ + using System.Threading.Tasks; + using LearningHub.Nhs.Models.Common; + using LearningHub.Nhs.Models.Databricks; + using LearningHub.Nhs.Models.Paging; + using LearningHub.Nhs.OpenApi.Models.ViewModels; + using LearningHub.Nhs.OpenApi.Services.Interface.Services; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + + /// + /// Report Controller. + /// + [ApiController] + [Authorize(Policy = "AuthorizeOrCallFromLH")] + [Route("Report")] + public class ReportController : OpenApiControllerBase + { + private readonly IDatabricksService databricksService; + + /// + /// Initializes a new instance of the class. + /// + /// The catalogue service. + public ReportController(IDatabricksService databricksService) + { + this.databricksService = databricksService; + } + + /// + /// Get all catalogues. + /// + /// Task. + [HttpGet] + [Route("GetReporterPermission")] + public async Task GetReporterPermission() + { + return await this.databricksService.IsUserReporter(this.CurrentUserId.GetValueOrDefault()); + } + + /// + /// Get CourseProgressReport from Databricks. + /// + /// requestModel. + /// Task. + [HttpPost] + [Route("GetCourseProgressReport")] + public async Task CourseProgressReport(DatabricksRequestModel requestModel) + { + return await this.databricksService.CourseCompletionReport(this.CurrentUserId.GetValueOrDefault(),requestModel); + } + + /// + /// Get Report History from Databricks. + /// + /// request. + /// Task. + [HttpPost] + [Route("GetReportHistory")] + public async Task> GetReportHistory(PagingRequestModel request) + { + return await this.databricksService.GetPagedReportHistory(this.CurrentUserId.GetValueOrDefault(), request.Page, request.PageSize); + } + + /// + /// Get GetReportHistoryById. + /// + /// reportHistoryId. + /// Task. + [HttpGet] + [Route("GetReportHistoryById/{reportHistoryId}")] + public async Task GetReportHistoryById(int reportHistoryId) + { + return await this.databricksService.GetPagedReportHistoryById(this.CurrentUserId.GetValueOrDefault(), reportHistoryId); + } + + /// + /// Get QueueReportDownload. + /// + /// reportHistoryId. + /// Task. + [HttpGet] + [Route("QueueReportDownload/{reportHistoryId}")] + public async Task QueueReportDownload(int reportHistoryId) + { + return await this.databricksService.QueueReportDownload(this.CurrentUserId.GetValueOrDefault(), reportHistoryId); + } + + /// + /// Get DownloadReport. + /// + /// reportHistoryId. + /// Task. + [HttpGet] + [Route("DownloadReport/{reportHistoryId}")] + public async Task DownloadReport(int reportHistoryId) + { + return await this.databricksService.DownloadReport(this.CurrentUserId.GetValueOrDefault(), reportHistoryId); + } + + /// + /// DatabricksJobNotify. + /// + /// databricksNotification. + /// Task. + [HttpPost] + [AllowAnonymous] + [Route("DatabricksJobNotify")] + public async Task DatabricksJobNotify(DatabricksNotification databricksNotification) + { + await this.databricksService.DatabricksJobUpdate(this.CurrentUserId.GetValueOrDefault(), databricksNotification); + return this.Ok(new ApiResponse(true)); + } + + /// + /// UpdateDatabricksReport. + /// + /// databricksUpdateRequest. + /// Task. + [HttpPost] + [Route("UpdateDatabricksReport")] + public async Task UpdateDatabricksReport(DatabricksUpdateRequest databricksUpdateRequest) + { + await this.databricksService.UpdateDatabricksReport(this.CurrentUserId.GetValueOrDefault(), databricksUpdateRequest); + return this.Ok(new ApiResponse(true)); + } + } +} diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/SearchController.cs b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/SearchController.cs index 2b463125b..d90ab47c0 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/SearchController.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/SearchController.cs @@ -271,7 +271,7 @@ private async Task GetSearchResults(SearchRequestModel searchRe { var results = await this.searchService.GetSearchResultAsync(searchRequestModel, this.CurrentUserId.GetValueOrDefault()); var documents = results.DocumentList.Documents.ToList(); - var catalogueIds = results.DocumentList.Documents.Select(x => x.CatalogueIds.FirstOrDefault()).Where(x => x != 0).ToHashSet().ToList(); + var catalogueIds = results.DocumentList.Documents.Select(x => x.CatalogueIds?.FirstOrDefault() ?? 0).Where(id => id != 0).ToHashSet().ToList(); var catalogues = this.catalogueService.GetCataloguesByNodeId(catalogueIds); var allProviders = await this.providerService.GetAllAsync(); @@ -282,12 +282,12 @@ private async Task GetSearchResults(SearchRequestModel searchRe document.Providers = allProviders.Where(n => document.ProviderIds.Contains(n.Id)).ToList(); } - if (document.CatalogueIds.Any(x => x == 1)) + if (document.CatalogueIds?.Any(x => x == 1) == true) { continue; } - var catalogue = catalogues.SingleOrDefault(x => x.NodeId == document.CatalogueIds.SingleOrDefault()); + var catalogue = catalogues.SingleOrDefault(x => x.NodeId == document.CatalogueIds?.SingleOrDefault() == true); if (catalogue == null) { @@ -345,7 +345,7 @@ private async Task GetSearchResults(SearchRequestModel searchRe var relatedCatalogueIds = new List(); foreach (var document in results.DocumentList.Documents) { - foreach (int catalogueId in document.CatalogueIds) + foreach (int catalogueId in document.CatalogueIds ?? Enumerable.Empty()) { if (relatedCatalogueIds.IndexOf(catalogueId) == -1) { diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/UserController.cs b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/UserController.cs index 98590761f..54d043086 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/UserController.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi/Controllers/UserController.cs @@ -353,7 +353,6 @@ public async Task>> GetLHUserNavigation() return this.MenuItems(model); } - private List> MenuItems(NavigationModel model) { var menu = new List> @@ -382,6 +381,12 @@ private List> MenuItems(NavigationModel model) { "url", this.learningHubConfig.BrowseCataloguesUrl }, { "visible", model.ShowBrowseCatalogues }, }, + new Dictionary + { + { "title", "Reports" }, + { "url", this.learningHubConfig.ReportUrl }, + { "visible", model.ShowReports }, + }, new Dictionary { { "title", "Admin" }, diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj b/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj index f23f74ba3..de5b7a6d7 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj +++ b/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj @@ -19,7 +19,7 @@ - + diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj.user b/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj.user index 5bdd12166..1d8db97aa 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj.user +++ b/OpenAPI/LearningHub.Nhs.OpenApi/LearningHub.NHS.OpenAPI.csproj.user @@ -5,7 +5,7 @@ IIS Local - ApiControllerEmptyScaffolder - root/Common/Api + MvcControllerEmptyScaffolder + root/Common/MVC/Controller \ No newline at end of file diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/Startup.cs b/OpenAPI/LearningHub.Nhs.OpenApi/Startup.cs index 68981d051..695565457 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/Startup.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi/Startup.cs @@ -83,7 +83,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddRepositories(this.Configuration); - services.AddServices(); + services.AddServices(this.Configuration); services.AddApplicationInsightsTelemetry(); services.AddControllers(options => { @@ -93,11 +93,16 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc() .AddNewtonsoftJson(options => options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore); + var swaggerTitle = this.Configuration["Swagger:Title"]; + var swaggerVersion = this.Configuration["Swagger:Version"]; + var swaggerDescription = $"A set of API endpoints for retrieving learning resource information from the Learning Hub learning platform. The [Learning Hub](https://learninghub.nhs.uk/) is a platform for hosting and sharing learning resources for health and social care provided by Technology Enhanced Learning (TEL) at NHS England. An application API key must be used to authorise calls to the API from external applications. To contact TEL to discuss connecting your external system to the Learning Hub, email england.tel@nhs.net.\n\n Build Number: {this.Configuration["Swagger:BuildNumber"]} \n\n"; + services.AddSwaggerGen( c => { // For docs see https://github.com/domaindrivendev/Swashbuckle.AspNetCore - c.SwaggerDoc("dev", new OpenApiInfo { Title = "LearningHub.NHS.OpenAPI", Version = "dev" }); + c.SwaggerDoc("dev", new OpenApiInfo { Title = swaggerTitle, Version = swaggerVersion, Description = swaggerDescription }); + c.CustomSchemaIds(type => type.FullName); c.AddSecurityDefinition( "ApiKey", @@ -220,12 +225,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseSwagger(); app.UseSwaggerUI(c => { - if (env.IsDevelopment()) - { - c.SwaggerEndpoint("/swagger/dev/swagger.json", "Auto-generated"); - } - - c.SwaggerEndpoint("/SwaggerDefinitions/v1.3.0.json", "v1.3.0"); + c.SwaggerEndpoint("/swagger/dev/swagger.json", "v1.4.0"); c.OAuthClientId(this.Configuration.GetValue("LearningHubAuthServiceConfig:ClientId")); c.OAuthClientSecret(this.Configuration.GetValue("LearningHubAuthServiceConfig:ClientSecret")); c.OAuthScopes(this.Configuration.GetValue("LearningHubAuthServiceConfig:Scopes")); diff --git a/OpenAPI/LearningHub.Nhs.OpenApi/appsettings.json b/OpenAPI/LearningHub.Nhs.OpenApi/appsettings.json index bec2f2309..42b84d3b8 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi/appsettings.json +++ b/OpenAPI/LearningHub.Nhs.OpenApi/appsettings.json @@ -7,6 +7,11 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "Swagger": { + "Title": "Swagger XML DOC LearningHub.NHS.OpenAPI", + "Version": "v4.1.0", + "BuildNumber": "" + }, "AllowedHosts": "*", "ConnectionStrings": { "ElfhHubDbConnection": "", @@ -49,6 +54,25 @@ }, "AzureStorageQueueConnectionString": "" }, + "FeatureFlags": { + "UseAzureSearch": false, + "InPlatformReport": false + }, + "AzureSearch": { + "ServiceEndpoint": "", + "AdminApiKey": "", + "QueryApiKey": "", + "IndexName": "", + "SuggesterName": "", + "ConceptsSuggesterSize": 5, + "ResourceCollectionSuggesterSize": 5, + "SearchQueryType": "semantic", //semantic, full, or simple + "SemanticResultBufferSize": 50, + "ScoringProfile": "boostExactTitle", + "DefaultItemLimitForSearch": 10, + "DescriptionLengthLimit": 3000, + "MaximumDescriptionLength": 150 + }, "FindWise": { "IndexUrl": "", "SearchBaseUrl": "", @@ -80,6 +104,7 @@ "ResourcePublishQueueRouteName": "", "HierarchyEditPublishQueueName": "", "ContentManagementQueueName": "", + "DatabricksProcessingQueueName": "", "AuthClientIdentityKey": "", "LHClientIdentityKey": "", "ReportApiClientIdentityKey": "", @@ -93,7 +118,9 @@ "ResourcePublishFailedWithReason": "

    The resource you contributed failed to publish, which means that users cannot access it.

    The error message generated was:
    [ErrorMessage]

    Please contact the support team for more information.

    ", "ResourceAccessTitle": "What you can do in the Learning Hub has changed", "ResourceReadonlyAccess": "

    You can continue to search for and access resources in the Learning Hub, however you cannot contribute to it.

    If you have any questions about this, please contact the support team.

    ", - "ResourceContributeAccess": "

    You can now contribute to the Learning Hub and continue to search for and access resources.

    If you have any questions about this, please contact the support team.

    " + "ResourceContributeAccess": "

    You can now contribute to the Learning Hub and continue to search for and access resources.

    If you have any questions about this, please contact the support team.

    ", + "ReportTitle": "[ReportName] report for [ReportContent] is ready", + "Report": "

    Your [ReportName] report for [ReportContent] is ready. You can view and download the report in the reports section

    " }, "MyContributionsUrl": "/my-contributions", "MyLearningUrl": "/MyLearning", @@ -107,7 +134,9 @@ "RegisterUrl": "/register", "SignOutUrl": "/home/logout", "MyAccountUrl": "/myaccount", - "BrowseCataloguesUrl": "/allcatalogue" + "BrowseCataloguesUrl": "/allcatalogue", + "ReportUrl": "/reports", + "StatMandId": 0 }, "LearningHubAPIConfig": { "BaseUrl": "https://learninghub.nhs.uk/api" @@ -126,5 +155,17 @@ "ApiBaseUrl": "", "ApiWsRestFormat": "json", "ApiWsToken": "" + }, + "Databricks": { + "InstanceUrl": "", + "Token": "", + "WarehouseId": "", + "JobId": "", + "UserPermissionEndpoint": "", + "CourseCompletionEndpoint": "", + "ResourceId": "", + "TenantId": "", + "ClientId": "", + "ClientSecret": "" } } diff --git a/ReportAPI/LearningHub.Nhs.ReportApi.Services.Interface/LearningHub.Nhs.ReportApi.Services.Interface.csproj b/ReportAPI/LearningHub.Nhs.ReportApi.Services.Interface/LearningHub.Nhs.ReportApi.Services.Interface.csproj index 9925c85ea..2a259d6b2 100644 --- a/ReportAPI/LearningHub.Nhs.ReportApi.Services.Interface/LearningHub.Nhs.ReportApi.Services.Interface.csproj +++ b/ReportAPI/LearningHub.Nhs.ReportApi.Services.Interface/LearningHub.Nhs.ReportApi.Services.Interface.csproj @@ -16,7 +16,7 @@ - + diff --git a/ReportAPI/LearningHub.Nhs.ReportApi.Services.UnitTests/LearningHub.Nhs.ReportApi.Services.UnitTests.csproj b/ReportAPI/LearningHub.Nhs.ReportApi.Services.UnitTests/LearningHub.Nhs.ReportApi.Services.UnitTests.csproj index 0948fa06c..aebb8bb75 100644 --- a/ReportAPI/LearningHub.Nhs.ReportApi.Services.UnitTests/LearningHub.Nhs.ReportApi.Services.UnitTests.csproj +++ b/ReportAPI/LearningHub.Nhs.ReportApi.Services.UnitTests/LearningHub.Nhs.ReportApi.Services.UnitTests.csproj @@ -18,7 +18,7 @@ - + diff --git a/ReportAPI/LearningHub.Nhs.ReportApi.Services/LearningHub.Nhs.ReportApi.Services.csproj b/ReportAPI/LearningHub.Nhs.ReportApi.Services/LearningHub.Nhs.ReportApi.Services.csproj index 1e3c6f360..acfb75739 100644 --- a/ReportAPI/LearningHub.Nhs.ReportApi.Services/LearningHub.Nhs.ReportApi.Services.csproj +++ b/ReportAPI/LearningHub.Nhs.ReportApi.Services/LearningHub.Nhs.ReportApi.Services.csproj @@ -19,7 +19,7 @@ - + diff --git a/ReportAPI/LearningHub.Nhs.ReportApi.Shared/LearningHub.Nhs.ReportApi.Shared.csproj b/ReportAPI/LearningHub.Nhs.ReportApi.Shared/LearningHub.Nhs.ReportApi.Shared.csproj index 2be568801..9d86c24a0 100644 --- a/ReportAPI/LearningHub.Nhs.ReportApi.Shared/LearningHub.Nhs.ReportApi.Shared.csproj +++ b/ReportAPI/LearningHub.Nhs.ReportApi.Shared/LearningHub.Nhs.ReportApi.Shared.csproj @@ -17,7 +17,7 @@ - + diff --git a/ReportAPI/LearningHub.Nhs.ReportApi/LearningHub.Nhs.ReportApi.csproj b/ReportAPI/LearningHub.Nhs.ReportApi/LearningHub.Nhs.ReportApi.csproj index 99ba74bda..f31ff35e5 100644 --- a/ReportAPI/LearningHub.Nhs.ReportApi/LearningHub.Nhs.ReportApi.csproj +++ b/ReportAPI/LearningHub.Nhs.ReportApi/LearningHub.Nhs.ReportApi.csproj @@ -20,7 +20,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.API/Controllers/ActivityController.cs b/WebAPI/LearningHub.Nhs.API/Controllers/ActivityController.cs index 49a9d01eb..fc5767d6a 100644 --- a/WebAPI/LearningHub.Nhs.API/Controllers/ActivityController.cs +++ b/WebAPI/LearningHub.Nhs.API/Controllers/ActivityController.cs @@ -29,7 +29,7 @@ public class ActivityController : ApiControllerBase private readonly IResourceService resourceService; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The elfh user service. /// The activity service. @@ -211,6 +211,27 @@ public async Task CompleteScormActivity(ScormActivityViewModel co } } + /// + /// Complete Scorm Activity. + /// + /// The scorm activity. + /// The . + [HttpPost] + [Route("ScormCompleteActivity")] + public async Task ScormCompleteActivity(ScormActivityViewModel completeScormActivityViewModel) + { + var vr = await this.activityService.ScormCompleteActivity(this.CurrentUserId, completeScormActivityViewModel); + + if (vr.IsValid) + { + return this.Ok(new ApiResponse(true, vr)); + } + else + { + return this.BadRequest(new ApiResponse(false, vr)); + } + } + /// /// Launch Scorm Activity. /// diff --git a/WebAPI/LearningHub.Nhs.API/LearningHub.Nhs.Api.csproj b/WebAPI/LearningHub.Nhs.API/LearningHub.Nhs.Api.csproj index 8040a9f8d..3a7660599 100644 --- a/WebAPI/LearningHub.Nhs.API/LearningHub.Nhs.Api.csproj +++ b/WebAPI/LearningHub.Nhs.API/LearningHub.Nhs.Api.csproj @@ -29,7 +29,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.Api.Shared/LearningHub.Nhs.Api.Shared.csproj b/WebAPI/LearningHub.Nhs.Api.Shared/LearningHub.Nhs.Api.Shared.csproj index 05dbc4e35..e1b7896b8 100644 --- a/WebAPI/LearningHub.Nhs.Api.Shared/LearningHub.Nhs.Api.Shared.csproj +++ b/WebAPI/LearningHub.Nhs.Api.Shared/LearningHub.Nhs.Api.Shared.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/WebAPI/LearningHub.Nhs.Api.UnitTests/LearningHub.Nhs.Api.UnitTests.csproj b/WebAPI/LearningHub.Nhs.Api.UnitTests/LearningHub.Nhs.Api.UnitTests.csproj index 72f200f46..4acbd2e13 100644 --- a/WebAPI/LearningHub.Nhs.Api.UnitTests/LearningHub.Nhs.Api.UnitTests.csproj +++ b/WebAPI/LearningHub.Nhs.Api.UnitTests/LearningHub.Nhs.Api.UnitTests.csproj @@ -11,7 +11,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.Database/LearningHub.Nhs.Database.sqlproj b/WebAPI/LearningHub.Nhs.Database/LearningHub.Nhs.Database.sqlproj index a88a4bcff..6be19efc9 100644 --- a/WebAPI/LearningHub.Nhs.Database/LearningHub.Nhs.Database.sqlproj +++ b/WebAPI/LearningHub.Nhs.Database/LearningHub.Nhs.Database.sqlproj @@ -103,6 +103,7 @@ + @@ -577,7 +578,6 @@ - @@ -622,7 +622,6 @@ - @@ -680,7 +679,6 @@ - @@ -691,6 +689,13 @@ + + + + + + + diff --git a/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6148-ADFtableData.sql b/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6148-ADFtableData.sql index e748cbf8a..d9e8acd39 100644 --- a/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6148-ADFtableData.sql +++ b/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6148-ADFtableData.sql @@ -42,4 +42,5 @@ VALUES ('ELFHtoLH', 'JobRoleTbl', '1900-01-01'), ('ELFHtoLH', 'userTermsAndConditionsTBL', '1900-01-01'), ('ELFHtoLH', 'medicalCouncilTBL', '1900-01-01'), -('ELFHtoLH', 'staffGroupTBL', '1900-01-01') +('ELFHtoLH', 'staffGroupTBL', '1900-01-01'), +('ELFHtoLH', 'usergroupreportertbl', '1900-01-01') diff --git a/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6625-AddtionalTable-CDC.sql b/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6625-AddtionalTable-CDC.sql new file mode 100644 index 000000000..f87485a2e --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Scripts/Post-Deploy/Scripts/TD-6625-AddtionalTable-CDC.sql @@ -0,0 +1,10 @@ +-- DROP NodeEditor table +DROP TABLE elfh.userHistoryAttributeTBL + +--Enable CDC +EXEC sys.sp_cdc_enable_table + @source_schema = N'elfh', + @source_name = N'userReportingUserTBL', + @role_name = NULL, + @supports_net_changes = 0; +GO diff --git a/WebAPI/LearningHub.Nhs.Database/Scripts/Pre-Deploy/Scripts/InPlatform_Report_Predeployment.sql b/WebAPI/LearningHub.Nhs.Database/Scripts/Pre-Deploy/Scripts/InPlatform_Report_Predeployment.sql new file mode 100644 index 000000000..08469490e --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Scripts/Pre-Deploy/Scripts/InPlatform_Report_Predeployment.sql @@ -0,0 +1,8 @@ +INSERT [hub].[NotificationType] ([Id], [Name], [Description], [Deleted], [CreateUserId], [CreateDate], [AmendUserId], [AmendDate]) VALUES (8, N'AccessRequest', N'Access request', 0, 4, CAST(N'2021-07-14T13:33:51.0233333+00:00' AS DateTimeOffset), 4, CAST(N'2021-07-14T13:33:51.0233333+00:00' AS DateTimeOffset)) +GO + + + +INSERT [messaging].[EmailTemplate] ([Id], [LayoutId], [Title], [Subject], [Body], [AvailableTags], [Deleted], [CreateUserId], [CreateDate], [AmendUserId], [AmendDate]) VALUES (2007, 1, N'ReportProcessed', N'[ReportName] report for [ReportContent] is ready', N'

    Dear [UserFirstName],

    +

    Your [ReportName] report for [ReportContent] is ready. You can view and download the report in the reports section

    ', N'[UserFirstName][ReportSection][ReportName][ReportContent]', 0, 4, CAST(N'2025-12-22T00:00:00.0000000+00:00' AS DateTimeOffset), 4, CAST(N'2025-12-22T00:00:00.0000000+00:00' AS DateTimeOffset)) +GO \ No newline at end of file diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserInProgressLearningActivities.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserInProgressLearningActivities.sql index fdd153a7a..12357075e 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserInProgressLearningActivities.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserInProgressLearningActivities.sql @@ -5,6 +5,7 @@ -- -- Modification History -- 01-10-2025 SA added assesment score and passmark and provider details +-- 05-02-2025 SA TD-6860 : Fixed the null issue with the search history ------------------------------------------------------------------------------- CREATE PROCEDURE [activity].[GetUserInProgressLearningActivities] ( @userId INT @@ -23,7 +24,7 @@ BEGIN ( SELECT TOP 1 rr.OriginalResourceReferenceId FROM [resources].[ResourceReference] rr - WHERE rr.ResourceId = rv.ResourceId AND rr.Deleted = 0 + WHERE rr.ResourceId = rv.ResourceId --AND rr.Deleted = 0 ) AS ResourceReferenceID, ra.MajorVersion AS MajorVersion, ra.MinorVersion AS MinorVersion, diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserRecentLearningActivities.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserRecentLearningActivities.sql index 6e5160787..2626862f2 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserRecentLearningActivities.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUserRecentLearningActivities.sql @@ -7,6 +7,7 @@ -- 02-Sep-2025 SA Incorrect Syntax -- 23-09-2025 SA Added new columns for displaying video/Audio Progress -- 01-10-2025 SA added assesment score and passmark and provider details +-- 05-02-2025 SA TD-6860 : Fixed the null issue with the search history ------------------------------------------------------------------------------- CREATE PROCEDURE [activity].[GetUserRecentLearningActivities] ( @userId INT, @@ -26,7 +27,7 @@ BEGIN ( SELECT TOP 1 rr.OriginalResourceReferenceId FROM [resources].[ResourceReference] rr - WHERE rr.ResourceId = rv.ResourceId AND rr.Deleted = 0 + WHERE rr.ResourceId = rv.ResourceId --AND rr.Deleted = 0 ) AS ResourceReferenceID, ra.MajorVersion AS MajorVersion, ra.MinorVersion AS MinorVersion, diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory.sql index 81669c476..c60018b71 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory.sql @@ -6,6 +6,7 @@ -- Modification History -- 23-09-2025 SA Added new columns for displaying video/Audio Progress -- 01-10-2025 SA added assesment score and passmark and provider details +-- 05-02-2025 SA TD-6860 : Fixed the null issue with the search history ------------------------------------------------------------------------------- CREATE PROCEDURE [activity].[GetUsersLearningHistory] ( @userId INT, @@ -24,7 +25,7 @@ BEGIN ( SELECT TOP 1 rr.OriginalResourceReferenceId FROM [resources].[ResourceReference] rr - WHERE rr.ResourceId = rv.ResourceId AND rr.Deleted = 0 + WHERE rr.ResourceId = rv.ResourceId --AND rr.Deleted = 0 ) AS ResourceReferenceId, ra.MajorVersion AS MajorVersion, ra.MinorVersion AS MinorVersion, diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory_Search.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory_Search.sql index ba4d565dd..80c9f79d3 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory_Search.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/GetUsersLearningHistory_Search.sql @@ -6,6 +6,7 @@ -- Modification History -- 23-09-2025 SA Added new columns for displaying video/Audio Progress -- 01-10-2025 SA added assesment score and passmark and provider details +-- 05-02-2025 SA TD-6860 : Fixed the null issue with the search history ------------------------------------------------------------------------------- CREATE PROCEDURE [activity].[GetUsersLearningHistory_Search] ( @userId INT, @@ -27,7 +28,7 @@ BEGIN ( SELECT TOP 1 rr.OriginalResourceReferenceId FROM [resources].[ResourceReference] rr - WHERE rr.ResourceId = rv.ResourceId AND rr.Deleted = 0 + WHERE rr.ResourceId = rv.ResourceId --AND rr.Deleted = 0 ) AS ResourceReferenceId, ra.MajorVersion AS MajorVersion, ra.MinorVersion AS MinorVersion, diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/ScormActivityComplete.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/ScormActivityComplete.sql index c04238a5a..db09d0c3a 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/ScormActivityComplete.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Activity/ScormActivityComplete.sql @@ -7,6 +7,7 @@ -- -- 11-06-2021 Killian Davies Initial Revision -- 17-04-2024 Swapna Abraham Reverted TD-1325 changes +-- 10-02-2026 Swapna Abraham TD-6848 Certificate Generation Does Not Trigger After Successful Completion of the Resources ------------------------------------------------------------------------------- CREATE PROCEDURE [activity].[ScormActivityComplete] ( @@ -45,13 +46,14 @@ BEGIN IF EXISTS (SELECT 'X' FROM activity.ResourceActivity WHERE LaunchResourceActivityId = @ScormResourceActivityId AND ActivityStatusId IN (3, 4, 5)) BEGIN DECLARE @ActivityStatusErrorMessage nvarchar(1024) - SELECT @ActivityStatusErrorMessage = 'ResourceActivity entry with Completed status already exists for ScormActivityId=' + @ScormActivityId + SELECT @ActivityStatusErrorMessage = 'ResourceActivity entry with Completed status already exists for ScormActivityId=' + CONVERT(nvarchar(20), @ScormActivityId); RAISERROR (@ActivityStatusErrorMessage, 16, -- Severity. 1 -- State. ); END - +ELSE +BEGIN -- Validation ported from e-LfH: completed status requires duration > 0 IF (@ActivityStatusId IN (3, 4, 5) AND @DurationSeconds > 0) BEGIN TRY @@ -123,5 +125,6 @@ BEGIN ); END CATCH END +END GO \ No newline at end of file diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserAdminLocation.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserAdminLocation.sql index 28e48027f..3d7d41724 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserAdminLocation.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserAdminLocation.sql @@ -6,45 +6,76 @@ -- Modification History -- -- 04-11-2025 Sarathlal Initial Revision +-- 14-01-2026 Swapna TD-6760: To handle duplicate records in the Sync table ------------------------------------------------------------------------------- -CREATE PROCEDURE [AdfMergeUserAdminLocation] +CREATE PROCEDURE [dbo].[AdfMergeUserAdminLocation] @UserAdminLocationList dbo.UserAdminLocationType READONLY AS BEGIN SET NOCOUNT ON; - MERGE [elfh].[userAdminLocationTBL] AS target - USING @UserAdminLocationList AS source - ON target.[userId] = source.[userId] - AND target.[adminLocationId] = source.[adminLocationId] -- composite key match + -------------------------------------------------------------------- + -- 1. Remove deleted duplicates when an active row exists + -------------------------------------------------------------------- + DELETE tgt + FROM elfh.userAdminLocationTBL tgt + INNER JOIN @UserAdminLocationList src + ON tgt.userId = src.userId + AND tgt.adminLocationId = src.adminLocationId + WHERE tgt.deleted = 1 + AND EXISTS ( + SELECT 1 + FROM elfh.userAdminLocationTBL a + WHERE a.userId = src.userId + AND a.adminLocationId = src.adminLocationId + AND a.deleted = 0 + ); - WHEN MATCHED THEN - UPDATE SET - target.[deleted] = source.[deleted], - target.[amendUserId] = source.[amendUserId], - target.[amendDate] = source.[amendDate], - target.[createdUserId] = source.[createdUserId], - target.[createdDate] = source.[createdDate] + -------------------------------------------------------------------- + -- 2. Update existing ACTIVE rows only + -- (do NOT overwrite deleted rows) + -------------------------------------------------------------------- + UPDATE tgt + SET + tgt.amendUserId = src.amendUserId, + tgt.amendDate = src.amendDate, + tgt.createdUserId = src.createdUserId, + tgt.createdDate = src.createdDate, + tgt.deleted = src.deleted + FROM elfh.userAdminLocationTBL tgt + INNER JOIN @UserAdminLocationList src + ON tgt.userId = src.userId + AND tgt.adminLocationId = src.adminLocationId + WHERE tgt.deleted = 0; - WHEN NOT MATCHED BY TARGET THEN - INSERT ( - [userId], - [adminLocationId], - [deleted], - [amendUserId], - [amendDate], - [createdUserId], - [createdDate] - ) - VALUES ( - source.[userId], - source.[adminLocationId], - source.[deleted], - source.[amendUserId], - source.[amendDate], - source.[createdUserId], - source.[createdDate] - ); - -END + -------------------------------------------------------------------- + -- 3. Insert rows that do not exist + -- (both deleted = 0 and deleted = 1 allowed) + -------------------------------------------------------------------- + INSERT INTO elfh.userAdminLocationTBL ( + userId, + adminLocationId, + deleted, + amendUserId, + amendDate, + createdUserId, + createdDate + ) + SELECT + src.userId, + src.adminLocationId, + src.deleted, + src.amendUserId, + src.amendDate, + src.createdUserId, + src.createdDate + FROM @UserAdminLocationList src + WHERE NOT EXISTS ( + SELECT 1 + FROM elfh.userAdminLocationTBL tgt WITH (UPDLOCK, HOLDLOCK) + WHERE tgt.userId = src.userId + AND tgt.adminLocationId = src.adminLocationId + AND tgt.deleted = src.deleted + ); +END; GO diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserEmploymentReference.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserEmploymentReference.sql index 5a5aa29a6..e6f0d2ad3 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserEmploymentReference.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserEmploymentReference.sql @@ -7,13 +7,14 @@ -- -- 04-11-2025 Sarathlal Initial Revision ------------------------------------------------------------------------------- -CREATE PROCEDURE [AdfMergeUserEmploymentReference] +CREATE PROCEDURE [dbo].[AdfMergeUserEmploymentReference] @UserEmploymentReferenceList dbo.UserEmploymentReferenceType READONLY AS BEGIN SET NOCOUNT ON; SET IDENTITY_INSERT [elfh].[userEmploymentReferenceTBL] ON; + ALTER TABLE [elfh].[userEmploymentReferenceTBL] NOCHECK CONSTRAINT ALL; MERGE [elfh].[userEmploymentReferenceTBL] AS target USING @UserEmploymentReferenceList AS source ON target.[userEmploymentReferenceId] = source.[userEmploymentReferenceId] @@ -47,5 +48,6 @@ BEGIN source.[amendDate] ); SET IDENTITY_INSERT [elfh].[userEmploymentReferenceTBL] OFF; + ALTER TABLE [elfh].[userEmploymentReferenceTBL] NOCHECK CONSTRAINT ALL; END GO diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserGroupReporter.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserGroupReporter.sql new file mode 100644 index 000000000..d1817a389 --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserGroupReporter.sql @@ -0,0 +1,50 @@ +------------------------------------------------------------------------------- +-- Author Sarathlal +-- Created 25-11-2025 +-- Purpose ELFH-LH Data sync +-- +-- Modification History +-- +-- 25-11-2025 Sarathlal Initial Revision +------------------------------------------------------------------------------- +CREATE PROCEDURE [dbo].[AdfMergeUserGroupReporter] + @UserGroupReporterList [dbo].[UserGroupReporterType] READONLY +AS +BEGIN + SET NOCOUNT ON; + SET IDENTITY_INSERT [elfh].[userGroupReporterTBL] ON; + ALTER TABLE elfh.userGroupReporterTBL NOCHECK CONSTRAINT FK_userGroupReporterTBL_userGroupTBL; + MERGE [elfh].[userGroupReporterTBL] AS target + USING @UserGroupReporterList AS source + ON target.[userGroupReporterId] = source.[userGroupReporterId] + + WHEN MATCHED THEN + UPDATE SET + target.[userId] = source.[userId], + target.[userGroupId] = source.[userGroupId], + target.[deleted] = source.[deleted], + target.[amendUserId] = source.[amendUserId], + target.[amendDate] = source.[amendDate] + + WHEN NOT MATCHED BY TARGET THEN + INSERT ( + [userGroupReporterId], + [userId], + [userGroupId], + [deleted], + [amendUserId], + [amendDate] + ) + VALUES ( + source.[userGroupReporterId], + source.[userId], + source.[userGroupId], + source.[deleted], + source.[amendUserId], + source.[amendDate] + ); + SET IDENTITY_INSERT [elfh].[userGroupReporterTBL] OFF; + ALTER TABLE elfh.userGroupReporterTBL CHECK CONSTRAINT FK_userGroupReporterTBL_userGroupTBL; +END +GO + diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserHistoryAttribute.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserHistoryAttribute.sql deleted file mode 100644 index cc17b95fa..000000000 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserHistoryAttribute.sql +++ /dev/null @@ -1,62 +0,0 @@ -------------------------------------------------------------------------------- --- Author Sarathlal --- Created 04-11-2025 --- Purpose ELFH-LH Data sync --- --- Modification History --- --- 04-11-2025 Sarathlal Initial Revision -------------------------------------------------------------------------------- -CREATE PROCEDURE [AdfMergeUserHistoryAttribute] - @UserHistoryAttributeList dbo.UserHistoryAttributeType READONLY -AS -BEGIN - SET NOCOUNT ON; - - ALTER TABLE [elfh].[userHistoryAttributeTBL] NOCHECK CONSTRAINT ALL; - SET IDENTITY_INSERT [elfh].[userHistoryAttributeTBL] ON; - MERGE [elfh].[userHistoryAttributeTBL] AS target - USING @UserHistoryAttributeList AS source - ON target.[userHistoryAttributeId] = source.[userHistoryAttributeId] - - WHEN MATCHED THEN - UPDATE SET - target.[userHistoryId] = source.[userHistoryId], - target.[attributeId] = source.[attributeId], - target.[intValue] = source.[intValue], - target.[textValue] = source.[textValue], - target.[booleanValue] = source.[booleanValue], - target.[dateValue] = source.[dateValue], - target.[deleted] = source.[deleted], - target.[amendUserId] = source.[amendUserId], - target.[amendDate] = source.[amendDate] - - WHEN NOT MATCHED BY TARGET THEN - INSERT ( - [userHistoryAttributeId], - [userHistoryId], - [attributeId], - [intValue], - [textValue], - [booleanValue], - [dateValue], - [deleted], - [amendUserId], - [amendDate] - ) - VALUES ( - source.[userHistoryAttributeId], - source.[userHistoryId], - source.[attributeId], - source.[intValue], - source.[textValue], - source.[booleanValue], - source.[dateValue], - source.[deleted], - source.[amendUserId], - source.[amendDate] - ); - SET IDENTITY_INSERT [elfh].[userHistoryAttributeTBL] OFF; - ALTER TABLE [elfh].[userHistoryAttributeTBL] CHECK CONSTRAINT ALL; -END -GO diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserPasswordValidationToke.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserPasswordValidationToke.sql index d9c65fd6d..c37cbd567 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserPasswordValidationToke.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeUserPasswordValidationToke.sql @@ -15,6 +15,8 @@ BEGIN -- Enable identity insert if userPasswordValidationTokenId is an IDENTITY column SET IDENTITY_INSERT [elfh].[userPasswordValidationTokenTBL] ON; + ALTER TABLE [elfh].[userPasswordValidationTokenTBL] NOCHECK CONSTRAINT ALL; + ALTER TABLE [hub].[User] NOCHECK CONSTRAINT ALL; MERGE [elfh].[userPasswordValidationTokenTBL] AS target USING @userPasswordValidationTokenList AS source ON target.userPasswordValidationTokenId = source.userPasswordValidationTokenId @@ -56,5 +58,7 @@ BEGIN -- Disable identity insert SET IDENTITY_INSERT [elfh].[userPasswordValidationTokenTBL] OFF; + ALTER TABLE [hub].[User] CHECK CONSTRAINT ALL; + ALTER TABLE [elfh].[userPasswordValidationTokenTBL] CHECK CONSTRAINT ALL; END GO diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeattribute.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeattribute.sql index d6851b39d..33969535a 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeattribute.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeattribute.sql @@ -32,20 +32,6 @@ CREATE TYPE dbo.UserReportingUserType AS TABLE [AmendDate] DATETIMEOFFSET ); GO -CREATE TYPE UserHistoryAttributeType AS TABLE -( - [userHistoryAttributeId] INT, - [userHistoryId] INT, - [attributeId] INT, - [intValue] INT NULL, - [textValue] NVARCHAR(1000) NULL, - [booleanValue] BIT NULL, - [dateValue] DATETIMEOFFSET NULL, - [deleted] BIT, - [amendUserId] INT, - [amendDate] DATETIMEOFFSET -); -GO CREATE TYPE UserEmploymentResponsibilityType AS TABLE ( [userEmploymentResponsibilityId] INT, @@ -584,6 +570,16 @@ CREATE TYPE dbo.MedicalCouncil AS TABLE amendDate datetimeoffset(7) ); GO +CREATE TYPE [dbo].[UserGroupReporterType] AS TABLE +( + [userGroupReporterId] INT, + [userId] INT, + [userGroupId] INT, + [deleted] BIT, + [amendUserId] INT, + [amendDate] DATETIMEOFFSET(7) +); +GO CREATE PROCEDURE [dbo].[AdfMergeattribute] @attributeList dbo.Attribute READONLY -- Table-valued parameter AS diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeuserEmployment.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeuserEmployment.sql index 7e4dc75e7..5be94cd10 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeuserEmployment.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Adf/AdfMergeuserEmployment.sql @@ -14,6 +14,8 @@ BEGIN SET NOCOUNT ON; SET IDENTITY_INSERT [elfh].[userEmploymentTBL] ON; + ALTER TABLE [elfh].[userEmploymentTBL] NOCHECK CONSTRAINT ALL; + ALTER TABLE [hub].[User] NOCHECK CONSTRAINT ALL; MERGE [elfh].[userEmploymentTBL] AS target USING @userEmploymentList AS source ON target.userEmploymentId = source.userEmploymentId @@ -73,5 +75,7 @@ BEGIN -- Disable identity insert SET IDENTITY_INSERT [elfh].[userEmploymentTBL] OFF; + ALTER TABLE [elfh].[userEmploymentTBL] NOCHECK CONSTRAINT ALL; + ALTER TABLE [hub].[User] NOCHECK CONSTRAINT ALL; END GO diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Hierarchy/GetNodePathNodes.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Hierarchy/GetNodePathNodes.sql index 43e41a31c..c7b93adb2 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Hierarchy/GetNodePathNodes.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Hierarchy/GetNodePathNodes.sql @@ -11,25 +11,37 @@ -- 22-06-2023 RS Switched order of joins to ensure catalogue node is always returned first. -- 24-08-2023 RS Proper fix for ordering issue - STRING_SPLIT doesn't return substrings in order. -- 08-09-2023 RS A further fix for ordering issue that works on SQL Server 2019 (for developer installs). +-- 17-12-2025 SA TD-6520 - Refractored the SP to fix the DTU spike. ------------------------------------------------------------------------------- CREATE PROCEDURE [hierarchy].[GetNodePathNodes] ( - @NodePathId INT + @NodePathId INT ) - AS - BEGIN + SET NOCOUNT ON; - SELECT - CAST(value AS INT) AS NodeId, - COALESCE(fnv.Name, cnv.Name) AS Name - FROM hierarchy.NodePath np - CROSS APPLY hub.fn_Split(NodePath, '\') as ss - INNER JOIN hierarchy.NodeVersion nv ON nv.NodeId = ss.value - LEFT JOIN hierarchy.CatalogueNodeVersion cnv ON cnv.NodeVersionId = nv.Id - LEFT JOIN hierarchy.FolderNodeVersion fnv ON fnv.NodeVersionId = nv.Id - WHERE np.Id = @NodePathId - ORDER BY ss.idx - + -- If NodePath is NVARCHAR, keep types consistent to avoid implicit conversions + ;WITH PathParts AS + ( + SELECT + ordinal, + TRY_CAST(s.value AS int) AS NodeIdInt + FROM hierarchy.NodePath AS np + CROSS APPLY STRING_SPLIT(np.NodePath, N'\', 1) AS s -- 1 = ordinal + WHERE np.Id = @NodePathId + ) + SELECT + pp.NodeIdInt AS NodeId, + COALESCE(fnv.Name, cnv.Name) AS Name + FROM PathParts AS pp + JOIN hierarchy.NodeVersion AS nv + ON nv.NodeId = pp.NodeIdInt + LEFT JOIN hierarchy.CatalogueNodeVersion AS cnv + ON cnv.NodeVersionId = nv.Id + LEFT JOIN hierarchy.FolderNodeVersion AS fnv + ON fnv.NodeVersionId = nv.Id + WHERE pp.NodeIdInt IS NOT NULL + ORDER BY pp.ordinal + OPTION (RECOMPILE); -- helps if @NodePathId cardinality varies a lot END \ No newline at end of file diff --git a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Resources/GetUsercertificateDetails.sql b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Resources/GetUsercertificateDetails.sql index 50e795c87..eec995f79 100644 --- a/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Resources/GetUsercertificateDetails.sql +++ b/WebAPI/LearningHub.Nhs.Database/Stored Procedures/Resources/GetUsercertificateDetails.sql @@ -48,13 +48,14 @@ BEGIN AND mar.PercentComplete = 100 ) OR (r.ResourceTypeId = 6 AND ( - EXISTS ( - SELECT 1 - FROM activity.ScormActivity sa - WHERE sa.ResourceActivityId = ra.Id - AND sa.CmiCoreLesson_status IN (3,5) - ) - OR ra.ActivityStatusId IN (3,5) + --EXISTS ( + -- SELECT 1 + -- FROM activity.ScormActivity sa + -- WHERE sa.ResourceActivityId = ra.Id + -- AND sa.CmiCoreLesson_status IN (3,5) + --) + --OR + ra.ActivityStatusId IN (3,5) )) OR ( r.ResourceTypeId = 11 diff --git a/WebAPI/LearningHub.Nhs.Database/Tables/Databricks/ReportHistory.sql b/WebAPI/LearningHub.Nhs.Database/Tables/Databricks/ReportHistory.sql new file mode 100644 index 000000000..1f41cd695 --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Tables/Databricks/ReportHistory.sql @@ -0,0 +1,25 @@ +CREATE TABLE [reports].[ReportHistory] +( + [Id] INT NOT NULL IDENTITY (1, 1), + [CourseFilter] NVARCHAR(512) NULL, + [FirstRun] DATETIMEOFFSET(7) NOT NULL, + [LastRun] DATETIMEOFFSET(7) NOT NULL, + [PeriodDays] INT NOT NULL, + [StartDate][datetimeoffset](7) NULL, + [EndDate][datetimeoffset](7) NULL, + [DownloadRequest] BIT NULL, + [DownloadRequested][datetimeoffset](7) NULL, + [DownloadReady][datetimeoffset](7) NULL, + [ReportStatusId] INT NULL, + [FilePath] NVARCHAR(1024) NULL, + [DownloadedDate] [datetimeoffset](7) NULL, + [ParentJobRunId] BIGINT NULL, + [JobRunId] BIGINT NULL, + [ProcessingMessage] NVARCHAR(1024) NULL, + [Deleted] [bit] NOT NULL, + [CreateUserId] INT NOT NULL, + [CreateDate] [datetimeoffset](7) NOT NULL, + [AmendUserId] INT NOT NULL, + [AmendDate] [datetimeoffset](7) NOT NULL, + CONSTRAINT [PK_Reports_ReportHistory] PRIMARY KEY CLUSTERED ([Id] ASC) ON [PRIMARY] +); \ No newline at end of file diff --git a/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserGroupReporterTBL.sql b/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserGroupReporterTBL.sql new file mode 100644 index 000000000..8794a0a64 --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserGroupReporterTBL.sql @@ -0,0 +1,30 @@ +CREATE TABLE [elfh].[userGroupReporterTBL]( + [userGroupReporterId] [int] IDENTITY(1,1) NOT FOR REPLICATION NOT NULL, + [userId] [int] NOT NULL, + [userGroupId] [int] NOT NULL, + [deleted] [bit] NOT NULL, + [amendUserId] [int] NOT NULL, + [amendDate] [datetimeoffset](7) NOT NULL, + CONSTRAINT [PK_userGroupReporterTBL] PRIMARY KEY CLUSTERED +( + [userGroupReporterId] ASC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 100) ON [PRIMARY] +) ON [PRIMARY] +GO + +ALTER TABLE [elfh].[userGroupReporterTBL] ADD DEFAULT (sysdatetimeoffset()) FOR [amendDate] +GO + +ALTER TABLE [elfh].[userGroupReporterTBL] WITH CHECK ADD CONSTRAINT [FK_userGroupReporterTBL_userGroupTBL] FOREIGN KEY([userGroupId]) +REFERENCES [hub].[userGroup] ([Id]) +GO + +ALTER TABLE [elfh].[userGroupReporterTBL] CHECK CONSTRAINT [FK_userGroupReporterTBL_userGroupTBL] +GO + +ALTER TABLE [elfh].[userGroupReporterTBL] WITH CHECK ADD CONSTRAINT [FK_userGroupReporterTBL_userTBL] FOREIGN KEY([userId]) +REFERENCES [hub].[user] ([Id]) +GO + +ALTER TABLE [elfh].[userGroupReporterTBL] CHECK CONSTRAINT [FK_userGroupReporterTBL_userTBL] +GO \ No newline at end of file diff --git a/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserHistoryAttributeTBL.sql b/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserHistoryAttributeTBL.sql deleted file mode 100644 index 0ab661329..000000000 --- a/WebAPI/LearningHub.Nhs.Database/Tables/Elfh/UserHistoryAttributeTBL.sql +++ /dev/null @@ -1,39 +0,0 @@ -CREATE TABLE [elfh].[userHistoryAttributeTBL]( - [userHistoryAttributeId] [int] IDENTITY(1,1) NOT NULL, - [userHistoryId] [int] NOT NULL, - [attributeId] [int] NOT NULL, - [intValue] [int] NULL, - [textValue] [nvarchar](1000) NULL, - [booleanValue] [bit] NULL, - [dateValue] [datetimeoffset](7) NULL, - [deleted] [bit] NOT NULL, - [amendUserId] [int] NOT NULL, - [amendDate] [datetimeoffset](7) NOT NULL, - CONSTRAINT [PK_userHistoryAttributeTBL] PRIMARY KEY CLUSTERED -( - [userHistoryAttributeId] ASC -)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] -) ON [PRIMARY] -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] ADD CONSTRAINT [DF_userHistoryAttributeTBL_deleted] DEFAULT ((0)) FOR [deleted] -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] ADD CONSTRAINT [DF_userHistoryAttributeTBL_amendDate] DEFAULT (sysdatetimeoffset()) FOR [amendDate] -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] WITH CHECK ADD CONSTRAINT [FK_userHistoryAttributeTBL_attributeId] FOREIGN KEY([attributeId]) -REFERENCES [elfh].[attributeTBL] ([attributeId]) -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] CHECK CONSTRAINT [FK_userHistoryAttributeTBL_attributeId] -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] WITH CHECK ADD CONSTRAINT [FK_userHistoryAttributeTBL_userHistoryId] FOREIGN KEY([userHistoryId]) -REFERENCES [elfh].[userHistoryTBL] ([userHistoryId]) -GO - -ALTER TABLE [elfh].[userHistoryAttributeTBL] CHECK CONSTRAINT [FK_userHistoryAttributeTBL_userHistoryId] -GO - - diff --git a/WebAPI/LearningHub.Nhs.Database/Views/SearchCataloguesView.sql b/WebAPI/LearningHub.Nhs.Database/Views/SearchCataloguesView.sql new file mode 100644 index 000000000..57b36273f --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Views/SearchCataloguesView.sql @@ -0,0 +1,58 @@ +------------------------------------------------------------------------------- +-- Author Binon Yesudhas +-- Created 06-02-2026 +-- Purpose View of catalogues for Azure AI search +-- +-- Modification History +-- TD-6212 - https://hee-tis.atlassian.net/browse/TD-6212 +-- 06-02-2026 Binon Yesudhas Initial Revision +------------------------------------------------------------------------------- +CREATE VIEW [dbo].[SearchCataloguesView] AS +WITH Catalogues AS ( + SELECT + nv.NodeId AS Id, + cnv.Name, + cnv.Description, + cnv.URL, + n.Hidden, + amend.MaxAmendDate AS AmendDate, + cnv.Deleted, + cnv.Id AS CatalogueNodeVersionId + FROM hierarchy.CatalogueNodeVersion cnv + INNER JOIN hierarchy.Node n ON n.CurrentNodeVersionId = cnv.NodeVersionId + INNER JOIN hierarchy.NodeVersion nv ON nv.NodeId = n.Id + CROSS APPLY ( + SELECT MAX(v) AS MaxAmendDate + FROM (VALUES (cnv.AmendDate), (nv.AmendDate), (n.AmendDate)) AS value(v) + ) AS amend + WHERE nv.VersionStatusId = 2 + AND cnv.Deleted = 0 + AND n.Deleted = 0 + AND nv.Deleted = 0 +) +SELECT + c.Id, + c.Name, + c.Description, + c.URL, + c.Hidden, + c.CatalogueNodeVersionId, + c.AmendDate, + c.Deleted, + -- Aggregate keywords into JSON array + ( + SELECT STRING_AGG(cnvk.Keyword, ',') + FROM hierarchy.CatalogueNodeVersionKeyword cnvk + WHERE cnvk.CatalogueNodeVersionId = c.CatalogueNodeVersionId AND cnvk.Deleted = 0 + ) AS Keywords, + + -- Aggregate providers into JSON array + ( + SELECT STRING_AGG(CAST(cvp.ProviderId AS varchar(10)), ',') + FROM hierarchy.CatalogueNodeVersionProvider cvp + WHERE cvp.CatalogueNodeVersionId = c.CatalogueNodeVersionId AND cvp.Deleted = 0 + ) AS Providers + +FROM Catalogues c; +GO; + diff --git a/WebAPI/LearningHub.Nhs.Database/Views/SearchResourcesView.sql b/WebAPI/LearningHub.Nhs.Database/Views/SearchResourcesView.sql new file mode 100644 index 000000000..bcacb902e --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Views/SearchResourcesView.sql @@ -0,0 +1,125 @@ +------------------------------------------------------------------------------- +-- Author Binon Yesudhas +-- Created 06-02-2026 +-- Purpose View of resources for Azure AI search +-- +-- Modification History +-- TD-6212 - https://hee-tis.atlassian.net/browse/TD-6212 +-- 06-02-2026 Binon Yesudhas Initial Revision +-- 13-02-2026 Binon Yesudhas Handle inconsistenrt resource type +------------------------------------------------------------------------------- +CREATE VIEW [dbo].[SearchResourcesView] +AS +WITH BaseResource AS ( + SELECT + r.Id, + r.ResourceTypeId, + -- r.ResourceTypeId as ContentType, + CASE + WHEN LOWER(rt.Name) LIKE '%scorm%' THEN 'scorm' + ELSE LOWER(rt.Name) + END AS ContentType, + rv.Id AS ResourceVersionId, + rv.Title, + rv.Description, + rv.ResourceAccessibilityId AS ResourceAccessLevel, + p.Id AS PublicationId, + p.CreateDate AS PublicationDate, + rvrs.AverageRating, + amend.MaxAmendDate AS AmendDate, + CASE WHEN rv.VersionStatusId = 2 THEN CAST(0 AS bit) ELSE CAST(1 AS bit) END AS Deleted, + ROW_NUMBER() OVER(PARTITION BY r.Id ORDER BY p.Id DESC) AS RowNumber + FROM resources.ResourceVersion rv + INNER JOIN hub.[User] u ON u.Id = rv.CreateUserId + INNER JOIN resources.[Resource] r ON r.Id = rv.ResourceId + INNER JOIN resources.ResourceVersionRatingSummary rvrs ON rvrs.ResourceVersionId = rv.Id + INNER JOIN hierarchy.Publication p ON p.Id = rv.PublicationId + INNER JOIN resources.ResourceType rt ON r.ResourceTypeId = rt.id + CROSS APPLY ( + SELECT MAX(v) AS MaxAmendDate + FROM (VALUES (rv.AmendDate), (r.AmendDate), (rvrs.AmendDate)) AS value(v) + ) AS amend + WHERE + rv.VersionStatusId in (2,3) + AND rv.Deleted = 0 AND r.Deleted = 0 AND rvrs.Deleted = 0 AND p.Deleted = 0 +) +SELECT + CAST(b.Id AS NVARCHAR(50)) AS ResourceId, + b.ContentType, + b.ResourceVersionId, + b.Title, + b.Description, + b.ResourceAccessLevel, + b.PublicationId, + b.PublicationDate, + b.AverageRating, + b.AmendDate, + b.Deleted, + -- Keywords (JSON array) + ( + SELECT STRING_AGG(rvk.Keyword, ',') + FROM resources.ResourceVersionKeyword rvk + WHERE rvk.ResourceVersionId = b.ResourceVersionId AND rvk.Deleted = 0 + ) AS Keywords, + + -- Authors (JSON array) + ( + SELECT STRING_AGG( + CASE + WHEN (rva.AuthorName IS NULL OR rva.AuthorName = '') THEN rva.Organisation + WHEN (rva.Organisation IS NULL OR rva.Organisation = '') THEN rva.AuthorName + ELSE rva.AuthorName + ', ' + rva.Organisation + END, '; ' + ) + FROM resources.ResourceVersionAuthor rva + WHERE rva.ResourceVersionId = b.ResourceVersionId AND rva.Deleted = 0 + ) AS Authors, + + -- Active Catalogues (JSON array) + ( + SELECT DISTINCT np.CatalogueNodeId, rr.OriginalResourceReferenceId, cv.Name as LocationPaths + FROM resources.ResourceReference rr + INNER JOIN hierarchy.NodeResource nr ON nr.ResourceId = b.Id + INNER JOIN hierarchy.Node n ON n.Id = nr.NodeId + INNER JOIN hierarchy.NodePath np ON np.NodeId = n.Id + LEFT JOIN hierarchy.CatalogueNodeVersion cv ON n.CurrentNodeVersionId = cv.NodeVersionId + WHERE rr.ResourceId = b.Id + AND nr.VersionStatusId = 2 + AND np.IsActive = 1 + AND rr.Deleted = 0 AND nr.Deleted = 0 AND n.Deleted = 0 AND np.Deleted = 0 + AND np.Id = rr.NodePathId + FOR JSON PATH + ) AS Catalogues, + + -- Author Date (only if type = 9, JSON object) + ( + SELECT + TRY_CONVERT(date, + CONCAT( + gfrv.AuthoredYear, '-', + RIGHT('0' + CAST(gfrv.AuthoredMonth AS varchar(2)), 2), '-', + RIGHT('0' + CAST(gfrv.AuthoredDayOfMonth AS varchar(2)), 2) + ) + ) AS AuthoredDate + FROM resources.GenericFileResourceVersion gfrv + INNER JOIN resources.[File] f ON f.Id = gfrv.FileId + INNER JOIN resources.FileType ft ON ft.Id = f.FileTypeId + WHERE gfrv.ResourceVersionId = b.ResourceVersionId + AND b.ResourceTypeId = 9 + AND gfrv.Deleted = 0 + AND f.Deleted = 0 + AND ft.Deleted = 0 +) + AS AuthoredDate, + + + -- Providers (JSON array) + ( + SELECT STRING_AGG(CAST(rvp.ProviderId AS varchar(10)), ',') + FROM resources.ResourceVersionProvider rvp + WHERE rvp.ResourceVersionId = b.ResourceVersionId AND rvp.Deleted = 0 + ) AS Providers + +FROM BaseResource b +WHERE b.RowNumber = 1; +GO; diff --git a/WebAPI/LearningHub.Nhs.Database/Views/SupersetSearchView.sql b/WebAPI/LearningHub.Nhs.Database/Views/SupersetSearchView.sql new file mode 100644 index 000000000..f99e0c8e7 --- /dev/null +++ b/WebAPI/LearningHub.Nhs.Database/Views/SupersetSearchView.sql @@ -0,0 +1,74 @@ +------------------------------------------------------------------------------- +-- Author Binon Yesudhas +-- Created 06-02-2026 +-- Purpose Combined view of both catalogues and resources for Azure AI search +-- +-- Modification History +-- TD-6212 - https://hee-tis.atlassian.net/browse/TD-6212 +-- 06-02-2026 Binon Yesudhas Initial Revision +-- 05-03-2026 Binon Yesudhas Added new parameter, ResourceAccessLevel to the view +------------------------------------------------------------------------------- +CREATE VIEW [dbo].[SupersetSearchView] +AS + +-- ============================ +-- Catalogue Rows +-- ============================ +SELECT + 'cat-'+ CAST(c.Id AS NVARCHAR(50)) AS id, + c.Name AS title, + LOWER(c.Name) AS normalised_title, + c.Description AS description, + 'catalogue' AS resource_collection, + c.Keywords as manual_tag, -- e.g., comma-separated or JSON if multiple + 'catalogue' AS resource_type, + NULL as resource_access_level, + NULL AS publication_date, + NULL AS date_authored, + NULL AS rating, + NULL AS catalogue_id, + NULL AS resource_reference_id, + NULL AS location_paths, + CAST(0 AS bit) AS statutory_mandatory, + c.Providers AS provider_ids, -- e.g., comma-separated or JSON if multiple + NULL AS author, + CAST(HIdden AS bit) AS hidden, + URL AS url, + --c.AmendDate AS last_modified, + SWITCHOFFSET(CAST(c.AmendDate AS datetimeoffset), '+00:00') AS last_modified, + CAST(HIdden AS bit) is_deleted +FROM dbo.SearchCataloguesView c + +UNION ALL + +-- ============================ +-- Resource Rows +-- ============================ +SELECT + 'res-'+ CAST(r.ResourceId AS NVARCHAR(50)) AS id, + r.title, + LOWER(r.title) AS normalised_title, + r.Description AS description, + 'resource' AS resource_collection, + r.Keywords AS manual_tag, + r.ContentType AS resource_type, + r.ResourceAccessLevel as resource_access_level, + r.PublicationDate AS publication_date, + r.AuthoredDate AS date_authored, + CAST(r.AverageRating AS FLOAT) AS rating, + JSON_VALUE(r.Catalogues, '$[0].CatalogueNodeId') AS catalogue_id, + JSON_VALUE(r.Catalogues, '$[0].OriginalResourceReferenceId') AS resource_reference_id, + JSON_VALUE(r.Catalogues, '$[0].LocationPaths') AS location_paths, + CAST(0 AS bit) AS statutory_mandatory, + r.Providers AS provider_ids, + r.Authors AS author, + CAST(0 AS bit) AS hidden, + NULL AS url, + --r.AmendDate AS last_modified, + SWITCHOFFSET(CAST(r.AmendDate AS datetimeoffset), '+00:00') AS last_modified, + r.Deleted AS is_deleted +FROM dbo.SearchResourcesView r; +GO; + + + diff --git a/WebAPI/LearningHub.Nhs.Repository.Interface/LearningHub.Nhs.Repository.Interface.csproj b/WebAPI/LearningHub.Nhs.Repository.Interface/LearningHub.Nhs.Repository.Interface.csproj index 337b1120d..5d12c4600 100644 --- a/WebAPI/LearningHub.Nhs.Repository.Interface/LearningHub.Nhs.Repository.Interface.csproj +++ b/WebAPI/LearningHub.Nhs.Repository.Interface/LearningHub.Nhs.Repository.Interface.csproj @@ -10,7 +10,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.Repository/LearningHub.Nhs.Repository.csproj b/WebAPI/LearningHub.Nhs.Repository/LearningHub.Nhs.Repository.csproj index 2a2cf49da..b7cca29dc 100644 --- a/WebAPI/LearningHub.Nhs.Repository/LearningHub.Nhs.Repository.csproj +++ b/WebAPI/LearningHub.Nhs.Repository/LearningHub.Nhs.Repository.csproj @@ -9,7 +9,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.Services.Interface/IActivityService.cs b/WebAPI/LearningHub.Nhs.Services.Interface/IActivityService.cs index 5cbeb453d..99a9d0f0e 100644 --- a/WebAPI/LearningHub.Nhs.Services.Interface/IActivityService.cs +++ b/WebAPI/LearningHub.Nhs.Services.Interface/IActivityService.cs @@ -106,6 +106,14 @@ public interface IActivityService /// The . Task CompleteScormActivity(int currentUserId, ScormActivityViewModel completeScormActivityViewModel); + /// + /// Complete scorm activity. + /// + /// The user Id. + /// The update scorm Activity View Model. + /// The . + Task ScormCompleteActivity(int currentUserId, ScormActivityViewModel completeScormActivityViewModel); + /// /// The resolve scorm activity. /// Resolves any completed active content that does not have associated completion events. diff --git a/WebAPI/LearningHub.Nhs.Services.Interface/LearningHub.Nhs.Services.Interface.csproj b/WebAPI/LearningHub.Nhs.Services.Interface/LearningHub.Nhs.Services.Interface.csproj index 827bc8750..6ee630341 100644 --- a/WebAPI/LearningHub.Nhs.Services.Interface/LearningHub.Nhs.Services.Interface.csproj +++ b/WebAPI/LearningHub.Nhs.Services.Interface/LearningHub.Nhs.Services.Interface.csproj @@ -16,7 +16,7 @@ - + all diff --git a/WebAPI/LearningHub.Nhs.Services.UnitTests/LearningHub.Nhs.Services.UnitTests.csproj b/WebAPI/LearningHub.Nhs.Services.UnitTests/LearningHub.Nhs.Services.UnitTests.csproj index 658c2bc1b..dbb1f6a30 100644 --- a/WebAPI/LearningHub.Nhs.Services.UnitTests/LearningHub.Nhs.Services.UnitTests.csproj +++ b/WebAPI/LearningHub.Nhs.Services.UnitTests/LearningHub.Nhs.Services.UnitTests.csproj @@ -13,7 +13,7 @@ - + diff --git a/WebAPI/LearningHub.Nhs.Services/ActivityService.cs b/WebAPI/LearningHub.Nhs.Services/ActivityService.cs index 19c98e376..9b5245504 100644 --- a/WebAPI/LearningHub.Nhs.Services/ActivityService.cs +++ b/WebAPI/LearningHub.Nhs.Services/ActivityService.cs @@ -18,6 +18,7 @@ using LearningHub.Nhs.Repository.Interface.Resources; using LearningHub.Nhs.Services.Helpers; using LearningHub.Nhs.Services.Interface; + using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Rest; @@ -526,6 +527,39 @@ public async Task CompleteScormActivity(int current return new LearningHubValidationResult(true); } + /// + /// Complete scorm activity. + /// + /// The user Id. + /// The update scorm Activity View Model. + /// The . + public async Task ScormCompleteActivity(int currentUserId, ScormActivityViewModel completeScormActivityViewModel) + { + try + { + if (completeScormActivityViewModel.LessonStatusId.HasValue + && (completeScormActivityViewModel.LessonStatusId.Value == (int)ActivityStatusEnum.Completed + || completeScormActivityViewModel.LessonStatusId == (int)ActivityStatusEnum.Passed + || completeScormActivityViewModel.LessonStatusId == (int)ActivityStatusEnum.Failed)) + { + // Handle activity "complete" event - create new ResourceActivity record & perform any re-calc status updates. + this.scormActivityRepository.Complete(currentUserId, completeScormActivityViewModel.InstanceId); + } + } + catch (SqlException ex) + { + if (!ex.Message.Contains( + "ResourceActivity entry with Completed status already exists", + StringComparison.OrdinalIgnoreCase)) + { + throw; + } + // else: intentionally ignore + } + + return new LearningHubValidationResult(true); + } + /// /// The resolve scorm activity. /// Resolves any completed active content that does not have associated completion events. diff --git a/WebAPI/LearningHub.Nhs.Services/LearningHub.Nhs.Services.csproj b/WebAPI/LearningHub.Nhs.Services/LearningHub.Nhs.Services.csproj index 979ee394d..98cec165a 100644 --- a/WebAPI/LearningHub.Nhs.Services/LearningHub.Nhs.Services.csproj +++ b/WebAPI/LearningHub.Nhs.Services/LearningHub.Nhs.Services.csproj @@ -13,7 +13,7 @@ - + diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.ConsoleApp/LearningHub.Nhs.Migration.ConsoleApp.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.ConsoleApp/LearningHub.Nhs.Migration.ConsoleApp.csproj index 1555adfe3..3a2cb8961 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.ConsoleApp/LearningHub.Nhs.Migration.ConsoleApp.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.ConsoleApp/LearningHub.Nhs.Migration.ConsoleApp.csproj @@ -25,7 +25,7 @@ - + all diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Interface/LearningHub.Nhs.Migration.Interface.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Interface/LearningHub.Nhs.Migration.Interface.csproj index b03e83409..b275eae94 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Interface/LearningHub.Nhs.Migration.Interface.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Interface/LearningHub.Nhs.Migration.Interface.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Models/LearningHub.Nhs.Migration.Models.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Models/LearningHub.Nhs.Migration.Models.csproj index 1d0a0e3e9..a18eb4196 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Models/LearningHub.Nhs.Migration.Models.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Models/LearningHub.Nhs.Migration.Models.csproj @@ -10,7 +10,7 @@ - + all diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Staging.Repository/LearningHub.Nhs.Migration.Staging.Repository.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Staging.Repository/LearningHub.Nhs.Migration.Staging.Repository.csproj index 15b2ec905..bb23ca661 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Staging.Repository/LearningHub.Nhs.Migration.Staging.Repository.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.Staging.Repository/LearningHub.Nhs.Migration.Staging.Repository.csproj @@ -9,7 +9,7 @@ - + diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.UnitTests/LearningHub.Nhs.Migration.UnitTests.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.UnitTests/LearningHub.Nhs.Migration.UnitTests.csproj index 4d44dba23..82a9b4aa6 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration.UnitTests/LearningHub.Nhs.Migration.UnitTests.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration.UnitTests/LearningHub.Nhs.Migration.UnitTests.csproj @@ -10,7 +10,7 @@ - + diff --git a/WebAPI/MigrationTool/LearningHub.Nhs.Migration/LearningHub.Nhs.Migration.csproj b/WebAPI/MigrationTool/LearningHub.Nhs.Migration/LearningHub.Nhs.Migration.csproj index 094c39953..de745363e 100644 --- a/WebAPI/MigrationTool/LearningHub.Nhs.Migration/LearningHub.Nhs.Migration.csproj +++ b/WebAPI/MigrationTool/LearningHub.Nhs.Migration/LearningHub.Nhs.Migration.csproj @@ -12,7 +12,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/replacements.txt b/replacements.txt new file mode 100644 index 000000000..4819f42b1 --- /dev/null +++ b/replacements.txt @@ -0,0 +1 @@ +Databricks_API_Token==>REMOVED \ No newline at end of file