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/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 22f0f2eee6..550c95ff22 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,36 @@ 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(); + cy.wait(2000); // Wait for any potential UI updates after clicking + 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 +623,7 @@ export class ApplicationDetailsPage extends BasePage { | "close" | "withdraw" | "defer" - | "onHold" + | "onHold", ): void { const actionSelectors: Record = { startReview: this.statusActions.startReview, @@ -636,7 +655,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 d34c424860..5b480b2aab 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -4,61 +4,117 @@ * 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 { + cy.reload(); + 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 +123,35 @@ 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 +161,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 +187,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,7 +199,6 @@ 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 }); @@ -145,8 +214,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 +236,6 @@ const TEST_CONFIG = { // ============ Comments & Attachments ============ it("Add a comment", () => { - // Dismiss any error modals from previous steps detailsPage.dismissErrorModalIfPresent(); rightTabPage .goToCommentsTab() @@ -177,46 +244,155 @@ 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 + cy.reload(); // Refresh to ensure all changes are reflected before approval 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(); + loginIfNeeded({ + username: Cypress.env("test2username") as string, + password: Cypress.env("test2password") as string, + }); + }); + + it("Navigate to Payments tab and search for submission (L2)", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + 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(($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) => { + 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..74902a5dd9 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -320,7 +320,13 @@ const isProd = if (response.body.id) { createdSubmissionId = response.body.id; + const confirmationId = response.body.confirmationId || response.body.id; cy.log(`✅ Submission created with ID: ${response.body.id}`); + cy.log(`✅ Confirmation ID: ${confirmationId}`); + cy.writeFile("cypress/scripts/last-submission-id.json", { + submissionId: confirmationId, + createdAt: new Date().toISOString(), + }); } expect(response.body).to.have.property("formVersionId", environment.versionId); 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 diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index c2094e4c05..c937e86596 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -56,6 +56,8 @@ AuthServer__OidcSignoutCallback="http://localhost:44342/signout-callback-oidc" ##S3__AssessmentS3Folder="Unity/Adjudication" ##S3__DisallowedFileTypes="[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]" ##S3__MaxFileSize="25" +##S3__EmailAttachmentMaxFileSize="20" +##S3__EmailAttachmentsTotalMaxFileSize="25" ## RABBIT MQ UNITY_RABBIT_MQ_HOST="rabbitmq" UNITY_RABBIT_MQ_PORT="5672" 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/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.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/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()); + } +} 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 b4244f5390..d6b84d6ca1 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 @@ -6,6 +6,8 @@ "Permission:AI.ApplicationAnalysis": "AI Application Analysis", "Permission:AI.AttachmentSummary": "AI Attachment Summary", "Permission:AI.ScoringAssistant": "AI Scoring Assistant", + "Setting:AI.ScoringAssistantEnabled": "AI Scoring Assistant", + "Permission:AI.ConfigureAI": "AI Configuration", "Permission:AI.Prompts": "AI Prompt Management", "Permission:AI.Prompts.Create": "Create Prompts", "Permission:AI.Prompts.Update": "Edit Prompts", 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"; +} 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 cda63f400a..26b64ccb61 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,19 +1,23 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; using Unity.AI.Web.Menus; +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.UI.Navigation; 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 { @@ -47,5 +51,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 9b6f9cb856..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 @@ -14,6 +14,7 @@ + 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.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs index 23f2ae4f03..cdea764cb9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs @@ -6,4 +6,5 @@ namespace Unity.Notifications.Emails; public interface IEmailLogAttachmentUploadService { Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType); + Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 71ca3e02be..6a0172dd08 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; using Volo.Abp.DependencyInjection; @@ -174,6 +175,12 @@ public async Task> GetAttachmentsAsync(Guid emailLogId) return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); } + public async Task GetTotalFileSizeAsync(Guid emailLogId) + { + var attachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); + return attachments.Sum(a => a.FileSize); + } + private static string BuildUserAttachmentS3Key(Guid? tenantId, Guid emailLogId, Guid attachmentId, string fileName) { var basePath = "Email/Attachments"; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs index b7e1c4d75b..31a5a9b20b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -27,7 +27,7 @@ public async Task> GetListByEmailLogIdAsync(Guid ema foreach (var attachment in attachments) { - var dto = new EmailLogAttachmentDto + dtos.Add(new EmailLogAttachmentDto { Id = attachment.Id, FileName = attachment.FileName, @@ -37,8 +37,7 @@ public async Task> GetListByEmailLogIdAsync(Guid ema ContentType = attachment.ContentType, S3ObjectKey = attachment.S3ObjectKey, AttachedBy = await ResolveUserNameAsync(attachment.UserId) - }; - dtos.Add(dto); + }); } return dtos; @@ -65,6 +64,11 @@ public async Task DeleteAsync(Guid id) await emailLogAttachmentRepository.DeleteAsync(id); } + public async Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId) + { + return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId); + } + public async Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType) { var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, tenantId, fileName, content, contentType); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index dbab087b04..dc9371b52e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -43,14 +43,30 @@ $(function () { { text: 'Check Status', className: 'custom-table-btn flex-none btn btn-secondary payment-check-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { + if (!dt.rows({ selected: true }).any() || !selectedPaymentIds || selectedPaymentIds.length === 0) { + abp.notify.info('No Payment Requests were selected for this action.') + return; + } + $.ajax({ url: '/api/app/payment-request/manually-add-payment-requests-to-reconciliation-queue', method: 'POST', contentType: 'application/json', data: JSON.stringify(selectedPaymentIds) }) - .done(() => abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.')) + .done(() => { + abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.'); + $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); + }) .fail(() => abp.notify.error(l('Failed To Add To Reconciliation Queue'))); } }, @@ -733,8 +749,10 @@ $(function () { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); payment_approve_buttons.disable(); - + payment_check_status_buttons.disable(); + history_button.disable(); selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); }); function getStatusTextColor(status) { @@ -785,6 +803,11 @@ $(function () { (msg, data) => { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); } ); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index 38e077e83b..d7ab43374f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -189,6 +189,7 @@ $(function () { 'Payment Tags' ); selectedPaymentIds = []; + manageActionButtons(); PubSub.publish("refresh_payment_list"); }); }); 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..29a5068c9f 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); @@ -636,12 +638,13 @@ function adjustColumnsWithRetry(api) { /** * Initializes FilterRow plugin if available and button exists. * @param {DataTable} iDt - DataTable instance + * @param {string} externalFilterButtonId - ID of external filter button element, skips initialization if null */ -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.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs index bc1b54c02f..38b7855fdd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs @@ -9,7 +9,7 @@ public class SubmissionInfoItemDto public DateTime ReceivedTime { get; set; } public DateTime SubmissionTime { get; set; } public string ReferenceNo { get; set; } = string.Empty; - public string ProjectName { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index 234cc5e79d..2491deb1c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -4,23 +4,23 @@ using Unity.Modules.Shared; using Volo.Abp.Application.Dtos; -namespace Unity.GrantManager.GrantApplications +namespace Unity.GrantManager.GrantApplications; + +public interface IGrantApplicationAppService { - public interface IGrantApplicationAppService - { - Task GetApplicationStatusAsync(Guid id); - Task> GetActions(Guid applicationId, bool includeInternal = false); - Task UpdateProjectInfoAsync(Guid id, CreateUpdateProjectInfoDto input); - Task UpdatePartialProjectInfoAsync(Guid id, PartialUpdateDto input); - Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input); - Task UpdateSupplierNumberAsync(Guid applicationId, string supplierNumber); - Task> GetAllApplicationsAsync(); - Task> GetApplicationDetailsListAsync(List applicationIds); - Task GetAsync(Guid id); - Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); - Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); - Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); - Task> GetListAsync(GrantApplicationListInputDto input); - } + Task GetApplicationStatusAsync(Guid id); + Task> GetActions(Guid applicationId, bool includeInternal = false); + Task UpdateProjectInfoAsync(Guid id, CreateUpdateProjectInfoDto input); + Task UpdatePartialProjectInfoAsync(Guid id, PartialUpdateDto input); + Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input); + Task UpdateSupplierNumberAsync(Guid applicationId, string supplierNumber); + Task> GetAllApplicationsAsync(); + Task> GetApplicationDetailsListAsync(List applicationIds); + Task GetAsync(Guid id); + Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); + Task GetAccountCodingIdFromFormIdAsync(Guid formId); + Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); + Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); + Task> GetListAsync(GrantApplicationListInputDto input); + Task IsApplicantRedStopAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs index 7a3fe34a40..2e55de9895 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.Settings; using Unity.GrantManager.AI.Operations; using Unity.GrantManager.Intakes.Events; using Volo.Abp.BackgroundJobs; @@ -8,6 +9,7 @@ using Volo.Abp.EventBus.Local; using Volo.Abp.Features; using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; namespace Unity.GrantManager.AI.BackgroundJobs; @@ -17,6 +19,7 @@ public class GenerateContentBackgroundJob( IApplicationScoringService applicationScoringService, IAIService aiService, IFeatureChecker featureChecker, + ISettingProvider settingProvider, ILocalEventBus localEventBus, ICurrentTenant currentTenant, ILogger logger) : AsyncBackgroundJob, ITransientDependency @@ -29,6 +32,11 @@ public override async Task ExecuteAsync(GenerateContentBackgroundJobArgs args) var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + if (scoringEnabled) + { + scoringEnabled = await settingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false); + } + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) { logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs index 7b745b6faf..9ca7329475 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; @@ -16,6 +17,7 @@ using Unity.GrantManager.AI.Requests; using Unity.GrantManager.AI.Responses; using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.AI.Runtime { @@ -26,6 +28,8 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; + private readonly ICurrentTenant _currentTenant; + private readonly IHostEnvironment _hostEnvironment; private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; @@ -45,10 +49,10 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string DefaultMaxTokensParameterName = "max_completion_tokens"; private const string LegacyMaxTokensParameterName = "max_tokens"; private const string DefaultProviderName = "OpenAI"; - private const int DefaultCompletionTokens = 150; - private const int DefaultAttachmentSummaryCompletionTokens = 500; - private const int DefaultApplicationAnalysisCompletionTokens = 2500; - private const int DefaultApplicationScoringCompletionTokens = 5000; + private const int DefaultCompletionTokens = 2000; + private const int DefaultAttachmentSummaryCompletionTokens = 2000; + private const int DefaultApplicationAnalysisCompletionTokens = 4000; + private const int DefaultApplicationScoringCompletionTokens = 8000; private int AttachmentSummaryCompletionTokens => ResolveCompletionTokens(AttachmentSummaryPromptType, DefaultAttachmentSummaryCompletionTokens); private int ApplicationAnalysisCompletionTokens => ResolveCompletionTokens(ApplicationAnalysisPromptType, DefaultApplicationAnalysisCompletionTokens); @@ -75,12 +79,16 @@ public OpenAIRuntimeService( HttpClient httpClient, IConfiguration configuration, ILogger logger, - ITextExtractionService textExtractionService) + ITextExtractionService textExtractionService, + ICurrentTenant currentTenant, + IHostEnvironment hostEnvironment) { _httpClient = httpClient; _configuration = configuration; _logger = logger; _textExtractionService = textExtractionService; + _currentTenant = currentTenant; + _hostEnvironment = hostEnvironment; } public Task IsAvailableAsync() @@ -214,7 +222,7 @@ private async Task GenerateSummaryAsync( "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", response.StatusCode, responseContent?.Length ?? 0); - LogProviderMetadata(operationName, providerResponse); + LogProviderMetadata(operationName, providerResponse, response.IsSuccessStatusCode); if (!response.IsSuccessStatusCode) { @@ -621,7 +629,7 @@ private static AIProviderResult BuildProviderResponseFromMetadata(string content } } - private void LogProviderMetadata(string? operationName, AIProviderResult response) + private void LogProviderMetadata(string? operationName, AIProviderResult response, bool success) { if (string.IsNullOrWhiteSpace(response.Model) && string.IsNullOrWhiteSpace(response.FinishReason) @@ -633,6 +641,19 @@ private void LogProviderMetadata(string? operationName, AIProviderResult respons return; } + if (response.PromptTokens != null || response.CompletionTokens != null || response.TotalTokens != null) + { + _logger.LogInformation( + "AI token usage. FeatureName={FeatureName}, InputTokens={InputTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, Environment={Environment}, TenantId={TenantId}, Status={Status}", + operationName ?? "completion", + response.PromptTokens, + response.CompletionTokens, + response.TotalTokens, + _hostEnvironment.EnvironmentName, + _currentTenant.Id, + success ? "success" : "failed"); + } + _logger.LogDebug( "AI provider response metadata for {OperationName}: Model={Model}, FinishReason={FinishReason}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, ReasoningTokens={ReasoningTokens}", operationName ?? "completion", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 6d67a53a31..ed12b08554 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -24,6 +24,7 @@ public class SubmissionInfoDataProvider( ICurrentTenant currentTenant, IRepository applicationFormSubmissionRepository, IRepository applicationRepository, + IRepository applicationFormRepository, IRepository applicationStatusRepository, IEndpointManagementAppService endpointManagementAppService, ILogger logger) @@ -49,11 +50,13 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ { var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); var applicationsQuery = await applicationRepository.GetQueryableAsync(); + var formsQuery = await applicationFormRepository.GetQueryableAsync(); var statusesQuery = await applicationStatusRepository.GetQueryableAsync(); var results = await ( from submission in submissionsQuery join application in applicationsQuery on submission.ApplicationId equals application.Id + join form in formsQuery on application.ApplicationFormId equals form.Id join status in statusesQuery on application.ApplicationStatusId equals status.Id where submission.OidcSub == normalizedSubject select new @@ -63,7 +66,7 @@ join status in statusesQuery on application.ApplicationStatusId equals status.Id submission.CreationTime, submission.Submission, application.ReferenceNo, - application.ProjectName, + FormName = form.ApplicationFormName ?? string.Empty, Status = status.ExternalStatus }).ToListAsync(); @@ -74,7 +77,7 @@ join status in statusesQuery on application.ApplicationStatusId equals status.Id ReceivedTime = s.CreationTime, SubmissionTime = ResolveSubmissionTime(s.Submission, s.CreationTime), ReferenceNo = s.ReferenceNo, - ProjectName = s.ProjectName, + Type = s.FormName, Status = s.Status })); } 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 ab304c2b9b..a94fa33b62 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/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 29d8c8bdf6..022a1ad34d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -14,11 +14,11 @@ using System.Threading.Tasks; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; +using Unity.GrantManager.AI.Models; +using Unity.GrantManager.AI.Responses; using Unity.GrantManager.Applicants; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; -using Unity.GrantManager.AI.Models; -using Unity.GrantManager.AI.Responses; using Unity.GrantManager.Events; using Unity.GrantManager.Flex; using Unity.GrantManager.Identity; @@ -955,6 +955,14 @@ public async Task GetApplicationStatusAsync(Guid id) return form.AccountCodingId; } + + + public async Task IsApplicantRedStopAsync(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId, true); + return application.Applicant != null && application.Applicant.RedStop == true; + } + #region APPLICATION WORKFLOW /// /// Fetches the list of actions and their status context for a given application. @@ -974,9 +982,10 @@ public async Task> GetActions(Guid applicati // NOTE: Authorization is applied on the AppService layer and is false by default // AUTHORIZATION HANDLING + bool isRedStop = application.Applicant != null && application.Applicant.RedStop == true; foreach (var item in actionDtos) { - item.IsPermitted = item.IsPermitted && (await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(item.ApplicationAction))); + item.IsPermitted = !isRedStop && item.IsPermitted && (await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(item.ApplicationAction))); item.IsAuthorized = true; } @@ -1002,6 +1011,12 @@ public async Task TriggerAction(Guid applicationId, GrantAp throw new UnauthorizedAccessException(); } + // RED STOP CHECK: Block all status actions when the applicant has RedStop = true + if (application.Applicant != null && application.Applicant.RedStop == true) + { + throw new UserFriendlyException(L["GrantApplication:ActionButton.RedStopWarning"]); + } + application = await applicationManager.TriggerAction(applicationId, triggerAction); await LocalEventBus.PublishAsync( 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 7926fe41c1..b75842328c 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,12 +1,14 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.Settings; using Unity.GrantManager.Assessments; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes.Events; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.Features; +using Volo.Abp.Settings; using Volo.Abp.Uow; namespace Unity.GrantManager.Intakes.Handlers; @@ -15,6 +17,7 @@ public class CreateAIAssessmentHandler( AssessmentManager assessmentManager, IApplicationRepository applicationRepository, IFeatureChecker featureChecker, + ISettingProvider settingProvider, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ILocalEventHandler, ITransientDependency { @@ -31,6 +34,11 @@ public async Task HandleEventAsync(AIApplicationScoringGeneratedEvent eventData) return; } + if (!await settingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false)) + { + return; + } + try { using var uow = unitOfWorkManager.Begin(requiresNew: true); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 00e7708474..fe3e30cd2c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -269,6 +269,7 @@ "AssessmentResultsView:AssessmentResultsForm.RiskRanking": "Risk Ranking", "GrantApplication:ActionButtonName": "Status Actions", + "GrantApplication:ActionButton.RedStopWarning": "This application is from an applicant with a Red-Stop. Status actions are not permitted.", "Enum:GrantApplicationAction.Open": "Open", "Enum:GrantApplicationAction.Submit": "Submit", "Enum:GrantApplicationAction.Internal_Assign": "Assign", 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 490d2bd65d..d1b132d5f4 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.Flex.Permissions; using Unity.GrantManager.Identity; using Unity.Modules.Shared; @@ -133,6 +134,7 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, .. Notifications_CommonPermissions, .. Dashboard_CommonPermissions, .. Tags_CommonPermissions, + AIPermissions.Configuration.ConfigureAI, FlexPermissions.Worksheets.Delete ], context.TenantId); @@ -241,6 +243,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 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs index 42f004ef31..cd74758f62 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs @@ -280,6 +280,35 @@ public async Task UploadEmailAttachments(Guid emailLogId, IList f.Length * 0.000001 > maxFileSizeMB).ToList(); + if (oversizedFiles.Count > 0) + { + var sizeErrors = oversizedFiles.Select(f => + new ValidationResult($"File '{f.FileName}' exceeds the maximum allowed size of {maxFileSizeMB} MB for email attachments.", [f.FileName]) + ).ToList(); + throw new AbpValidationException("One or more files exceed the maximum allowed size for email attachments.", sizeErrors); + } + } + + var totalMaxFileSizeConfig = _configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; + if (double.TryParse(totalMaxFileSizeConfig, out double totalMaxSizeMB)) + { + long existingTotalBytes = await _emailLogAttachmentUploadService + .GetTotalFileSizeByEmailLogIdAsync(emailLogId); + long newFilesBytes = files.Sum(f => f.Length); + double combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; + + if (combinedMB > totalMaxSizeMB) + { + throw new AbpValidationException( + $"The total size of all attachments ({combinedMB:F1} MB) would exceed the maximum allowed {totalMaxSizeMB} MB for email attachments. Please remove existing attachments or select a smaller file.", + [new ValidationResult("Total attachment size exceeds the allowed limit.")]); + } + } + var results = new List(); foreach (var file in files) { 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..80c2d7a984 --- /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.HttpStatusCode; + + 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..246f000a8d --- /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 HttpStatusCode { get; private set; } + + public void OnGet(int httpStatusCode = 0) + { + HttpStatusCode = httpStatusCode;//HTTP Status Code + } +} 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 3fc4232c54..a62fccad43 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 @@ -35,7 +35,8 @@ 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 PermissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault) + && await SettingProvider.GetAsync(Unity.AI.Settings.AISettings.ScoringAssistantEnabled, defaultValue: false); var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); } @section styles @@ -69,6 +70,8 @@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index aaec1bb21e..092b831c63 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -85,6 +85,8 @@ public class DetailsModel : AbpPageModel public string? CurrentUserName { get; set; } public string Extensions { get; set; } public string MaxFileSize { get; set; } + public string EmailAttachmentMaxFileSize { get; set; } + public string TotalEmailAttachmentMaxFileSize { get; set; } [BindProperty(SupportsGet = true)] public List CustomTabs { get; set; } = []; @@ -123,6 +125,8 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; + EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "20"; + TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; IsDevPromptControlsEnabled = aiPromptToolViewOptionsProvider.IsDevPromptControlsEnabled; DefaultPromptVersion = aiPromptToolViewOptionsProvider.DefaultPromptVersion; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidget.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidget.cs index a2371e9cff..f15c495f7e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidget.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidget.cs @@ -28,7 +28,8 @@ public async Task InvokeAsync(Guid applicationId) var viewModel = new ApplicationActionWidgetViewModel() { ApplicationId = applicationId, - ApplicationActions = await _applicationAppService.GetActions(applicationId) + ApplicationActions = await _applicationAppService.GetActions(applicationId), + IsRedStop = await _applicationAppService.IsApplicantRedStopAsync(applicationId) }; return View(viewModel); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidgetViewModel.cs index c417c1b4af..8d324382dd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/ApplicationActionWidgetViewModel.cs @@ -8,4 +8,5 @@ public class ApplicationActionWidgetViewModel { public Guid ApplicationId { get; set; } public ListResultDto ApplicationActions { get; set; } = new(); + public bool IsRedStop { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.cshtml index 43704eaf19..2e2259a920 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationActionWidget/Default.cshtml @@ -2,16 +2,23 @@ @using Unity.GrantManager.Localization; @using Microsoft.Extensions.Localization; @using Unity.GrantManager.Web.Views.Shared.Components.ApplicationActionWidget; +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Button; @model ApplicationActionWidgetViewModel; @inject IStringLocalizer L @inject IAuthorizationService AuthorizationService +@{ + var actionButtonType = Model.IsRedStop ? AbpButtonType.Danger : AbpButtonType.Light; + var actionButtonTitleText = Model.IsRedStop ? L["GrantApplication:ActionButton.RedStopWarning"].Value : string.Empty; +} + + - +