diff --git a/.github/azure-pipeline-webui-ci.yml b/.github/azure-pipeline-webui-ci.yml index 21d9d6ddc..343c9ed8c 100644 --- a/.github/azure-pipeline-webui-ci.yml +++ b/.github/azure-pipeline-webui-ci.yml @@ -51,6 +51,57 @@ jobs: inputs: command: restore projects: $(BuildParameters.RestoreBuildProjects) + # Step 1: Get the version tag from the repository +- script: | + echo "Extracting version from Git tag: $(versionTag)" + VERSION=$(echo $(versionTag) | sed 's/^v//') + echo "VERSION=$VERSION" >> $(Build.ArtifactStagingDirectory)/version.txt + displayName: 'Extract version tag' + +# Step 2: Update AssemblyVersion in Web UI .csproj +- powershell: | + $versionFile = "$(Build.ArtifactStagingDirectory)/version.txt" + $version = Get-Content $versionFile + $version = $version.Trim() + + # Path to your .csproj file (adjust if necessary) + $csprojFile = "$(Build.SourcesDirectory)\LearningHub.Nhs.WebUI\LearningHub.Nhs.WebUI.csproj" + + Write-Host "Updating AssemblyVersion and FileVersion and Version in $csprojFile to $version" + + # Load the .csproj XML file + [xml]$csprojXml = Get-Content -Path $csprojFile + + # Update the AssemblyVersion and FileVersion + $csprojXml.Project.PropertyGroup.AssemblyVersion = $version + $csprojXml.Project.PropertyGroup.FileVersion = $version + $csprojXml.Project.PropertyGroup.Version = $version + + # Save the updated .csproj file + $csprojXml.Save($csprojFile) + displayName: 'Update AssemblyVersion in WebUI .csproj' + # Step 2: Update AssemblyVersion in Admin UI .csproj +- powershell: | + $versionFile = "$(Build.ArtifactStagingDirectory)/version.txt" + $version = Get-Content $versionFile + $version = $version.Trim() + + # Path to your .csproj file (adjust if necessary) + $csprojFile = "$(Build.SourcesDirectory)\AdminUI\LearningHub.Nhs.AdminUI\LearningHub.Nhs.AdminUI.csproj" + + Write-Host "Updating AssemblyVersion and FileVersion and Version in $csprojFile to $version" + + # Load the .csproj XML file + [xml]$csprojXml = Get-Content -Path $csprojFile + + # Update the AssemblyVersion and FileVersion + $csprojXml.Project.PropertyGroup.AssemblyVersion = $version + $csprojXml.Project.PropertyGroup.FileVersion = $version + $csprojXml.Project.PropertyGroup.Version = $version + + # Save the updated .csproj file + $csprojXml.Save($csprojFile) + displayName: 'Update AssemblyVersion in AdminUI .csproj' - task: DotNetCoreCLI@2 displayName: Build inputs: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..95bc92002 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,56 @@ +# 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 +updates: + - package-ecosystem: "nuget" + directory: "/" # Location of package manifests + schedule: + interval: "daily" + open-pull-requests-limit: 10 + 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: "linqtotwitter*" + # Ignore some updates to the package + - dependency-name: "Microsoft.VisualStudio.Web.CodeGeneration.Design" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Mvc.Testing" + versions: [">7.0.0"] + - 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/.github/pull_request_template.md b/.github/pull_request_template.md index d705a4791..b276131f7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,25 +1,25 @@ ### JIRA link -_TD-###_ +[TD-####](https://hee-tis.atlassian.net/browse/TD-####) ### Description -_Describe what has changed and how that will affect the app. If relevant, add references to the resources you used. Use this as your opportunity to highlight anything odd and give people context around particular decisions._ +_Describe what has changed and how that will affect the app. If relevant, add links to any sources/documentation you used. Highlight anything unusual and give people context around particular decisions._ ### Screenshots -_Attach screenshots on mobile, tablet and desktop._ +_Paste screenshots for all views created or changed: mobile, tablet and desktop, wave analyser showing no errors._ ----- ### Developer checks (Leave tasks unticked if they haven't been appropriate for your ticket.) I have: -- [ ] Run the formatter and made sure there are no IDE errors -- [ ] Written appropriate unit tests for the changes, including: - - accessibility tests for new views - - tests for new controller methods - - tests for new or modified API endpoints -- [ ] Manually tested my work with and without JavaScript -- [ ] Tested any Views or partials created or changed with [Wave Chrome plugin](https://chrome.google.com/webstore/detail/wave-evaluation-tool/jbbplnpkjmmeebjpijfedlgcdilocofh/related) and addressed any valid accessibility issues -- [ ] Updated/added documentation in [Confluence](https://hee-tis.atlassian.net/wiki/spaces/TP/pages/3477930003/Learning+Hub) and/or [GitHub Readme](https://github.com/TechnologyEnhancedLearning/LearningHub.Nhs.UserApi/blob/master/README.md). List of documentation links added/changed: +- [ ] Run the IDE auto formatter on all files I’ve worked on and made sure there are no IDE errors relating to them +- [ ] Written or updated tests for the changes (accessibility ui tests for views, tests for controller, data services, services, view models created or modified) and made sure all tests are passing +- [ ] Manually tested my work with and without JavaScript (adding notes where functionality requires JavaScript) +- [ ] Tested any Views or partials created or changed with [Wave Chrome plugin](https://chrome.google.com/webstore/detail/wave-evaluation-tool/jbbplnpkjmmeebjpijfedlgcdilocofh/related). Addressed any valid accessibility issues and documented any invalid errors +- [ ] Updated my Jira ticket with testing notes, including information about other parts of the system that were touched as part of the MR and need to be tested to ensure nothing is broken +- [ ] Scanned over my pull request in GitHub and addressed any warnings from the GitHub Build and Test checks in the GitHub PR ‘Files Changed’ tab +Either: +- [ ] Documented my work in [Confluence](https://hee-tis.atlassian.net/wiki/spaces/TP/pages/3461087233/Development), updating any business rules applied or modified. Updated GitHub readme/documentation for the repository if appropriate. List of documentation links added/changed: - [doc_1_here](link_1_here) -- [ ] Updated my Jira ticket with information about other parts of the system that were touched as part of the MR and have to be sanity tested to ensure nothing is broken -- [ ] Scanned over my pull request in GitHub and addressed any warnings from the GitHub Build and Test checks. +Or: +- [ ] Confirmed that none of the work that I have undertaken requires any updates to documentation \ No newline at end of file diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 000000000..028886a22 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,183 @@ +name: Auto Merge Dependabot PRs + +on: + pull_request: + types: + - opened + - synchronize + +permissions: + pull-requests: write + contents: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + + steps: + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Set up GitHub CLI + run: | + # Install GitHub CLI (gh) + sudo apt-get update + sudo apt-get install gh + + # Authenticate GitHub CLI using the provided token + echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token + + - name: Wait for CI workflow to pass (Ensure CI workflow succeeded) + id: wait_for_ci + run: | + # Get the PR number from the GitHub event + PR_NUMBER=${{ github.event.pull_request.number }} + echo "Checking CI status for PR #$PR_NUMBER" + + # Define the maximum wait time (in seconds) and the polling interval (in seconds) + MAX_WAIT_TIME=1800 # 30 minutes + POLL_INTERVAL=10 # Check every 10 seconds + + # Initialize a timer + elapsed_time=0 + + # Poll CI status until all checks are completed + while true; do + # Fetch the status check rollup for the PR + CI_STATUS=$(gh pr view $PR_NUMBER --json statusCheckRollup) + + # Log the fetched response + echo "CI Status Response: $CI_STATUS" + + # Parse the checks and check their status + ALL_COMPLETED=true + ALL_CHECKS_PASSED=true + + for check in $(echo "$CI_STATUS" | jq -r '.statusCheckRollup[] | @base64'); do + _jq() { + echo "${check}" | base64 --decode | jq -r "${1}" + } + + status=$(_jq '.status') + conclusion=$(_jq '.conclusion') + check_name=$(_jq '.name') + + # Log check details + echo "Check: $check_name, Status: $status, Conclusion: $conclusion" + if [[ "$check_name" == "auto-merge" ]]; then + echo "Skipping 'auto-merge' workflow check to prevent self-referencing." + continue + fi + + # If any check is still queued, set ALL_COMPLETED to false + if [[ "$status" == "QUEUED" ]]; then + ALL_COMPLETED=false + fi + + # If any check is still in progress, set ALL_COMPLETED to false + if [[ "$status" == "IN_PROGRESS" ]]; then + ALL_COMPLETED=false + fi + + # If any completed check has failed, set ALL_CHECKS_PASSED to false + if [[ "$status" == "COMPLETED" && "$conclusion" != "SUCCESS" ]]; then + ALL_CHECKS_PASSED=false + fi + done + + # Break the loop if all checks are completed + if [[ "$ALL_COMPLETED" == true ]]; then + break + fi + + # Wait for the next polling interval + echo "Waiting for checks to complete... ($elapsed_time/$MAX_WAIT_TIME seconds elapsed)" + sleep $POLL_INTERVAL + elapsed_time=$((elapsed_time + POLL_INTERVAL)) + + # Exit if the maximum wait time is exceeded + if [[ "$elapsed_time" -ge "$MAX_WAIT_TIME" ]]; then + echo "Timed out waiting for CI checks to complete." + exit 1 + fi + done + + # Final check: Ensure all CI checks passed + if [[ "$ALL_CHECKS_PASSED" == false ]]; then + echo "One or more CI checks failed. Aborting merge." + exit 1 + fi + + echo "All CI checks passed successfully." + + + - name: Check Target Branch and PR Title + id: check_branch + run: | + PR_TITLE='${{ github.event.pull_request.title }}' + echo "Original PR Title: $PR_TITLE" + + # Escape problematic quotes + ESCAPED_TITLE=$(echo "$PR_TITLE" | sed 's/"/\\"/g') + echo "Escaped PR Title: $ESCAPED_TITLE" + + if [[ "$ESCAPED_TITLE" =~ ([0-9]+\.[0-9]+\.[0-9]+).*to.*([0-9]+\.[0-9]+\.[0-9]+) ]]; then + # Extract version numbers + OLD_VERSION="${BASH_REMATCH[1]}" + NEW_VERSION="${BASH_REMATCH[2]}" + echo "Version change detected: $OLD_VERSION to $NEW_VERSION" + + # Split version into major, minor, patch components + OLD_MAJOR=$(echo "$OLD_VERSION" | cut -d '.' -f1) + OLD_MINOR=$(echo "$OLD_VERSION" | cut -d '.' -f2) + OLD_PATCH=$(echo "$OLD_VERSION" | cut -d '.' -f3) + + NEW_MAJOR=$(echo "$NEW_VERSION" | cut -d '.' -f1) + NEW_MINOR=$(echo "$NEW_VERSION" | cut -d '.' -f2) + NEW_PATCH=$(echo "$NEW_VERSION" | cut -d '.' -f3) + + # Check if it's a minor or patch update + if [[ "$OLD_MAJOR" == "$NEW_MAJOR" ]] && [[ "$OLD_MINOR" == "$NEW_MINOR" ]] && [[ "$NEW_PATCH" -gt "$OLD_PATCH" ]]; then + echo "Patch update detected" + echo "should_merge=true" >> $GITHUB_ENV + elif [[ "$OLD_MAJOR" == "$NEW_MAJOR" ]] && [[ "$NEW_MINOR" -gt "$OLD_MINOR" ]]; then + echo "Minor update detected" + echo "should_merge=true" >> $GITHUB_ENV + else + echo "No minor/patch update detected" + echo "should_merge=false" >> $GITHUB_ENV + fi + else + echo "No version change detected" + echo "should_merge=false" >> $GITHUB_ENV + fi + + - name: Debug Context + uses: actions/github-script@v6 + with: + script: | + console.log("Target branch:", context.payload.pull_request.base.ref); + + - name: Check if Should Merge + run: | + echo "DEBUG: should_merge=${{ env.should_merge }}" + if [[ "${{ env.should_merge }}" == "true" ]] && [[ "${{ github.event.pull_request.base.ref }}" == "Automatic_version_update_dependabot" ]]; then + echo "DEBUG: should merge PR" + echo "should_merge=true" >> $GITHUB_ENV + else + echo "DEBUG: skip merge" + echo "should_merge=false" >> $GITHUB_ENV + fi + + - name: Merge Pull Request + if: ${{ env.should_merge == 'true' }} + uses: actions/github-script@v6 + with: + script: | + github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + merge_method: "squash" + }); diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 027e35695..f1609bb44 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -10,20 +10,83 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET Core SDK 6.0 + - name: Modify web.config files in all apps + shell: pwsh + run: | + $webConfigPaths = @( + "${{ github.workspace }}\AdminUI\LearningHub.Nhs.AdminUI\web.config", + "${{ github.workspace }}\WebAPI\LearningHub.Nhs.Api\web.config", + "${{ github.workspace }}\LearningHub.Nhs.WebUI\web.config" + ) + + foreach ($path in $webConfigPaths) { + if (Test-Path $path) { + Write-Host "Modifying $path" + [xml]$config = Get-Content $path + + if (-not $config.configuration.'system.webServer') { + $systemWebServer = $config.CreateElement("system.webServer") + $config.configuration.AppendChild($systemWebServer) | Out-Null + } else { + $systemWebServer = $config.configuration.'system.webServer' + } + + if (-not $systemWebServer.httpProtocol) { + $httpProtocol = $config.CreateElement("httpProtocol") + $systemWebServer.AppendChild($httpProtocol) | Out-Null + } else { + $httpProtocol = $systemWebServer.httpProtocol + } + + if (-not $httpProtocol.customHeaders) { + $customHeaders = $config.CreateElement("customHeaders") + $httpProtocol.AppendChild($customHeaders) | Out-Null + } else { + $customHeaders = $httpProtocol.customHeaders + } + + foreach ($name in @("X-Powered-By", "Server")) { + $removeNode = $config.CreateElement("remove") + $removeNode.SetAttribute("name", $name) + $customHeaders.AppendChild($removeNode) | Out-Null + } + + if (-not $systemWebServer.security) { + $security = $config.CreateElement("security") + $systemWebServer.AppendChild($security) | Out-Null + } else { + $security = $systemWebServer.security + } + + if (-not $security.requestFiltering) { + $requestFiltering = $config.CreateElement("requestFiltering") + $requestFiltering.SetAttribute("removeServerHeader", "true") + $security.AppendChild($requestFiltering) | Out-Null + } + + $config.Save($path) + } else { + Write-Host "File not found: $path" + } + } + + - name: Setup .NET Core SDK 8.0 uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Add Azure artifact run: dotnet nuget add source 'https://pkgs.dev.azure.com/e-LfH/_packaging/LearningHubFeed/nuget/v3/index.json' --name 'LearningHubFeed' --username 'kevin.whittaker' --password ${{ secrets.AZURE_DEVOPS_PAT }} --store-password-in-clear-text - - name: Use Node 12.19 with Yarn + - name: Use Node 20 with Yarn uses: actions/setup-node@v4 with: - node-version: '18' - npm: '6.14.8' + node-version: '20' + cache: 'npm' + - name: Upgrade npm to the latest version + run: npm install -g npm@6.14.8 + - name: Typescript install WebUI run: yarn install --network-timeout 600000 --frozen-lockfile working-directory: ./LearningHub.Nhs.WebUI @@ -43,26 +106,80 @@ jobs: - name: Setup MSBuild uses: microsoft/setup-msbuild@v1.0.3 - - name: Build SQL Server Database project + - name: Build SQL Server Database Project run: | - # List all .sqlproj files except for .sqlproj + # Enable strict error handling + $ErrorActionPreference = 'Stop' + + # Initialize an error collection + $errors = @() + + # List all .sqlproj files $sqlproj_files = Get-ChildItem -Path . -Filter *.sqlproj -Recurse - - # Build each .csproj file - foreach ($sqlproj_file in $sqlproj_files) { - Write-Host "Building $($sqlproj_file.FullName)" - msbuild "$($sqlproj_file.FullName)" /p:Configuration=Release - } - - name: Build solution excluding SQL project + + if ($sqlproj_files.Count -eq 0) { + $errors += "No .sqlproj files found." + } else { + foreach ($sqlproj_file in $sqlproj_files) { + Write-Host "Building $($sqlproj_file.FullName)" + try { + $output = &msbuild "$($sqlproj_file.FullName)" /p:Configuration=Release /nologo 2>&1 + if (!$?) { + $errors += "Failed to build $($csproj_file.FullName): $output" + } + } catch { + # Capture detailed error information + $errorMessage = "Error building $($sqlproj_file.FullName): $($_.Exception.Message)" + Write-Host $errorMessage + $errors += $errorMessage + } + } + } + + # Display all accumulated errors + if ($errors.Count -gt 0) { + Write-Host "SQL Project Build Errors:" + $errors | ForEach-Object { Write-Host $_ } + exit 1 + } + + - name: Build Solution Excluding SQL Project run: | - # List all .csproj files except for .sqlproj + # Enable strict error handling + $ErrorActionPreference = 'Stop' + + # Initialize an error collection + $errors = @() + + # List all .csproj files except .sqlproj $csproj_files = Get-ChildItem -Path . -Filter *.csproj -Recurse | Where-Object { $_.FullName -notmatch '\\.sqlproj$' } - # Build each .csproj file - foreach ($csproj_file in $csproj_files) { - Write-Host "Building $($csproj_file.FullName)" - dotnet build "$($csproj_file.FullName)" - } + + if ($csproj_files.Count -eq 0) { + $errors += "No .csproj files found." + } else { + foreach ($csproj_file in $csproj_files) { + Write-Host "Building $($csproj_file.FullName)" + try { + $output = &dotnet build "$($csproj_file.FullName)" --configuration Release 2>&1 + if (!$?) { + $errors += "Failed to build $($csproj_file.FullName): $output" + } + } catch { + # Capture detailed error information + $errorMessage = "Error building $($csproj_file.FullName): $($_.Exception.Message)" + Write-Host $errorMessage + $errors += $errorMessage + } + } + } + # Display all accumulated errors + if ($errors.Count -gt 0) { + Write-Host "Solution Build Errors:" + $errors | ForEach-Object { Write-Host $_ } + exit 1 + } + # - name: Test # run: dotnet test ${{ env.BuildParameters.TestProjects }} diff --git a/.gitignore b/.gitignore index 04baa838f..fcd06e014 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ obj /LearningHub.Nhs.WebUI.AutomatedUiTests/appsettings.Development.json /OpenAPI/LearningHub.Nhs.OpenApi/appsettings.Development.json /OpenAPI/LearningHub.Nhs.OpenApi/web.config +/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj.user +/WebAPI/LearningHub.Nhs.API/LearningHub.Nhs.Api.csproj.user +/ReportAPI/LearningHub.Nhs.ReportApi/web.config diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs index 2338e457c..845a12a66 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/ResourceController.cs @@ -91,11 +91,15 @@ public ResourceController( /// The Details. /// /// The id. + /// The activeTab. + /// The status. /// The . [HttpGet] - public async Task Details(int id) + public async Task Details(int id, string activeTab = "details", string status = "") { var resource = await this.resourceService.GetResourceVersionExtendedViewModelAsync(id); + this.ViewBag.ActiveTab = activeTab; + this.ViewBag.Status = status; return this.View(resource); } @@ -138,6 +142,46 @@ public async Task GetValidationResults(int resourceVersionId) return this.PartialView("_ValidationResults", vm); } + /// + /// The GetDevIdDetails. + /// + /// The resourceVersionId. + /// The . + [HttpPost] + public async Task GetDevIdDetails(int resourceVersionId) + { + var vm = await this.resourceService.GetResourceVersionDevIdDetailsAsync(resourceVersionId); + + return this.PartialView("_DevIdDetails", vm); + } + + /// + /// The update the dev Id details. + /// + /// The model. + /// The . + [HttpPost] + public async Task UpdateDevIdDetails(ResourceVersionDevIdViewModel model) + { + var message = string.Empty; + if (string.IsNullOrEmpty(model.DevId)) + { + message = "Enter a Dev id for the resource"; + } + else if (await this.resourceService.DoesDevIdExistsAsync(model.DevId.Trim())) + { + message = "Duplicate"; + } + else + { + model.DevId = model.DevId.Trim(); + await this.resourceService.UpdateDevIdDetailsAsync(model); + message = "Success"; + } + + return this.RedirectToAction("Details", new { id = model.ResourceVersionId, activeTab = "devId", status = message }); + } + /// /// The Index. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/RoadmapController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/RoadmapController.cs index 319c8938c..bd7261a87 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/RoadmapController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/RoadmapController.cs @@ -99,7 +99,7 @@ public async Task AddUpdate(UpdateViewModel update) RoadmapTypeId = update.RoadmapTypeId, }; var roadmapId = await this.roadmapService.AddRoadmap(roadmap); - return this.RedirectToAction("EditUpdate", new { id = roadmapId }); + return this.RedirectToAction("Details", new { id = roadmapId }); } /// @@ -160,7 +160,7 @@ public async Task EditUpdate(UpdateViewModel update) Id = update.Id, }; await this.roadmapService.UpdateRoadmap(roadmap); - return this.RedirectToAction("EditUpdate", new { roadmap.Id }); + return this.RedirectToAction("Details", new { roadmap.Id }); } /// @@ -202,6 +202,18 @@ public async Task Updates(string searchTerm = null) return this.View(model); } + /// + /// The Details. + /// + /// The id. + /// The . + [HttpGet] + public async Task Details(int id) + { + var roadmap = await this.roadmapService.GetIdAsync(id); + return this.View(roadmap); + } + /// /// The UploadFile. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs index 3a27787f4..1be7f211c 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/UserGroupController.cs @@ -257,6 +257,7 @@ public async Task AddUsersToUserGroup(int userGroupId, string use var vr = await this.userGroupService.AddUsersToUserGroup(userGroupId, userIdList); if (vr.IsValid) { + this.ClearUserCachedPermissions(userIdList); return this.Json(new { success = true, @@ -527,5 +528,16 @@ public async Task UserGroupCatalogues(int id) return this.PartialView("_UserGroupCatalogues", catalogues); } + + private void ClearUserCachedPermissions(string userIdList) + { + if (!string.IsNullOrWhiteSpace(userIdList)) + { + foreach (var userId in userIdList.Split(",")) + { + _ = Task.Run(async () => { await this.userService.ClearUserCachedPermissions(int.Parse(userId)); }); + } + } + } } } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs index 2cf327f6e..f89aa6c3f 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/MediaManifestProxyController.cs @@ -51,16 +51,27 @@ public string Get(string playBackUrl, string token) { using (var reader = new StreamReader(stream)) { - const string qualityLevelRegex = @"(QualityLevels\(\d+\))"; + const string qualityLevelRegex = @"(|)([^""\s]+\.m3u8\(encryption=cbc\))"; const string fragmentsRegex = @"(Fragments\([\w\d=-]+,[\w\d=-]+\))"; - const string urlRegex = @"("")(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\/?[\?&][^&=]+=[^&=#]*)("")"; + const string urlRegex = @"(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\?[^,\s""]*)"; var baseUrl = playBackUrl.Substring(0, playBackUrl.IndexOf(".ism", System.StringComparison.OrdinalIgnoreCase)) + ".ism"; this.logger.LogDebug($"baseUrl={baseUrl}"); var content = reader.ReadToEnd(); - var newContent = Regex.Replace(content, urlRegex, string.Format(CultureInfo.InvariantCulture, "$1$2&token={0}$3", token)); + content = ReplaceUrisWithProxy(content, baseUrl); + var newContent = Regex.Replace(content, urlRegex, match => + { + string baseUrlWithQuery = match.Groups[1].Value; // URL including the query string + + // Append the token correctly without modifying surrounding characters + string newUrl = baseUrlWithQuery.Contains("?") ? + $"{baseUrlWithQuery}&token={token}" : + $"{baseUrlWithQuery}?token={token}"; + + return newUrl; + }); this.logger.LogDebug($"newContent={newContent}"); var match = Regex.Match(playBackUrl, qualityLevelRegex); @@ -87,5 +98,33 @@ public string Get(string playBackUrl, string token) return null; } + + private static string ReplaceUrisWithProxy(string playlistContent, string proxyUrl) + { + // Split the playlist content into lines + var lines = playlistContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + // Process each line to replace media or map URIs + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith("#EXT-X-MAP:URI=", StringComparison.OrdinalIgnoreCase)) + { + // Extract the URI from the current line for EXT-X-MAP + var existingUri = lines[i].Substring(lines[i].IndexOf('=') + 1).Trim('"'); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i] = lines[i].Replace(existingUri, newUri); + } + else if (lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase) && i + 1 < lines.Length) + { + // Get the URI from the next line for EXTINF + var existingUri = lines[i + 1].Trim(); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i + 1] = newUri; + } + } + + // Join the modified lines back into a single string + return string.Join("\r\n", lines); + } } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/UserController.cs b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/UserController.cs new file mode 100644 index 000000000..d2b97e13c --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Controllers/api/UserController.cs @@ -0,0 +1,60 @@ +namespace LearningHub.Nhs.AdminUI.Controllers.Api +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using elfhHub.Nhs.Models.Common; + using elfhHub.Nhs.Models.Enums; + using LearningHub.Nhs.AdminUI.Interfaces; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + + /// + /// The UserController class. + /// + [Authorize] + [Route("api/[controller]")] + [ApiController] + public class UserController : BaseApiController + { + /// + /// The elfh user service.. + /// + private IUserService userService; + + /// + /// Initializes a new instance of the class. + /// + /// The userService. + /// loginWizardService. + /// logger. + /// Settings. + public UserController(IUserService userService, ILogger logger) + : base(logger) + { + this.userService = userService; + } + + /// + /// The SessionTimeout. + /// + /// The . + [HttpPost("browser-close")] + public IActionResult BrowserClose() + { + // Add browser close to the UserHistory + UserHistoryViewModel userHistory = new UserHistoryViewModel() + { + UserId = this.CurrentUserId, + UserHistoryTypeId = (int)UserHistoryType.Logout, + Detail = @"User browser closed", + }; + + this.userService.StoreUserHistory(userHistory); + + return this.Ok(true); + } + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs index 826c98b55..703be694e 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IResourceService.cs @@ -32,9 +32,30 @@ public interface IResourceService /// The GetResourceVersionValidationResultAsync. /// /// The resourceVersionId. - /// The . + /// The . Task GetResourceVersionValidationResultAsync(int resourceVersionId); + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The resourceVersionId. + /// The . + Task GetResourceVersionDevIdDetailsAsync(int resourceVersionId); + + /// + /// Check dev id already exist against a resource. + /// + /// string devId. + /// The . + Task DoesDevIdExistsAsync(string devId); + + /// + /// To update dev id details for a resource. + /// + /// the ResourceVersionDevIdViewModel. + /// The . + Task UpdateDevIdDetailsAsync(ResourceVersionDevIdViewModel model); + /// /// The GetResourceVersionExtendedViewModelAsync. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IRoadmapService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IRoadmapService.cs index cb96b6037..a0606ebc3 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IRoadmapService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Interfaces/IRoadmapService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using LearningHub.Nhs.Models.Entities; + using LearningHub.Nhs.Models.RoadMap; /// /// Defines the . @@ -36,6 +37,13 @@ public interface IRoadmapService /// The . Task> GetUpdates(); + /// + /// The GetIdAsync. + /// + /// The id. + /// The . + Task GetIdAsync(int id); + /// /// The UpdateRoadmap. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj index f222dc100..eba6b6ffa 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj +++ b/AdminUI/LearningHub.Nhs.AdminUI/LearningHub.Nhs.AdminUI.csproj @@ -1,642 +1,645 @@  - net6.0 + net8.0 + 1.0.0.0 + 1.0.0.0 + 1.0.0 31abd8b9-4223-4ff3-896b-a46530c9e15c /subscriptions/57c55d5f-78c1-4373-a021-ff8357548f51/resourceGroups/LearningHubNhsUk-AdminUI-Prod-RG/providers/microsoft.insights/components/LearningHubNhsUk-AdminUI-Prod true true x64 + + + + + + - - - - - - + + + + - - - - - - - - - - - - - + + + + + + + + - - <_ContentIncludedByDefault Remove="bundleconfig.json" /> - + + <_ContentIncludedByDefault Remove="bundleconfig.json" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + Always + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - Always - - - - - - - diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue index 824b025cd..b2defb5c2 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/ckeditorwithhint.vue @@ -1,6 +1,6 @@ diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue index ec21a1ba4..290f00605 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/cmsPageRow.vue @@ -71,6 +71,8 @@ import { AzureMediaAssetModel } from '../models/content/videoAssetModel'; import { MKPlayer } from '@mediakind/mkplayer'; import { MKPlayerType, MKStreamType } from '../MKPlayerConfigEnum'; + //import { getPlayerConfig, getSourceConfig, initializePlayer } from '../mkiomediaplayer'; + import { buildControlbar } from '../mkioplayer-controlbar'; export default Vue.extend({ props: { @@ -89,14 +91,20 @@ player: null, videoContainer: null, mkioKey: '', + isIphone: false, + requestURL: '' }; }, - async created(): Promise { + async created(): Promise { await this.getMKIOPlayerKey(); this.load(); this.getDisplayAVFlag(); this.getAudioVideoUnavailableView(); }, + mounted() { + this.checkIfIphone(); + this.requestURL = window.location.origin; + }, computed: { getStyle() { console.log("getLinkStyle", (this.pageSectionDetail || {}).draftHidden); @@ -168,11 +176,37 @@ this.audioVideoUnavailableView = response; }); }, + //onSubtitleAdded() { + // this.player.subtitles.enable("subtitle" + this.section.id.toString()); + //}, onPlayerReady() { - const videoElement = document.getElementById("bitmovinplayer-video-" + this.getPlayerUniqueId) as HTMLVideoElement; - if (videoElement) { - videoElement.controls = true; - } + var contanierId = this.section.id.toString(); + var uniquePlayer = this.player;// (player_@Model.Id); + buildControlbar(contanierId, uniquePlayer); + + // [BY] When we set UI to false we need to manually add the controls to the video element + //const videoElement = document.getElementById("bitmovinplayer-video-" + this.getPlayerUniqueId) as HTMLVideoElement; + //if (videoElement) { + // videoElement.controls = true; + //} + + // var subtitleTrack; + //if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { + // const captionsInfo = this.pageSectionDetail.videoAsset.closedCaptionsFile; + // var srcPath = "file/download/" + captionsInfo.filePath + "/" + captionsInfo.fileName; + // //srcPath = '@requestURL' + srcPath; + // srcPath = "https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt"; + + // subtitleTrack = { + // id: "subtitle" + this.section.id.toString(), + // lang: "en", + // label: "english", + // url: srcPath, + // kind: "subtitle" + // }; + //}; + + //this.player.addSubtitle(subtitleTrack); }, async getMKIOPlayerKey(): Promise { this.mkioKey = await contentData.getMKPlayerKey(); @@ -189,14 +223,14 @@ // Grab the video container this.videoContainer = document.getElementById(this.getPlayerUniqueId); - if(!this.mkioKey) { + if (!this.mkioKey) { this.getMKIOPlayerKey(); } // Prepare the player configuration const playerConfig = { key: this.mkioKey, - ui: false, + ui: true, playback: { muted: false, autoplay: false, @@ -205,15 +239,33 @@ theme: "dark", events: { ready: this.onPlayerReady, + //subtitleadded: this.onSubtitleAdded, } }; // Initialize the player with video container and player configuration this.player = new MKPlayer(this.videoContainer, playerConfig); + var subtitleTrack = null; + var sectionId = this.section.id.toString(); + if (this.pageSectionDetail.videoAsset.azureMediaAsset && this.pageSectionDetail.videoAsset.closedCaptionsFile) { + var captionsInfo = this.pageSectionDetail.videoAsset.closedCaptionsFile;; + + if (captionsInfo) { + var srcPath = "/file/download/" + captionsInfo.filePath + "/" + captionsInfo.fileName; + subtitleTrack = { + id: "subtitle" + sectionId, + lang: "en", + label: "english", + url: this.requestURL + srcPath, + kind: "subtitle" + }; + } + } // Load source const sourceConfig = { hls: this.getMediaPlayUrl(this.pageSectionDetail.videoAsset.azureMediaAsset.locatorUri), + subtitleTracks: [subtitleTrack], drm: { clearkey: { LA_URL: "HLS_AES", @@ -282,8 +334,18 @@ }, getMediaPlayUrl(url: string): string { let sourceUrl = url.substring(0, url.lastIndexOf("manifest")) + "manifest(format=m3u8-cmaf,encryption=cbc)"; + + if (this.isIphone) { + var token = this.pageSectionDetail.videoAsset.azureMediaAsset.authenticationToken; + sourceUrl = "/Media/MediaManifest?playBackUrl=" + sourceUrl + "&token=" + token; + } + return sourceUrl; }, + checkIfIphone() { + const userAgent = navigator.userAgent || navigator.vendor; + this.isIphone = /iPhone/i.test(userAgent); + }, }, watch: { section() { @@ -318,4 +380,8 @@ video[id^="bitmovinplayer-video"] { width: 100%; } + + .bmpui-ui-controlbar .control-right { + float: right; + } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue index d091b8081..4d1faf9b0 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/pageSectionToolbar.vue @@ -2,7 +2,7 @@
-
+
{{ contentLib.getPageSectionStatusText(this.pageSection.pageSectionDetail) }}
@@ -167,8 +167,8 @@ methods: { getStatusStyle(): string { return this.pageSection.pageSectionDetail.pageSectionStatus === PageSectionStatus.Live - ? 'background: #007F3B; height: 45px; margin: -8px;border: 1px solid #FFFFFF;' - : ((this.pageSection.pageSectionDetail.deletePending == null || !this.pageSection.pageSectionDetail.deletePending) ? 'background: #FFB81C; height: 45px; margin: -8px;border: 1px solid #FFFFFF;color: #425563;' : 'background: #DA291C; height: 45px; margin: -8px; border: 1px solid #FFFFFF;'); + ? 'background: #007F3B; height: 50px; margin: -12px;border: 1px solid #FFFFFF;' + : ((this.pageSection.pageSectionDetail.deletePending == null || !this.pageSection.pageSectionDetail.deletePending) ? 'background: #FFB81C; height: 50px; margin: -12px;border: 1px solid #FFFFFF;color: #425563;' : 'background: #DA291C; height: 50px; margin: -12px; border: 1px solid #FFFFFF;'); }, getStatusIconStyle(): string { return 'margin-top:20px;' @@ -215,8 +215,9 @@ .toolBarBox { position: absolute; - right: 20px; - top: 20px; + right: 12px; + left:10px; + top: 2px; z-index: 1; } @@ -237,7 +238,7 @@ .toolBar { display: flex; padding: 16px; - height: 64px; + height: 60px; float: right; background: $nhsuk-grey; border: 2px solid $nhsuk-white; @@ -269,4 +270,14 @@ .toolBarButton i .fa, .fas { font-weight: 100; } + @media (max-width: 420px) { + .toolBarBox { + position: absolute; + right: 12px; + left: 10px; + top: 2px; + z-index: 1; + width: fit-content; + } + } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts index c92d88b7e..a950f3a21 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/content/upload/fileValidation.ts @@ -8,7 +8,7 @@ export const file_size_validation = (value: any) => { export const file_extension_validation = (value: any) => { if (!value) { return true; } let fileExtension = value.name.split(".").pop(); - let fileType = ['mp4', 'avi', 'm4v', 'mov', 'mkv', 'mpg', 'm2v', 'vob'].find(ext => ext == fileExtension); + let fileType = ['mp4', 'avi', 'm4v', 'mov', 'mkv', 'mpg', 'm2v', 'vob','wmv'].find(ext => ext == fileExtension); return fileType != undefined; }; export const transcriptfile_extension_validation = (value: any) => { diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts new file mode 100644 index 000000000..46704b4df --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkiomediaplayer.ts @@ -0,0 +1,104 @@ +import { MKPlayer, MKPlayerConfig } from '@mediakind/mkplayer'; +import { MKPlayerType, MKStreamType } from './MKPlayerConfigEnum'; +interface ClearKeyConfig { + LA_URL: string; + headers: { + Authorization: string; + }; +} + +interface PlayerConfig { + key: string; + ui: boolean; + playback: { + muted: boolean; + autoplay: boolean; + preferredTech: Array<{ player: string; streaming: string }>; + }; + theme: string; + events: { + ready: () => void; + }; +} + +interface SourceConfig { + enableLowLatency: boolean; + hls: string; + drm: { + clearkey: ClearKeyConfig; + }; +} + +function getBearerToken(authenticationToken: string): string { + // Replace this with your actual logic to get the bearer token + return `Bearer ${authenticationToken}`; +} + +function getPlayerConfig( + mkioKey: string, + onPlayerReady: () => void +): PlayerConfig { + return { + key: mkioKey, + ui: true, + playback: { + muted: false, + autoplay: false, + preferredTech: [{ player: "Html5", streaming: "Hls" }] // Adjust these strings if you have specific types + }, + theme: "dark", + events: { + ready: onPlayerReady, + } + }; +} + +function getSourceConfig( + locatorUri: string, + authenticationToken: string +): SourceConfig { + return { + enableLowLatency: true, + hls: locatorUri, + drm: { + clearkey: { + LA_URL: "HLS_AES", + headers: { + Authorization: getBearerToken(authenticationToken) + } + } + } + }; +} + +function initializePlayer(videoContainer: HTMLElement, playerConfig: MKPlayerConfig, playBackUrl: string, bearerToken: string): any { + const player = new MKPlayer(videoContainer, playerConfig); + + var clearKeyConfig = { + //LA_URL: "https://ottapp-appgw-amp.prodc.mkio.tv3cloud.com/drm/clear-key?ownerUid=azuki", + LA_URL: "HLS_AES", + headers: { + "Authorization": bearerToken + } + }; + + const sourceConfig: SourceConfig = { + enableLowLatency: true, + hls: playBackUrl, + drm: { + clearkey: clearKeyConfig + } + }; + + player.load(sourceConfig) + .then(() => { + console.log("Source loaded successfully!"); + }) + .catch(() => { + console.error("An error occurred while loading the source!"); + }); + + return player; +}; + +export { getPlayerConfig, getSourceConfig, initializePlayer }; diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts new file mode 100644 index 000000000..a9db5fb13 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Scripts/vuesrc/mkioplayer-controlbar.ts @@ -0,0 +1,77 @@ +/** + * Constructs and configures the control bar for the UI. + * + * This function performs the following tasks: + * 1. Selects the titlebar and controlbar elements from the DOM. + * 2. Creates a playback toggle button with an initial "Play" state and appends it to the controlbar. + * 3. Adds an event listener to the playback toggle button to handle play/pause functionality. + * 4. Retrieves all buttons from the titlebar, aligns them to the right (except for the "Mute" button), and appends them to the controlbar. + * 5. Selects the UI container element and sets up a MutationObserver to monitor changes in the container's class attribute. + * 6. Updates the playback toggle button state based on the player's state (playing or paused) when the container's class changes. + */ + +function buildControlbar(id: string, player: { isPlaying: () => boolean; pause: () => void; play: () => void; }): void { + const mediacontainerId = 'videoContainer_' + id; + + // Select the titlebar and controlbar elements from the DOM + const titlebar = document.querySelector(`#${mediacontainerId} .bmpui-ui-titlebar`) as HTMLElement; + const controlbar = document.querySelector(`#${mediacontainerId} .bmpui-ui-controlbar`) as HTMLElement; + + // Check if both titlebar and controlbar elements exist + if (titlebar && controlbar) { + + // Create a playback toggle button and set its initial state and appearance + const playbackToggleButton = document.createElement('button'); + playbackToggleButton.classList.add('bmpui-ui-playbacktogglebutton', 'bmpui-off'); + playbackToggleButton.setAttribute('aria-label', 'Play'); + playbackToggleButton.innerHTML = 'Play'; + playbackToggleButton.id = 'playback-toggle-btn-' + id; + controlbar.appendChild(playbackToggleButton); + + // Add an event listener to the playback toggle button + playbackToggleButton.addEventListener('click', function () { + // Toggle playback state based on the current state + if (player.isPlaying()) { + player.pause(); + playbackToggleButton.classList.remove('bmpui-on'); + playbackToggleButton.classList.add('bmpui-off'); + playbackToggleButton.innerHTML = 'Play'; + } else { + player.play(); + playbackToggleButton.classList.remove('bmpui-off'); + playbackToggleButton.classList.add('bmpui-on'); + playbackToggleButton.innerHTML = 'Pause'; + } + }); + + // Get all button elements from the titlebar + const buttons = titlebar.querySelectorAll('button'); + + // Reverse the button list and append each button to the controlbar + Array.from(buttons).reverse().forEach(button => { + if (button.textContent !== "Mute") { + button.classList.add('control-right'); // Add a class to align buttons to the right + } + controlbar.appendChild(button); // Append the button to the controlbar + }); + + // Select the UI container element + const uiOverlayElement = document.querySelector(`#${mediacontainerId} .bmpui-ui-playbacktoggle-overlay`) as HTMLElement; + uiOverlayElement.addEventListener('click', function () { + const uiContainerElement = document.querySelector(`#${mediacontainerId} .bmpui-ui-uicontainer`) as HTMLElement; + // Update the playback toggle button state based on the player's state + if (uiContainerElement.classList.contains('bmpui-player-state-playing')) { + playbackToggleButton.classList.remove('bmpui-on'); + playbackToggleButton.classList.add('bmpui-off'); + } else { + playbackToggleButton.classList.remove('bmpui-off'); + playbackToggleButton.classList.add('bmpui-on'); + } + }); + + } else { + console.error('UI container element not found'); + } +} + +export { buildControlbar }; \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs index 5517fa636..81867365f 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/ServiceCollectionExtension.cs @@ -141,6 +141,7 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); services.AddAuthentication(options => { @@ -195,6 +196,8 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur // Auto Mapper Configurations var mappingConfig = new MapperConfiguration(mc => { + mc.AllowNullCollections = true; + mc.ShouldMapMethod = m => false; mc.AddProfile(new MappingProfile()); }); IMapper mapper = mappingConfig.CreateMapper(); diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs index c4241817b..dc44a6959 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/MKIOMediaService.cs @@ -91,19 +91,21 @@ public async Task CreateMediaInputAsset(IFormFile file) /// . public async Task DownloadMediaInputAsset(string inputAssetName, string fileName) { - IAzureMediaServicesClient client = await this.CreateMediaServicesClientAsync(); + var client = this.GetMKIOServicesClientAsync(); + var assets = client.Assets.Get(inputAssetName); + + BlobServiceClient blobServiceClient = new BlobServiceClient(this.settings.MediaKindSettings.MediaKindStorageConnectionString); - AssetContainerSas assetContainerSas = await client.Assets.ListContainerSasAsync( - this.settings.AzureMediaResourceGroup, - this.settings.AzureMediaAccountName, - inputAssetName, - permissions: AssetContainerPermission.Read, - expiryTime: DateTime.UtcNow.AddHours(1).ToUniversalTime()); + BlobContainerClient containerClient = blobServiceClient.GetBlobContainerClient(assets.Properties.Container); + if (!await containerClient.ExistsAsync().ConfigureAwait(false)) + { + await containerClient.CreateIfNotExistsAsync().ConfigureAwait(false); + } - string sasUri = assetContainerSas.AssetContainerSasUrls.First(); + var filename1 = Regex.Replace(fileName, "[^a-zA-Z0-9.]", string.Empty); + filename1 = string.IsNullOrEmpty(filename1) ? "file.txt" : filename1; - var blobServiceClient = new BlobContainerClient(new Uri(sasUri)); - var blobClient = blobServiceClient.GetBlockBlobClient(fileName); + BlobClient blobClient = containerClient.GetBlobClient(filename1); var fileContent = await blobClient.DownloadContentAsync(); return fileContent; @@ -153,7 +155,7 @@ public string GetTopLevelManifestForToken(string manifestProxyUrl, string topLev { using (var reader = new StreamReader(stream)) { - const string qualityLevelRegex = @"(QualityLevels\(\d+\)/Manifest\(.+\))"; + const string qualityLevelRegex = @"([^""\s]+\.m3u8\(encryption=cbc\))"; var toplevelmanifestcontent = reader.ReadToEnd(); diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs index d0d595b52..4c6e3fe46 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/ResourceService.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -130,6 +131,89 @@ public async Task GetResourceVersionVa return viewmodel; } + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The resourceVersionId. + /// The . + public async Task GetResourceVersionDevIdDetailsAsync(int resourceVersionId) + { + ResourceVersionDevIdViewModel viewmodel = null; + + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/GetResourceVersionDevIdDetails/{resourceVersionId.ToString()}"; + 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; + } + + /// + /// The GetResourceVersionDevIdDetailsAsync. + /// + /// The devId. + /// The . + public async Task DoesDevIdExistsAsync(string devId) + { + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/DoesDevIdExists/{devId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + var doesDevIdExist = false; + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + doesDevIdExist = JsonConvert.DeserializeObject(result); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized + || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return doesDevIdExist; + } + + /// + /// Update dev id details for a resource. + /// + /// The model. + /// The . + /// the exception. + public async Task UpdateDevIdDetailsAsync(ResourceVersionDevIdViewModel model) + { + var json = JsonConvert.SerializeObject(model); + var stringContent = new StringContent(json, UnicodeEncoding.UTF8, "application/json"); + + var client = await this.LearningHubHttpClient.GetClientAsync(); + + var request = $"Resource/UpdateDevId"; + var response = await client.PutAsync(request, stringContent).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + if (!response.IsSuccessStatusCode) + { + throw new Exception("Update first name failed!"); + } + } + /// /// The GetResourceVersionExtendedViewModelAsync. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/RoadmapService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/RoadmapService.cs index bf4878d8b..48507ae31 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Services/RoadmapService.cs +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/RoadmapService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using LearningHub.Nhs.AdminUI.Interfaces; using LearningHub.Nhs.Models.Entities; + using LearningHub.Nhs.Models.RoadMap; using Newtonsoft.Json; /// @@ -134,6 +135,34 @@ public async Task> GetUpdates() return viewmodel; } + /// + /// The GetIdAsync. + /// + /// The id. + /// The . + public async Task GetIdAsync(int id) + { + RoadMapViewModel viewmodel = null; + + var client = await this.LearningHubHttpClient.GetClientAsync(); + var request = $"Roadmap/GetRoadMapsById/{id}"; + 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; + } + /// /// The UpdateRoadmap. /// diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Services/VersionService.cs b/AdminUI/LearningHub.Nhs.AdminUI/Services/VersionService.cs new file mode 100644 index 000000000..45361c052 --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/Services/VersionService.cs @@ -0,0 +1,18 @@ +namespace LearningHub.Nhs.AdminUI.Services +{ + /// + /// Defines the . + /// + public class VersionService + { + /// + /// The GetVersion. + /// + /// The . + public string GetVersion() + { + var version = typeof(Program).Assembly.GetName().Version; + return $"{version?.Major}.{version?.Minor}.{version?.Build}"; + } + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss index 3423503ae..c372b4b21 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss +++ b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Layout/_footer.scss @@ -28,9 +28,13 @@ .footer-primary, .footer-secondary { - font-size: 17px; - line-height: 28px; - padding-top: 30px; + font-size: 17px; + line-height: 28px; + padding-top: 30px; + + @media (max-width: 420px) { + padding-top: 20px !important; + } } .footer-primary { @@ -63,7 +67,7 @@ padding-right: 15px; @media (max-width: 420px) { - padding-top: 10px !important; + padding-top: 0px !important; text-align: left !important; } } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss index b037259dd..139bd025d 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss +++ b/AdminUI/LearningHub.Nhs.AdminUI/Styles/Pages/page_detail.scss @@ -252,6 +252,10 @@ h4 { font-size: 16px; } +.information-page__row h1,h2,h3,h4 { + font-family: "Frutiger W01", Arial, sans-serif !important; +} + /* desktop */ @media (min-width: 990px) { } diff --git a/AdminUI/LearningHub.Nhs.AdminUI/ViewComponents/VersionViewComponent.cs b/AdminUI/LearningHub.Nhs.AdminUI/ViewComponents/VersionViewComponent.cs new file mode 100644 index 000000000..db857b6ce --- /dev/null +++ b/AdminUI/LearningHub.Nhs.AdminUI/ViewComponents/VersionViewComponent.cs @@ -0,0 +1,34 @@ +namespace LearningHub.Nhs.AdminUI.ViewComponents +{ + using LearningHub.Nhs.AdminUI.Services; + using Microsoft.AspNetCore.Html; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.ViewComponents; + + /// + /// Initializes a new instance of the class. + /// + public class VersionViewComponent : ViewComponent + { + private readonly VersionService versionService; + + /// + /// Initializes a new instance of the class. + /// + /// . + public VersionViewComponent(VersionService versionService) + { + this.versionService = versionService; + } + + /// + /// The Invoke. + /// + /// A representing the result of the synchronous operation. + public IViewComponentResult Invoke() + { + var version = this.versionService.GetVersion(); + return new HtmlContentViewComponentResult(new HtmlString($"")); + } + } +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/CatalogueOwner.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/CatalogueOwner.cshtml index faa03ac40..09709fd29 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/CatalogueOwner.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/CatalogueOwner.cshtml @@ -47,14 +47,14 @@
- +
- +
diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml index e0e81edcf..1d8ab66da 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Catalogue/Edit.cshtml @@ -3,442 +3,448 @@ @using LearningHub.Nhs.Models.Enums @using Newtonsoft.Json @{ - var page = Model.CatalogueNodeVersionId == 0 ? CatalogueNavPage.Add : CatalogueNavPage.Edit; - ViewData["Title"] = page == CatalogueNavPage.Add ? "Add catalogue" : "Edit catalogue"; - var baseUrl = _settings.Value.LearningHubUrl + "catalogue/"; - var url = baseUrl + (Model.Url ?? ""); - var aToZ = (int)CatalogueOrder.AlphabeticalAscending; - var keywords = Model.Keywords ?? new List(); - var Providers = Model.Providers; - var CatalogueNodeVersionProviderId = Model.CatalogueNodeVersionProvider?.ProviderId ?? 0; - var CatalogueNodeVersionProvider = Model.CatalogueNodeVersionProvider; - var action = page.ToString(); - var keywordsJson = Html.Raw(JsonConvert.SerializeObject(keywords)); - var imageBaseUrl = "/file/download/CatalogueImageDirectory/"; - var lastModifiedDate = Model.LastModifiedDate?.ToString("dd MMM yyyy"); + var page = Model.CatalogueNodeVersionId == 0 ? CatalogueNavPage.Add : CatalogueNavPage.Edit; + ViewData["Title"] = page == CatalogueNavPage.Add ? "Add catalogue" : "Edit catalogue"; + var baseUrl = _settings.Value.LearningHubUrl + "catalogue/"; + var url = baseUrl + (Model.Url ?? ""); + var aToZ = (int)CatalogueOrder.AlphabeticalAscending; + var keywords = Model.Keywords ?? new List(); + var Providers = Model.Providers; + var CatalogueNodeVersionProviderId = Model.CatalogueNodeVersionProvider?.ProviderId ?? 0; + var CatalogueNodeVersionProvider = Model.CatalogueNodeVersionProvider; + var action = page.ToString(); + var keywordsJson = Html.Raw(JsonConvert.SerializeObject(keywords)); + var imageBaseUrl = "/file/download/CatalogueImageDirectory/"; + var lastModifiedDate = Model.LastModifiedDate?.ToString("dd MMM yyyy"); } @section SideMenu { - @{ - await Html.RenderPartialAsync("_NavSection"); - } + @{ + await Html.RenderPartialAsync("_NavSection"); + } } @if (!string.IsNullOrEmpty(ViewBag.ErrorMessage)) { - + } @if (page == CatalogueNavPage.Edit) { -
-
-

@Model.Name

+
+
+

@Model.Name

+
-
} @{ - await Html.RenderPartialAsync("_CatalogueNav.cshtml", new CatalogueNavViewModel { Page = page, CatalogueId = Model.CatalogueNodeVersionId }); + await Html.RenderPartialAsync("_CatalogueNav.cshtml", new CatalogueNavViewModel { Page = page, CatalogueId = Model.CatalogueNodeVersionId }); }
-
- - - - -
-
-
- @if (page == CatalogueNavPage.Edit) - { - var idString = Model.CatalogueNodeVersionId.ToString(); - var paddedIdString = idString.Length > 3 ? idString : idString.PadLeft(3, '0'); - ID: @paddedIdString - } - else - { - NEW - } + + + + + +
+
+
+ @if (page == CatalogueNavPage.Edit) + { + var idString = Model.CatalogueNodeVersionId.ToString(); + var paddedIdString = idString.Length > 3 ? idString : idString.PadLeft(3, '0'); + ID: @paddedIdString + } + else + { + NEW + } +
+ @if (page == CatalogueNavPage.Add || Model.Hidden) + { + Hidden + } + @if (lastModifiedDate != null) + { +

Last modified on: @lastModifiedDate

+ } +
- @if (page == CatalogueNavPage.Add || Model.Hidden) - { - Hidden - } - @if (lastModifiedDate != null) - { -

Last modified on: @lastModifiedDate

- } -
-
- - @if (!ViewData.ModelState.IsValid) - { -
This form failed to save because there are errors below.
- } -
-
- -

You can enter a maximum of 255 characters including spaces

- - -
-
-
-
- - @if (page == CatalogueNavPage.Add) - { - - } - else - { - - } - -
-
- @if (Model.Status == VersionStatusEnum.Published && !Model.Hidden) + @if (!ViewData.ModelState.IsValid) { - +
This form failed to save because there are errors below.
} - else - { -

- } -
-
-
-
- -
-
-
-
- - -
-
-
-
- -
-
-
-
- -
-
-
-
- - - Only the first 3,000 characters of the description will be used by search - -
-
-
-
- -

- Add one or more relevant keywords to help learners find this catalogue, ensuring that the keyword is specifically related to the catalogue rather than the resources associated with it. A maximum of 5 keywords can be added. Enter one keyword and add it before entering another. -

-
-
- -
- + +
+
+ +

You can enter a maximum of 255 characters including spaces

+ + +
+
+
+
+ + @if (page == CatalogueNavPage.Add) + { + + } + else + { + + } + +
+
+ @if (Model.Status == VersionStatusEnum.Published && !Model.Hidden) + { + + } + else + { +

+ }
-
-
-
- - -
+
+
+ +
+
+
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+
+ + + Only the first 3,000 characters of the description will be used by search + +
+
+
+
+ +

+ Add one or more relevant keywords to help learners find this catalogue, ensuring that the keyword is specifically related to the catalogue rather than the resources associated with it. A maximum of 5 keywords can be added. Enter one keyword and add it before entering another. +

+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
-
-
-

You can enter a maximum of 50 characters per keyword.

-
- @{ - var i = 0; - } - @foreach (var keyword in keywords) - { -
-

@keyword

- -
- } +
+

You can enter a maximum of 50 characters per keyword.

+
+ @{ + var i = 0; + } + @foreach (var keyword in keywords) + { +
+

@keyword

+ + +
+ } +
+
-
-
-
+
-
-
- -
-
+
+
+ +
+
-
-
-
- @if (!Model.RestrictedAccess) - { - - } - else - { - - } - Unrestricted access (default) -
-
- @if (Model.RestrictedAccess) - { - - } - else +
+
+
+ @if (!Model.RestrictedAccess) + { + + } + else + { + + } + Unrestricted access (default) +
+
+ @if (Model.RestrictedAccess) + { + + } + else + { + + } + Restricted access +
+
+
+ @if (!Model.HasUserGroup) { - +
+

There are no user groups associated with this catalogue. You can add some in the User Groups tab.

+
} - Restricted access -
-
-
- @if (!Model.HasUserGroup) - { -
-

There are no user groups associated with this catalogue. You can add some in the User Groups tab.

- } -
-
+
-
-
- -

When applicable please select the provider of this content. This will allow a contributor to flag content from a specific provider.

-

This will enable learners to search for content produced by organisations and help separate learning resources from community contributions.

-

Developed with;

-
-
+
+
+ +

When applicable please select the provider of this content. This will allow a contributor to flag content from a specific provider.

+

This will enable learners to search for content produced by organisations and help separate learning resources from community contributions.

+

Developed with;

+
+
-
-
- @if (Providers != null && Providers.Count() > 0) - { -
- @foreach (var provider in Providers) - { - - @provider.Name -
- } - - Not applicable +
+
+ @if (Providers != null && Providers.Count() > 0) + { +
+ @foreach (var provider in Providers) + { + + @provider.Name +
+ } + + Not applicable +
+ } +
- }
-
-
-
-
- @if (page == CatalogueNavPage.Add) - { - - } - else - { - - } -
-
- -
+
+
+ @if (page == CatalogueNavPage.Add) + { + + } + else + { + + } +
+
+ +
@section Scripts { - - -} \ No newline at end of file + + +} diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml index f6ae3a2da..ca848cc75 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Cms/PageDetail.cshtml @@ -3,7 +3,8 @@ } @section Styles{ - + @* *@ + } @@ -14,5 +15,5 @@ @section Scripts{ - + @* *@ } \ No newline at end of file diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Log/Index.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Log/Index.cshtml index 8e9c75ae2..8f3eb95d3 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Log/Index.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Log/Index.cshtml @@ -23,6 +23,8 @@ + @if (Model.Results != null) + { @foreach (var item in Model.Results.Items) { @@ -46,6 +48,7 @@ } + }
diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Notifications/CreateEdit.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Notifications/CreateEdit.cshtml index 327cce4b8..627deb40b 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Notifications/CreateEdit.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Notifications/CreateEdit.cshtml @@ -73,7 +73,7 @@
- +
diff --git a/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml b/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml index a2df6a2c6..53d548c59 100644 --- a/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml +++ b/AdminUI/LearningHub.Nhs.AdminUI/Views/Resource/Details.cshtml @@ -6,9 +6,10 @@ @{ ViewData["Title"] = "Details"; + var activetab = this.ViewBag.ActiveTab; } -@section Styles{ +@section Styles { } @@ -52,11 +53,11 @@
If you unpublish this resource learners will not be able to find or access it.
Please provide a reason for unpublishing this resource (everything you write here can be seen by the contributor of the resource).
+ rows="5" + data-val-required="A reason for unpublishing is required." + data-val-maxlength-max="1024" + data-val-maxlength="The reason for unpublishing has a maximum length of '1024' characters." + data-val="true">
@@ -105,10 +106,10 @@
Current Owner Username: @Model.CreateUser
Please provide the Username of the new Owner:
+ data-val-required="New Resource Owner is required." + data-val-maxlength-max="50" + data-val-maxlength="New Resource Owner has a maximum length of '50' characters." + data-val="true">
@@ -124,7 +125,7 @@
public int CatalogueSearchPageSize { get; set; } + + /// + /// Gets or sets the AllCatalogueSearchPageSize. + /// + public int AllCatalogueSearchPageSize { get; set; } } } diff --git a/LearningHub.Nhs.WebUI/Configuration/Settings.cs b/LearningHub.Nhs.WebUI/Configuration/Settings.cs index d107ac2d5..f0f2da3c1 100644 --- a/LearningHub.Nhs.WebUI/Configuration/Settings.cs +++ b/LearningHub.Nhs.WebUI/Configuration/Settings.cs @@ -186,6 +186,21 @@ public Settings() ///
public string GoogleAnalyticsId { get; set; } + /// + /// Gets or sets the PasswordRequestLimitingPeriod. + /// + public int PasswordRequestLimitingPeriod { get; set; } + + /// + /// Gets or sets the PasswordRequestLimit. + /// + public int PasswordRequestLimit { get; set; } + + /// + /// Gets or sets the ConcurrentId. + /// + public int ConcurrentId { get; set; } + /// /// Gets or sets the SupportUrls. /// @@ -245,5 +260,10 @@ public Settings() /// Gets or sets the MediaKindSettings. ///
public MediaKindSettings MediaKindSettings { get; set; } = new MediaKindSettings(); + + /// + /// Gets or sets AllCataloguePageSize. + /// + public int AllCataloguePageSize { 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 dac3a38e9..2d2017cd2 100644 --- a/LearningHub.Nhs.WebUI/Controllers/AccountController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/AccountController.cs @@ -429,6 +429,7 @@ public async Task CreateAccountCountrySearch() public async Task CreateAccountCountrySelection(AccountCreationViewModel accountCreationViewModel) { var accountDetails = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (!string.IsNullOrWhiteSpace(accountCreationViewModel.FilterText)) { string filterText = Regex.Replace(accountCreationViewModel.FilterText, "[:!@#$%^&*()}{|\":?><\\[\\]\\;'/.,~\\\"\"\\'\\\\/]", " "); @@ -469,41 +470,55 @@ public async Task CreateAccountRegionSelection(AccountCreationVie { var countryCheck = int.TryParse(accountCreationViewModel.CountryId, out int countryId); var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); + if (accountCreationViewModel.CountryId != null) + { + accountCreation.CountryId = accountCreationViewModel.CountryId; + } + if (string.IsNullOrWhiteSpace(accountCreationViewModel.CountryId) || !countryCheck) { this.ModelState.AddModelError("CountryId", CommonValidationErrorMessages.CountryRequired); var countries = await this.countryService.GetFilteredAsync(accountCreationViewModel.FilterText); return this.View("CreateAccountCountrySelection", new AccountCreationListViewModel { FilterText = accountCreationViewModel.FilterText, CountryList = countries.ToList(), ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); } - - accountCreation.CountryId = accountCreationViewModel.CountryId; - await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - if (countryId != 1) + else if (accountCreation.CountryId == "1") { - accountCreationViewModel.RegionId = string.Empty; - accountCreation.RegionId = string.Empty; await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - - if (accountCreation.IsLoginWizard) + var regionData = await this.regionService.GetAllPagedAsync(accountCreationViewModel.CurrentPageIndex, UserRegistrationContentPageSize); + return this.View(new AccountCreationListViewModel { Region = regionData.Item2, AccountCreationPaging = new AccountCreationPagingModel { TotalItems = regionData.Item1, PageSize = UserRegistrationContentPageSize, HasItems = regionData.Item1 > 0, CurrentPage = accountCreationViewModel.CurrentPageIndex }, RegionId = accountCreation.RegionId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); + } + else + { + accountCreation.CountryId = accountCreationViewModel.CountryId; + await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + if (countryId != 1) { - return this.CheckConfirmationUpdate() ? this.RedirectToAction("AccountConfirmation", "LoginWizard") : this.RedirectToAction("NextStage", "LoginWizard"); - } + accountCreationViewModel.RegionId = string.Empty; + accountCreation.RegionId = string.Empty; + await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - return this.CheckConfirmationUpdate() ? this.RedirectToAction("CreateAccountConfirmation", new AccountCreationViewModel { LocationId = accountCreation.LocationId }) : this.RedirectToAction("CreateAccountSearchRole", accountCreationViewModel); - } + if (accountCreation.IsLoginWizard) + { + return this.CheckConfirmationUpdate() ? this.RedirectToAction("AccountConfirmation", "LoginWizard") : this.RedirectToAction("NextStage", "LoginWizard"); + } - switch (accountCreationViewModel.AccountCreationPagingEnum) - { - case AccountCreationPagingEnum.NextPageChange: - accountCreationViewModel.CurrentPageIndex += 1; - break; + return this.CheckConfirmationUpdate() ? this.RedirectToAction("CreateAccountConfirmation", new AccountCreationViewModel { LocationId = accountCreation.LocationId }) : this.RedirectToAction("CreateAccountSearchRole", accountCreationViewModel); + } - case AccountCreationPagingEnum.PreviousPageChange: - accountCreationViewModel.CurrentPageIndex -= 1; - break; - case AccountCreationPagingEnum.Default: - accountCreationViewModel.CurrentPageIndex = 1; - break; + switch (accountCreationViewModel.AccountCreationPagingEnum) + { + case AccountCreationPagingEnum.NextPageChange: + accountCreationViewModel.CurrentPageIndex += 1; + break; + + case AccountCreationPagingEnum.PreviousPageChange: + accountCreationViewModel.CurrentPageIndex -= 1; + break; + case AccountCreationPagingEnum.Default: + accountCreationViewModel.CurrentPageIndex = 1; + break; + } } var region = await this.regionService.GetAllPagedAsync(accountCreationViewModel.CurrentPageIndex, UserRegistrationContentPageSize); @@ -522,10 +537,10 @@ public async Task CreateAccountRegionSelection(AccountCreationVie public async Task CreateAccountSubmitRegionSelection(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (string.IsNullOrWhiteSpace(accountCreationViewModel.RegionId)) { - if (accountCreation.CountryId == "1") + if (accountCreation.CountryId == "1" || accountCreation.CountryId == null) { this.ModelState.AddModelError("RegionId", CommonValidationErrorMessages.RegionRequired); var region = await this.regionService.GetAllPagedAsync(1, UserRegistrationContentPageSize); @@ -533,6 +548,11 @@ public async Task CreateAccountSubmitRegionSelection(AccountCreat } } + if (accountCreationViewModel.CountryId != null) + { + accountCreation.CountryId = accountCreationViewModel.CountryId; + } + accountCreation.RegionId = accountCreationViewModel.RegionId; await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); @@ -546,7 +566,7 @@ public async Task CreateAccountSubmitRegionSelection(AccountCreat return this.CheckConfirmationUpdate() ? this.RedirectToAction("AccountConfirmation", "LoginWizard") : this.RedirectToAction("NextStage", "LoginWizard"); } - return this.CheckConfirmationUpdate() ? this.RedirectToAction("CreateAccountConfirmation", new AccountCreationViewModel { LocationId = accountCreation.LocationId }) : this.RedirectToAction("CreateAccountSearchRole", new AccountCreationViewModel() { CountryId = accountCreation.CountryId }); + return this.CheckConfirmationUpdate() ? this.RedirectToAction("CreateAccountConfirmation", new AccountCreationViewModel { LocationId = accountCreation.LocationId }) : this.RedirectToAction("CreateAccountSearchRole", new AccountCreationViewModel() { CountryId = accountCreation.CountryId, RegionId = accountCreation.RegionId }); } /// @@ -561,7 +581,18 @@ public async Task CreateAccountSearchRole(AccountCreationViewMode { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - return this.View("CreateAccountSearchRole", new AccountCreationViewModel { CountryId = accountCreationViewModel.CountryId }); + var currentJobRole = int.TryParse(accountCreation.CurrentRole, out int currentRole); + if (currentJobRole && currentRole > 0) + { + var filterText = await this.jobRoleService.GetByIdAsync(currentRole); + accountCreationViewModel.FilterText = filterText.Name; + var jobrole = await this.jobRoleService.GetByIdAsync(currentRole); + return this.View("CreateAccountCurrentRole", new AccountCreationListViewModel { RoleList = new List { jobrole }, AccountCreationPaging = new AccountCreationPagingModel { TotalItems = 1, PageSize = UserRegistrationContentPageSize, HasItems = jobrole != null, CurrentPage = 1 }, CurrentRole = accountCreation.CurrentRole, CountryId = accountCreation.CountryId, RegionId = accountCreation.RegionId, FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); + } + else + { + return this.View("CreateAccountSearchRole", new AccountCreationViewModel { CountryId = accountCreationViewModel.CountryId }); + } } /// @@ -575,13 +606,14 @@ public async Task CreateAccountSearchRole(AccountCreationViewMode public async Task CreateAccountCurrentRole(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (!string.IsNullOrWhiteSpace(accountCreationViewModel.FilterText)) { string filterText = Regex.Replace(accountCreationViewModel.FilterText, "[:!@#$%^&*()}{|\":?><\\[\\]\\;'/.,~\\\"\"\\'\\\\/]", " "); if (string.IsNullOrWhiteSpace(filterText)) { this.ModelState.AddModelError("FilterText", CommonValidationErrorMessages.SearchTermRequired); - return this.View("CreateAccountSearchRole", new AccountCreationViewModel { RegionId = accountCreation.RegionId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); + return this.View("CreateAccountSearchRole", new AccountCreationViewModel { CountryId = accountCreation.CountryId, RegionId = accountCreation.RegionId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); } } @@ -598,7 +630,7 @@ public async Task CreateAccountCurrentRole(AccountCreationViewMod else { this.ModelState.AddModelError("FilterText", CommonValidationErrorMessages.SearchTermRequired); - return this.View("CreateAccountSearchRole", new AccountCreationViewModel { RegionId = accountCreation.RegionId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); + return this.View("CreateAccountSearchRole", new AccountCreationViewModel { CountryId = accountCreation.CountryId, RegionId = accountCreation.RegionId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); } } @@ -631,15 +663,17 @@ public async Task CreateAccountCurrentRole(AccountCreationViewMod public async Task CreateAccountProfessionalRegNumber(AccountCreationViewModel accountCreationViewModel) { var roleCheck = int.TryParse(accountCreationViewModel.CurrentRole, out int roleId); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); + var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + if (string.IsNullOrWhiteSpace(accountCreationViewModel.CurrentRole) || !roleCheck) { this.ModelState.AddModelError("CurrentRole", CommonValidationErrorMessages.RoleRequired); var jobroles = await this.jobRoleService.GetPagedFilteredAsync(accountCreationViewModel.FilterText, 1, UserRegistrationContentPageSize); - return this.View("CreateAccountCurrentRole", new AccountCreationListViewModel { FilterText = accountCreationViewModel.FilterText, RoleList = jobroles.Item2, AccountCreationPaging = new AccountCreationPagingModel { TotalItems = jobroles.Item1, PageSize = UserRegistrationContentPageSize, HasItems = jobroles.Item1 > 0, CurrentPage = 1 }, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); + return this.View("CreateAccountCurrentRole", new AccountCreationListViewModel { FilterText = accountCreationViewModel.FilterText, CountryId = accountCreation.CountryId, RoleList = jobroles.Item2, AccountCreationPaging = new AccountCreationPagingModel { TotalItems = jobroles.Item1, PageSize = UserRegistrationContentPageSize, HasItems = jobroles.Item1 > 0, CurrentPage = 1 }, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); } var jobrole = await this.jobRoleService.GetByIdAsync(roleId); - var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); accountCreation.CurrentRole = jobrole.Id.ToString(); accountCreation.CurrentRoleName = jobrole.Name; accountCreation.MedicalCouncilId = jobrole.MedicalCouncilId; @@ -662,6 +696,7 @@ public async Task CreateAccountProfessionalRegNumber(AccountCreat public async Task CreateAccountGradeSelection(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); int gradePageSize = UserRegistrationContentPageSize + 5; var roleCheck = int.TryParse(accountCreation.CurrentRole, out int roleId); if (!roleCheck || roleId == 0) @@ -672,7 +707,7 @@ public async Task CreateAccountGradeSelection(AccountCreationView if (string.IsNullOrWhiteSpace(accountCreationViewModel.RegistrationNumber) && accountCreation.MedicalCouncilId.HasValue && (int)accountCreation.MedicalCouncilId > 0) { - this.ModelState.AddModelError("RegistrationNumber", $"You must provide a {accountCreation.MedicalCouncilCode} Number"); + this.ModelState.AddModelError("RegistrationNumber", $"{accountCreation.MedicalCouncilName} Number is required"); this.ViewBag.Job = await this.jobRoleService.GetByIdAsync(roleId); accountCreationViewModel.CurrentRole = roleId.ToString(); return this.View("CreateAccountProfessionalRegNumber", accountCreationViewModel); @@ -726,11 +761,12 @@ public async Task CreateAccountGradeSelection(AccountCreationView public async Task CreateAccountPrimarySpecialty(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); var gradeCheck = int.TryParse(accountCreationViewModel.GradeId, out int gradeId); if (string.IsNullOrWhiteSpace(accountCreationViewModel.GradeId) || !gradeCheck) { int gradePageSize = UserRegistrationContentPageSize + 5; - this.ModelState.AddModelError(string.Empty, CommonValidationErrorMessages.GradeRequired); + this.ModelState.AddModelError("GradeId", CommonValidationErrorMessages.GradeRequired); var gradeLevel = await this.gradeService.GetPagedGradesForJobRoleAsync(int.Parse(accountCreation.CurrentRole), 1, gradePageSize); return this.View("CreateAccountGradeSelection", new AccountCreationListViewModel { GradeList = gradeLevel.Item2, AccountCreationPaging = new AccountCreationPagingModel { TotalItems = gradeLevel.Item1, PageSize = gradePageSize, HasItems = gradeLevel.Item1 > 0, CurrentPage = 1 }, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }); } @@ -758,6 +794,7 @@ public async Task CreateAccountPrimarySpecialty(AccountCreationVi public async Task CreateAccountPrimarySpecialtySelection(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (accountCreationViewModel.PrimarySpecialtyId?.ToLower() == "not applicable") { var specialties = await this.specialtyService.GetSpecialtiesAsync(); @@ -833,6 +870,7 @@ public async Task CreateAccountWorkStartDate(AccountCreationViewM { int specialtyId; var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (accountCreationViewModel.PrimarySpecialtyId?.ToLower() == "not applicable") { var specialties = await this.specialtyService.GetSpecialtiesAsync(); @@ -852,14 +890,14 @@ public async Task CreateAccountWorkStartDate(AccountCreationViewM this.ModelState.AddModelError("PrimarySpecialtyId", CommonValidationErrorMessages.SpecialtyNotApplicable); } - return this.View("CreateAccountPrimarySpecialtySelection", new AccountCreationListViewModel { FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation, SpecialtyList = shortlist.Item2, OptionalSpecialtyItem = optionalSpecialty.FirstOrDefault(x => x.Name.ToLower() == "not applicable"), AccountCreationPaging = new AccountCreationPagingModel { TotalItems = shortlist.Item1, PageSize = UserRegistrationContentPageSize, HasItems = shortlist.Item1 > 0, CurrentPage = 1 } }); + return this.View("CreateAccountPrimarySpecialtySelection", new AccountCreationListViewModel { FilterText = accountCreationViewModel.FilterText, GradeId = accountCreation.GradeId, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation, SpecialtyList = shortlist.Item2, OptionalSpecialtyItem = optionalSpecialty.FirstOrDefault(x => x.Name.ToLower() == "not applicable"), AccountCreationPaging = new AccountCreationPagingModel { TotalItems = shortlist.Item1, PageSize = UserRegistrationContentPageSize, HasItems = shortlist.Item1 > 0, CurrentPage = 1 } }); } accountCreation.PrimarySpecialtyId = accountCreationViewModel.PrimarySpecialtyId; } await this.multiPageFormService.SetMultiPageFormData(accountCreation, MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - var dateVM = accountCreation.StartDate.HasValue ? new AccountCreationDateViewModel() { Day = accountCreation.StartDate.Value.Day, Month = accountCreation.StartDate.GetValueOrDefault().Month, Year = accountCreation.StartDate.Value.Year, FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation } : new AccountCreationDateViewModel() { FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }; + var dateVM = accountCreation.StartDate.HasValue ? new AccountCreationDateViewModel() { Day = accountCreation.StartDate.HasValue ? accountCreation.StartDate.Value.Day.ToString() : string.Empty, Month = accountCreation.StartDate.HasValue ? accountCreation.StartDate.GetValueOrDefault().Month.ToString() : string.Empty, Year = accountCreation.StartDate.HasValue ? accountCreation.StartDate.Value.Year.ToString() : string.Empty, FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation } : new AccountCreationDateViewModel() { FilterText = accountCreationViewModel.FilterText, ReturnToConfirmation = accountCreationViewModel.ReturnToConfirmation }; if (!string.IsNullOrWhiteSpace(accountCreationViewModel.PrimarySpecialtyId) && string.IsNullOrWhiteSpace(accountCreationViewModel.FilterText)) { var specialty = this.specialtyService.GetSpecialtiesAsync().Result.FirstOrDefault(x => x.Id == specialtyId); @@ -880,6 +918,7 @@ public async Task CreateAccountWorkStartDate(AccountCreationViewM [TypeFilter(typeof(RedirectMissingMultiPageFormData), Arguments = new object[] { nameof(MultiPageFormDataFeature.AddRegistrationPrompt) })] public async Task CreateAccountStartDate(AccountCreationDateViewModel accountCreationDateViewModel) { + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (!this.ModelState.IsValid) { return this.View("CreateAccountWorkStartDate", accountCreationDateViewModel); @@ -920,7 +959,7 @@ public async Task CreateAccountStartDate(bool? returnToConfirmati } } - var dateVM = accountCreation.StartDate.HasValue ? new AccountCreationDateViewModel() { Day = accountCreation.StartDate.Value.Day, Month = accountCreation.StartDate.GetValueOrDefault().Month, Year = accountCreation.StartDate.Value.Year, ReturnToConfirmation = returnToConfirmation } : new AccountCreationDateViewModel() { ReturnToConfirmation = returnToConfirmation }; + var dateVM = accountCreation.StartDate.HasValue ? new AccountCreationDateViewModel() { Day = accountCreation.StartDate.HasValue ? accountCreation.StartDate.Value.Day.ToString() : string.Empty, Month = accountCreation.StartDate.HasValue ? accountCreation.StartDate.GetValueOrDefault().Month.ToString() : string.Empty, Year = accountCreation.StartDate.HasValue ? accountCreation.StartDate.Value.Year.ToString() : string.Empty, ReturnToConfirmation = returnToConfirmation } : new AccountCreationDateViewModel() { ReturnToConfirmation = returnToConfirmation }; return this.View("CreateAccountWorkStartDate", dateVM); } @@ -934,6 +973,7 @@ public async Task CreateAccountStartDate(bool? returnToConfirmati public async Task CreateAccountWorkPlaceSearch() { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (!string.IsNullOrWhiteSpace(accountCreation.LocationId) && !this.CheckConfirmationUpdate()) { return this.RedirectToAction("CreateAccountWorkPlace", new AccountCreationViewModel { LocationId = accountCreation.LocationId }); @@ -953,6 +993,7 @@ public async Task CreateAccountWorkPlaceSearch() public async Task CreateAccountWorkPlace(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (string.IsNullOrWhiteSpace(accountCreationViewModel.FilterText)) { if (!string.IsNullOrWhiteSpace(accountCreation.LocationId)) @@ -995,7 +1036,7 @@ public async Task CreateAccountWorkPlace(AccountCreationViewModel public async Task CreateAccountConfirmation(AccountCreationViewModel accountCreationViewModel) { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); - + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (accountCreation.AccountCreationType == AccountCreationTypeEnum.FullAccess) { var placeOfWorkCheck = int.TryParse(accountCreationViewModel.LocationId, out int locationId); @@ -1032,7 +1073,7 @@ public async Task CreateAccountConfirmation() { var accountCreation = await this.multiPageFormService.GetMultiPageFormData(MultiPageFormDataFeature.AddRegistrationPrompt, this.TempData); this.ViewBag.AccountCreationType = accountCreation.AccountCreationType; - + AccountCreationFormHelper.PopulateGroupedFormControlMetadata(this.ViewData); if (accountCreation.CountryId == "1" && (string.IsNullOrWhiteSpace(accountCreation.RegionId) || accountCreation.RegionId == "0")) { this.ModelState.AddModelError(string.Empty, CommonValidationErrorMessages.RegionRequiredSummary); @@ -1126,6 +1167,16 @@ public IActionResult InvalidUserAccount() return this.View(); } + /// + /// The user already has an already active session. Then prevent concurrent access to the Learning Hub. + /// + /// The . + [HttpGet] + public IActionResult AlreadyAnActiveSession() + { + return this.View(); + } + /// /// The ForgotPassword. /// @@ -1157,8 +1208,20 @@ public async Task ForgotPassword(Models.Account.ForgotPasswordVie return this.Ok(new { duplicate = true }); } - await this.userService.ForgotPasswordAsync(model.EmailAddress); - return this.View("ForgotPasswordAcknowledgement"); + var passwordRequestLimitingPeriod = this.Settings.PasswordRequestLimitingPeriod; + var passwordRequestLimit = this.Settings.PasswordRequestLimit; + var status = await this.userService.CanRequestPasswordResetAsync(model.EmailAddress, passwordRequestLimitingPeriod, passwordRequestLimit); + if (status) + { + await this.userService.ForgotPasswordAsync(model.EmailAddress); + return this.View("ForgotPasswordAcknowledgement"); + } + else + { + this.ViewBag.Period = passwordRequestLimitingPeriod; + this.ViewBag.Limit = passwordRequestLimit; + return this.View("TooManyRequests"); + } } /// diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/ContributeController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/ContributeController.cs index 6bfbcca6c..572cd69c9 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/ContributeController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/ContributeController.cs @@ -349,11 +349,18 @@ public async Task PublishResourceVersionAsync([FromBody] PublishVi { if (associatedResource.ResourceType != ResourceTypeEnum.Scorm && associatedResource.ResourceType != ResourceTypeEnum.Html) { + try + { var obsoleteFiles = await this.resourceService.GetObsoleteResourceFile(publishViewModel.ResourceVersionId); if (obsoleteFiles != null && obsoleteFiles.Any()) { - await this.fileService.PurgeResourceFile(null, obsoleteFiles); + _ = Task.Run(async () => { await this.fileService.PurgeResourceFile(null, obsoleteFiles); }); } + } + catch (Exception ex) + { + this.Logger.LogError($"File Archive Error: {ex.Message}", $"ResourceVersionId -{publishViewModel.ResourceVersionId}"); + } } } @@ -707,8 +714,8 @@ private async Task RemoveBlockCollectionFiles(int resourceVersionId, BlockCollec { foreach (var oldblock in existingImages) { - var entry = newBlocks.FirstOrDefault(x => x.BlockType == BlockType.Media && x.MediaBlock != null && x.MediaBlock.MediaType == MediaType.Image && x.MediaBlock.Image != null && (x.MediaBlock?.Image?.File?.FileId == oldblock.MediaBlock?.Image?.File?.FileId || x.MediaBlock?.Image?.File?.FilePath == oldblock.MediaBlock?.Image?.File?.FilePath)); - if (entry == null) + var entry = newBlocks.FirstOrDefault(x => x.BlockType == BlockType.Media && x.MediaBlock != null && x.MediaBlock.MediaType == MediaType.Image && x.MediaBlock.Image != null && (x.MediaBlock?.Image?.File?.FileId == oldblock.MediaBlock?.Image?.File?.FileId || x.MediaBlock?.Image?.File?.FilePath == oldblock.MediaBlock?.Image?.File?.FilePath)); + if (entry == null) { filePaths.Add(oldblock?.MediaBlock?.Image?.File?.FilePath); } @@ -790,8 +797,10 @@ private async Task RemoveBlockCollectionFiles(int resourceVersionId, BlockCollec _ = Task.Run(async () => { await this.fileService.PurgeResourceFile(null, deleteList); }); } } - catch + catch (Exception ex) { + var param = new object[] { resourceVersionId, existingResource, newResource }; + this.Logger.LogError($"BlockCollection Archive Error: {ex.Message}", param); } } diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/MediaManifestProxyController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/MediaManifestProxyController.cs index b8760cbdf..5c7b9c61d 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/MediaManifestProxyController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/MediaManifestProxyController.cs @@ -7,13 +7,13 @@ using System.Net.Cache; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; /// /// Defines the . /// - [Authorize] [Route("api/[controller]")] [ApiController] public class MediaManifestProxyController : ControllerBase @@ -36,6 +36,7 @@ public MediaManifestProxyController(ILogger logger /// The playBackUrl. /// The token. /// The MediaManifestProxy string. + [Authorize] public string Get(string playBackUrl, string token) { this.logger.LogDebug($"playBackUrl={playBackUrl} token={token}"); @@ -53,16 +54,28 @@ public string Get(string playBackUrl, string token) { using (var reader = new StreamReader(stream)) { - const string qualityLevelRegex = @"(QualityLevels\(\d+\))"; + const string qualityLevelRegex = @"(|)([^""\s]+\.m3u8\(encryption=cbc\))"; const string fragmentsRegex = @"(Fragments\([\w\d=-]+,[\w\d=-]+\))"; - const string urlRegex = @"("")(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\/?[\?&][^&=]+=[^&=#]*)("")"; + const string urlRegex = @"(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\?[^,\s""]*)"; var baseUrl = playBackUrl.Substring(0, playBackUrl.IndexOf(".ism", System.StringComparison.OrdinalIgnoreCase)) + ".ism"; this.logger.LogDebug($"baseUrl={baseUrl}"); var content = reader.ReadToEnd(); - var newContent = Regex.Replace(content, urlRegex, string.Format(CultureInfo.InvariantCulture, "$1$2&token={0}$3", token)); + content = ReplaceUrisWithProxy(content, baseUrl); + var newContent = Regex.Replace(content, urlRegex, match => + { + string baseUrlWithQuery = match.Groups[1].Value; // URL including the query string + + // Append the token correctly without modifying surrounding characters + string newUrl = baseUrlWithQuery.Contains("?") ? + $"{baseUrlWithQuery}&token={token}" : + $"{baseUrlWithQuery}?token={token}"; + + return newUrl; + }); + this.logger.LogDebug($"newContent={newContent}"); var match = Regex.Match(playBackUrl, qualityLevelRegex); @@ -89,5 +102,107 @@ public string Get(string playBackUrl, string token) return null; } + + /// + /// The LandingPageGet. + /// + /// The playBackUrl. + /// The token. + /// The MediaManifestProxy string. + [HttpGet] + [Route("LandingPageGet")] + public string LandingPageGet(string playBackUrl, string token) + { + this.logger.LogDebug($"playBackUrl={playBackUrl} token={token}"); + var httpRequest = (HttpWebRequest)WebRequest.Create(new Uri(playBackUrl)); + httpRequest.CachePolicy = new HttpRequestCachePolicy(HttpRequestCacheLevel.NoCacheNoStore); + httpRequest.Timeout = 30000; + + var httpResponse = httpRequest.GetResponse(); + + try + { + this.logger.LogDebug($"Calling httpResponse.GetResponseStream(): playBackUrl={playBackUrl} "); + var stream = httpResponse.GetResponseStream(); + if (stream != null) + { + using (var reader = new StreamReader(stream)) + { + const string qualityLevelRegex = @"(|)([^""\s]+\.m3u8\(encryption=cbc\))"; + const string fragmentsRegex = @"(Fragments\([\w\d=-]+,[\w\d=-]+\))"; + const string urlRegex = @"(https?:\/\/[\da-z\.-]+\.[a-z\.]{2,6}[\/\w \.-]*\?[^,\s""]*)"; + + var baseUrl = playBackUrl.Substring(0, playBackUrl.IndexOf(".ism", System.StringComparison.OrdinalIgnoreCase)) + ".ism"; + this.logger.LogDebug($"baseUrl={baseUrl}"); + + var content = reader.ReadToEnd(); + + content = ReplaceUrisWithProxy(content, baseUrl); + var newContent = Regex.Replace(content, urlRegex, match => + { + string baseUrlWithQuery = match.Groups[1].Value; // URL including the query string + + // Append the token correctly without modifying surrounding characters + string newUrl = baseUrlWithQuery.Contains("?") ? + $"{baseUrlWithQuery}&token={token}" : + $"{baseUrlWithQuery}?token={token}"; + + return newUrl; + }); + + this.logger.LogDebug($"newContent={newContent}"); + + var match = Regex.Match(playBackUrl, qualityLevelRegex); + if (match.Success) + { + this.logger.LogDebug($"match.Success"); + var qualityLevel = match.Groups[0].Value; + newContent = Regex.Replace(newContent, fragmentsRegex, m => string.Format(CultureInfo.InvariantCulture, baseUrl + "/" + qualityLevel + "/" + m.Value)); + this.logger.LogDebug($"Updated newContent={newContent}"); + } + + return newContent; + } + } + } + catch (Exception ex) + { + this.logger.LogError(ex.Message); + } + finally + { + httpResponse.Close(); + } + + return null; + } + + private static string ReplaceUrisWithProxy(string playlistContent, string proxyUrl) + { + // Split the playlist content into lines + var lines = playlistContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + // Process each line to replace media or map URIs + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith("#EXT-X-MAP:URI=", StringComparison.OrdinalIgnoreCase)) + { + // Extract the URI from the current line for EXT-X-MAP + var existingUri = lines[i].Substring(lines[i].IndexOf('=') + 1).Trim('"'); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i] = lines[i].Replace(existingUri, newUri); + } + else if (lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase) && i + 1 < lines.Length) + { + // Get the URI from the next line for EXTINF + var existingUri = lines[i + 1].Trim(); + var newUri = $"{proxyUrl}/{existingUri}"; + lines[i + 1] = newUri; + } + } + + // Join the modified lines back into a single string + return string.Join("\r\n", lines); + } } } diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/ResourceController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/ResourceController.cs index 0616474ff..294785798 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/ResourceController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/ResourceController.cs @@ -2,6 +2,10 @@ namespace LearningHub.Nhs.WebUI.Controllers.Api { using System; using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Http.Headers; + using System.Threading; using System.Threading.Tasks; using LearningHub.Nhs.Models.Enums; using LearningHub.Nhs.Models.Resource; @@ -9,6 +13,7 @@ namespace LearningHub.Nhs.WebUI.Controllers.Api using LearningHub.Nhs.Models.Resource.Contribute; using LearningHub.Nhs.WebUI.Interfaces; using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; @@ -68,9 +73,10 @@ public async Task DownloadResource(string filePath, string fileNa } var file = await this.fileService.DownloadFileAsync(filePath, fileName); + if (file != null) { - return this.File(file.Content, file.ContentType, fileName); + return !string.IsNullOrEmpty(file.DownloadUrl) ? this.Redirect(file.DownloadUrl) : this.File(file.Content, file.ContentType, fileName); } else { @@ -105,7 +111,8 @@ public async Task DownloadResourceAndRecordActivity(int resourceV ActivityStatus = ActivityStatusEnum.Completed, }; await this.activityService.CreateResourceActivityAsync(activity); - return this.File(file.Content, file.ContentType, fileName); + + return !string.IsNullOrEmpty(file.DownloadUrl) ? this.Redirect(file.DownloadUrl) : this.File(file.Content, file.ContentType, fileName); } else { @@ -251,6 +258,33 @@ public async Task RecordExternalReferenceUserAgreementAsync([FromB } } + /// + /// The RecordExternalReferenceUserAgreementAsync. + /// + /// model. + /// A representing the result of the asynchronous operation. + [HttpPost] + [Route(" CreateResourceVersionValidationResult")] + public async Task CreateResourceVersionValidationResultAsync([FromBody] ResourceVersionValidationResultViewModel model) + { + await this.resourceService.CreateResourceVersionValidationResultAsync(model); + return this.Ok(); + } + + /// + /// The RecordExternalReferenceUserAgreementAsync. + /// + /// model. + /// A representing the result of the asynchronous operation. + [HttpPost] + [Route(" CreateResourceVersionValidationResults")] + public async Task CreateResourceVersionValidationResultAsync([FromBody] string filename) + { + ResourceVersionValidationResultViewModel model = new ResourceVersionValidationResultViewModel(); + await this.resourceService.CreateResourceVersionValidationResultAsync(model); + return this.Ok(); + } + /// /// The GetHeaderById. /// @@ -565,9 +599,9 @@ public async Task DeleteResourceProviderAsync(int resourceVersionI /// A representing the asynchronous operation. [HttpPost] [Route("ArchiveResourceFile")] - public ActionResult ArchiveResourceFile(List filePaths) + public ActionResult ArchiveResourceFile(IEnumerable filePaths) { - _ = Task.Run(async () => { await this.fileService.PurgeResourceFile(null, filePaths); }); + _ = Task.Run(async () => { await this.fileService.PurgeResourceFile(null, filePaths.ToList()); }); return this.Ok(); } @@ -584,5 +618,20 @@ public async Task> GetObsoleteResourceFile(int resourceVersionId, b var result = await this.resourceService.GetObsoleteResourceFile(resourceVersionId, deletedResource); return result; } + + /// + /// Reads from the source stream in chunks and writes to the destination stream, + /// flushing after each chunk to help keep the connection active. + /// + private async Task StreamFileWithKeepAliveAsync(Stream source, Stream destination, CancellationToken cancellationToken) + { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken); + await destination.FlushAsync(cancellationToken); + } + } } } diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/UserController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/UserController.cs index fc5c5579a..48f2c0e9e 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/UserController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/UserController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using elfhHub.Nhs.Models.Common; + using elfhHub.Nhs.Models.Enums; using LearningHub.Nhs.WebUI.Configuration; using LearningHub.Nhs.WebUI.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -62,6 +63,26 @@ public async Task CurrentProfile() return this.Ok(await this.userService.GetCurrentUserProfileAsync()); } + /// + /// The SessionTimeout. + /// + /// The . + [HttpPost("browser-close")] + public IActionResult BrowserClose() + { + // Add browser close to the UserHistory + UserHistoryViewModel userHistory = new UserHistoryViewModel() + { + UserId = this.CurrentUserId, + UserHistoryTypeId = (int)UserHistoryType.Logout, + Detail = @"User browser closed", + }; + + this.userService.StoreUserHistory(userHistory); + + return this.Ok(true); + } + /// /// Get current user's basic details. /// @@ -98,6 +119,27 @@ public async Task CheckUserRole() return this.Ok(isSystemAdmin); } + /// + /// to check user password is correct. + /// + /// The currentPassword. + /// The . + [HttpGet] + [Route("ConfirmPassword/{currentPassword}")] + public async Task ConfirmPassword(string currentPassword) + { + string passwordHash = this.userService.Base64MD5HashDigest(currentPassword); + var userPersonalDetails = await this.userService.GetCurrentUserPersonalDetailsAsync(); + if (userPersonalDetails != null && userPersonalDetails.PasswordHash == passwordHash) + { + return this.Ok(true); + } + else + { + return this.Ok(false); + } + } + /// /// The GetCurrentUserPersonalDetails. /// diff --git a/LearningHub.Nhs.WebUI/Controllers/BaseController.cs b/LearningHub.Nhs.WebUI/Controllers/BaseController.cs index 8972fa822..6b3ad9a20 100644 --- a/LearningHub.Nhs.WebUI/Controllers/BaseController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/BaseController.cs @@ -3,6 +3,7 @@ using System.Net.Http; using LearningHub.Nhs.Models.Extensions; using LearningHub.Nhs.WebUI.Configuration; + using LearningHub.Nhs.WebUI.Extensions; using LearningHub.Nhs.WebUI.Filters; using LearningHub.Nhs.WebUI.Helpers; using LearningHub.Nhs.WebUI.Models; @@ -76,6 +77,11 @@ protected BaseController( /// protected int CurrentUserId => this.User.Identity.GetCurrentUserId(); + /// + /// Gets the CurrentUserId. + /// + protected int CurrentMoodleUserId => this.User.Identity.GetMoodleUserId(); + /// /// The OnActionExecuting. /// diff --git a/LearningHub.Nhs.WebUI/Controllers/CatalogueController.cs b/LearningHub.Nhs.WebUI/Controllers/CatalogueController.cs index fb4d3def3..12d9973ac 100644 --- a/LearningHub.Nhs.WebUI/Controllers/CatalogueController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/CatalogueController.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; + using System.Text.RegularExpressions; using System.Threading.Tasks; using LearningHub.Nhs.Caching; using LearningHub.Nhs.Models.Catalogue; @@ -109,7 +110,30 @@ public async Task Index(int pageIndex = 1, string term = null) PageSize = itemsOnPage, }); + // Did you mean suggestion when no hits found + if (termCatalogues?.TotalHits == 0 && termCatalogues?.Spell?.Suggestions?.Count > 0) + { + // pass the spell suggestion as new search text - catalogues + if (termCatalogues?.Spell?.Suggestions?.Count > 0) + { + var suggestedCatalogue = Regex.Replace(termCatalogues?.Spell?.Suggestions?.FirstOrDefault().ToString(), "<.*?>", string.Empty); + + // calling findwise endpoint with new search text - catalogues + termCatalogues = await this.searchService.GetCatalogueSearchResultAsync( + new CatalogueSearchRequestModel + { + SearchText = suggestedCatalogue, + PageIndex = pageIndex - 1, + PageSize = itemsOnPage, + }); + + catalogues.DidYouMeanEnabled = true; + catalogues.SuggestedCatalogue = suggestedCatalogue; + } + } + catalogues.TotalCount = termCatalogues.TotalHits; + catalogues.GroupId = Guid.NewGuid(); catalogues.Catalogues = termCatalogues.DocumentModel.Select(t => new DashboardCatalogueViewModel { Url = t.Url, @@ -123,6 +147,8 @@ public async Task Index(int pageIndex = 1, string term = null) BookmarkId = t.BookmarkId, NodeId = int.Parse(t.Id), BadgeUrl = t.BadgeUrl, + Providers = t.Providers, + ClickPayload = t.Click.Payload, }).ToList(); } else @@ -551,5 +577,69 @@ public async Task RequestPreviewAccess(CatalogueRequestAccessView return this.View("RequestPreviewAccess", viewModel); } } + + /// + /// Get all catelogues, filter and pagination based on alphabets. + /// + /// filterChar. + /// rk. + [Route("/allcatalogue")] + [Route("/allcatalogue/{filterChar}")] + public async Task GetAllCatalogue(string filterChar = "a") + { + var catalogues = await this.catalogueService.GetAllCatalogueAsync(filterChar); + return this.View("allcatalogue", catalogues); + } + + /// + /// AllCatalogues Search. + /// + /// pageIndex. + /// Search term. + /// IActionResult. + [Route("/allcataloguesearch")] + public async Task GetAllCatalogueSearch(int pageIndex = 1, string term = null) + { + var catalogues = new AllCatalogueSearchResponseViewModel(); + var searchString = term?.Trim() ?? string.Empty; + var allCatalogueSearchPageSize = this.settings.FindwiseSettings.AllCatalogueSearchPageSize; + + if (!string.IsNullOrWhiteSpace(term)) + { + var termCatalogues = await this.searchService.GetAllCatalogueSearchResultAsync( + new AllCatalogueSearchRequestModel + { + SearchText = searchString, + PageIndex = pageIndex - 1, + PageSize = allCatalogueSearchPageSize, + }); + + catalogues.TotalCount = termCatalogues.TotalHits; + catalogues.Catalogues = termCatalogues.DocumentModel.Select(t => new AllCatalogueViewModel + { + Url = t.Url, + Name = t.Name, + CardImageUrl = t.CardImageUrl, + BannerUrl = t.BannerUrl, + Description = t.Description, + RestrictedAccess = t.RestrictedAccess, + HasAccess = t.HasAccess, + IsBookmarked = t.IsBookmarked, + BookmarkId = t.BookmarkId, + NodeId = int.Parse(t.Id), + BadgeUrl = t.BadgeUrl, + Providers = t.Providers, + }).ToList(); + } + else + { + catalogues.TotalCount = 0; + catalogues.Catalogues = new List(); + } + + this.ViewBag.PageIndex = pageIndex; + this.ViewBag.PageSize = allCatalogueSearchPageSize; + return this.View("AllCatalogueSearch", catalogues); + } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Controllers/HomeController.cs b/LearningHub.Nhs.WebUI/Controllers/HomeController.cs index 87d01e668..9b91ed9ac 100644 --- a/LearningHub.Nhs.WebUI/Controllers/HomeController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/HomeController.cs @@ -6,7 +6,9 @@ namespace LearningHub.Nhs.WebUI.Controllers using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using AspNetCoreRateLimit; using elfhHub.Nhs.Models.Common; + using elfhHub.Nhs.Models.Enums; using LearningHub.Nhs.Models.Content; using LearningHub.Nhs.Models.Enums.Content; using LearningHub.Nhs.Models.Extensions; @@ -15,16 +17,17 @@ namespace LearningHub.Nhs.WebUI.Controllers using LearningHub.Nhs.WebUI.Helpers; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models; - using Microsoft.ApplicationInsights.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; + using UAParser; using Settings = LearningHub.Nhs.WebUI.Configuration.Settings; /// @@ -39,6 +42,7 @@ public class HomeController : BaseController private readonly IDashboardService dashboardService; private readonly IContentService contentService; private readonly IFeatureManager featureManager; + private readonly Microsoft.Extensions.Configuration.IConfiguration configuration; /// /// Initializes a new instance of the class. @@ -53,6 +57,7 @@ public class HomeController : BaseController /// Dashboard service. /// Content service. /// featureManager. + /// config. public HomeController( IHttpClientFactory httpClientFactory, IWebHostEnvironment hostingEnvironment, @@ -63,7 +68,8 @@ public HomeController( LearningHubAuthServiceConfig authConfig, IDashboardService dashboardService, IContentService contentService, - IFeatureManager featureManager) + IFeatureManager featureManager, + Microsoft.Extensions.Configuration.IConfiguration configuration) : base(hostingEnvironment, httpClientFactory, logger, settings.Value) { this.authConfig = authConfig; @@ -72,6 +78,7 @@ public HomeController( this.dashboardService = dashboardService; this.contentService = contentService; this.featureManager = featureManager; + this.configuration = configuration; } /// @@ -133,11 +140,12 @@ public IActionResult CreateAccount() public IActionResult Error(int? httpStatusCode) { string originalPathUrlMessage = null; - + string originalPath = null; if (httpStatusCode.HasValue && httpStatusCode.Value == 404) { var exceptionHandlerPathFeature = this.HttpContext.Features.Get(); - originalPathUrlMessage = $"Page Not Found url: {exceptionHandlerPathFeature?.OriginalPath}. "; + originalPath = exceptionHandlerPathFeature?.OriginalPath; + originalPathUrlMessage = $"Page Not Found url: {originalPath}. "; } if (this.User.Identity.IsAuthenticated) @@ -200,32 +208,54 @@ public async Task Index(string myLearningDashboard = "my-in-progr { if (this.User?.Identity.IsAuthenticated == true) { - this.Logger.LogInformation("User is authenticated: User is {fullname} and userId is: {lhuserid}", this.User.Identity.GetCurrentName(), this.User.Identity.GetCurrentUserId()); - if (this.User.IsInRole("Administrator") || this.User.IsInRole("BlueUser") || this.User.IsInRole("ReadOnly") || this.User.IsInRole("BasicUser")) - { - var learningTask = this.dashboardService.GetMyAccessLearningsAsync(myLearningDashboard, 1); - var resourcesTask = this.dashboardService.GetResourcesAsync(resourceDashboard, 1); - var cataloguesTask = this.dashboardService.GetCataloguesAsync(catalogueDashboard, 1); - - await Task.WhenAll(learningTask, resourcesTask, cataloguesTask); + var userHistoryDetail = await this.userService.CheckUserHasAnActiveSessionAsync(this.CurrentUserId); + var uaParser = Parser.GetDefault(); + var clientInfo = uaParser.Parse(this.Request.Headers["User-Agent"]); - var model = new DashboardViewModel() + if (userHistoryDetail.Items.Count == 0 || userHistoryDetail.Items[0].BrowserName == clientInfo.UA.Family) + { + this.Settings.ConcurrentId = this.CurrentUserId; + this.Logger.LogInformation("User is authenticated: User is {fullname} and userId is: {lhuserid}", this.User.Identity.GetCurrentName(), this.User.Identity.GetCurrentUserId()); + if (this.User.IsInRole("Administrator") || this.User.IsInRole("BlueUser") || this.User.IsInRole("ReadOnly") || this.User.IsInRole("BasicUser")) { - MyLearnings = await learningTask, - Resources = await resourcesTask, - Catalogues = await cataloguesTask, - }; - - if (!string.IsNullOrEmpty(this.Request.Query["preview"]) && Convert.ToBoolean(this.Request.Query["preview"])) + var learningTask = this.dashboardService.GetMyAccessLearningsAsync(myLearningDashboard, 1); + var resourcesTask = this.dashboardService.GetResourcesAsync(resourceDashboard, 1); + var cataloguesTask = this.dashboardService.GetCataloguesAsync(catalogueDashboard, 1); + + var enrolledCoursesTask = Task.FromResult(new List()); + var enableMoodle = Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.EnableMoodle)).Result; + this.ViewBag.EnableMoodle = enableMoodle; + this.ViewBag.ValidMoodleUser = this.CurrentMoodleUserId > 0; + if (enableMoodle && myLearningDashboard == "my-enrolled-courses") + { + enrolledCoursesTask = this.dashboardService.GetEnrolledCoursesFromMoodleAsync(this.CurrentMoodleUserId, 1); + } + + await Task.WhenAll(learningTask, resourcesTask, cataloguesTask); + + var model = new DashboardViewModel() + { + MyLearnings = await learningTask, + Resources = await resourcesTask, + Catalogues = await cataloguesTask, + EnrolledCourses = await enrolledCoursesTask, + }; + + if (!string.IsNullOrEmpty(this.Request.Query["preview"]) && Convert.ToBoolean(this.Request.Query["preview"])) + { + return this.View("LandingPage", await this.GetLandingPageContent(Convert.ToBoolean(this.Request.Query["preview"]))); + } + + return this.View("Dashboard", model); + } + else { - return this.View("LandingPage", await this.GetLandingPageContent(Convert.ToBoolean(this.Request.Query["preview"]))); + return this.RedirectToAction("InvalidUserAccount", "Account"); } - - return this.View("Dashboard", model); } else { - return this.RedirectToAction("InvalidUserAccount", "Account"); + return this.RedirectToAction("AlreadyAnActiveSession", "Account"); } } else @@ -320,14 +350,19 @@ public IActionResult NhsSites() } /// - /// The Logout. - /// This is directly referenced in the LoginWizardFilter to allow - /// logouts to bypass LoginWizard redirects. - /// If the name is changed, the LoginWizardFilter MUST be updated. + /// The ChangePasswordAcknowledgement. /// /// The . - [AllowAnonymous] - public IActionResult Logout() + public IActionResult ChangePasswordAcknowledgement() + { + return this.View(); + } + + /// + /// StatusUpdate. + /// + /// Actionresult. + public IActionResult UserLogout() { if (!(this.User?.Identity.IsAuthenticated ?? false)) { @@ -337,6 +372,20 @@ public IActionResult Logout() return new SignOutResult(new[] { CookieAuthenticationDefaults.AuthenticationScheme, "oidc" }); } + /// + /// The Logout. + /// This is directly referenced in the LoginWizardFilter to allow + /// logouts to bypass LoginWizard redirects. + /// If the name is changed, the LoginWizardFilter MUST be updated. + /// + /// The . + [AllowAnonymous] + public IActionResult Logout() + { + var redirectUri = $"{this.configuration["LearningHubAuthServiceConfig:Authority"]}/Home/SetIsPasswordUpdate?isLogout=true"; + return this.Redirect(redirectUri); + } + /// /// The SessionTimeout. /// @@ -350,11 +399,41 @@ public IActionResult SessionTimeout(string returnUrl = "/") return this.Redirect(returnUrl); } + // Add successful logout to the UserHistory + UserHistoryViewModel userHistory = new UserHistoryViewModel() + { + UserId = this.Settings.ConcurrentId, + UserHistoryTypeId = (int)UserHistoryType.Logout, + Detail = @"User session time out", + }; + + this.userService.StoreUserHistory(userHistory); + this.ViewBag.AuthTimeout = this.authConfig.AuthTimeout; this.ViewBag.ReturnUrl = returnUrl; + return this.View(); } + /// + /// The SessionTimeout. + /// + /// The . + [HttpPost("browser-close")] + public IActionResult BrowserClose() + { + // Add browser close to the UserHistory + UserHistoryViewModel userHistory = new UserHistoryViewModel() + { + UserId = this.CurrentUserId, + UserHistoryTypeId = (int)UserHistoryType.Logout, + Detail = @"User browser closed", + }; + + this.userService.StoreUserHistory(userHistory); + return this.Ok(true); + } + /// /// The SitemapXml. /// diff --git a/LearningHub.Nhs.WebUI/Controllers/LearningSessionsController.cs b/LearningHub.Nhs.WebUI/Controllers/LearningSessionsController.cs index 4f77c00bb..f6d6efbd8 100644 --- a/LearningHub.Nhs.WebUI/Controllers/LearningSessionsController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/LearningSessionsController.cs @@ -68,6 +68,7 @@ public async Task Scorm(int id) /// filePath. /// bool. //// [ResponseCache(VaryByQueryKeys = new[] { "*" }, Duration = 0, NoStore = true)] // disable caching + //// Removed Request.Headers["Referer"] Referer URL checking based on issue reported in TD-4283 [AllowAnonymous] [Route("ScormContent/{*filePath}")] public async Task ScormContent(string filePath) @@ -79,12 +80,6 @@ public async Task ScormContent(string filePath) try { - var referringUrl = this.Request.Headers["Referer"].ToString(); - if (string.IsNullOrEmpty(referringUrl)) - { - throw new UnauthorizedAccessException("Referer URL is required."); - } - if (!this.User.Identity.IsAuthenticated) { throw new UnauthorizedAccessException("User is not authenticated."); @@ -92,6 +87,7 @@ public async Task ScormContent(string filePath) var directory = filePath.Substring(0, filePath.LastIndexOf("/")); fileName = filePath.Substring(filePath.LastIndexOf("/") + 1, filePath.Length - filePath.LastIndexOf("/") - 1); + string extension = Path.GetExtension(fileName); var file = await this.fileService.DownloadFileAsync(directory, fileName); @@ -100,6 +96,11 @@ public async Task ScormContent(string filePath) contentType = "application/octet-stream"; } + if (extension == ".mp4") + { + contentType = "application/x-mpegURL"; + } + result = this.File(file.Content, contentType); bytesServed = file.ContentLength; } diff --git a/LearningHub.Nhs.WebUI/Controllers/MediaController.cs b/LearningHub.Nhs.WebUI/Controllers/MediaController.cs index 547b8e8ef..4acd69e07 100644 --- a/LearningHub.Nhs.WebUI/Controllers/MediaController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/MediaController.cs @@ -38,16 +38,26 @@ public MediaController(IWebHostEnvironment hostingEnvironment, ILogger /// The playBackUrl. /// The token. + /// The orgin node. + /// The isLandingPage. /// The . [Route("Media/MediaManifest")] - public IActionResult MediaManifest(string playBackUrl, string token) + public IActionResult MediaManifest(string playBackUrl, string token, string origin = "*", bool isLandingPage = false) { try { this.Logger.LogDebug($"playBackUrl={playBackUrl} token={token}"); var hostPortion = this.Request.Host; + var manifestProxyUrl = string.Empty; + if (isLandingPage) + { + manifestProxyUrl = string.Format("https://{0}/api/MediaManifestProxy/LandingPageGet", hostPortion); + } + else + { + manifestProxyUrl = string.Format("https://{0}/api/MediaManifestProxy", hostPortion); + } - var manifestProxyUrl = string.Format("https://{0}/api/MediaManifestProxy", hostPortion); this.Logger.LogDebug($"manifestProxyUrl={manifestProxyUrl}"); var modifiedTopLeveLManifest = this.azureMediaService.GetTopLevelManifestForToken(manifestProxyUrl, playBackUrl, token); @@ -58,7 +68,8 @@ public IActionResult MediaManifest(string playBackUrl, string token) Content = modifiedTopLeveLManifest, ContentType = @"application/vnd.apple.mpegurl", }; - this.Response.Headers.Append("Access-Control-Allow-Origin", "*"); + + this.Response.Headers.Append("Access-Control-Allow-Origin", origin); this.Response.Headers.Append("X-Content-Type-Options", "nosniff"); return response; diff --git a/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs b/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs index 4494d9463..8711dd817 100644 --- a/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/MyAccountController.cs @@ -21,10 +21,12 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NHSUKViewComponents.Web.ViewModels; using ChangePasswordViewModel = LearningHub.Nhs.WebUI.Models.UserProfile.ChangePasswordViewModel; + using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; /// /// The UserController. @@ -43,6 +45,7 @@ public partial class MyAccountController : BaseController private readonly ISpecialtyService specialtyService; private readonly ILocationService locationService; private readonly ICacheService cacheService; + private readonly IConfiguration configuration; /// /// Initializes a new instance of the class. @@ -61,6 +64,7 @@ public partial class MyAccountController : BaseController /// The locationService. /// The multiPageFormService. /// The cacheService. + /// The cacheService. public MyAccountController( IWebHostEnvironment hostingEnvironment, ILogger logger, @@ -75,7 +79,8 @@ public MyAccountController( ISpecialtyService specialtyService, ILocationService locationService, IMultiPageFormService multiPageFormService, - ICacheService cacheService) + ICacheService cacheService, + IConfiguration configuration) : base(hostingEnvironment, httpClientFactory, logger, settings.Value) { this.userService = userService; @@ -88,6 +93,7 @@ public MyAccountController( this.locationService = locationService; this.multiPageFormService = multiPageFormService; this.cacheService = cacheService; + this.configuration = configuration; } private string LoginWizardCacheKey => $"{this.CurrentUserId}:LoginWizard"; @@ -452,9 +458,8 @@ public async Task UpdatePassword(ChangePasswordViewModel model) if (this.ModelState.IsValid) { await this.userService.UpdatePassword(model.NewPassword); - - this.ViewBag.SuccessMessage = CommonValidationErrorMessages.PasswordSuccessMessage; - return this.View("SuccessMessage"); + var redirectUri = $"{this.configuration["LearningHubAuthServiceConfig:Authority"]}/Home/SetIsPasswordUpdate?isLogout=false"; + return this.Redirect(redirectUri); } else { diff --git a/LearningHub.Nhs.WebUI/Controllers/PoliciesController.cs b/LearningHub.Nhs.WebUI/Controllers/PoliciesController.cs index 4d6a54c4f..9f619e572 100644 --- a/LearningHub.Nhs.WebUI/Controllers/PoliciesController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/PoliciesController.cs @@ -100,5 +100,15 @@ public IActionResult AcceptableUsePolicy() { return this.View(); } + + /// + /// AI-generated images statement. + /// + /// The . + [Route("/policies/ai-generated-images-statement")] + public IActionResult AIGeneratedImagesStatement() + { + return this.View(); + } } } diff --git a/LearningHub.Nhs.WebUI/Controllers/ResourceController.cs b/LearningHub.Nhs.WebUI/Controllers/ResourceController.cs index 7775ec6cd..5df61185e 100644 --- a/LearningHub.Nhs.WebUI/Controllers/ResourceController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/ResourceController.cs @@ -189,6 +189,9 @@ public async Task Index(int resourceReferenceId, bool? acceptSens ActivityStart = DateTime.UtcNow, // TODO: What about user's timezone offset when Javascript is disabled? Needs JavaScript. ActivityStatus = ActivityStatusEnum.Completed, }; + + // setting time delay to avoid multiple records in same time-TD-4299 + await Task.Delay(10000); await this.activityService.CreateResourceActivityAsync(activity); } diff --git a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs index 2cebfd11c..ff1afba0d 100644 --- a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs @@ -6,6 +6,7 @@ namespace LearningHub.Nhs.WebUI.Controllers using System.Net.Http; using System.Threading.Tasks; using LearningHub.Nhs.Models.Search; + using LearningHub.Nhs.Models.Search.SearchClick; using LearningHub.Nhs.WebUI.Filters; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models.Search; @@ -214,8 +215,9 @@ public async Task RecordCatalogueNavigation(SearchRequestViewMode /// time of search. /// user query. /// search query. + /// the title. [HttpGet("record-resource-click")] - public void RecordResourceClick(string url, int nodePathId, int itemIndex, int pageIndex, int totalNumberOfHits, string searchText, int resourceReferenceId, Guid groupId, string searchId, long timeOfSearch, string userQuery, string query) + public void RecordResourceClick(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 { @@ -230,6 +232,7 @@ public void RecordResourceClick(string url, int nodePathId, int itemIndex, int p TimeOfSearch = timeOfSearch, UserQuery = userQuery, Query = query, + Title = title, }; this.searchService.CreateResourceSearchActionAsync(searchActionResourceModel); @@ -251,9 +254,10 @@ public void RecordResourceClick(string url, int nodePathId, int itemIndex, int p /// time of search. /// user query. /// search query. + /// the name. /// A representing the asynchronous operation. [HttpGet("record-catalogue-click")] - public async Task RecordCatalogueClick(string url, int nodePathId, int itemIndex, int pageIndex, int totalNumberOfHits, string searchText, int catalogueId, Guid groupId, string searchId, long timeOfSearch, string userQuery, string query) + public async Task RecordCatalogueClick(string url, int nodePathId, int itemIndex, int pageIndex, int totalNumberOfHits, string searchText, int catalogueId, Guid groupId, string searchId, long timeOfSearch, string userQuery, string query, string name) { SearchActionCatalogueModel searchActionCatalogueModel = new SearchActionCatalogueModel { @@ -268,6 +272,7 @@ public async Task RecordCatalogueClick(string url, int nodePathId TimeOfSearch = timeOfSearch, UserQuery = userQuery, Query = query, + Name = name, }; await this.searchService.CreateCatalogueSearchActionAsync(searchActionCatalogueModel); @@ -292,5 +297,70 @@ public async Task Image(string name) return this.Ok(this.Content("No file found")); } } + + /// + /// GetAutoSuggestion returns the auto suggestion options. + /// + /// search term. + /// ActionResult. + [HttpGet("GetAutoSuggestion")] + public async Task GetAutoSuggestion(string term) + { + if (!this.User.Identity.IsAuthenticated) + { + return this.RedirectToAction("AccessDenied", "Home"); + } + + var autoSuggestions = await this.searchService.GetAutoSuggestionList(term); + + return this.PartialView("_AutoComplete", autoSuggestions); + } + + /// + /// Records the AutoSuggestion Click logs. + /// + /// the term. + /// the searchType. + /// click Payload Model. + /// itemIndex. + /// total Number Of Hits. + /// containerId. + /// name. + /// query. + /// userQuery. + /// searchId. + /// timeOfSearch. + /// title. + /// Action result. + [HttpGet("record-autosuggestion-click")] + public IActionResult RecordAutoSuggestionClick(string term, string url, string clickTargetUrl, int itemIndex, int totalNumberOfHits, string containerId, string name, string query, string userQuery, string searchId, long timeOfSearch, string title) + { + AutoSuggestionClickPayloadModel clickPayloadModel = new AutoSuggestionClickPayloadModel + { + ClickTargetUrl = clickTargetUrl, + ContainerId = containerId, + HitNumber = itemIndex, + TimeOfClick = timeOfSearch, + DocumentFields = new SearchClickDocumentModel + { + Name = name, + Title = title, + }, + SearchSignal = new SearchClickSignalModel + { + Query = query, + SearchId = searchId, + TimeOfSearch = timeOfSearch, + UserQuery = userQuery, + Stats = new SearchClickStatsModel + { + TotalHits = totalNumberOfHits, + }, + }, + }; + + this.searchService.SendAutoSuggestionClickActionAsync(clickPayloadModel); + return this.Redirect(url); + } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Extensions/ClaimsPrincipalExtensions.cs b/LearningHub.Nhs.WebUI/Extensions/ClaimsPrincipalExtensions.cs index 30653fcbc..186c54eb8 100644 --- a/LearningHub.Nhs.WebUI/Extensions/ClaimsPrincipalExtensions.cs +++ b/LearningHub.Nhs.WebUI/Extensions/ClaimsPrincipalExtensions.cs @@ -2,6 +2,7 @@ { using System; using System.Security.Claims; + using System.Security.Principal; /// /// Defines the . @@ -23,5 +24,21 @@ public static string GetTimezoneOffsetCacheKey(this ClaimsPrincipal claimsPrinci return $"usr_{userId}_tz"; } + + /// + /// Get MoodleUserId. + /// + /// The identity. + /// The System.Int32. + public static int GetMoodleUserId(this IIdentity identity) + { + Claim claim = (identity as ClaimsIdentity)?.FindFirst("moodle_username"); + if (claim != null) + { + return int.Parse(claim.Value); + } + + return 0; + } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Helpers/AccountCreationFormHelper.cs b/LearningHub.Nhs.WebUI/Helpers/AccountCreationFormHelper.cs new file mode 100644 index 000000000..657c0828f --- /dev/null +++ b/LearningHub.Nhs.WebUI/Helpers/AccountCreationFormHelper.cs @@ -0,0 +1,28 @@ +namespace LearningHub.Nhs.WebUI.Helpers +{ + using System.Collections.Generic; + using Microsoft.AspNetCore.Mvc.ViewFeatures; + + /// + /// Defines the . + /// + public static class AccountCreationFormHelper + { + /// + /// The PopulateGroupedFormControlMetadata. + /// + /// viewData. + public static void PopulateGroupedFormControlMetadata(ViewDataDictionary viewData) + { + viewData["GroupedFormControlMetadata"] = new Dictionary + { + { "CountryId", true }, + { "RegionId", true }, + { "CurrentRole", true }, + { "PrimarySpecialtyId", true }, + { "GradeId", true }, + { "LocationId", true }, + }; + } + } +} diff --git a/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs b/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs index 3e2753448..c9b3aa7bc 100644 --- a/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs +++ b/LearningHub.Nhs.WebUI/Helpers/CommonValidationErrorMessages.cs @@ -58,7 +58,7 @@ public static class CommonValidationErrorMessages /// /// Grade Required. /// - public const string GradeRequired = "Select a grade"; + public const string GradeRequired = "Select a pay band. You can find this information from your paper payslip or online via ESR"; /// /// Primary specialty Not Applicable. diff --git a/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs b/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs index f4b01af4d..7f019e336 100644 --- a/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs +++ b/LearningHub.Nhs.WebUI/Helpers/FeatureFlags.cs @@ -14,5 +14,10 @@ public static class FeatureFlags /// The DisplayAudioVideoResource. /// public const string DisplayAudioVideoResource = "DisplayAudioVideoResource"; + + /// + /// The EnableMoodle. + /// + public const string EnableMoodle = "EnableMoodle"; } } diff --git a/LearningHub.Nhs.WebUI/Helpers/InMemoryTicketStore.cs b/LearningHub.Nhs.WebUI/Helpers/InMemoryTicketStore.cs new file mode 100644 index 000000000..4d17cc5b7 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Helpers/InMemoryTicketStore.cs @@ -0,0 +1,104 @@ +namespace LearningHub.Nhs.WebUI.Helpers +{ + using System; + using System.Collections.Concurrent; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + + /// + /// Defines the . + /// + public class InMemoryTicketStore : ITicketStore + { + private readonly ConcurrentDictionary cache; + + /// + /// Initializes a new instance of the class. + /// The InMemoryTicketStore. + /// + /// the cache. + public InMemoryTicketStore(ConcurrentDictionary cache) + { + this.cache = cache; + } + + /// + /// The StoreAsync. + /// + /// The ticket. + /// The key. + public async Task StoreAsync(AuthenticationTicket ticket) + { + var ticketUserId = ticket.Principal.Claims.Where(c => c.Type == "sub") + .FirstOrDefault() + .Value; + var matchingAuthTicket = this.cache.Values.FirstOrDefault( + t => t.Principal.Claims.FirstOrDefault( + c => c.Type == "sub" + && c.Value == ticketUserId) != null); + if (matchingAuthTicket != null) + { + var cacheKey = this.cache.Where( + entry => entry.Value == matchingAuthTicket) + .Select(entry => entry.Key) + .FirstOrDefault(); + this.cache.TryRemove( + cacheKey, + out _); + } + + var key = Guid + .NewGuid() + .ToString(); + await this.RenewAsync( + key, + ticket); + return key; + } + + /// + /// The RenewAsync. + /// + /// The key. + /// The ticket. + /// The Task. + public Task RenewAsync( + string key, + AuthenticationTicket ticket) + { + this.cache.AddOrUpdate( + key, + ticket, + (_, _) => ticket); + return Task.CompletedTask; + } + + /// + /// The RetrieveAsync. + /// + /// The Key. + /// The Task. + public Task RetrieveAsync(string key) + { + this.cache.TryGetValue( + key, + out var ticket); + return Task.FromResult(ticket); + } + + /// + /// The RemoveAsync. + /// + /// The key. + /// The Task. + public Task RemoveAsync(string key) + { + this.cache.TryRemove( + key, + out _); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Interfaces/ICatalogueService.cs b/LearningHub.Nhs.WebUI/Interfaces/ICatalogueService.cs index 30ba46a54..e7770fd86 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/ICatalogueService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/ICatalogueService.cs @@ -138,5 +138,12 @@ public interface ICatalogueService /// The user - user group id. /// The validation result. Task RemoveUserFromRestrictedAccessUserGroup(int userUserGroupId); + + /// + /// The GetAllCatalogueAsync. + /// + /// The letter. + /// The allcatalogue result based on letters. + Task GetAllCatalogueAsync(string filterChar); } } diff --git a/LearningHub.Nhs.WebUI/Interfaces/IDashboardService.cs b/LearningHub.Nhs.WebUI/Interfaces/IDashboardService.cs index de8996766..9eb8c3266 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IDashboardService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IDashboardService.cs @@ -1,5 +1,6 @@ namespace LearningHub.Nhs.WebUI.Interfaces { + using System.Collections.Generic; using System.Threading.Tasks; using LearningHub.Nhs.Models.Dashboard; using LearningHub.Nhs.WebUI.Models; @@ -39,5 +40,13 @@ public interface IDashboardService /// dashboardEventViewModel. /// A representing the result of the asynchronous operation. Task RecordDashBoardEventAsync(DashboardEventViewModel dashboardEventViewModel); + + /// + /// GetEnrolledCoursesFromMoodleAsync. + /// + /// The current User Id type. + /// The page Number. + /// A representing the result of the asynchronous operation. + Task> GetEnrolledCoursesFromMoodleAsync(int currentUserId, int pageNumber); } } diff --git a/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs b/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs index c74a1681f..eb94d0185 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IFileService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Azure.Storage.Files.Shares.Models; using LearningHub.Nhs.Models.Resource; + using LearningHub.Nhs.WebUI.Models; /// /// Defines the . @@ -25,7 +26,8 @@ public interface IFileService /// File path. /// File name. /// A representing the result of the asynchronous operation. - Task DownloadFileAsync(string filePath, string fileName); + // Task DownloadFileAsync(string filePath, string fileName); + Task DownloadFileAsync(string filePath, string fileName); /// /// The StreamFileAsync. diff --git a/LearningHub.Nhs.WebUI/Interfaces/IMoodleApiService.cs b/LearningHub.Nhs.WebUI/Interfaces/IMoodleApiService.cs new file mode 100644 index 000000000..d92c01fad --- /dev/null +++ b/LearningHub.Nhs.WebUI/Interfaces/IMoodleApiService.cs @@ -0,0 +1,30 @@ +namespace LearningHub.Nhs.WebUI.Interfaces +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using LearningHub.Nhs.Models.Dashboard; + using LearningHub.Nhs.WebUI.Models; + + /// + /// IMoodleApiService. + /// + public interface IMoodleApiService + { + /// + /// GetEnrolledCoursesAsync. + /// + /// Moodle user id. + /// pageNumber. + /// List of MoodleCourseResponseViewModel. + Task> GetEnrolledCoursesAsync(int currentUserId, int pageNumber); + + /// + /// GetEnrolledCoursesAsync. + /// + /// Moodle user id. + /// Moodle course id. + /// pageNumber. + /// List of MoodleCourseResponseViewModel. + Task GetCourseCompletionAsync(int userId, int courseId, int pageNumber); + } +} diff --git a/LearningHub.Nhs.WebUI/Interfaces/IMoodleHttpClient.cs b/LearningHub.Nhs.WebUI/Interfaces/IMoodleHttpClient.cs new file mode 100644 index 000000000..3348a20a9 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Interfaces/IMoodleHttpClient.cs @@ -0,0 +1,23 @@ +namespace LearningHub.Nhs.Services.Interface +{ + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// The Moodle Http Client interface. + /// + public interface IMoodleHttpClient + { + /// + /// The get cient async. + /// + /// The . + Task GetClient(); + + /// + /// GetDefaultParameters. + /// + /// defaultParameters. + string GetDefaultParameters(); + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Interfaces/IResourceService.cs b/LearningHub.Nhs.WebUI/Interfaces/IResourceService.cs index c09ab0704..f5057402d 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IResourceService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IResourceService.cs @@ -269,6 +269,13 @@ public interface IResourceService /// The . Task CreateResourceVersionProviderAsync(ResourceVersionProviderViewModel model); + /// + /// Creates resource version validation results corresponding to the value in the corresponding input view model. + /// + /// Details of the validation results. + /// A representing the result of the asynchronous operation. + Task CreateResourceVersionValidationResultAsync(ResourceVersionValidationResultViewModel validationResultViewModel); + /// /// Delete resource version provider. /// diff --git a/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs b/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs index 4f8ed36c7..3f083bc38 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/ISearchService.cs @@ -3,6 +3,7 @@ using System.Security.Principal; using System.Threading.Tasks; using LearningHub.Nhs.Models.Search; + using LearningHub.Nhs.Models.Search.SearchClick; using LearningHub.Nhs.WebUI.Models.Search; /// @@ -77,5 +78,26 @@ public interface ISearchService /// catalogue search request model. /// The . Task CreateCatalogueSearchTermEventAsync(CatalogueSearchRequestModel catalogueSearchRequestModel); + + /// + /// Get AllCatalogue Search Result Async. + /// + /// The catalogue Search Request Model. + /// The . + Task GetAllCatalogueSearchResultAsync(AllCatalogueSearchRequestModel catalogueSearchRequestModel); + + /// + /// The Get AutoSuggestion List. + /// + /// The term. + /// The . + Task GetAutoSuggestionList(string term); + + /// + /// The Send AutoSuggestion Click Action Async. + /// + /// The click Payload Model. + /// The . + Task SendAutoSuggestionClickActionAsync(AutoSuggestionClickPayloadModel clickPayloadModel); } } diff --git a/LearningHub.Nhs.WebUI/Interfaces/IUserService.cs b/LearningHub.Nhs.WebUI/Interfaces/IUserService.cs index 0b9dd4b77..19ee0a927 100644 --- a/LearningHub.Nhs.WebUI/Interfaces/IUserService.cs +++ b/LearningHub.Nhs.WebUI/Interfaces/IUserService.cs @@ -418,6 +418,15 @@ public interface IUserService /// A representing the result of the asynchronous operation. Task RegenerateEmailChangeValidationTokenAsync(string newPrimaryEmail, bool isUserRoleUpgrade); + /// + /// User Can request for password reset. + /// + /// The email Address. + /// The passwordRequestLimitingPeriod. + /// ThepasswordRequestLimit. + /// A representing the result of the asynchronous operation. + Task CanRequestPasswordResetAsync(string emailAddress, int passwordRequestLimitingPeriod, int passwordRequestLimit); + /// /// GenerateEmailChangeValidationTokenAndSendEmail. /// @@ -456,6 +465,13 @@ public interface IUserService /// providers. Task> GetProvidersByUserIdAsync(int userId); + /// + /// To Check User Has An ActiveSession. + /// + /// The userId. + /// A representing the result of the asynchronous operation. + Task> CheckUserHasAnActiveSessionAsync(int userId); + /// /// To get the Base64MD5HashDigest value. /// diff --git a/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj b/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj index 6a9ff6ca0..dc2cf661d 100644 --- a/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj +++ b/LearningHub.Nhs.WebUI/LearningHub.Nhs.WebUI.csproj @@ -1,12 +1,15 @@  - net6.0 - InProcess + net8.0 + 1.0.0.0 + 1.0.0.0 + 1.0.0 + InProcess a2ecb5d2-cf13-4551-9cb6-3d86dfbcf8ef true true - x64 + x64 true @@ -101,43 +104,46 @@ - - - - - - + + + + + + + + - + - - - + + + - + - + - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + @@ -175,72 +181,76 @@ - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - - Code - - + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + + Code + + - + + + + + diff --git a/LearningHub.Nhs.WebUI/Models/Account/AccountCreationDateViewModel.cs b/LearningHub.Nhs.WebUI/Models/Account/AccountCreationDateViewModel.cs index c17fad8a9..68727eb35 100644 --- a/LearningHub.Nhs.WebUI/Models/Account/AccountCreationDateViewModel.cs +++ b/LearningHub.Nhs.WebUI/Models/Account/AccountCreationDateViewModel.cs @@ -13,17 +13,32 @@ public class AccountCreationDateViewModel : AccountCreationViewModel, IValidatab /// /// Gets or sets the Day. /// - public int? Day { get; set; } + public string Day { get; set; } + + /// + /// Gets or sets the Day Field. + /// + public int? DayInput { get; set; } /// /// Gets or sets the Country. /// - public int? Month { get; set; } + public string Month { get; set; } + + /// + /// Gets or sets the Month input. + /// + public int? MonthInput { get; set; } /// /// Gets or sets the Year. /// - public int? Year { get; set; } + public string Year { get; set; } + + /// + /// Gets or sets YearInput. + /// + public int? YearInput { get; set; } /// /// Gets or sets the GetDate. @@ -31,14 +46,46 @@ public class AccountCreationDateViewModel : AccountCreationViewModel, IValidatab /// DateTime. public DateTime? GetDate() { - return (this.Day.HasValue && this.Month.HasValue && this.Year.HasValue) ? new DateTime(this.Year!.Value, this.Month!.Value, this.Day!.Value) : (DateTime?)null; + return (this.DayInput.HasValue && this.MonthInput.HasValue && this.YearInput.HasValue) ? new DateTime(this.YearInput!.Value, this.MonthInput!.Value, this.DayInput!.Value) : (DateTime?)null; } /// public IEnumerable Validate(ValidationContext validationContext) { - return DateValidator.ValidateDate(this.Day, this.Month, this.Year, "valid start date") - .ToValidationResultList(nameof(this.Day), nameof(this.Month), nameof(this.Year)); + var validationResults = new List(); + int parsedDay = 0; + int parsedMonth = 0; + int parsedYear = 0; + + if (!string.IsNullOrWhiteSpace(this.Day) && !int.TryParse(this.Day, out parsedDay)) + { + validationResults.Add(new ValidationResult( + $"The value '{this.Day}' is not valid for Day.", new[] { nameof(this.Day) })); + } + + if (!string.IsNullOrWhiteSpace(this.Month) && !int.TryParse(this.Month, out parsedMonth)) + { + validationResults.Add(new ValidationResult( + $"The value '{this.Month}' is not valid for Month.", new[] { nameof(this.Month) })); + } + + if (!string.IsNullOrWhiteSpace(this.Year) && !int.TryParse(this.Year, out parsedYear)) + { + validationResults.Add(new ValidationResult( + $"The value '{this.Year}' is not valid for Year.", new[] { nameof(this.Year) })); + } + + if (validationResults.Count > 0) + { + return validationResults; + } + + this.DayInput = parsedDay; + this.MonthInput = parsedMonth; + this.YearInput = parsedYear; + + return DateValidator.ValidateDate(this.DayInput, this.MonthInput, this.YearInput, "valid start date") + .ToValidationResultList(nameof(this.Day), nameof(this.Month), nameof(this.Year)); } } } diff --git a/LearningHub.Nhs.WebUI/Models/DashboardViewModel.cs b/LearningHub.Nhs.WebUI/Models/DashboardViewModel.cs index c1358827f..9ac98c7e0 100644 --- a/LearningHub.Nhs.WebUI/Models/DashboardViewModel.cs +++ b/LearningHub.Nhs.WebUI/Models/DashboardViewModel.cs @@ -1,5 +1,6 @@ namespace LearningHub.Nhs.WebUI.Models { + using System.Collections.Generic; using LearningHub.Nhs.Models.Dashboard; /// @@ -28,5 +29,10 @@ public DashboardViewModel() /// Gets or sets a list of catalogues to be displayed in the dashboard. /// public DashboardCatalogueResponseViewModel Catalogues { get; set; } + + /// + /// Gets or sets a list of enrolled courses to be displayed in the dashboard. + /// + public List EnrolledCourses { get; set; } } } diff --git a/LearningHub.Nhs.WebUI/Models/FileDownloadResponse.cs b/LearningHub.Nhs.WebUI/Models/FileDownloadResponse.cs new file mode 100644 index 000000000..1a4d76679 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/FileDownloadResponse.cs @@ -0,0 +1,30 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + using System.IO; + + /// + /// Defines the . + /// + public class FileDownloadResponse + { + /// + /// Gets or sets the Content. + /// + public Stream Content { get; set; } + + /// + /// Gets or sets the ContentType. + /// + public string ContentType { get; set; } + + /// + /// Gets or sets the ContentType. + /// + public long ContentLength { get; set; } + + /// + /// Gets or sets when downloading large files, a SAS URL is returned so the client can download directly from Azure Files. + /// + public string DownloadUrl { get; set; } + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Models/MoodleCompletionResponseViewModel.cs b/LearningHub.Nhs.WebUI/Models/MoodleCompletionResponseViewModel.cs new file mode 100644 index 000000000..1692a32e6 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/MoodleCompletionResponseViewModel.cs @@ -0,0 +1,28 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + /// + /// MoodleCompletionResponseViewModel. + /// + public class MoodleCompletionResponseViewModel + { + /// + /// Gets or sets the completion status. + /// + public string Exception { get; set; } + + /// + /// Gets or sets error code. + /// + public string Errorcode { get; set; } + + /// + /// Gets or sets Error message. + /// + public string Message { get; set; } + + /// + /// Gets or sets Debug info. + /// + public string Debuginfo { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/MoodleCourseCompletionViewModel.cs b/LearningHub.Nhs.WebUI/Models/MoodleCourseCompletionViewModel.cs new file mode 100644 index 000000000..06eb5cb8f --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/MoodleCourseCompletionViewModel.cs @@ -0,0 +1,103 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + using System.Collections.Generic; + + /// + /// MoodleCourseCompletionViewModel. + /// + public class MoodleCourseCompletionViewModel + { + /// + /// Gets or sets the completion status. + /// + public CompletStatus CompletionStatus { get; set; } + + /// + /// Gets or sets the list of warnings. + /// + public List Warnings { get; set; } + + /// + /// CompletionStatus. + /// + public class CompletStatus + { + /// + /// Gets or sets a value indicating whether the course is completed. + /// + public bool Completed { get; set; } + + /// + /// Gets or sets the aggregation method. + /// + public int Aggregation { get; set; } + + /// + /// Gets or sets the list of completions. + /// + public List Completions { get; set; } + + /// + /// Completion. + /// + public class Completion + { + /// + /// Gets or sets the type of completion. + /// + public int Type { get; set; } + + /// + /// Gets or sets the title of the completion requirement. + /// + public string Title { get; set; } + + /// + /// Gets or sets the status of the completion. + /// + public string Status { get; set; } + + /// + /// Gets or sets a value indicating whether the requirement is complete. + /// + public bool Complete { get; set; } + + /// + /// Gets or sets the timestamp when completion was achieved. + /// + public long? TimeCompleted { get; set; } + + /// + /// Gets or sets the completion details. + /// + public CompletionDetails Details { get; set; } + + /// + /// CompletionDetails. + /// + public class CompletionDetails + { + /// + /// Gets or sets the type of completion requirement. + /// + public string Type { get; set; } + + /// + /// Gets or sets the criteria for completion. + /// + public string Criteria { get; set; } + + /// + /// Gets or sets the requirement for completion. + /// + public string Requirement { get; set; } + + /// + /// Gets or sets the status of the requirement. + /// + public string Status { get; set; } + } + } + } + } +} \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Models/MoodleCourseResponseViewModel.cs b/LearningHub.Nhs.WebUI/Models/MoodleCourseResponseViewModel.cs new file mode 100644 index 000000000..ad94f7b7a --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/MoodleCourseResponseViewModel.cs @@ -0,0 +1,160 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + using System.Collections.Generic; + + /// + /// MoodleCourseResponseViewModel. + /// + public class MoodleCourseResponseViewModel + { + /// + /// Gets or sets the ID. + /// + public int? Id { get; set; } + + /// + /// Gets or sets the short name. + /// + public string ShortName { get; set; } + + /// + /// Gets or sets the full name. + /// + public string FullName { get; set; } + + /// + /// Gets or sets the display name. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the enrolled user count. + /// + public int? EnrolledUserCount { get; set; } + + /// + /// Gets or sets the ID number. + /// + public string IdNumber { get; set; } + + /// + /// Gets or sets the visibility status. + /// + public int? Visible { get; set; } + + /// + /// Gets or sets the summary. + /// + public string Summary { get; set; } + + /// + /// Gets or sets the summary format. + /// + public int? SummaryFormat { get; set; } + + /// + /// Gets or sets the format. + /// + public string Format { get; set; } + + /// + /// Gets or sets the course image URL. + /// + public string CourseImage { get; set; } + + /// + /// Gets or sets a value indicating whether grades are shown. + /// + public bool? ShowGrades { get; set; } + + /// + /// Gets or sets the language. + /// + public string Lang { get; set; } + + /// + /// Gets or sets a value indicating whether completion is enabled. + /// + public bool? EnableCompletion { get; set; } + + /// + /// Gets or sets a value indicating whether completion has criteria. + /// + public bool? CompletionHasCriteria { get; set; } + + /// + /// Gets or sets a value indicating whether completion is user-tracked. + /// + public bool? CompletionUserTracked { get; set; } + + /// + /// Gets or sets the category ID. + /// + public int? Category { get; set; } + + /// + /// Gets or sets the progress. + /// + public int? Progress { get; set; } + + /// + /// Gets or sets the completion status. + /// + public bool? Completed { get; set; } + + /// + /// Gets or sets the start date (Unix timestamp). + /// + public long? StartDate { get; set; } + + /// + /// Gets or sets the end date. + /// + public int? EndDate { get; set; } + + /// + /// Gets or sets the marker. + /// + public int? Marker { get; set; } + + /// + /// Gets or sets the last access timestamp. + /// + public int? LastAccess { get; set; } + + /// + /// Gets or sets a value indicating whether the course is a favorite. + /// + public bool? IsFavourite { get; set; } + + /// + /// Gets or sets a value indicating whether the course is hidden. + /// + public bool? Hidden { get; set; } + + /// + /// Gets or sets the list of overview files. + /// + public List OverviewFiles { get; set; } + + /// + /// Gets or sets a value indicating whether activity dates are shown. + /// + public bool? ShowActivityDates { get; set; } + + /// + /// Gets or sets a value indicating whether completion conditions are shown. + /// + public bool? ShowCompletionConditions { get; set; } + + /// + /// Gets or sets the last modified timestamp (Unix timestamp). + /// + public long? TimeModified { get; set; } + + /// + /// Gets or sets the moodle course completion view model. + /// + public MoodleCourseCompletionViewModel CourseCompletionViewModel { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/MoodleOverviewFileViewModel.cs b/LearningHub.Nhs.WebUI/Models/MoodleOverviewFileViewModel.cs new file mode 100644 index 000000000..3dd335c41 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/MoodleOverviewFileViewModel.cs @@ -0,0 +1,38 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + /// + /// MoodleOverviewFileViewModel. + /// + public class MoodleOverviewFileViewModel + { + /// + /// Gets or sets the file name. + /// + public string? FileName { get; set; } + + /// + /// Gets or sets the file path. + /// + public string? FilePath { get; set; } + + /// + /// Gets or sets the file size in bytes. + /// + public int FileSize { get; set; } + + /// + /// Gets or sets the file URL. + /// + public string? FileUrl { get; set; } + + /// + /// Gets or sets the time the file was modified (Unix timestamp). + /// + public long TimeModified { get; set; } + + /// + /// Gets or sets the MIME type of the file. + /// + public string? MimeType { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/MoodleUserResponseViewModel.cs b/LearningHub.Nhs.WebUI/Models/MoodleUserResponseViewModel.cs new file mode 100644 index 000000000..7940dc725 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/MoodleUserResponseViewModel.cs @@ -0,0 +1,121 @@ +namespace LearningHub.Nhs.WebUI.Models +{ + using System.Collections.Generic; + + /// + /// MoodleUserResponseViewModel. + /// + public class MoodleUserResponseViewModel + { + /// + /// Gets or sets the list of users. + /// + public List Users { get; set; } + + /// + /// Gets or sets the warnings. + /// + public List Warnings { get; set; } + + /// + /// MoodleUser. + /// + public class MoodleUser + { + /// + /// Gets or sets the user ID. + /// + public int Id { get; set; } + + /// + /// Gets or sets the username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the first name. + /// + public string FirstName { get; set; } + + /// + /// Gets or sets the last name. + /// + public string LastName { get; set; } + + /// + /// Gets or sets the full name. + /// + public string FullName { get; set; } + + /// + /// Gets or sets the email. + /// + public string Email { get; set; } + + /// + /// Gets or sets the department. + /// + public string Department { get; set; } + + /// + /// Gets or sets the first access timestamp. + /// + public long FirstAccess { get; set; } + + /// + /// Gets or sets the last access timestamp. + /// + public long LastAccess { get; set; } + + /// + /// Gets or sets the authentication method. + /// + public string Auth { get; set; } + + /// + /// Gets or sets a value indicating whether the user is suspended. + /// + public bool Suspended { get; set; } + + /// + /// Gets or sets a value indicating whether the user is confirmed. + /// + public bool Confirmed { get; set; } + + /// + /// Gets or sets the language. + /// + public string Lang { get; set; } + + /// + /// Gets or sets the theme. + /// + public string Theme { get; set; } + + /// + /// Gets or sets the timezone. + /// + public string Timezone { get; set; } + + /// + /// Gets or sets the mail format. + /// + public int MailFormat { get; set; } + + /// + /// Gets or sets the forum tracking preference. + /// + public int TrackForums { get; set; } + + /// + /// Gets or sets the small profile image URL. + /// + public string ProfileImageUrlSmall { get; set; } + + /// + /// Gets or sets the profile image URL. + /// + public string ProfileImageUrl { get; set; } + } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/NavigationModel.cs b/LearningHub.Nhs.WebUI/Models/NavigationModel.cs index ca4dee523..950343bde 100644 --- a/LearningHub.Nhs.WebUI/Models/NavigationModel.cs +++ b/LearningHub.Nhs.WebUI/Models/NavigationModel.cs @@ -69,5 +69,10 @@ public class NavigationModel /// Gets or sets a value indicating whether to show my account. /// public bool ShowMyAccount { get; set; } + + /// + /// Gets or sets a value indicating whether to show Browse Catalogues. + /// + public bool ShowBrowseCatalogues { get; set; } } } diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs index bf8837d01..543a50e5f 100644 --- a/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchResultViewModel.cs @@ -78,5 +78,20 @@ public class SearchResultViewModel /// Gets or sets the catalogue result paging. /// public PagingViewModel CatalogueResultPaging { get; set; } + + /// + /// Gets or sets a value indicating whether Did You Mean Enabled or not. + /// + public bool DidYouMeanEnabled { get; set; } + + /// + /// Gets or sets Suggested Catalogue name. + /// + public string SuggestedCatalogue { get; set; } + + /// + /// Gets or sets Suggested Resource name. + /// + public string SuggestedResource { get; set; } } } diff --git a/LearningHub.Nhs.WebUI/Program.cs b/LearningHub.Nhs.WebUI/Program.cs index 8aee50089..c24d9057c 100644 --- a/LearningHub.Nhs.WebUI/Program.cs +++ b/LearningHub.Nhs.WebUI/Program.cs @@ -84,7 +84,6 @@ app.UseAuthorization(); app.UseMiddleware(); - app.UseStaticFiles(); app.Map(TimezoneInfoMiddleware.TimezoneInfoUrl, b => b.UseMiddleware()); diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/bookmark/togglebookmark.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/bookmark/togglebookmark.vue index bfcace2de..658024396 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/bookmark/togglebookmark.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/bookmark/togglebookmark.vue @@ -24,7 +24,8 @@ diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/catalogueaccessrequest.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/catalogueaccessrequest.vue index 1404eba89..b178e70f3 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/catalogueaccessrequest.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/catalogueaccessrequest.vue @@ -98,7 +98,8 @@
- + +
diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/managecatalogue.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/managecatalogue.vue index 371ac968b..d76b0bef1 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/managecatalogue.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/catalogue/managecatalogue.vue @@ -14,10 +14,14 @@

Catalogue Management

@@ -72,6 +76,7 @@
+
- +
diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributions.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributions.vue index 4065e9a3e..8198e3705 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributions.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributions.vue @@ -11,13 +11,13 @@ Role: Editor
-
+
@@ -85,12 +85,12 @@
@@ -255,6 +255,13 @@ setRestrictToCurrentUser() { this.$store.commit("myContributionsState/setRestrictToCurrentUser", this.restrictToCurrentUser); }, + keyboardCatalogueSelection(catalogueNodeVersionId: number, nodeId: number) { + this.catalogueChange(catalogueNodeVersionId, nodeId); + const dropdownMenu = document.getElementById('catalogueMenuItem'); + if (dropdownMenu) { + dropdownMenu.classList.remove('show'); + } + }, keyboardSelection(tabIndex: number) { switch (tabIndex) { case MyContributeTabEnum.ActionRequired: @@ -382,6 +389,13 @@ margin-right: auto; } + a.dropdown-item:hover, + a.dropdown-item:focus { + background-color: $nhsuk-blue !important; + color: $nhsuk-white !important; + box-shadow: none !important; + } + @media (min-width: 769px) { .border-y-grey { border-top: 1px solid $nhsuk-grey-light; @@ -392,6 +406,7 @@ @media (max-width: 768px) { .dropdown-toggle { display: block !important; + white-space: wrap !important; } } diff --git a/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributionscardheader.vue b/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributionscardheader.vue index aafcb47bb..13bcc4a3a 100644 --- a/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributionscardheader.vue +++ b/LearningHub.Nhs.WebUI/Scripts/vuesrc/mycontributions/mycontributionscardheader.vue @@ -1,7 +1,7 @@