From ed7aeab33f96f518da7dd2f8c1a893d63cc9d24e Mon Sep 17 00:00:00 2001 From: Velang Date: Tue, 24 Mar 2026 07:32:11 -0700 Subject: [PATCH 01/31] Adding l1 and l2 approval flow --- applications/Unity.AutoUI/cypress.config.ts | 10 + .../cypress/regression/ApprovalFlow.cy.ts | 265 ++++++++++++++---- .../scripts/chefs-api-submission.cy.ts | 5 + 3 files changed, 227 insertions(+), 53 deletions(-) diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index 2d846e5c58..b1e381022d 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -8,6 +8,16 @@ export default defineConfig({ e2e: { setupNodeEvents(on) { on("task", { + readJsonIfExists(filePath: string): Record | null { + try { + // path.resolve handles both forward and back slashes on Windows + const content = fs.readFileSync(path.resolve(filePath), "utf-8"); + return JSON.parse(content); + } catch { + return null; + } + }, + async uploadChefsFile({ baseURL, authToken, diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index d34c424860..1fa2f8324c 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -4,61 +4,109 @@ * Approval Flow Regression Test - Full Approval Workflow * * This test validates the complete application approval workflow including: - * - Dynamic submission ID fetching from API + * - Submission ID resolution (seeded file β†’ static override β†’ dynamic API fetch) * - Searching and opening a submission * - Review and assessment process * - Payment info configuration * - Adding comments and attachments - * - Approval action (confirmed via dialog) - * - * The submission ID is fetched dynamically from the API after login, - * ensuring tests always run against valid, available data. + * - Application approval (confirmed via dialog) + * - Payment request submission + * - L1 and L2 payment approvals (two separate users) + * - Post-approval status and date validation on the Payments table */ import { ApplicationsListPage } from "../pages/ApplicationsListPage"; import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; +import { NavigationPage } from "../pages/NavigationPage"; import { loginIfNeeded } from "../support/auth"; -const isProd = (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; +const isProd = + (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; // ============ Test Configuration ============ -// Set submissionId to null for dynamic fetch, or provide a value to override const TEST_CONFIG = { - // Dynamic submission: set to null to fetch from API, or provide ID to use static value + // Set to null to resolve ID automatically, or provide a value to force a specific submission submissionId: null as string | null, grantProgram: "Default Grants Program", approvedAmount: "5000", supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", paymentGroup: "Cheque" as const, testComment: "Test comment from automated regression test", - // Options for dynamic submission fetching (only used when submissionId is null) - // Results are sorted by submissionDate descending (latest first) by default + // Options used when fetching dynamically (ignored when submissionId is set or seeded file exists) fetchOptions: { - // Filter by category (required for this test) categoryFilter: "Data Seeder", - // Filter by status (uncomment to enable): - // Available: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + // Available statuses: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' statusFilter: ["Submitted"], - // Limit to submissions within N days (uncomment to enable): - maxAge: 30, - // Which submission to use after sorting (0 = latest, 1 = second-latest, etc.) - // Use index > 0 to avoid picking the same submission as other concurrent tests - index: 0, + maxAge: 30, // Only consider submissions created within the last N days + index: 0, // 0 = latest; increment to avoid collision with concurrent tests }, }; (isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { - // Page object instances (reused across all tests) + // Page object instances reused across all tests const listPage = new ApplicationsListPage(); const detailsPage = new ApplicationDetailsPage(); const reviewPage = new ReviewAssessmentPage(); const rightTabPage = new ApplicationDetailsRightTabPage(); + const navPage = new NavigationPage(); - // Dynamic submission ID - populated after login + // Resolved after "Fetch submission ID from API" β€” shared across all subsequent tests let submissionId: string; + // ============ Shared Helpers ============ + + /** Navigate to the Payments tab and filter the table by submissionId. */ + function navigateToPaymentsAndSearch(): void { + navPage.goToPayments(); + cy.location("pathname", { timeout: 20000 }).should("include", "Payment"); + cy.get("#search", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(submissionId); + cy.contains("tr", submissionId, { timeout: 20000 }).should("exist"); + } + + /** Select the submissionId row and open the Approve Payments modal. */ + function selectRowAndOpenApproveModal(): void { + cy.contains("tr", submissionId, { timeout: 20000 }) + .find(".checkbox-select") + .click({ force: true }); + cy.contains("button", "Approve", { timeout: 20000 }) + .should("be.visible") + .click(); + cy.contains(".modal-title", "Approve Payments", { timeout: 20000 }).should( + "be.visible", + ); + } + + /** + * Validate the Approve Payments modal fields, enter an auto-generated note, + * submit the approval and assert the modal closes. + * @param notePrefix - Short prefix to distinguish L1 vs L2 notes (max length enforced at 50 chars total) + */ + function validateAndSubmitApproveModal(notePrefix: string): void { + cy.get("#ApplicationCount").should("have.value", "1"); + cy.get("#UpdateTotalAmount").should("not.have.value", "0"); + cy.get("#Note").should("be.visible"); + + const approvalNote = `${notePrefix}-${submissionId}-${Date.now()}`.slice(0, 50); + cy.get("#Note").clear().type(approvalNote); + + cy.get("#btnSubmitPayment") + .should("be.visible") + .and("not.be.disabled") + .click(); + + cy.contains(".modal-title", "Approve Payments", { timeout: 20000 }).should( + "not.exist", + ); + cy.log(`βœ… Payment approved with note: ${approvalNote}`); + } + + // ============ Setup ============ + before(() => { Cypress.config("includeShadowDom", true); loginIfNeeded(); @@ -67,18 +115,32 @@ const TEST_CONFIG = { // ============ Dynamic Submission Fetch ============ it("Fetch submission ID from API", () => { - // Use static ID if provided, otherwise fetch dynamically - if (TEST_CONFIG.submissionId) { - submissionId = TEST_CONFIG.submissionId; - cy.log(`πŸ“Œ Using static submission ID: ${submissionId}`); - return; - } - - // Fetch submission ID dynamically from API using session cookies - cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { - submissionId = id; - cy.log(`βœ… Fetched dynamic submission ID: ${submissionId}`); - }); + // Priority 1: ID written by the seed script when running test:approval-flow + cy.task("readJsonIfExists", "cypress/scripts/last-submission-id.json").then( + (result) => { + const seeded = result as { submissionId?: string; createdAt?: string } | null; + if (seeded?.submissionId) { + submissionId = seeded.submissionId; + cy.log(`πŸ“Œ Using seeded submission ID: ${submissionId}`); + // Clear file to prevent reuse on the next standalone run + cy.writeFile("cypress/scripts/last-submission-id.json", {}); + return; + } + + // Priority 2: static override in TEST_CONFIG + if (TEST_CONFIG.submissionId) { + submissionId = TEST_CONFIG.submissionId; + cy.log(`πŸ“Œ Using static submission ID: ${submissionId}`); + return; + } + + // Priority 3: fetch the latest matching submission from the Unity API + cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { + submissionId = id; + cy.log(`βœ… Fetched dynamic submission ID: ${submissionId}`); + }); + }, + ); }); // ============ Navigation & Search ============ @@ -88,7 +150,6 @@ const TEST_CONFIG = { }); it("Search for submission", () => { - // Ensure submissionId is available before searching expect(submissionId, "Submission ID should be set").to.exist; listPage .selectQuickDateRange("alltime") @@ -115,11 +176,9 @@ const TEST_CONFIG = { }); it("Create and complete assessment", () => { - // Wait for assessment section to load - cy.wait(2000); + cy.wait(2000); // Allow assessment section to fully load reviewPage.scrollToAssessmentList(); - // Check if Create button exists and click it cy.get("body").then(($body) => { if ($body.find("#CreateButton").length > 0) { cy.get("#CreateButton").click({ force: true }); @@ -129,15 +188,12 @@ const TEST_CONFIG = { } }); - // Check if Complete button exists and click it cy.get("body").then(($body) => { if ($body.find("#CompleteButton").length > 0) { cy.get("#CompleteButton").click({ force: true }); cy.wait(1000); } else { - cy.log( - "Complete Assessment button not found - may already be completed", - ); + cy.log("Complete Assessment button not found - may already be completed"); } }); }); @@ -145,8 +201,7 @@ const TEST_CONFIG = { // ============ Payment Info ============ it("Configure payment info", () => { - // Reload page to get fresh data and avoid concurrency issues - cy.reload(); + cy.reload(); // Reload to get fresh data and avoid concurrency issues cy.wait(2000); detailsPage .goToPaymentInfoTab() @@ -168,7 +223,6 @@ const TEST_CONFIG = { // ============ Comments & Attachments ============ it("Add a comment", () => { - // Dismiss any error modals from previous steps detailsPage.dismissErrorModalIfPresent(); rightTabPage .goToCommentsTab() @@ -177,46 +231,151 @@ const TEST_CONFIG = { }); it("Add an attachment", () => { - // Dismiss any error modals from previous steps detailsPage.dismissErrorModalIfPresent(); rightTabPage.goToAttachmentsTab(); cy.wait(1000); // Allow tab content to load - // Store initial count to verify upload rightTabPage.getAttachmentsCount().then((initialCount) => { cy.log(`Initial attachment count: ${initialCount}`); - // Generate unique filename to ensure new file is added const timestamp = Date.now(); const uniqueFileName = `test-attachment-${timestamp}.txt`; - // Upload file with unique content rightTabPage.uploadUniqueAttachment(uniqueFileName, timestamp); - // Verify upload success cy.contains("Successful").should("be.visible"); - cy.wait(2000); // Allow UI to update + cy.wait(2000); // Allow UI to update after upload - // Verify count increased rightTabPage.getAttachmentsCount().then((newCount) => { cy.log(`New attachment count: ${newCount}`); expect(newCount).to.be.greaterThan(initialCount); }); - // Verify file appears in list rightTabPage.verifyAttachmentExists(uniqueFileName); cy.screenshot("attachment-upload-complete"); }); }); - // ============ Approval Action ============ + // ============ Application Approval ============ it("Test approval workflow (confirm)", () => { - // Dismiss any error modals from previous steps detailsPage.dismissErrorModalIfPresent(); detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); }); + // ============ Post-Approval Verification ============ + + it("Navigate back to applications list", () => { + cy.visit(`${Cypress.env("webapp.url")}GrantApplications`); + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Verify application status is Approved", () => { + expect(submissionId, "Submission ID should be set").to.exist; + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + + cy.contains("tr", submissionId, { timeout: 20000 }).should( + "contain.text", + "Approved", + ); + }); + + // ============ Payment Request ============ + + it("Select approved application and submit payment request", () => { + listPage.selectRowByText(submissionId).clickPaymentButtonWithWait(); + listPage.waitForPaymentModalVisible(); + + // Description field has a max length of 40 chars + const paymentDescription = `AutoTest-${submissionId}`.slice(0, 40); + + // Modal uses divs (not a table) β€” target description input by id suffix + cy.get("#payment-modal input[id$='__Description']") + .should("be.visible") + .clear() + .type(paymentDescription); + + cy.contains("button", "Submit Payment Requests") + .should("be.visible") + .and("not.be.disabled") + .click(); + + cy.get("#payment-modal", { timeout: 20000 }).should("not.be.visible"); + cy.log(`βœ… Payment request submitted: ${paymentDescription}`); + }); + + // ============ L1 Payment Approval (User 1) ============ + + it("Navigate to Payments tab and search for submission", () => { + navigateToPaymentsAndSearch(); + }); + + it("Select payment row and open Approve Payments modal", () => { + selectRowAndOpenApproveModal(); + }); + + it("L1 Approval - Validate modal details, enter note and approve", () => { + validateAndSubmitApproveModal("AutoApproval"); + }); + + // ============ L2 Payment Approval (User 2) ============ + + it("Logout user1 and login as user2 for L2 approval", () => { + cy.logout(); + cy.clearCookies(); + cy.clearLocalStorage(); + loginIfNeeded({ + username: Cypress.env("test2username") as string, + password: Cypress.env("test2password") as string, + }); + }); + + it("Navigate to Payments tab and search for submission (L2)", () => { + navigateToPaymentsAndSearch(); + }); + + it("Select payment row and open Approve Payments modal (L2)", () => { + selectRowAndOpenApproveModal(); + }); + + it("L2 Approval - Validate modal details, enter note and approve", () => { + validateAndSubmitApproveModal("L2Approval"); + }); + + // ============ Post-L2 Validation ============ + + it("Validate payment status and approval dates on Payments table", () => { + const today = listPage.getTodayIsoLocal(); + + // Re-search to ensure the table reflects the latest state after L2 approval + cy.get("#search", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(submissionId); + + cy.contains("tr", submissionId, { timeout: 20000 }).should( + "contain.text", + "Sent to Accounts Payable", + ); + + // Validate date columns by resolving each column's index from its header title + ["Updated On", "L1 Approval Date", "L2 Approval Date"].forEach((header) => { + cy.get(".dt-scroll-head span.dt-column-title") + .contains(header) + .closest("th") + .invoke("index") + .then((colIndex) => { + cy.contains("tr", submissionId) + .find("td") + .eq(colIndex) + .should("contain.text", today); + }); + }); + }); + // ============ Cleanup ============ it("Logout", () => { diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts index ff81ec0ffd..7d04f98ce7 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -321,6 +321,11 @@ const isProd = if (response.body.id) { createdSubmissionId = response.body.id; cy.log(`βœ… Submission created with ID: ${response.body.id}`); + cy.log(`βœ… Confirmation ID: ${response.body.confirmationId}`); + cy.writeFile("cypress/scripts/last-submission-id.json", { + submissionId: response.body.confirmationId, + createdAt: new Date().toISOString(), + }); } expect(response.body).to.have.property("formVersionId", environment.versionId); From 14456692bb9fecb7e9dceef281c715c52173e9b0 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 24 Mar 2026 11:27:45 -0700 Subject: [PATCH 02/31] Added SettingManagement.ConfigureAI permission. --- .../AIApplicationContractsModule.cs | 4 ++- .../AIPermissionDefinitionProvider.cs | 7 +++++ .../Permissions/AIPermissions.cs | 6 ++++- .../Unity.AI.Application.Contracts.csproj | 1 + .../Settings/AISettingDefinitionProvider.cs | 27 +++++++++++++++++++ .../Localization/AI/en.json | 4 ++- .../Settings/AISettings.cs | 6 +++++ 7 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs index f9e53089f8..c9c5d44334 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs @@ -1,13 +1,15 @@ using Volo.Abp.Application; using Volo.Abp.Modularity; using Volo.Abp.Authorization; +using Volo.Abp.SettingManagement; namespace Unity.AI; [DependsOn( typeof(AIDomainSharedModule), typeof(AbpDddApplicationContractsModule), - typeof(AbpAuthorizationModule) + typeof(AbpAuthorizationModule), + typeof(AbpSettingManagementApplicationContractsModule) )] public class AIApplicationContractsModule : AbpModule { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index 05a8f98c81..fb7980f037 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -2,6 +2,7 @@ using Volo.Abp.Authorization.Permissions; using Volo.Abp.Localization; using Volo.Abp.Features; +using Volo.Abp.SettingManagement; namespace Unity.AI.Permissions; @@ -35,6 +36,12 @@ public override void Define(IPermissionDefinitionContext context) L("Permission:AI.ScoringAssistant")) .RequireFeatures("Unity.AI.Scoring"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); + settingManagement.AddPermission( + AIPermissions.Configuration.ConfigureAI, + L("Permission:AI.ConfigureAI")) + .RequireFeatures("Unity.AI.Scoring"); + } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index 844a8d8e1f..8caef403c4 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -26,8 +26,12 @@ public static class AttachmentSummary public static class ScoringAssistant { public const string ScoringAssistantDefault = GroupName + ".ScoringAssistant"; - } + } + public static class Configuration + { + public const string ConfigureAI = "SettingManagement.ConfigureAI"; + } public static string[] GetAll() { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj index cd71888469..dcf9c1f296 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -12,6 +12,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs new file mode 100644 index 0000000000..5032d042af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs @@ -0,0 +1,27 @@ +using Unity.AI.Localization; +using Volo.Abp.Localization; +using Volo.Abp.Settings; + +namespace Unity.AI.Settings; + +public class AISettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + AISettings.ScoringAssistantEnabled, + "false", + L("Setting:AI.ScoringAssistantEnabled"), + isVisibleToClients: false, + isInherited: false, + isEncrypted: false) + .WithProviders(TenantSettingValueProvider.ProviderName) + ); + } + + private static LocalizableString L(string name) + { + return LocalizableString.Create(name); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index f660d259d3..2e24b35597 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -5,6 +5,8 @@ "Permission:AI.Reporting": "AI Reporting", "Permission:AI.ApplicationAnalysis": "AI Application Analysis", "Permission:AI.AttachmentSummary": "AI Attachment Summary", - "Permission:AI.ScoringAssistant": "AI Scoring Assistant" + "Permission:AI.ScoringAssistant": "AI Scoring Assistant", + "Setting:AI.ScoringAssistantEnabled": "AI Scoring Assistant", + "Permission:AI.ConfigureAI": "AI Configuration" } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs new file mode 100644 index 0000000000..d335050785 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public static class AISettings +{ + public const string ScoringAssistantEnabled = "GrantManager.AI.ScoringAssistantEnabled"; +} From 926824de35a9110b3c42880b1bfa16763ccb1567 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 24 Mar 2026 17:29:31 -0700 Subject: [PATCH 03/31] Seeded permission to ProgramManager and SystemAdmin roles --- .../Permissions/PermissionGrantsDataSeeder.cs | 5 ++++- .../Unity.GrantManager.Domain.csproj | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index a4e280e487..cf7c7c788d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -1,5 +1,6 @@ ο»Ώusing System.Collections.Generic; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.Identity; using Unity.Modules.Shared; using Unity.Notifications.Permissions; @@ -131,7 +132,8 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, UnitySelector.Payment.Supplier.Update, .. Notifications_CommonPermissions, .. Dashboard_CommonPermissions, - .. Tags_CommonPermissions + .. Tags_CommonPermissions, + AIPermissions.Configuration.ConfigureAI ], context.TenantId); // - Reviewer @@ -239,6 +241,7 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, .. Tags_CommonPermissions, UnitySettingManagementPermissions.ConfigurePayments, UnitySettingManagementPermissions.BackgroundJobSettings, + AIPermissions.Configuration.ConfigureAI, ], context.TenantId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 18ffd18ae2..b3f506871d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -10,6 +10,7 @@ + From 5cb3a8f52ebed0bbfe332d58223b9c269484f934 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 24 Mar 2026 18:07:21 -0700 Subject: [PATCH 04/31] Added AI setting backend --- .../Settings/AIScoringSettingsDto.cs | 6 +++ .../Settings/IAIConfigurationAppService.cs | 10 +++++ .../Settings/UpdateAIScoringSettingsDto.cs | 6 +++ .../Settings/AIConfigurationAppService.cs | 44 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs new file mode 100644 index 0000000000..14837f27b4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public class AIScoringSettingsDto +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs new file mode 100644 index 0000000000..8a21259aa6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.AI.Settings; + +public interface IAIConfigurationAppService : IApplicationService +{ + Task GetScoringSettingsAsync(); + Task UpdateScoringSettingsAsync(UpdateAIScoringSettingsDto input); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs new file mode 100644 index 0000000000..a6323e4a34 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public class UpdateAIScoringSettingsDto +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs new file mode 100644 index 0000000000..af5dfd02e7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.AI.Permissions; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.AI.Settings; + +public class AIConfigurationAppService : AIAppService, IAIConfigurationAppService +{ + private readonly ISettingProvider _settingProvider; + private readonly ISettingManager _settingManager; + private readonly ICurrentTenant _currentTenant; + + public AIConfigurationAppService( + ISettingProvider settingProvider, + ISettingManager settingManager, + ICurrentTenant currentTenant) + { + _settingProvider = settingProvider; + _settingManager = settingManager; + _currentTenant = currentTenant; + } + + public virtual async Task GetScoringSettingsAsync() + { + return new AIScoringSettingsDto + { + ScoringAssistantEnabled = await _settingProvider.GetAsync( + AISettings.ScoringAssistantEnabled, defaultValue: false) + }; + } + + [Authorize(AIPermissions.Configuration.ConfigureAI)] + public virtual async Task UpdateScoringSettingsAsync(UpdateAIScoringSettingsDto input) + { + await _settingManager.SetAsync( + AISettings.ScoringAssistantEnabled, + input.ScoringAssistantEnabled.ToString().ToLowerInvariant(), + TenantSettingValueProvider.ProviderName, + _currentTenant.Id?.ToString()); + } +} From ea2045f2b97822827cb672c3a2c9d198071b769f Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 24 Mar 2026 19:55:59 -0700 Subject: [PATCH 05/31] Added AI setting UI --- .../Unity.AI/src/Unity.AI.Web/AIWebModule.cs | 13 ++++- .../src/Unity.AI.Web/Unity.AI.Web.csproj | 6 +++ .../AISettingGroup/AISettingViewComponent.cs | 35 ++++++++++++++ .../AISettingGroup/AISettingViewModel.cs | 6 +++ .../Settings/AISettingGroup/Default.cshtml | 48 +++++++++++++++++++ .../Views/Settings/AISettingGroup/Default.js | 41 ++++++++++++++++ .../Settings/AISettingPageContributor.cs | 27 +++++++++++ .../Unity.AI.Web/Views/_ViewImports.cshtml | 4 ++ 8 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs index e82f53daa7..11a92491a5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs @@ -1,17 +1,21 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; +using Unity.AI.Web.Views.Settings; using Volo.Abp.AspNetCore.Mvc.Localization; using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; using Volo.Abp.AutoMapper; using Volo.Abp.Modularity; +using Volo.Abp.SettingManagement.Web; +using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; using Volo.Abp.VirtualFileSystem; namespace Unity.AI.Web; [DependsOn( - typeof(AIApplicationContractsModule), + typeof(AIApplicationModule), typeof(AbpAspNetCoreMvcUiThemeSharedModule), - typeof(AbpAutoMapperModule) + typeof(AbpAutoMapperModule), + typeof(AbpSettingManagementWebModule) )] public class AIWebModule : AbpModule { @@ -40,5 +44,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) { options.AddMaps(validate: true); }); + + Configure(options => + { + options.Contributors.Add(new AISettingPageContributor()); + }); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj index 79de5268e6..40c586592c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj @@ -8,11 +8,13 @@ true Library Unity.AI.Web + true + @@ -24,6 +26,10 @@ + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs new file mode 100644 index 0000000000..3f880adaa6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Unity.AI.Settings; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Settings; + +namespace Unity.AI.Web.Views.Settings.AISettingGroup; + +[Widget( + ScriptTypes = [typeof(AISettingScriptBundleContributor)], + AutoInitialize = true +)] +public class AISettingViewComponent(ISettingProvider settingProvider) : AbpViewComponent +{ + public virtual async Task InvokeAsync() + { + var model = new AISettingViewModel + { + ScoringAssistantEnabled = await settingProvider.GetAsync( + AISettings.ScoringAssistantEnabled, defaultValue: false) + }; + + return View("~/Views/Settings/AISettingGroup/Default.cshtml", model); + } + + public class AISettingScriptBundleContributor : BundleContributor + { + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.Add("/Views/Settings/AISettingGroup/Default.js"); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs new file mode 100644 index 0000000000..3ae4713935 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Web.Views.Settings.AISettingGroup; + +public class AISettingViewModel +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml new file mode 100644 index 0000000000..809bd07b6d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml @@ -0,0 +1,48 @@ +@model Unity.AI.Web.Views.Settings.AISettingGroup.AISettingViewModel + + + +
+
+

AI Configuration

+
+ +
+
+
+ + +
+ + +
+
+
+ + +
+
+ + + +
+
+
+
+
+
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js new file mode 100644 index 0000000000..8ed936e630 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js @@ -0,0 +1,41 @@ +$(function () { + const uiElements = { + settingForm: $('#AISettingsForm'), + saveButton: $('#AISettingsSaveButton'), + discardButton: $('#AISettingsDiscardButton') + }; + + let initialFormState = uiElements.settingForm.serialize(); + + function checkFormChanges() { + let isFormChanged = uiElements.settingForm.serialize() !== initialFormState; + uiElements.saveButton.prop('disabled', !isFormChanged); + uiElements.discardButton.prop('disabled', !isFormChanged); + } + + uiElements.settingForm.on('change', function () { + checkFormChanges(); + }); + + uiElements.settingForm.on('submit', function (event) { + event.preventDefault(); + + const scoringEnabled = $('#ScoringAssistantEnabled').is(':checked'); + + unity.aI.settings.aIConfiguration.updateScoringSettings({ + scoringAssistantEnabled: scoringEnabled + }).then(function () { + $(document).trigger('AbpSettingSaved'); + initialFormState = uiElements.settingForm.serialize(); + checkFormChanges(); + }); + }); + + uiElements.discardButton.on('click', function () { + uiElements.settingForm[0].reset(); + initialFormState = uiElements.settingForm.serialize(); + checkFormChanges(); + }); + + checkFormChanges(); +}); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs new file mode 100644 index 0000000000..2d89a6709a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Unity.AI.Permissions; +using Unity.AI.Web.Views.Settings.AISettingGroup; +using Volo.Abp.SettingManagement; +using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; + +namespace Unity.AI.Web.Views.Settings; + +public class AISettingPageContributor : SettingPageContributorBase +{ + public override Task ConfigureAsync(SettingPageCreationContext context) + { + RequiredFeatures(SettingManagementFeatures.Enable); + RequiredPermissions(AIPermissions.Configuration.ConfigureAI); + + context.Groups.Add( + new SettingPageGroup( + "AI.Configuration", + "AI Configuration", + typeof(AISettingViewComponent), + order: 5 + ) + ); + + return Task.CompletedTask; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..231948b339 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling From 3707e2749a8545d7fa2657e9472cb8a42f7c9a17 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Wed, 25 Mar 2026 12:01:10 -0700 Subject: [PATCH 06/31] Added AI Scoring setting check to existing handlers --- .../Assessments/AssessmentAppService.cs | 7 +- .../Handlers/CreateAiAssessmentHandler.cs | 8 + .../Handlers/GenerateAIContentHandler.cs | 8 + .../Pages/GrantApplications/Details.cshtml | 395 +++++++++--------- .../AssessmentScoresWidgetViewComponent.cs | 8 +- .../Intakes/CreateAiAssessmentHandlerTests.cs | 44 +- .../Intakes/GenerateAIContentHandlerTests.cs | 107 +++++ .../Components/AssessmentScoresWidgetTests.cs | 6 +- 8 files changed, 375 insertions(+), 208 deletions(-) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/GenerateAIContentHandlerTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs index 4cbc3f3ff4..fb7239c2cd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Permissions; +using Unity.AI.Settings; using Unity.Flex; using Unity.Flex.Scoresheets; using Unity.Flex.Scoresheets.Enums; @@ -23,6 +24,7 @@ using Volo.Abp.EventBus.Local; using Volo.Abp.Features; using Volo.Abp.Identity.Integration; +using Volo.Abp.Settings; using Volo.Abp.Users; using Volo.Abp.Validation; @@ -92,11 +94,12 @@ public async Task GetDisplayList(Guid applicationId) var assessments = await _assessmentRepository.GetListWithAssessorsAsync(applicationId); var assessmentList = ObjectMapper.Map, List>(assessments); - // If AI Scoring feature is disabled, or user doesn't have permissions to view AI assessments, filter out AI assessments from the list + // If AI Scoring feature is disabled, tenant setting is off, or user lacks permission, filter out AI assessments var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + var aiScoringSettingEnabled = aiScoringEnabled && await SettingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false); var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault); assessmentList = assessmentList - .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI)) + .Where(a => !a.IsAiAssessment || (aiScoringSettingEnabled && canViewAI)) .OrderByDescending(a => a.IsAiAssessment) .ThenByDescending(a => a.StartDate) .ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs index a69723573f..13c52443d0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/CreateAiAssessmentHandler.cs @@ -1,17 +1,20 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.Settings; using Unity.GrantManager.Assessments; using Unity.GrantManager.Intakes.Events; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.Features; +using Volo.Abp.Settings; namespace Unity.GrantManager.Intakes.Handlers; public class CreateAiAssessmentHandler( AssessmentManager assessmentManager, IFeatureChecker featureChecker, + ISettingProvider settingProvider, ILogger logger) : ILocalEventHandler, ITransientDependency { public async Task HandleEventAsync(AiScoresheetAnswersGeneratedEvent eventData) @@ -27,6 +30,11 @@ public async Task HandleEventAsync(AiScoresheetAnswersGeneratedEvent eventData) return; } + if (!await settingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false)) + { + return; + } + try { await assessmentManager.CreateAiAssessmentAsync(eventData.Application); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index 21cc065690..d71353d502 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Settings; using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes.Events; @@ -12,6 +13,7 @@ using Unity.Flex.Domain.Scoresheets; using System.Text.Json; using Volo.Abp.Features; +using Volo.Abp.Settings; using Newtonsoft.Json.Linq; namespace Unity.GrantManager.Intakes.Handlers @@ -35,6 +37,7 @@ public class GenerateAIContentHandler : ILocalEventHandler NonDataComponentTypes = new() { @@ -115,6 +118,11 @@ public async Task HandleEventAsync(ApplicationProcessEvent eventData) var applicationAnalysisEnabled = await _featureChecker.IsEnabledAsync(ApplicationAnalysisFeatureName); var scoringEnabled = await _featureChecker.IsEnabledAsync(ScoringFeatureName); + if (scoringEnabled) + { + scoringEnabled = await SettingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false); + } + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) { _logger.LogDebug("All AI features are disabled, skipping AI generation for application {ApplicationId}.", eventData.Application.Id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index f52738140b..f41d7b6c84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -26,18 +26,19 @@ @inject ICurrentTenant CurrentTenant @inject ISettingProvider SettingProvider -@{ - PageLayout.Content.Title = L["Grants"].Value; - var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); - var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); - var aiAttachmentSummariesEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") - && await PermissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); - var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") - && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); - var aiScoringEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.Scoring") - && await PermissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault); - var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); -} +@{ + PageLayout.Content.Title = L["Grants"].Value; + var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); + var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); + var aiAttachmentSummariesEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") + && await PermissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); + var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); + var aiScoringEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.Scoring") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault) + && await SettingProvider.GetAsync(Unity.AI.Settings.AISettings.ScoringAssistantEnabled, defaultValue: false); + var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); +} @section styles { @@ -67,11 +68,11 @@ - - - - - + + + + + @functions { @@ -267,19 +268,19 @@ - @if (aiApplicationAnalysisEnabled) - { - - } - @if (Model.IsDevPromptControlsEnabled) - { - - } - + @if (aiApplicationAnalysisEnabled) + { + + } + @if (Model.IsDevPromptControlsEnabled) + { + + } +
@@ -312,9 +313,9 @@ @await Component.InvokeAsync("UserInfoWidget", new { displayName = "", badge = "", title = "" })
-
- @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId }) -
+
+ @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId }) +
Scoring Attachments
@@ -382,11 +383,11 @@ @*-------- Comments Tab Section END ---------*@ @*-------- Attachments Tab Section ---------*@ -
- @await Component.InvokeAsync("ApplicationAttachments") - - @await Component.InvokeAsync("ChefsAttachments") -
+
+ @await Component.InvokeAsync("ApplicationAttachments") + + @await Component.InvokeAsync("ChefsAttachments") +
@*-------- Attachments Tab Section END ---------*@ @*-------- Links Tab Section ---------*@ @@ -406,165 +407,165 @@ @*-------- History Tab Section END ---------*@ - @if (aiApplicationAnalysisEnabled) - { -
-
-
AI Application Analysis
- -
-
- @* Default message when no analysis data is available *@ -
-
- -
No AI Analysis Available
-

AI analysis results will appear here when available.

-
-
-
- @* Analysis sections will be dynamically generated here *@ -
-
- - @* Hidden HTML templates for AI analysis items *@ -
-
-
-
- - -
- - -
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
- } - @*-------- AI Analysis Tab Section END ---------*@ - @if (Model.IsDevPromptControlsEnabled) - { -
-
-
AI Dev Tools
-
-
- -
-
-
-
- -
-
-
Attachment
- @if (aiAttachmentSummariesEnabled) - { - - } -
-
-
- -
- -
-
- -
-
-
Analysis
- -
-
-
- -
- -
-
- -
-
-
Scoring
- @if (aiScoringEnabled) - { - - } -
-
-
- -
- -
-
-
-
- } - + @if (aiApplicationAnalysisEnabled) + { +
+
+
AI Application Analysis
+ +
+
+ @* Default message when no analysis data is available *@ +
+
+ +
No AI Analysis Available
+

AI analysis results will appear here when available.

+
+
+
+ @* Analysis sections will be dynamically generated here *@ +
+
+ + @* Hidden HTML templates for AI analysis items *@ +
+
+
+
+ + +
+ + +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ } + @*-------- AI Analysis Tab Section END ---------*@ + @if (Model.IsDevPromptControlsEnabled) + { +
+
+
AI Dev Tools
+
+
+ +
+
+
+
+ +
+
+
Attachment
+ @if (aiAttachmentSummariesEnabled) + { + + } +
+
+
+ +
+ +
+
+ +
+
+
Analysis
+ +
+
+
+ +
+ +
+
+ +
+
+
Scoring
+ @if (aiScoringEnabled) + { + + } +
+
+
+ +
+ +
+
+
+
+ } + - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index 53b88bfd8d..ddf4058d67 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -18,8 +18,10 @@ using Unity.GrantManager.Applications; using System.Text.Json; using Unity.AI.Permissions; +using Unity.AI.Settings; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Features; +using Volo.Abp.Settings; namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget { @@ -33,7 +35,8 @@ public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmen IScoresheetInstanceRepository scoresheetInstanceRepository, IApplicationRepository applicationRepository, IFeatureChecker featureChecker, - IPermissionChecker permissionChecker) : AbpViewComponent + IPermissionChecker permissionChecker, + ISettingProvider settingProvider) : AbpViewComponent { public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { @@ -100,7 +103,8 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, IsAIScoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring") && - await permissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault), + await permissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault) && + await settingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false), IsAiAssessment = assessment.IsAiAssessment, }; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/CreateAiAssessmentHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/CreateAiAssessmentHandlerTests.cs index 769b3d540e..883243e4b1 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/CreateAiAssessmentHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/CreateAiAssessmentHandlerTests.cs @@ -3,12 +3,14 @@ using Shouldly; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Settings; using Unity.GrantManager.Applications; using Unity.GrantManager.Assessments; using Unity.GrantManager.Intakes.Events; using Unity.GrantManager.Intakes.Handlers; using Volo.Abp.Domain.Repositories; using Volo.Abp.Features; +using Volo.Abp.Settings; using Volo.Abp.Uow; using Xunit; using Xunit.Abstractions; @@ -37,7 +39,9 @@ public async Task HandleEventAsync_Should_Skip_When_Application_Is_Null() // Arrange var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); - var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, NullLogger.Instance); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(AISettings.ScoringAssistantEnabled).Returns("true"); + var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, settingProvider, NullLogger.Instance); using var uow = _unitOfWorkManager.Begin(); var beforeCount = (await _assessmentRepository.GetQueryableAsync()).Count(a => a.IsAiAssessment); @@ -57,7 +61,8 @@ public async Task HandleEventAsync_Should_Skip_When_Feature_Disabled() // Arrange β€” feature disabled var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false); - var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, NullLogger.Instance); + var settingProvider = Substitute.For(); + var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, settingProvider, NullLogger.Instance); using var uow = _unitOfWorkManager.Begin(); var application = (await _applicationRepository.GetListAsync())[0]; @@ -73,12 +78,37 @@ public async Task HandleEventAsync_Should_Skip_When_Feature_Disabled() [Fact] [Trait("Category", "Integration")] - public async Task HandleEventAsync_Should_Create_AI_Assessment_When_Feature_Enabled() + public async Task HandleEventAsync_Should_Skip_When_Feature_Enabled_But_Setting_Disabled() { - // Arrange β€” create a fresh application with no AI assessment + // Arrange β€” feature ON but tenant setting OFF var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); - var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, NullLogger.Instance); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(AISettings.ScoringAssistantEnabled).Returns("false"); + var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, settingProvider, NullLogger.Instance); + + using var uow = _unitOfWorkManager.Begin(); + var application = (await _applicationRepository.GetListAsync())[0]; + var beforeCount = (await _assessmentRepository.GetQueryableAsync()).Count(a => a.IsAiAssessment); + + // Act + await handler.HandleEventAsync(new AiScoresheetAnswersGeneratedEvent { Application = application }); + + // Assert β€” no new AI assessment created + var afterCount = (await _assessmentRepository.GetQueryableAsync()).Count(a => a.IsAiAssessment); + afterCount.ShouldBe(beforeCount); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task HandleEventAsync_Should_Create_AI_Assessment_When_Feature_Enabled_And_Setting_Enabled() + { + // Arrange β€” feature ON and tenant setting ON + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(AISettings.ScoringAssistantEnabled).Returns("true"); + var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, settingProvider, NullLogger.Instance); using var uow = _unitOfWorkManager.Begin(); // Application2 has no AI assessment seeded β€” ideal for the happy path @@ -100,7 +130,9 @@ public async Task HandleEventAsync_Should_Be_Idempotent() // Arrange β€” AiAssessment1_Id is already seeded for Application1_Id var featureChecker = Substitute.For(); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); - var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, NullLogger.Instance); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(AISettings.ScoringAssistantEnabled).Returns("true"); + var handler = new CreateAiAssessmentHandler(_assessmentManager, featureChecker, settingProvider, NullLogger.Instance); using var uow = _unitOfWorkManager.Begin(); var application = await _applicationRepository.GetAsync(GrantManagerTestData.Application1_Id); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/GenerateAIContentHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/GenerateAIContentHandlerTests.cs new file mode 100644 index 0000000000..f94e9d20ff --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Intakes/GenerateAIContentHandlerTests.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using System.Threading.Tasks; +using Unity.AI.Settings; +using Unity.GrantManager.AI; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes.Events; +using Unity.GrantManager.Intakes.Handlers; +using Unity.Flex.Domain.Scoresheets; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; +using Volo.Abp.Settings; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.Intakes; + +public class GenerateAIContentHandlerTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) +{ + private static GenerateAIContentHandler BuildHandler( + IFeatureChecker featureChecker, + ISettingProvider settingProvider, + IAIService? aiService = null) + { + var handler = new GenerateAIContentHandler( + aiService ?? Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + Substitute.For(), + featureChecker) + { + LocalEventBus = Substitute.For(), + SettingProvider = settingProvider + }; + return handler; + } + + [Fact] + public async Task HandleEventAsync_Should_Skip_Scoring_When_Feature_Disabled() + { + // Arrange β€” scoring feature OFF + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false); + featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); + featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); + var settingProvider = Substitute.For(); + var aiService = Substitute.For(); + + var handler = BuildHandler(featureChecker, settingProvider, aiService); + var application = new Application(); + + // Act + await handler.HandleEventAsync(new ApplicationProcessEvent { Application = application }); + + // Assert β€” setting never checked, AI service never called + await settingProvider.DidNotReceive().GetOrNullAsync(AISettings.ScoringAssistantEnabled); + await aiService.DidNotReceive().GenerateScoresheetSectionAsync(Arg.Any()); + } + + [Fact] + public async Task HandleEventAsync_Should_Skip_Scoring_When_Feature_Enabled_But_Setting_Disabled() + { + // Arrange β€” scoring feature ON, tenant setting OFF + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(true); + featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); + featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(AISettings.ScoringAssistantEnabled).Returns("false"); + var aiService = Substitute.For(); + aiService.IsAvailableAsync().Returns(true); + + var handler = BuildHandler(featureChecker, settingProvider, aiService); + var application = new Application(); + + // Act + await handler.HandleEventAsync(new ApplicationProcessEvent { Application = application }); + + // Assert β€” all three features effectively disabled β†’ early exit, scoresheet never called + await aiService.DidNotReceive().GenerateScoresheetSectionAsync(Arg.Any()); + } + + [Fact] + public async Task HandleEventAsync_Should_Not_Check_Setting_When_Feature_Disabled() + { + // Arrange β€” scoring feature OFF + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(false); + featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries").Returns(false); + featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis").Returns(false); + var settingProvider = Substitute.For(); + + var handler = BuildHandler(featureChecker, settingProvider); + var application = new Application(); + + // Act + await handler.HandleEventAsync(new ApplicationProcessEvent { Application = application }); + + // Assert β€” setting provider is never consulted when feature is OFF + await settingProvider.DidNotReceive().GetOrNullAsync(Arg.Any()); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs index 2202927cd9..00cb0f5659 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs @@ -12,6 +12,7 @@ using Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Features; +using Volo.Abp.Settings; using Xunit; namespace Unity.GrantManager.Components @@ -54,6 +55,8 @@ public async Task AssessmentScoresWidgetReturnsStatus() instanceRepository.GetByCorrelationAsync(assessmentId).Returns(Task.FromResult(null)); featureChecker.IsEnabledAsync("Unity.AI.Scoring").Returns(Task.FromResult(true)); permissionChecker.IsGrantedAsync(Arg.Any()).Returns(Task.FromResult(true)); + var settingProvider = Substitute.For(); + settingProvider.GetOrNullAsync(Unity.AI.Settings.AISettings.ScoringAssistantEnabled).Returns(Task.FromResult("true")); var viewContext = new ViewContext { @@ -70,7 +73,8 @@ public async Task AssessmentScoresWidgetReturnsStatus() instanceRepository, applicationRepository, featureChecker, - permissionChecker) + permissionChecker, + settingProvider) { ViewComponentContext = viewComponentContext }; From 1d91a95a89085015d1d515765a52434cd8872a8b Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 25 Mar 2026 18:35:06 -0700 Subject: [PATCH 07/31] AB#31159: Universal catch-all error page --- .../GrantManagerWebModule.cs | 14 +++++--- .../Unity.GrantManager.Web/Pages/Error.cshtml | 36 +++++++++++++++++++ .../Pages/Error.cshtml.cs | 13 +++++++ 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 76b9d46596..ce8c47bbf6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -206,6 +206,13 @@ public override void ConfigureServices(ServiceConfigurationContext context) options.IgnoredUrls.AddIfNotContains("/healthz"); }); + Configure(options => + { + options.ErrorViewUrls["404"] = "/Error?httpStatusCode=404"; + options.ErrorViewUrls["403"] = "/Error?httpStatusCode=403"; + options.ErrorViewUrls["500"] = "/Error?httpStatusCode=500"; + }); + Configure(options => { options.Contributors.Add(new BackgroundJobsPageContributor()); @@ -547,16 +554,13 @@ public override void OnApplicationInitialization(ApplicationInitializationContex if (!env.IsProduction()) { - app.UseDeveloperExceptionPage(); IdentityModelEventSource.ShowPII = true; } app.UseAbpRequestLocalization(); - if (env.IsProduction()) - { - app.UseErrorPage(); - } + app.UseStatusCodePagesWithReExecute("/Error", "?httpStatusCode={0}"); + app.UseErrorPage(); if (Convert.ToBoolean(configuration["AuthServer:IsBehindTlsTerminationProxy"])) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml new file mode 100644 index 0000000000..3af34401ff --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml @@ -0,0 +1,36 @@ +@page +@model Unity.GrantManager.Web.Pages.ErrorModel + +@{ + var code = Model.StatusCode; + + string title; + string message; + + switch (code) + { + case 404: + title = "Page Not Found"; + message = "The page you are looking for does not exist or may have been moved."; + break; + case 403: + title = "Access Denied"; + message = "You do not have permission to view this page."; + break; + case 500: + title = "Something Went Wrong"; + message = "An unexpected error occurred. Please try again, or contact support if the problem persists."; + break; + default: + title = "An Error Occurred"; + message = "An error occurred while processing your request."; + break; + } +} + +
+
+

@title

+

@message

+
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs new file mode 100644 index 0000000000..074d8dd438 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Unity.GrantManager.Web.Pages; + +public class ErrorModel : PageModel +{ + public int StatusCode { get; private set; } + + public void OnGet(int httpStatusCode = 0) + { + StatusCode = httpStatusCode; + } +} From 9ee17e04bc081e6ae3add8e122ba4eaabe96195b Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 25 Mar 2026 19:01:43 -0700 Subject: [PATCH 08/31] AB#31159: Fix sonarqube issue --- .../src/Unity.GrantManager.Web/Pages/Error.cshtml | 2 +- .../src/Unity.GrantManager.Web/Pages/Error.cshtml.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml index 3af34401ff..80c2d7a984 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml @@ -2,7 +2,7 @@ @model Unity.GrantManager.Web.Pages.ErrorModel @{ - var code = Model.StatusCode; + var code = Model.HttpStatusCode; string title; string message; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs index 074d8dd438..83fc2ba79d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs @@ -4,10 +4,10 @@ namespace Unity.GrantManager.Web.Pages; public class ErrorModel : PageModel { - public int StatusCode { get; private set; } + public int HttpStatusCode { get; private set; } public void OnGet(int httpStatusCode = 0) { - StatusCode = httpStatusCode; + HttpStatusCode = httpStatusCode; } } From 4a0f8e9f0c2f392424f8196e60c2cc39997eac65 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 25 Mar 2026 19:33:49 -0700 Subject: [PATCH 09/31] AB#31159: Re-run sonarqube --- .../src/Unity.GrantManager.Web/Pages/Error.cshtml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs index 83fc2ba79d..246f000a8d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs @@ -8,6 +8,6 @@ public class ErrorModel : PageModel public void OnGet(int httpStatusCode = 0) { - HttpStatusCode = httpStatusCode; + HttpStatusCode = httpStatusCode;//HTTP Status Code } } From c06cdfd728925aca597b748c5241ecd7e6079094 Mon Sep 17 00:00:00 2001 From: Velang Date: Thu, 26 Mar 2026 07:40:27 -0700 Subject: [PATCH 10/31] Adding support for l1 and l2 --- .../cypress/pages/ApplicationDetailsPage.ts | 68 ++++++++++++------- .../cypress/pages/ReviewAssessmentPage.ts | 6 +- .../cypress/regression/ApprovalFlow.cy.ts | 37 +++++++--- applications/Unity.AutoUI/package.json | 5 +- 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 22f0f2eee6..73a32eca5d 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -182,7 +182,7 @@ export class ApplicationDetailsPage extends BasePage { | "projectInfo" | "applicantInfo" | "fundingAgreement" - | "paymentInfo" + | "paymentInfo", ): this { const tabSelectors: Record = { submission: this.tabs.submission, @@ -348,8 +348,10 @@ export class ApplicationDetailsPage extends BasePage { * Verify Site Info table is populated */ verifySiteInfoTablePopulated(): this { - cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) - .should("have.length.at.least", 1); + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }).should( + "have.length.at.least", + 1, + ); return this; } @@ -357,11 +359,13 @@ export class ApplicationDetailsPage extends BasePage { * Verify Site Info table has data in specific columns */ verifySiteInfoTableHasData(): this { - cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }).first().within(() => { - cy.get("td").eq(0).should("not.be.empty"); // Site # - cy.get("td").eq(1).should("not.be.empty"); // Pay Group - cy.get("td").eq(2).should("not.be.empty"); // Mailing Address - }); + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .first() + .within(() => { + cy.get("td").eq(0).should("not.be.empty"); // Site # + cy.get("td").eq(1).should("not.be.empty"); // Pay Group + cy.get("td").eq(2).should("not.be.empty"); // Mailing Address + }); return this; } @@ -392,8 +396,9 @@ export class ApplicationDetailsPage extends BasePage { * Select Payment Group in Edit Site modal */ selectPaymentGroup(paymentGroup: "EFT" | "Cheque"): this { - cy.get("#Site_PaymentGroup", { timeout: 20000 }) - .select(paymentGroup, { force: true }); + cy.get("#Site_PaymentGroup", { timeout: 20000 }).select(paymentGroup, { + force: true, + }); return this; } @@ -406,7 +411,9 @@ export class ApplicationDetailsPage extends BasePage { .click({ force: true }); cy.wait(2000); // Wait for save to process cy.get("body").type("{esc}"); - cy.get(".modal.show, .modal.fade.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal.show, .modal.fade.show", { timeout: 20000 }).should( + "not.exist", + ); cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); return this; } @@ -435,7 +442,9 @@ export class ApplicationDetailsPage extends BasePage { .click({ force: true }); } }); - cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should("be.visible"); + cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should( + "be.visible", + ); } /** @@ -472,26 +481,39 @@ export class ApplicationDetailsPage extends BasePage { /** * Click Approve action. - * If "Complete Assessment" is enabled in the dropdown, click it first, - * then reopen the dropdown before clicking Approve. + * If "Complete Assessment" is present and enabled in the dropdown, click it first + * (and confirm any resulting dialog), then reopen the dropdown before clicking Approve. */ clickApprove(): this { this.openStatusActionsDropdown(); - cy.get(this.statusActions.completeAssessment).then(($btn) => { - if (!$btn.is(":disabled")) { - cy.wrap($btn).click({ force: true }); - cy.get("body").then(($body) => { - if ($body.find(this.confirmModal.modal).filter(":visible").length > 0) { + + // Use body-check so we never timeout when the button is absent from the DOM + cy.get("body").then(($body) => { + const $completeBtn = $body.find(this.statusActions.completeAssessment); + if ($completeBtn.length > 0 && !$completeBtn.is(":disabled")) { + cy.wait(10000); // Wait for any potential UI updates before clicking + // cy.wrap($completeBtn).click({ force: true }); + cy.wrap($completeBtn).click(); + cy.wait(2000); // Wait for any potential UI updates after clicking + // Confirm any SweetAlert2 dialog that appears after clicking Complete Assessment, + // or dismiss an error modal if the action failed + cy.get("body").then(($b) => { + if ($b.find(this.confirmModal.modal).filter(":visible").length > 0) { cy.get(this.confirmModal.modal) .find(this.confirmModal.confirmButton) .click({ force: true }); } }); - // Wait for page to stabilize after status transition - cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }).should("be.visible"); + this.dismissErrorModalIfPresent(); + + // Wait for the page to stabilize before reopening the dropdown + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }).should( + "be.visible", + ); cy.wait(2000); } }); + // Always reopen dropdown fresh before clicking Approve (dropdown may have closed) this.openStatusActionsDropdown(); cy.get(this.statusActions.approve, { timeout: 10000 }) @@ -604,7 +626,7 @@ export class ApplicationDetailsPage extends BasePage { | "close" | "withdraw" | "defer" - | "onHold" + | "onHold", ): void { const actionSelectors: Record = { startReview: this.statusActions.startReview, @@ -636,7 +658,7 @@ export class ApplicationDetailsPage extends BasePage { | "close" | "withdraw" | "defer" - | "onHold" + | "onHold", ): void { const actionSelectors: Record = { startReview: this.statusActions.startReview, diff --git a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts index 4d27a480c0..b5f2636ef9 100644 --- a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts @@ -387,7 +387,11 @@ export class ReviewAssessmentPage extends BasePage { * Set decision date to today (format: YYYY-MM-DD) */ setDecisionDateToToday(): this { - const today = new Date().toISOString().split("T")[0]; + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${yyyy}-${mm}-${dd}`; cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) .clear() .type(today); diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 1fa2f8324c..6346568106 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -23,7 +23,11 @@ import { NavigationPage } from "../pages/NavigationPage"; import { loginIfNeeded } from "../support/auth"; const isProd = - (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; + ( + Cypress.env("CHEFS_ENV") || + Cypress.env("environment") || + "" + ).toLowerCase() === "prod"; // ============ Test Configuration ============ const TEST_CONFIG = { @@ -40,7 +44,7 @@ const TEST_CONFIG = { // Available statuses: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' statusFilter: ["Submitted"], maxAge: 30, // Only consider submissions created within the last N days - index: 0, // 0 = latest; increment to avoid collision with concurrent tests + index: 0, // 0 = latest; increment to avoid collision with concurrent tests }, }; @@ -59,6 +63,7 @@ const TEST_CONFIG = { /** Navigate to the Payments tab and filter the table by submissionId. */ function navigateToPaymentsAndSearch(): void { + cy.reload; navPage.goToPayments(); cy.location("pathname", { timeout: 20000 }).should("include", "Payment"); cy.get("#search", { timeout: 20000 }) @@ -91,7 +96,10 @@ const TEST_CONFIG = { cy.get("#UpdateTotalAmount").should("not.have.value", "0"); cy.get("#Note").should("be.visible"); - const approvalNote = `${notePrefix}-${submissionId}-${Date.now()}`.slice(0, 50); + const approvalNote = `${notePrefix}-${submissionId}-${Date.now()}`.slice( + 0, + 50, + ); cy.get("#Note").clear().type(approvalNote); cy.get("#btnSubmitPayment") @@ -118,7 +126,10 @@ const TEST_CONFIG = { // Priority 1: ID written by the seed script when running test:approval-flow cy.task("readJsonIfExists", "cypress/scripts/last-submission-id.json").then( (result) => { - const seeded = result as { submissionId?: string; createdAt?: string } | null; + const seeded = result as { + submissionId?: string; + createdAt?: string; + } | null; if (seeded?.submissionId) { submissionId = seeded.submissionId; cy.log(`πŸ“Œ Using seeded submission ID: ${submissionId}`); @@ -193,7 +204,9 @@ const TEST_CONFIG = { cy.get("#CompleteButton").click({ force: true }); cy.wait(1000); } else { - cy.log("Complete Assessment button not found - may already be completed"); + cy.log( + "Complete Assessment button not found - may already be completed", + ); } }); }); @@ -259,6 +272,7 @@ const TEST_CONFIG = { // ============ Application Approval ============ it("Test approval workflow (confirm)", () => { + cy.reload(); // Refresh to ensure all changes are reflected before approval detailsPage.dismissErrorModalIfPresent(); detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); }); @@ -334,6 +348,7 @@ const TEST_CONFIG = { }); it("Navigate to Payments tab and search for submission (L2)", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); navigateToPaymentsAndSearch(); }); @@ -356,10 +371,14 @@ const TEST_CONFIG = { .clear() .type(submissionId); - cy.contains("tr", submissionId, { timeout: 20000 }).should( - "contain.text", - "Sent to Accounts Payable", - ); + cy.contains("tr", submissionId, { timeout: 20000 }).should(($row) => { + const text = $row.text(); + expect( + text.includes("Sent to Accounts Payable") || + text.includes("Submitted to CAS"), + 'Expected row to contain "Sent to Accounts Payable" or "Submitted to CAS"', + ).to.be.true; + }); // Validate date columns by resolving each column's index from its header title ["Updated On", "L1 Approval Date", "L2 Approval Date"].forEach((header) => { diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index 76aa67a0f5..d275c5ea12 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -6,7 +6,8 @@ "test:regression-headless": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headless --browser chrome", "test:open": "env -u ELECTRON_RUN_AS_NODE cypress open --browser chrome", "test:seed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/scripts/chefs-api-submission.cy.ts' --browser chrome", - "test:approval-flow": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --headless --browser chrome" + "test:approval-flow": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --headed --browser chrome", + "test:approval-flow-headless": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --browser chrome" }, "dependencies": { "form-data": "^4.0.5", @@ -16,4 +17,4 @@ "@types/node": "^25.4.0", "cypress": "15.12.0" } -} +} \ No newline at end of file From c2870a686f931b8ea2a9a159b1391728dce4701b Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:00:23 -0700 Subject: [PATCH 11/31] [AB#24939] Add filter buttons on file attachments --- .../Views/Shared/Components/PaymentInfo/Default.js | 5 +++-- .../Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js | 10 ++++++---- .../ApplicationAttachments/ApplicationAttachments.js | 3 +++ .../Components/ApplicationAttachments/Default.cshtml | 11 ++++++++++- .../Components/ChefsAttachments/ChefsAttachments.js | 3 +++ .../Shared/Components/ChefsAttachments/Default.cshtml | 3 +++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 33abb20eb9..36ca7e1c7f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -148,10 +148,10 @@ { text: 'Filter', className: 'custom-table-btn flex-none btn btn-secondary', - id: 'btn-toggle-filter', + id: 'btn-toggle-filter-payment-list', action: function (e, dt, node, config) {}, attr: { - id: 'btn-toggle-filter', + id: 'btn-toggle-filter-payment-list', }, }, { @@ -214,6 +214,7 @@ languageSetValues: {}, dataTableName: 'ApplicationPaymentRequestListTable', externalSearchId: 'PaymentListSearch', + externalFilterButtonId: 'btn-toggle-filter-payment-list', dynamicButtonContainerId: 'dynamicButtonContainerId', lengthMenu: [10, 25, 50, -1] }); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 10a02a00dc..80d0f2b545 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -183,6 +183,7 @@ if ($.fn.dataTable !== undefined && $.fn.dataTable.Api) { * @param {string} options.dynamicButtonContainerId - DOM ID where buttons are rendered * @param {boolean} [options.useNullPlaceholder=false] - Replace nulls with placeholder character * @param {string} [options.externalSearchId='search'] - ID of external search input element + * @param {string} [options.externalFilterButtonId='btn-toggle-filter'] - ID of external filter button element * @param {boolean} [options.disableColumnSelect=false] - Disable column visibility toggle * @param {Array} [options.listColumnDefs] - Additional columnDefs configurations * @param {Function} [options.onStateSaveParams] - Hook for additional state save parameters @@ -218,6 +219,7 @@ function initializeDataTable(options) { dynamicButtonContainerId, useNullPlaceholder = false, externalSearchId = 'search', + externalFilterButtonId='btn-toggle-filter', disableColumnSelect = false, listColumnDefs, onStateSaveParams, //External hooks for save/load/loaded @@ -421,7 +423,7 @@ function initializeDataTable(options) { let iDt = new DataTable(dt, configuration); // Initialize FilterRow plugin - initializeFilterRowPlugin(iDt); + initializeFilterRowPlugin(iDt, externalFilterButtonId); // Move buttons to designated container moveButtonsToContainer(iDt, updatedActionButtons, dynamicButtonContainerId); @@ -637,11 +639,11 @@ function adjustColumnsWithRetry(api) { * Initializes FilterRow plugin if available and button exists. * @param {DataTable} iDt - DataTable instance */ -function initializeFilterRowPlugin(iDt) { - if (!$('#btn-toggle-filter').length) return; +function initializeFilterRowPlugin(iDt, externalFilterButtonId) { + if (!externalFilterButtonId || !$('#' + externalFilterButtonId).length) return; if ($.fn.dataTable?.FilterRow) { const filterRow = new $.fn.dataTable.FilterRow(iDt.settings()[0], { - buttonId: 'btn-toggle-filter', + buttonId: externalFilterButtonId, buttonText: FilterDesc.Default, buttonTextActive: FilterDesc.With_Filter, enablePopover: $.fn.popover !== undefined, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js index bd9a6bf975..fa807839c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js @@ -90,9 +90,12 @@ $(function () { orderable: false } ], + externalFilterButtonId: 'btn-toggle-filter-uploads', }) ); + initializeFilterRowPlugin(dataTable, 'btn-toggle-filter-uploads'); + dataTable.on('click', 'tbody tr', function (e) { e.currentTarget.classList.toggle('selected'); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/Default.cshtml index 4e19db9e93..0d99b4dd07 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/Default.cshtml @@ -1,4 +1,13 @@ -ο»Ώ
Internal Uploads by Program
+ο»Ώ
+
+
Internal Uploads by Program
+
+
+ +
+
{ chefsDataTable.ajax.reload(); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 2d599704dd..5b5a3f3698 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -22,6 +22,9 @@ } +