From 1070b972527c60673d953cd7387f446d75582f04 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg <66635118+simensma-fresh@users.noreply.github.com> Date: Thu, 28 Nov 2024 08:49:17 -0800 Subject: [PATCH] [MDS-6206] Add support for custom permit condition categories (#3318) * [MDS-6206] Added permit condition categories endpoints * Added initial frontend support for managing permit condition categories * MDS-6206 Create permit condition categories as is when importing * MDS-6206 Added FE tests + bugfixes * UX Tweaks, fix snapshot * Updated download/upload artifact versions * Fixed tests * Revert css change * Revert cypress change * MDS-6206 Updated permit action creator tests * MDS-6206Added more tests * MDS-6206 Added cancel button to inline category selector + more tests * Fixed tests * Fix missing required indicator * Fixed conditions text missing from terms and conditions * Added keyDown listener * Updated snapshot * Fix report test flakiness * MDS-6206 Fix flaky test * Fix filter * Updated after PR review --- .github/workflows/core-web.cypress.yaml | 4 +- .github/workflows/minespace.cypress.yaml | 4 +- .github/workflows/tests.coverage.yaml | 12 +- cypress/realm-export.json | 232 ++++++-- ...condition_category_amendment_id_column.sql | 2 + ..._condition_category_deleted_ind_column.sql | 1 + ...ndition_category_version_history_table.sql | 22 + ...ategory_version_history_table_backfill.sql | 6 + services/common/jest.config.js | 2 +- .../src/components/common/ScrollSideMenu.tsx | 15 +- .../common/ScrollSidePageWrapper.tsx | 22 +- .../src/components/forms/FormWrapper.tsx | 9 +- .../forms/RenderAutoComplete.spec.tsx | 158 ++++++ .../components/forms/RenderAutoComplete.tsx | 139 ++--- .../src/components/forms/RenderField.tsx | 2 + .../__snapshots__/Applicant.spec.tsx.snap | 26 + .../AuthorizationsInvolved.spec.tsx.snap | 2 + .../BasicInformation.spec.tsx.snap | 2 + .../FacilityOperator.spec.tsx.snap | 11 + .../PaymentContact.spec.tsx.snap | 9 + .../ProjectDocumentsTab.spec.tsx.snap | 85 ++- .../ReportDetailsForm-edit.spec.tsx.snap | 2 + services/common/src/constants/API.ts | 7 + services/common/src/constants/actionTypes.ts | 3 + services/common/src/constants/forms.ts | 2 + services/common/src/constants/reducerTypes.ts | 4 + .../common/sideMenuOption.interface.ts | 3 +- .../permits/permitAmendment.interface.ts | 4 +- .../permits/permitCondition.interface.ts | 6 + .../permitActionCreator.spec.js | 208 ++++++- .../actionCreators/permitActionCreator.ts | 537 +++++++++++------- .../{permitActions.js => permitActions.tsx} | 5 + services/common/src/redux/createAppSlice.ts | 1 + .../src/redux/reducers/permitReducer.ts | 35 +- .../src/redux/reducers/rootReducerShared.ts | 2 + .../redux/selectors/permitSelectors.spec.ts | 12 +- .../src/redux/selectors/permitSelectors.ts | 17 +- .../permitConditionCategorySlice.spec.ts | 99 ++++ .../slices/permitConditionCategorySlice.ts | 64 +++ .../src/redux/slices/permitServiceSlice.ts | 2 +- services/common/src/tests/handlers.ts | 19 +- services/common/src/tests/mocks/dataMocks.tsx | 31 + services/core-api/app/api/mines/namespace.py | 20 +- .../api/mines/permits/permit/models/permit.py | 29 +- .../models/permit_amendment.py | 28 +- .../models/permit_condition_category.py | 97 +++- .../models/permit_conditions.py | 16 +- ...ndment_condition_category_list_resource.py | 66 +++ ...t_amendment_condition_category_resource.py | 81 +++ ...ndment_condition_category_resource_base.py | 43 ++ .../permit_condition_category_resource.py | 24 +- .../create_permit_conditions.py | 66 ++- .../models/permit_condition_result.py | 11 +- .../models/response_model.py | 1 + .../permit_condition_extraction_resource.py | 4 + .../core-api/app/api/mines/response_models.py | 35 +- .../project_summary/models/project_summary.py | 60 +- .../models/project_summary_authorization.py | 44 +- .../core-api/app/api/utils/static_data.py | 28 +- .../app/flask_jwt_oidc_local/jwt_manager.py | 3 +- ...t_amendment_condition_category_resource.py | 411 ++++++++++++++ ...test_permit_condition_category_resource.py | 224 ++++++++ ...test_create_permit_conditions_from_task.py | 63 +- .../reports/resource/test_reports_resource.py | 15 +- services/core-web/jest.config.js | 2 +- .../MajorProject/MajorProjectSearchForm.tsx | 1 + .../MajorProjectTable.tsx | 4 +- .../Permit/PermitConditionCategory.spec.tsx | 124 ++++ .../mine/Permit/PermitConditionCategory.tsx | 124 ++++ .../PermitConditionCategoryEditModal.tsx | 46 ++ .../PermitConditionCategorySelector.spec.tsx | 121 ++++ .../PermitConditionCategorySelector.tsx | 59 ++ .../mine/Permit/PermitConditionLayer.tsx | 2 +- .../mine/Permit/PermitConditions.spec.tsx | 70 ++- .../mine/Permit/PermitConditions.tsx | 152 ++++- .../PermitConditions.spec.tsx.snap | 270 ++++++--- .../src/components/navigation/NavBar.tsx | 2 +- .../src/styles/components/Button.scss | 30 +- .../core-web/src/styles/components/Forms.scss | 9 + .../components/ScrollSideMenuWrapper.scss | 39 ++ .../src/styles/settings/variables.scss | 1 + .../ReportPermitRequirementForm.spec.tsx | 3 +- .../ReportPage-prr.spec.tsx.snap | 51 +- .../__snapshots__/ReportPage.spec.tsx.snap | 51 +- .../ReportPermitRequirementForm.spec.tsx.snap | 1 + .../MajorProjectHomePage.spec.js.snap | 1 + .../MajorProjectSearch.spec.js.snap | 1 + .../mine/Permit/ViewPermit.spec.tsx | 2 +- .../app/flask_jwt_oidc_local/jwt_manager.py | 1 - services/minespace-web/jest.config.js | 2 +- .../components/ScrollSideMenuWrapper.scss | 8 + .../MajorMineApplicationForm.spec.tsx.snap | 2 + .../ProjectSummaryPage.spec.tsx.snap | 2 + 93 files changed, 3682 insertions(+), 696 deletions(-) create mode 100644 migrations/sql/V2024.11.30.15.11__add_permit_condition_category_amendment_id_column.sql create mode 100644 migrations/sql/V2024.11.30.15.12__add_permit_condition_category_deleted_ind_column.sql create mode 100644 migrations/sql/V2024.11.30.15.13__add_permit_condition_category_version_history_table.sql create mode 100644 migrations/sql/V2024.11.30.15.14__add_permit_condition_category_version_history_table_backfill.sql create mode 100644 services/common/src/components/forms/RenderAutoComplete.spec.tsx rename services/{core-web/src/tests => common/src/redux}/actionCreators/permitActionCreator.spec.js (75%) rename services/common/src/redux/actions/{permitActions.js => permitActions.tsx} (84%) create mode 100644 services/common/src/redux/slices/permitConditionCategorySlice.spec.ts create mode 100644 services/common/src/redux/slices/permitConditionCategorySlice.ts create mode 100644 services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_list_resource.py create mode 100644 services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource.py create mode 100644 services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource_base.py create mode 100644 services/core-api/tests/mines/permit/resources/test_permit_amendment_condition_category_resource.py create mode 100644 services/core-api/tests/mines/permit/resources/test_permit_condition_category_resource.py create mode 100644 services/core-web/src/components/mine/Permit/PermitConditionCategory.spec.tsx create mode 100644 services/core-web/src/components/mine/Permit/PermitConditionCategory.tsx create mode 100644 services/core-web/src/components/mine/Permit/PermitConditionCategoryEditModal.tsx create mode 100644 services/core-web/src/components/mine/Permit/PermitConditionCategorySelector.spec.tsx create mode 100644 services/core-web/src/components/mine/Permit/PermitConditionCategorySelector.tsx diff --git a/.github/workflows/core-web.cypress.yaml b/.github/workflows/core-web.cypress.yaml index 7a148136a2..4f8e2000f1 100644 --- a/.github/workflows/core-web.cypress.yaml +++ b/.github/workflows/core-web.cypress.yaml @@ -66,7 +66,7 @@ jobs: CYPRESS_FLAGSMITH_URL: https://mds-flags-dev.apps.silver.devops.gov.bc.ca/api/v1/ CYPRESS_FLAGSMITH_KEY: 4Eu9eEMDmWVEHKDaKoeWY7 - name: Upload cypress video - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: cypress-recording @@ -81,7 +81,7 @@ jobs: run: tar cvzf ./logs.tgz ./logs - name: Upload logs to GitHub if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: logs.tgz path: ./logs.tgz diff --git a/.github/workflows/minespace.cypress.yaml b/.github/workflows/minespace.cypress.yaml index 3bc461cb61..54e29e7f40 100644 --- a/.github/workflows/minespace.cypress.yaml +++ b/.github/workflows/minespace.cypress.yaml @@ -66,7 +66,7 @@ jobs: CYPRESS_FLAGSMITH_URL: https://mds-flags-dev.apps.silver.devops.gov.bc.ca/api/v1/ CYPRESS_FLAGSMITH_KEY: 4Eu9eEMDmWVEHKDaKoeWY7 - name: Upload cypress video - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: cypress-recording @@ -81,7 +81,7 @@ jobs: run: tar cvzf ./logs.tgz ./logs - name: Upload logs to GitHub if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: logs.tgz path: ./logs.tgz diff --git a/.github/workflows/tests.coverage.yaml b/.github/workflows/tests.coverage.yaml index 779e2feb77..0d0aac9b76 100644 --- a/.github/workflows/tests.coverage.yaml +++ b/.github/workflows/tests.coverage.yaml @@ -56,7 +56,7 @@ jobs: ./../../cc-test-reporter format-coverage -t coverage.py --add-prefix services/core-api/ -o ../../coverage/backend-codeclimate.json coverage.xml - name: Create backend coverage file artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: backend-codeclimate.json path: ./coverage/backend-codeclimate.json @@ -112,7 +112,7 @@ jobs: ./../../cc-test-reporter format-coverage -t clover --add-prefix services/core-web/ -o ../../coverage/frontend-codeclimate.json clover.xml - name: Create frontend coverage file artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: frontend-codeclimate.json path: ./coverage/frontend-codeclimate.json @@ -171,7 +171,7 @@ jobs: ./../../cc-test-reporter format-coverage -t clover --add-prefix services/minespace-web/ -o ../../coverage/minespace-codeclimate.json clover.xml - name: Create minespace coverage file artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: minespace-codeclimate.json path: ./coverage/minespace-codeclimate.json @@ -211,17 +211,17 @@ jobs: # Fetch artifacts from other jobs - name: Download backend artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: backend-codeclimate.json path: ./coverage - name: Download frontend artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: frontend-codeclimate.json path: ./coverage - name: Download minespace artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: minespace-codeclimate.json path: ./coverage diff --git a/cypress/realm-export.json b/cypress/realm-export.json index e923b50d86..9ef85755d9 100644 --- a/cypress/realm-export.json +++ b/cypress/realm-export.json @@ -69,9 +69,15 @@ "description": "${role_default-roles}", "composite": true, "composites": { - "realm": ["offline_access", "uma_authorization"], + "realm": [ + "offline_access", + "uma_authorization" + ], "client": { - "account": ["manage-account", "view-profile"] + "account": [ + "manage-account", + "view-profile" + ] } }, "clientRole": false, @@ -858,7 +864,9 @@ "composite": true, "composites": { "client": { - "realm-management": ["query-clients"] + "realm-management": [ + "query-clients" + ] } }, "clientRole": true, @@ -1022,7 +1030,10 @@ "composite": true, "composites": { "client": { - "realm-management": ["query-users", "query-groups"] + "realm-management": [ + "query-users", + "query-groups" + ] } }, "clientRole": true, @@ -1106,7 +1117,9 @@ "composite": true, "composites": { "client": { - "account": ["manage-account-links"] + "account": [ + "manage-account-links" + ] } }, "clientRole": true, @@ -1129,7 +1142,9 @@ "composite": true, "composites": { "client": { - "account": ["view-consent"] + "account": [ + "view-consent" + ] } }, "clientRole": true, @@ -1148,16 +1163,23 @@ "clientRole": false, "containerId": "standard" }, - "requiredCredentials": ["password"], + "requiredCredentials": [ + "password" + ], "otpPolicyType": "totp", "otpPolicyAlgorithm": "HmacSHA1", "otpPolicyInitialCounter": 0, "otpPolicyDigits": 6, "otpPolicyLookAheadWindow": 1, "otpPolicyPeriod": 30, - "otpSupportedApplications": ["FreeOTP", "Google Authenticator"], + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": ["ES256"], + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], "webAuthnPolicyRpId": "", "webAuthnPolicyAttestationConveyancePreference": "not specified", "webAuthnPolicyAuthenticatorAttachment": "not specified", @@ -1167,7 +1189,9 @@ "webAuthnPolicyAvoidSameAuthenticatorRegister": false, "webAuthnPolicyAcceptableAaguids": [], "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"], + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], "webAuthnPolicyPasswordlessRpId": "", "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", @@ -1179,14 +1203,18 @@ "scopeMappings": [ { "clientScope": "offline_access", - "roles": ["offline_access"] + "roles": [ + "offline_access" + ] } ], "clientScopeMappings": { "account": [ { "client": "account-console", - "roles": ["manage-account"] + "roles": [ + "manage-account" + ] } ] }, @@ -1201,7 +1229,9 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["/realms/standard/account/*"], + "redirectUris": [ + "/realms/standard/account/*" + ], "webOrigins": [], "notBefore": 0, "bearerOnly": false, @@ -1217,8 +1247,18 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "6769a1d0-e2f7-4535-b6c8-e0f2c40d7cc1", @@ -1230,7 +1270,9 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["/realms/standard/account/*"], + "redirectUris": [ + "/realms/standard/account/*" + ], "webOrigins": [], "notBefore": 0, "bearerOnly": false, @@ -1258,8 +1300,18 @@ "config": {} } ], - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "d3533e96-2506-4553-a4e4-ac70e400d57d", @@ -1285,8 +1337,18 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "169b2d1e-dc91-4edc-afd8-d8170a4a41cf", @@ -1312,8 +1374,18 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "38bf108d-56a4-46a3-9929-feaa6fca039a", @@ -1323,8 +1395,12 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["*"], - "webOrigins": ["*"], + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], "notBefore": 0, "bearerOnly": false, "consentRequired": false, @@ -1454,7 +1530,12 @@ "bceidboth", "email" ], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "9e61e34d-c140-4083-8cdc-70e87037963f", @@ -1480,8 +1561,18 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] }, { "id": "a1c0963c-8a39-4578-9412-2dfada8f2324", @@ -1493,8 +1584,12 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "redirectUris": ["/admin/standard/console/*"], - "webOrigins": ["+"], + "redirectUris": [ + "/admin/standard/console/*" + ], + "webOrigins": [ + "+" + ], "notBefore": 0, "bearerOnly": false, "consentRequired": false, @@ -1528,8 +1623,18 @@ } } ], - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] } ], "clientScopes": [ @@ -2045,8 +2150,19 @@ } } ], - "defaultDefaultClientScopes": ["role_list", "profile", "email", "roles", "web-origins"], - "defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt"], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], "browserSecurityHeaders": { "contentSecurityPolicyReportOnly": "", "xContentTypeOptions": "nosniff", @@ -2058,7 +2174,9 @@ }, "smtpServer": {}, "eventsEnabled": false, - "eventsListeners": ["jboss-logging"], + "eventsListeners": [ + "jboss-logging" + ], "enabledEventTypes": [], "adminEventsEnabled": false, "adminEventsDetailsEnabled": false, @@ -2073,7 +2191,9 @@ "subType": "anonymous", "subComponents": {}, "config": { - "allow-default-scopes": ["true"] + "allow-default-scopes": [ + "true" + ] } }, { @@ -2083,8 +2203,12 @@ "subType": "anonymous", "subComponents": {}, "config": { - "host-sending-registration-request-must-match": ["true"], - "client-uris-must-match": ["true"] + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] } }, { @@ -2094,7 +2218,9 @@ "subType": "authenticated", "subComponents": {}, "config": { - "allow-default-scopes": ["true"] + "allow-default-scopes": [ + "true" + ] } }, { @@ -2158,7 +2284,9 @@ "subType": "anonymous", "subComponents": {}, "config": { - "max-clients": ["200"] + "max-clients": [ + "200" + ] } } ], @@ -2169,8 +2297,12 @@ "providerId": "rsa-enc-generated", "subComponents": {}, "config": { - "priority": ["100"], - "algorithm": ["RSA-OAEP"] + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] } }, { @@ -2179,7 +2311,9 @@ "providerId": "rsa-generated", "subComponents": {}, "config": { - "priority": ["100"] + "priority": [ + "100" + ] } }, { @@ -2188,7 +2322,9 @@ "providerId": "aes-generated", "subComponents": {}, "config": { - "priority": ["100"] + "priority": [ + "100" + ] } }, { @@ -2197,8 +2333,12 @@ "providerId": "hmac-generated", "subComponents": {}, "config": { - "priority": ["100"], - "algorithm": ["HS256"] + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] } } ] @@ -2894,4 +3034,4 @@ "clientPolicies": { "policies": [] } -} +} \ No newline at end of file diff --git a/migrations/sql/V2024.11.30.15.11__add_permit_condition_category_amendment_id_column.sql b/migrations/sql/V2024.11.30.15.11__add_permit_condition_category_amendment_id_column.sql new file mode 100644 index 0000000000..9d207748a6 --- /dev/null +++ b/migrations/sql/V2024.11.30.15.11__add_permit_condition_category_amendment_id_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE permit_condition_category ADD COLUMN permit_amendment_id INTEGER; +ALTER TABLE permit_condition_category ADD CONSTRAINT fk_permit_condition_category_permit_amendment_id FOREIGN KEY (permit_amendment_id) REFERENCES permit_amendment(permit_amendment_id); diff --git a/migrations/sql/V2024.11.30.15.12__add_permit_condition_category_deleted_ind_column.sql b/migrations/sql/V2024.11.30.15.12__add_permit_condition_category_deleted_ind_column.sql new file mode 100644 index 0000000000..785cb41511 --- /dev/null +++ b/migrations/sql/V2024.11.30.15.12__add_permit_condition_category_deleted_ind_column.sql @@ -0,0 +1 @@ +ALTER TABLE permit_condition_category ADD COLUMN deleted_ind boolean DEFAULT false NOT NULL; diff --git a/migrations/sql/V2024.11.30.15.13__add_permit_condition_category_version_history_table.sql b/migrations/sql/V2024.11.30.15.13__add_permit_condition_category_version_history_table.sql new file mode 100644 index 0000000000..cbb5e6186c --- /dev/null +++ b/migrations/sql/V2024.11.30.15.13__add_permit_condition_category_version_history_table.sql @@ -0,0 +1,22 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the corresponding history table definition for the {table} table +CREATE TABLE permit_condition_category_version ( + create_user VARCHAR(60), + create_timestamp TIMESTAMP WITHOUT TIME ZONE, + update_user VARCHAR(60), + update_timestamp TIMESTAMP WITHOUT TIME ZONE, + deleted_ind BOOLEAN, + condition_category_code VARCHAR NOT NULL, + step VARCHAR, + description VARCHAR, + active_ind BOOLEAN, + display_order INTEGER, + permit_amendment_id INTEGER, + transaction_id BIGINT NOT NULL, + end_transaction_id BIGINT, + operation_type SMALLINT NOT NULL, + PRIMARY KEY (condition_category_code, transaction_id) +); +CREATE INDEX ix_permit_condition_category_version_transaction_id ON permit_condition_category_version (transaction_id); +CREATE INDEX ix_permit_condition_category_version_end_transaction_id ON permit_condition_category_version (end_transaction_id); +CREATE INDEX ix_permit_condition_category_version_operation_type ON permit_condition_category_version (operation_type); diff --git a/migrations/sql/V2024.11.30.15.14__add_permit_condition_category_version_history_table_backfill.sql b/migrations/sql/V2024.11.30.15.14__add_permit_condition_category_version_history_table_backfill.sql new file mode 100644 index 0000000000..54abe29b0c --- /dev/null +++ b/migrations/sql/V2024.11.30.15.14__add_permit_condition_category_version_history_table_backfill.sql @@ -0,0 +1,6 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the data migration to backfill history records for the {table} table +with transaction AS (insert into transaction(id) values(DEFAULT) RETURNING id) +insert into permit_condition_category_version (transaction_id, operation_type, end_transaction_id, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "condition_category_code", "step", "description", "active_ind", "display_order", "permit_amendment_id") +select t.id, '0', null, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "condition_category_code", "step", "description", "active_ind", "display_order", "permit_amendment_id" +from permit_condition_category,transaction t; diff --git a/services/common/jest.config.js b/services/common/jest.config.js index 7e519b840b..0f2d5c596f 100644 --- a/services/common/jest.config.js +++ b/services/common/jest.config.js @@ -8,7 +8,7 @@ module.exports = { }, ], }, - maxWorkers: 4, + maxWorkers: '50%', verbose: true, testEnvironmentOptions: { customExportConditions: [""], diff --git a/services/common/src/components/common/ScrollSideMenu.tsx b/services/common/src/components/common/ScrollSideMenu.tsx index 0b52dbec09..e9e5ddcf7d 100644 --- a/services/common/src/components/common/ScrollSideMenu.tsx +++ b/services/common/src/components/common/ScrollSideMenu.tsx @@ -13,11 +13,13 @@ export interface ScrollSideMenuProps { featureUrlRouteArguments: (string | number)[]; tabSection?: string; offsetTop?: number; + view?: "default" | "steps" | "anchor"; } export const ScrollSideMenu: FC = ({ tabSection = "", offsetTop = 180, + view = "anchor", ...props }) => { const history = useHistory(); @@ -53,7 +55,7 @@ export const ScrollSideMenu: FC = ({ } updateUrlRoute(link); - document.querySelector(link)?.scrollIntoView(); + document.querySelector(`[id="${link}"]`)?.scrollIntoView(); }, []); const handleAnchorOnClick = (e, link) => { @@ -80,11 +82,14 @@ export const ScrollSideMenu: FC = ({ onChange={handleAnchorOnChange} onClick={handleAnchorOnClick} > - {props.menuOptions.map(({ href, title, icon }) => { + {props.menuOptions.map(({ href, title, icon, description }) => { const titleElement = (
- {icon && {icon}} - {title} + {icon && {icon}} +
+ {title} + {description &&
{description}
} +
); return ( @@ -92,7 +97,7 @@ export const ScrollSideMenu: FC = ({ key={href} href={`#${href}`} title={titleElement} - className="now-menu-link" + className="now-menu-link fade-in" /> ); })} diff --git a/services/common/src/components/common/ScrollSidePageWrapper.tsx b/services/common/src/components/common/ScrollSidePageWrapper.tsx index d1c5cb06cc..9e66ec70a1 100644 --- a/services/common/src/components/common/ScrollSidePageWrapper.tsx +++ b/services/common/src/components/common/ScrollSidePageWrapper.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactNode, useEffect, useState } from "react"; +import React, { FC, ReactElement, ReactNode, useEffect, useState } from "react"; import { useSelector } from "react-redux"; import ScrollSideMenu, { ScrollSideMenuProps } from "./ScrollSideMenu"; import { SystemFlagEnum } from "@mds/common/constants/enums"; @@ -8,16 +8,30 @@ interface ScrollSidePageWrapperProps { content: ReactNode; menuProps?: ScrollSideMenuProps; header: ReactNode; + extraItems?: ReactNode; headerHeight?: number; + view?: "default" | "steps" | "anchor"; } export const coreHeaderHeight = 62; // match scss variable $header-height const msHeaderHeight = 80; +/** + * A wrapper component that provides a side menu and a content area. The side menu can be fixed to the top of the page. + * The menu links will act as an achor to sections in the content area scroll to the section when clicked, and highlight the active section. + * + * If you need to add extra items to the side menu, you can pass them along as `extraItems`: + * + * const extraMenuItems = This is extra content that will be rendered under the side menu + * + * + */ const ScrollSidePageWrapper: FC = ({ menuProps, content, header, + extraItems, + view = "anchor", headerHeight = 170, }) => { const [isFixedTop, setIsFixedTop] = useState(false); @@ -59,7 +73,7 @@ const ScrollSidePageWrapper: FC = ({ const contentTopOffset = hasHeader && isFixedTop ? headerHeight : 0; return ( -
+
{hasHeader && (
= ({ style={{ top: menuTopOffset }} > {/* the 24 matches the margin/padding on the menu/content. Looks nicer */} - + + + {extraItems ? extraItems : ''}
)}
diff --git a/services/common/src/components/forms/FormWrapper.tsx b/services/common/src/components/forms/FormWrapper.tsx index 14eb1f03ba..749ab7eb7f 100644 --- a/services/common/src/components/forms/FormWrapper.tsx +++ b/services/common/src/components/forms/FormWrapper.tsx @@ -66,6 +66,7 @@ export interface FormWrapperProps { loading?: boolean; isEditMode?: boolean; scrollOnToggleEdit?: boolean; + layout?: "inline" | "horizontal" | "vertical"; } const FormWrapper: FC> = ({ @@ -73,6 +74,7 @@ const FormWrapper: FC> = ({ isModal = false, scrollOnToggleEdit = true, children, + layout, ...props }) => { const providerValues = { @@ -96,14 +98,13 @@ const FormWrapper: FC> = ({ } }; - const formClassName = `common-form common-form-${props.name} form-${ - isEditMode ? "edit" : "view" - }`; + const formClassName = `common-form common-form-${props.name} form-${isEditMode ? "edit" : "view" + }`; return (
{ + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders in view mode", () => { + render( + + + + + + ); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("renders in edit mode", () => { + render( + + + + + + ); + expect(screen.getByRole("combobox", { name: "test" })).toBeInTheDocument(); + }); + + it("shows validation error when touched", () => { + const propsWithError = { + ...mockProps, + meta: { + touched: true, + error: "Required field", + warning: undefined + } + }; + + render( + + + + + + ); + expect(screen.getByText("Required field")).toBeInTheDocument(); + }); + + it("adds missing option when addMissing is true", async () => { + const propsWithAddMissing = { + ...mockProps, + addMissing: true, + input: { + ...mockProps.input, + value: "New Option" + } + }; + + render( + + + + + + ); + + const select = screen.getByRole("combobox"); + fireEvent.mouseDown(select); + const options = await screen.findAllByText("New Option"); + expect(options).toHaveLength(2); + }); + + it("calls handleChange and input.onChange on search", () => { + render( + + + + + + ); + + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "test" } }); + + expect(mockProps.handleChange).toHaveBeenCalledWith("test"); + expect(mockProps.input.onChange).toHaveBeenCalledWith("test"); + }); + + it("calls handleSelect on option selection", () => { + render( + + + + + + ); + + const select = screen.getByRole("combobox"); + fireEvent.mouseDown(select); + fireEvent.click(screen.getByText("Option 1")); + + expect(mockProps.handleSelect).toHaveBeenCalledWith("1", { label: "Option 1", value: "1" }); + }); +}); \ No newline at end of file diff --git a/services/common/src/components/forms/RenderAutoComplete.tsx b/services/common/src/components/forms/RenderAutoComplete.tsx index 49fef8c0ac..1576408663 100644 --- a/services/common/src/components/forms/RenderAutoComplete.tsx +++ b/services/common/src/components/forms/RenderAutoComplete.tsx @@ -1,77 +1,84 @@ import React from "react"; -import PropTypes from "prop-types"; -import { Select, Form } from "antd"; +import { Select, Form, Spin } from "antd"; +import { FormConsumer, IFormContext } from "./FormWrapper"; +import { BaseInputProps, BaseViewInput, getFormItemLabel } from "./BaseInput"; -/** - * @constant RenderAutoComplete - Ant Design `AutoComplete` component for redux-form. - * - */ +interface IRenderAutoCompleteProps { + data: Array<{ label: string; value: any }>; + addMissing: boolean; // Add the input value to the list of selectable values if it doesn't exist + style?: React.CSSProperties; + handleChange: (value: string) => void; + handleSelect: (value: any) => void; +} -const propTypes = { - handleChange: PropTypes.func.isRequired, - handleSelect: PropTypes.func.isRequired, - data: PropTypes.arrayOf(PropTypes.any).isRequired, - placeholder: PropTypes.string, - disabled: PropTypes.bool, - meta: PropTypes.objectOf(PropTypes.any), - input: PropTypes.objectOf(PropTypes.any), - selected: PropTypes.objectOf(PropTypes.any), -}; +const RenderAutoComplete = (props: BaseInputProps & IRenderAutoCompleteProps) => { + const items = [...props.data]; -const defaultProps = { - placeholder: "", - disabled: false, - meta: {}, - input: null, - selected: undefined, -}; + if (props.addMissing && props.input?.value?.trim().length > 0) { + const isInputInList = items.find((item) => item.label === props.input.value); -const RenderAutoComplete = (props) => { + if (!isInputInList) { + items.push({ + label: props.input.value, + value: props.input.value, + }); + } + } return ( - {props.meta.error}) || - (props.meta.warning && {props.meta.warning})) - } - > - : "Not found"} + allowClear + dropdownMatchSelectWidth + defaultValue={props.input ? props.input.value : undefined} + value={props.input ? props.input.value : undefined} + style={{ width: "100%", ...(props.style || {}) }} + options={items} + placeholder={props.placeholder} + filterOption={(input, option) => + option.label + .toString() + .toLowerCase() + .indexOf(input.toLowerCase()) >= 0 + } + disabled={props.disabled} + onChange={props.input ? props.input.onChange : undefined} + onSelect={props.handleSelect} + onSearch={(event) => { + props.handleChange(event); + if (props.input) { + props.input.onChange(event); + } + }} + /> + ); + }} ); }; -RenderAutoComplete.propTypes = propTypes; -RenderAutoComplete.defaultProps = defaultProps; - export default RenderAutoComplete; diff --git a/services/common/src/components/forms/RenderField.tsx b/services/common/src/components/forms/RenderField.tsx index f239d3df87..c99fbb0005 100644 --- a/services/common/src/components/forms/RenderField.tsx +++ b/services/common/src/components/forms/RenderField.tsx @@ -26,6 +26,7 @@ const RenderField: FC = ({ if (!value.isEditMode) { return ; } + const labelString = (label || input.name) instanceof String ? (String)(label || input.name) : input.name; return ( = ({ disabled={disabled} defaultValue={defaultValue} id={id} + aria-label={labelString} placeholder={placeholder} allowClear={allowClear} {...input} diff --git a/services/common/src/components/projectSummary/__snapshots__/Applicant.spec.tsx.snap b/services/common/src/components/projectSummary/__snapshots__/Applicant.spec.tsx.snap index ee483b2f90..8bb4182823 100644 --- a/services/common/src/components/projectSummary/__snapshots__/Applicant.spec.tsx.snap +++ b/services/common/src/components/projectSummary/__snapshots__/Applicant.spec.tsx.snap @@ -265,6 +265,7 @@ exports[`Applicant Component should render the component with expected fields 1` class="ant-form-item-control-input-content" >
+
export const PERMIT_CONDITION = (mineGuid, permitGuid, permitAmendmentGuid, permitConditionGuid) => `/mines/${mineGuid}/permits/${permitGuid}/amendments/${permitAmendmentGuid}/conditions/${permitConditionGuid}`; +export const PERMIT_AMENDMENT_CONDITION_CATEGORIES = ( + mineGuid, + permitGuid, + permitAmendmentGuid, +) => + `/mines/${mineGuid}/permits/${permitGuid}/amendments/${permitAmendmentGuid}/condition-categories`; + export const STANDARD_PERMIT_CONDITIONS = (noticeOfWorkType) => `/mines/permits/standard-conditions/${noticeOfWorkType}`; export const STANDARD_PERMIT_CONDITION = (permitConditionGuid) => diff --git a/services/common/src/constants/actionTypes.ts b/services/common/src/constants/actionTypes.ts index 0798791b73..e2ded8d49e 100755 --- a/services/common/src/constants/actionTypes.ts +++ b/services/common/src/constants/actionTypes.ts @@ -70,6 +70,9 @@ export const STORE_EDITING_CONDITION_FLAG = "STORE_EDITING_CONDITION_FLAG"; export const STORE_STANDARD_PERMIT_CONDITIONS = "STORE_STANDARD_PERMIT_CONDITIONS"; export const STORE_EDITING_PREAMBLE_FLAG = "STORE_EDITING_PREAMBLE_FLAG"; +export const CREATE_PERMIT_CONDITION_CATEGORY = "CREATE_PERMIT_CONDITION_CATEGORY"; +export const STORE_PERMIT_CONDITION_CATEGORY = "STORE_PERMIT_CONDITION_CATEGORY"; + // Permit Notices of Departure export const STORE_NOTICES_OF_DEPARTURE = "STORE_NOTICES_OF_DEPARTURE"; export const STORE_NOTICE_OF_DEPARTURE = "STORE_NOTICE_OF_DEPARTURE"; diff --git a/services/common/src/constants/forms.ts b/services/common/src/constants/forms.ts index b4124a7964..56507ba5c8 100644 --- a/services/common/src/constants/forms.ts +++ b/services/common/src/constants/forms.ts @@ -8,6 +8,7 @@ export enum FORM { REVOKE_DIGITAL_CREDENTIAL = "REVOKE_DIGITAL_CREDENTIAL", REQUEST_REPORT = "REQUEST_REPORT", ADD_REPORT_TO_PERMIT_CONDITION = "ADD_REPORT_TO_PERMIT_CONDITION", + ADD_PERMIT_CONDITION_CATEGORY = "ADD_PERMIT_CONDITION_CATEGORY", // tailings ADD_TAILINGS = "ADD_TAILINGS", // documents @@ -15,4 +16,5 @@ export enum FORM { DELETE_DOCUMENT = "DELETE_DOCUMENT", ARCHIVE_DOCUMENT = "ARCHIVE_DOCUMENT", EDIT_HELP_GUIDE = "EDIT_HELP_GUIDE", + INLINE_EDIT_PERMIT_CONDITION_CATEGORY = "INLINE_EDIT_PERMIT_CONDITION_CATEGORY", } diff --git a/services/common/src/constants/reducerTypes.ts b/services/common/src/constants/reducerTypes.ts index d9a2670c07..2e8c09e3b0 100644 --- a/services/common/src/constants/reducerTypes.ts +++ b/services/common/src/constants/reducerTypes.ts @@ -103,6 +103,10 @@ export const GET_PERMIT_CONDITIONS = "GET_PERMIT_CONDITIONS"; export const CREATE_PERMIT_CONDITION = "CREATE_PERMIT_CONDITION"; export const UPDATE_PERMIT_CONDITION = "UPDATE_PERMIT_CONDITION"; export const DELETE_PERMIT_CONDITION = "DELETE_PERMIT_CONDITION"; +export const CREATE_PERMIT_CONDITION_CATEGORY = "CREATE_PERMIT_CONDITION_CATEGORY"; +export const UPDATE_PERMIT_CONDITION_CATEGORY = "UPDATE_PERMIT_CONDITION_CATEGORY"; +export const GET_PERMIT_CONDITION_CATEGORIES = "GET_PERMIT_CONDITION_CATEGORIES"; +export const DELETE_PERMIT_CONDITION_CATEGORY = "DELETE_PERMIT_CONDITION_CATEGORY"; export const SET_EDITING_CONDITION_FLAG = "SET_EDITING_CONDITION_FLAG"; // Explosive Storage & Use Permits diff --git a/services/common/src/interfaces/common/sideMenuOption.interface.ts b/services/common/src/interfaces/common/sideMenuOption.interface.ts index 1c25cb023b..12d8fa1beb 100644 --- a/services/common/src/interfaces/common/sideMenuOption.interface.ts +++ b/services/common/src/interfaces/common/sideMenuOption.interface.ts @@ -2,6 +2,7 @@ import { ReactNode } from "react"; export interface ISideMenuOption { href: string; - title: string; + title: string | ReactNode; icon?: ReactNode; + description?: string | ReactNode; } diff --git a/services/common/src/interfaces/permits/permitAmendment.interface.ts b/services/common/src/interfaces/permits/permitAmendment.interface.ts index 70bdd15077..5e72888dd4 100644 --- a/services/common/src/interfaces/permits/permitAmendment.interface.ts +++ b/services/common/src/interfaces/permits/permitAmendment.interface.ts @@ -4,6 +4,7 @@ import { INoWImportedApplicationDocument, IPermitCondition, VC_CRED_ISSUE_STATES, IMineReportPermitRequirement, + IPermitConditionCategory, } from "@mds/common/index"; export interface IPermitAmendment { @@ -33,5 +34,6 @@ export interface IPermitAmendment { is_generated_in_core: boolean; preamble_text: string; vc_credential_exch_state: VC_CRED_ISSUE_STATES; - mine_report_permit_requirements?: IMineReportPermitRequirement[] + mine_report_permit_requirements?: IMineReportPermitRequirement[]; + condition_categories: IPermitConditionCategory[]; } diff --git a/services/common/src/interfaces/permits/permitCondition.interface.ts b/services/common/src/interfaces/permits/permitCondition.interface.ts index a3d3ce88f8..b5bb918d72 100644 --- a/services/common/src/interfaces/permits/permitCondition.interface.ts +++ b/services/common/src/interfaces/permits/permitCondition.interface.ts @@ -1,5 +1,11 @@ import { IMineReportPermitRequirement } from "@mds/common/interfaces"; +export interface IPermitConditionCategory { + condition_category_code: string; + description: string; + display_order: number; + step: string; +} export interface IPermitCondition { permit_condition_id: number; permit_amendment_id: number; diff --git a/services/core-web/src/tests/actionCreators/permitActionCreator.spec.js b/services/common/src/redux/actionCreators/permitActionCreator.spec.js similarity index 75% rename from services/core-web/src/tests/actionCreators/permitActionCreator.spec.js rename to services/common/src/redux/actionCreators/permitActionCreator.spec.js index 2d9557bbe8..347bcd0b19 100644 --- a/services/core-web/src/tests/actionCreators/permitActionCreator.spec.js +++ b/services/common/src/redux/actionCreators/permitActionCreator.spec.js @@ -20,11 +20,15 @@ import { createPermitCondition, deletePermitCondition, updatePermitCondition, + fetchPermitAmendmentConditionCategories, + createPermitAmendmentConditionCategory, + updatePermitAmendmentConditionCategory, + deletePermitAmendmentConditionCategory } from "@mds/common/redux/actionCreators/permitActionCreator"; import * as genericActions from "@mds/common/redux/actions/genericActions"; import { ENVIRONMENT } from "@mds/common"; import * as API from "@mds/common/constants/API"; -import * as MOCK from "@/tests/mocks/dataMocks"; +import * as MOCK from "@mds/common/tests/mocks/dataMocks"; const dispatch = jest.fn(); const requestSpy = jest.spyOn(genericActions, "request"); @@ -654,4 +658,206 @@ describe("`createPermitCondition` action creator", () => { expect(dispatch).toHaveBeenCalledTimes(4); }); }); + + describe("`fetchPermitAmendmentConditionCategories` action creator", () => { + const mineGuid = "12345-6789"; + const permitGuid = "98765-4321"; + const permitAmdendmentGuid = "24680-1357"; + + const url = `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES( + mineGuid, + permitGuid, + permitAmdendmentGuid + )}`; + + it("Request successful, dispatches `success` with correct response", () => { + const mockResponse = { + data: { + records: [ + { id: 1, name: "Category 1" }, + { id: 2, name: "Category 2" } + ] + } + }; + mockAxios.onGet(url).reply(200, mockResponse); + + return fetchPermitAmendmentConditionCategories( + mineGuid, + permitGuid, + permitAmdendmentGuid + )(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("GET_PERMIT_CONDITION_CATEGORIES"); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + }); + + it("Request failure, dispatches `error` with correct response", () => { + mockAxios.onGet(url, MOCK.createMockHeader()).reply(418, MOCK.ERROR); + + return fetchPermitAmendmentConditionCategories( + mineGuid, + permitGuid, + permitAmdendmentGuid + )(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith("GET_PERMIT_CONDITION_CATEGORIES"); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + }); + }); + + describe("`createPermitAmendmentConditionCategory` action creator", () => { + const mineGuid = "12345-6789"; + const permitGuid = "98765-4321"; + const permitAmdendmentGuid = "24680-1357"; + + const payload = { + name: "Test Category", + description: "Test Description" + }; + + const url = `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES( + mineGuid, + permitGuid, + permitAmdendmentGuid + )}`; + + it("Request successful, dispatches `success` with correct response", () => { + const mockResponse = { data: { ...payload, id: 1 } }; + mockAxios.onPost(url, payload).reply(200, mockResponse); + + return createPermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + payload + )(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("CREATE_PERMIT_CONDITION_CATEGORY"); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(successSpy).toHaveBeenCalledWith("CREATE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(5); + }); + }); + + it("Request failure, dispatches `error` with correct response", () => { + mockAxios.onPost(url).reply(418, MOCK.ERROR); + + return createPermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + payload + )(dispatch).catch(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("CREATE_PERMIT_CONDITION_CATEGORY"); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith("CREATE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + }); + }); + + describe("`updatePermitAmendmentConditionCategory` action creator", () => { + const mineGuid = "12345-6789"; + const permitGuid = "98765-4321"; + const permitAmdendmentGuid = "24680-1357"; + + const payload = { + condition_category_code: "TEST123", + name: "Updated Test Category", + description: "Updated Test Description" + }; + + const url = `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES( + mineGuid, + permitGuid, + permitAmdendmentGuid + )}/${payload.condition_category_code}`; + + it("Request successful, dispatches `success` with correct response", () => { + const mockResponse = { data: { ...payload } }; + mockAxios.onPut(url, payload).reply(200, mockResponse); + + return updatePermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + payload + )(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("UPDATE_PERMIT_CONDITION_CATEGORY"); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(successSpy).toHaveBeenCalledWith("UPDATE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(5); + }); + }); + + it("Request failure, dispatches `error` with correct response", () => { + mockAxios.onPut(url).reply(418, MOCK.ERROR); + + return updatePermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + payload + )(dispatch).catch(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("UPDATE_PERMIT_CONDITION_CATEGORY"); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith("UPDATE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + }); + }); + + describe("`deletePermitAmendmentConditionCategory` action creator", () => { + const mineGuid = "12345-6789"; + const permitGuid = "98765-4321"; + const permitAmdendmentGuid = "24680-1357"; + const permitConditionCategoryCode = "TEST123"; + + const url = `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES( + mineGuid, + permitGuid, + permitAmdendmentGuid + )}/${permitConditionCategoryCode}`; + + it("Request successful, dispatches `success` with correct response", () => { + const mockResponse = { data: { success: true } }; + mockAxios.onDelete(url).reply(200, mockResponse); + + return deletePermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + permitConditionCategoryCode + )(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("DELETE_PERMIT_CONDITION_CATEGORY"); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(successSpy).toHaveBeenCalledWith("DELETE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(5); + }); + }); + + it("Request failure, dispatches `error` with correct response", () => { + mockAxios.onDelete(url).reply(418, MOCK.ERROR); + + return deletePermitAmendmentConditionCategory( + mineGuid, + permitGuid, + permitAmdendmentGuid, + permitConditionCategoryCode + )(dispatch).catch(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith("DELETE_PERMIT_CONDITION_CATEGORY"); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith("DELETE_PERMIT_CONDITION_CATEGORY"); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + }); + }); }); diff --git a/services/common/src/redux/actionCreators/permitActionCreator.ts b/services/common/src/redux/actionCreators/permitActionCreator.ts index c97c4f2154..fb8337768d 100644 --- a/services/common/src/redux/actionCreators/permitActionCreator.ts +++ b/services/common/src/redux/actionCreators/permitActionCreator.ts @@ -11,6 +11,7 @@ import { IPatchPermitNumber, IPatchPermitVCLocked, IStandardPermitCondition, + IPermitConditionCategory, } from "@mds/common/interfaces"; import { request, success, error, IDispatchError } from "../actions/genericActions"; import * as reducerTypes from "@mds/common/constants/reducerTypes"; @@ -115,27 +116,27 @@ export const createPermitAmendment = ( ): AppThunk>> => ( dispatch ): Promise> => { - dispatch(request(reducerTypes.CREATE_PERMIT_AMENDMENT)); - dispatch(showLoading("modal")); - return CustomAxios() - .post( - `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENTS(mineGuid, permitGuid)}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully created a new amendment", - duration: 10, - }); - dispatch(success(reducerTypes.CREATE_PERMIT_AMENDMENT)); - return response; - }) - .catch(() => { - dispatch(error(reducerTypes.CREATE_PERMIT_AMENDMENT)); - }) - .finally(() => dispatch(hideLoading("modal"))); -}; + dispatch(request(reducerTypes.CREATE_PERMIT_AMENDMENT)); + dispatch(showLoading("modal")); + return CustomAxios() + .post( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENTS(mineGuid, permitGuid)}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully created a new amendment", + duration: 10, + }); + dispatch(success(reducerTypes.CREATE_PERMIT_AMENDMENT)); + return response; + }) + .catch(() => { + dispatch(error(reducerTypes.CREATE_PERMIT_AMENDMENT)); + }) + .finally(() => dispatch(hideLoading("modal"))); + }; export const createPermitAmendmentVC = ( mineGuid: string, @@ -172,31 +173,147 @@ export const updatePermitAmendment = ( ): AppThunk>> => ( dispatch ): Promise> => { - dispatch(request(reducerTypes.UPDATE_PERMIT_AMENDMENT)); - dispatch(showLoading()); - return CustomAxios() - .put( - `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT(mineGuid, permitGuid, permitAmdendmentGuid)}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - const successMessage = - response.data.permit_amendment_status_code === "DFT" - ? "Successfully updated draft permit" - : "Successfully updated permit amendment"; - notification.success({ - message: successMessage, - duration: 10, - }); - dispatch(success(reducerTypes.UPDATE_PERMIT_AMENDMENT)); - return response; - }) - .catch(() => { - dispatch(error(reducerTypes.UPDATE_PERMIT_AMENDMENT)); - }) - .finally(() => dispatch(hideLoading())); -}; + dispatch(request(reducerTypes.UPDATE_PERMIT_AMENDMENT)); + dispatch(showLoading()); + return CustomAxios() + .put( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT(mineGuid, permitGuid, permitAmdendmentGuid)}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + const successMessage = + response.data.permit_amendment_status_code === "DFT" + ? "Successfully updated draft permit" + : "Successfully updated permit amendment"; + notification.success({ + message: successMessage, + duration: 10, + }); + dispatch(success(reducerTypes.UPDATE_PERMIT_AMENDMENT)); + return response; + }) + .catch(() => { + dispatch(error(reducerTypes.UPDATE_PERMIT_AMENDMENT)); + }) + .finally(() => dispatch(hideLoading())); + }; + + +export const fetchPermitAmendmentConditionCategories = ( + mineGuid: string, + permitGuid: string, + permitAmdendmentGuid: string +): AppThunk> => ( + dispatch +): Promise => { + dispatch(request(reducerTypes.GET_PERMIT_CONDITION_CATEGORIES)); + dispatch(showLoading()); + return CustomAxios() + .get( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES(mineGuid, permitGuid, permitAmdendmentGuid)}`, + createRequestHeader() + ) + .then((response: AxiosResponse<{ records: IPermitConditionCategory[] }>) => { + dispatch(permitActions.storeUpdatePermitConditionCategory({ + condition_categories: response.data.records, + permitGuid + })); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.GET_PERMIT_CONDITION_CATEGORIES))) + .finally(() => dispatch(hideLoading())); + }; + + +export const createPermitAmendmentConditionCategory = ( + mineGuid: string, + permitGuid: string, + permitAmdendmentGuid: string, + payload: IPermitConditionCategory +): AppThunk> => ( + dispatch +): Promise => { + dispatch(request(reducerTypes.CREATE_PERMIT_CONDITION_CATEGORY)); + dispatch(showLoading()); + return CustomAxios() + .post( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES(mineGuid, permitGuid, permitAmdendmentGuid)}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully created permit condition category", + duration: 10, + }); + + dispatch(success(reducerTypes.CREATE_PERMIT_CONDITION_CATEGORY)); + dispatch(fetchPermitAmendmentConditionCategories(mineGuid, permitGuid, permitAmdendmentGuid)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.CREATE_PERMIT_CONDITION_CATEGORY))) + .finally(() => dispatch(hideLoading())); + }; + +export const updatePermitAmendmentConditionCategory = ( + mineGuid: string, + permitGuid: string, + permitAmdendmentGuid: string, + payload: IPermitConditionCategory +): AppThunk> => ( + dispatch +): Promise => { + dispatch(request(reducerTypes.UPDATE_PERMIT_CONDITION_CATEGORY)); + dispatch(showLoading()); + return CustomAxios() + .put( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES(mineGuid, permitGuid, permitAmdendmentGuid)}/${payload.condition_category_code}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully updated permit condition category", + duration: 10, + }); + + dispatch(success(reducerTypes.UPDATE_PERMIT_CONDITION_CATEGORY)); + dispatch(fetchPermitAmendmentConditionCategories(mineGuid, permitGuid, permitAmdendmentGuid)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.UPDATE_PERMIT_CONDITION_CATEGORY))) + .finally(() => dispatch(hideLoading())); + }; + +export const deletePermitAmendmentConditionCategory = ( + mineGuid: string, + permitGuid: string, + permitAmdendmentGuid: string, + permitConditionCategoryCode: string, +): AppThunk> => ( + dispatch +): Promise => { + dispatch(request(reducerTypes.DELETE_PERMIT_CONDITION_CATEGORY)); + dispatch(showLoading()); + return CustomAxios() + .delete( + `${ENVIRONMENT.apiUrl}${API.PERMIT_AMENDMENT_CONDITION_CATEGORIES(mineGuid, permitGuid, permitAmdendmentGuid)}/${permitConditionCategoryCode}`, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully deleted permit condition category", + duration: 10, + }); + + dispatch(success(reducerTypes.DELETE_PERMIT_CONDITION_CATEGORY)); + dispatch(fetchPermitAmendmentConditionCategories(mineGuid, permitGuid, permitAmdendmentGuid)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.DELETE_PERMIT_CONDITION_CATEGORY))) + .finally(() => dispatch(hideLoading())); + }; export const getPermitAmendment = ( mineGuid: string, @@ -324,25 +441,25 @@ export const createPermitCondition = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.CREATE_PERMIT_CONDITION)); - dispatch(showLoading()); - return CustomAxios() - .post( - `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITIONS(null, null, permitAmdendmentGuid)}`, - { permit_condition: payload }, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully created a new condition", - duration: 10, - }); - dispatch(success(reducerTypes.CREATE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.CREATE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading())); -}; + dispatch(request(reducerTypes.CREATE_PERMIT_CONDITION)); + dispatch(showLoading()); + return CustomAxios() + .post( + `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITIONS(null, null, permitAmdendmentGuid)}`, + { permit_condition: payload }, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully created a new condition", + duration: 10, + }); + dispatch(success(reducerTypes.CREATE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.CREATE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading())); + }; export const deletePermitCondition = ( permitAmdendmentGuid: string, @@ -350,29 +467,29 @@ export const deletePermitCondition = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.DELETE_PERMIT_CONDITION)); - dispatch(showLoading("modal")); - return CustomAxios() - .delete( - `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITION( - null, - null, - permitAmdendmentGuid, - permitConditionGuid - )}`, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully deleted permit condition.", - duration: 10, - }); - dispatch(success(reducerTypes.DELETE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.DELETE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading("modal"))); -}; + dispatch(request(reducerTypes.DELETE_PERMIT_CONDITION)); + dispatch(showLoading("modal")); + return CustomAxios() + .delete( + `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITION( + null, + null, + permitAmdendmentGuid, + permitConditionGuid + )}`, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully deleted permit condition.", + duration: 10, + }); + dispatch(success(reducerTypes.DELETE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.DELETE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading("modal"))); + }; export const setEditingConditionFlag = (payload: any): AppThunk => (dispatch) => { dispatch(permitActions.storeEditingConditionFlag(payload)); @@ -385,30 +502,30 @@ export const updatePermitCondition = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.UPDATE_PERMIT_CONDITION)); - dispatch(showLoading()); - return CustomAxios() - .put( - `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITION( - null, - null, - permitAmdendmentGuid, - permitConditionGuid - )}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: `Successfully updated permit condition`, - duration: 10, - }); - dispatch(success(reducerTypes.UPDATE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.UPDATE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading())); -}; + dispatch(request(reducerTypes.UPDATE_PERMIT_CONDITION)); + dispatch(showLoading()); + return CustomAxios() + .put( + `${ENVIRONMENT.apiUrl}${API.PERMIT_CONDITION( + null, + null, + permitAmdendmentGuid, + permitConditionGuid + )}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: `Successfully updated permit condition`, + duration: 10, + }); + dispatch(success(reducerTypes.UPDATE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.UPDATE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading())); + }; export const patchPermitNumber = ( permitGuid: string, @@ -417,25 +534,25 @@ export const patchPermitNumber = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.PATCH_PERMIT)); - dispatch(showLoading("modal")); - return CustomAxios() - .patch( - `${ENVIRONMENT.apiUrl}${API.PERMITS(mineGuid)}/${permitGuid}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully updated permit", - duration: 10, - }); - dispatch(success(reducerTypes.PATCH_PERMIT)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.PATCH_PERMIT))) - .finally(() => dispatch(hideLoading("modal"))); -}; + dispatch(request(reducerTypes.PATCH_PERMIT)); + dispatch(showLoading("modal")); + return CustomAxios() + .patch( + `${ENVIRONMENT.apiUrl}${API.PERMITS(mineGuid)}/${permitGuid}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully updated permit", + duration: 10, + }); + dispatch(success(reducerTypes.PATCH_PERMIT)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.PATCH_PERMIT))) + .finally(() => dispatch(hideLoading("modal"))); + }; export const patchPermitVCLocked = ( permitGuid: string, @@ -444,25 +561,25 @@ export const patchPermitVCLocked = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.PATCH_PERMIT)); - dispatch(showLoading("modal")); - return CustomAxios() - .patch( - `${ENVIRONMENT.apiUrl}${API.PERMITS(mineGuid)}/${permitGuid}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully updated permit", - duration: 10, - }); - dispatch(success(reducerTypes.PATCH_PERMIT)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.PATCH_PERMIT))) - .finally(() => dispatch(hideLoading("modal"))); -}; + dispatch(request(reducerTypes.PATCH_PERMIT)); + dispatch(showLoading("modal")); + return CustomAxios() + .patch( + `${ENVIRONMENT.apiUrl}${API.PERMITS(mineGuid)}/${permitGuid}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully updated permit", + duration: 10, + }); + dispatch(success(reducerTypes.PATCH_PERMIT)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.PATCH_PERMIT))) + .finally(() => dispatch(hideLoading("modal"))); + }; // standard permit conditions export const fetchStandardPermitConditions = (noticeOfWorkType: string): AppThunk => (dispatch) => { @@ -487,54 +604,54 @@ export const createStandardPermitCondition = ( ): AppThunk> => ( dispatch ): Promise => { - const newPayload = { - ...payload, - notice_of_work_type: type, - parent_standard_permit_condition_id: payload.parent_permit_condition_id, + const newPayload = { + ...payload, + notice_of_work_type: type, + parent_standard_permit_condition_id: payload.parent_permit_condition_id, + }; + dispatch(request(reducerTypes.CREATE_PERMIT_CONDITION)); + dispatch(showLoading()); + return CustomAxios() + .post( + `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITIONS(type)}`, + { standard_permit_condition: newPayload }, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully created a new condition", + duration: 10, + }); + dispatch(success(reducerTypes.CREATE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.CREATE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading())); }; - dispatch(request(reducerTypes.CREATE_PERMIT_CONDITION)); - dispatch(showLoading()); - return CustomAxios() - .post( - `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITIONS(type)}`, - { standard_permit_condition: newPayload }, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully created a new condition", - duration: 10, - }); - dispatch(success(reducerTypes.CREATE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.CREATE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading())); -}; export const deleteStandardPermitCondition = ( permitConditionGuid: string ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.DELETE_PERMIT_CONDITION)); - dispatch(showLoading("modal")); - return CustomAxios() - .delete( - `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITION(permitConditionGuid)}`, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: "Successfully deleted permit condition.", - duration: 10, - }); - dispatch(success(reducerTypes.DELETE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.DELETE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading("modal"))); -}; + dispatch(request(reducerTypes.DELETE_PERMIT_CONDITION)); + dispatch(showLoading("modal")); + return CustomAxios() + .delete( + `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITION(permitConditionGuid)}`, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: "Successfully deleted permit condition.", + duration: 10, + }); + dispatch(success(reducerTypes.DELETE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.DELETE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading("modal"))); + }; export const updateStandardPermitCondition = ( permitConditionGuid: string, @@ -542,22 +659,22 @@ export const updateStandardPermitCondition = ( ): AppThunk> => ( dispatch ): Promise => { - dispatch(request(reducerTypes.UPDATE_PERMIT_CONDITION)); - dispatch(showLoading()); - return CustomAxios() - .put( - `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITION(permitConditionGuid)}`, - payload, - createRequestHeader() - ) - .then((response: AxiosResponse) => { - notification.success({ - message: `Successfully updated permit condition`, - duration: 10, - }); - dispatch(success(reducerTypes.UPDATE_PERMIT_CONDITION)); - return response.data; - }) - .catch(() => dispatch(error(reducerTypes.UPDATE_PERMIT_CONDITION))) - .finally(() => dispatch(hideLoading())); -}; + dispatch(request(reducerTypes.UPDATE_PERMIT_CONDITION)); + dispatch(showLoading()); + return CustomAxios() + .put( + `${ENVIRONMENT.apiUrl}${API.STANDARD_PERMIT_CONDITION(permitConditionGuid)}`, + payload, + createRequestHeader() + ) + .then((response: AxiosResponse) => { + notification.success({ + message: `Successfully updated permit condition`, + duration: 10, + }); + dispatch(success(reducerTypes.UPDATE_PERMIT_CONDITION)); + return response.data; + }) + .catch(() => dispatch(error(reducerTypes.UPDATE_PERMIT_CONDITION))) + .finally(() => dispatch(hideLoading())); + }; diff --git a/services/common/src/redux/actions/permitActions.js b/services/common/src/redux/actions/permitActions.tsx similarity index 84% rename from services/common/src/redux/actions/permitActions.js rename to services/common/src/redux/actions/permitActions.tsx index 5c30a44f86..7c22b5bed1 100644 --- a/services/common/src/redux/actions/permitActions.js +++ b/services/common/src/redux/actions/permitActions.tsx @@ -5,6 +5,11 @@ export const storePermits = (payload) => ({ payload, }); +export const storeUpdatePermitConditionCategory = (payload) => ({ + type: actionTypes.STORE_PERMIT_CONDITION_CATEGORY, + payload, +}); + export const storeDraftPermits = (payload) => ({ type: actionTypes.STORE_DRAFT_PERMITS, payload, diff --git a/services/common/src/redux/createAppSlice.ts b/services/common/src/redux/createAppSlice.ts index 3ab53babfa..8dee279f97 100644 --- a/services/common/src/redux/createAppSlice.ts +++ b/services/common/src/redux/createAppSlice.ts @@ -6,6 +6,7 @@ export const createAppSlice = buildCreateSlice({ }); export const rejectHandler = (action) => { + console.log(action); console.log(action.error); console.log(action.error.stack); }; diff --git a/services/common/src/redux/reducers/permitReducer.ts b/services/common/src/redux/reducers/permitReducer.ts index ab9417067a..5d3ee40a89 100644 --- a/services/common/src/redux/reducers/permitReducer.ts +++ b/services/common/src/redux/reducers/permitReducer.ts @@ -1,4 +1,4 @@ -import { IPermit, IPermitCondition, IStandardPermitCondition } from "@mds/common/interfaces"; +import { IPermit, IPermitAmendment, IPermitCondition, IStandardPermitCondition } from "@mds/common/interfaces"; import * as actionTypes from "@mds/common/constants/actionTypes"; import { PERMITS } from "@mds/common/constants/reducerTypes"; import { RootState } from "@mds/common/redux/rootState"; @@ -10,6 +10,7 @@ interface PermitState { editingConditionFlag: boolean; editingPreambleFlag: boolean; standardPermitConditions: IStandardPermitCondition[]; + permitAmendments: Record; } const initialState = { @@ -19,14 +20,43 @@ const initialState = { editingConditionFlag: false, editingPreambleFlag: false, standardPermitConditions: [], + permitAmendments: {}, }; export const permitReducer = (state: PermitState = initialState, action) => { switch (action.type) { case actionTypes.STORE_PERMITS: + const amendments = action.payload.records.reduce((acc, permit) => { + const latestAmendment = permit.permit_amendments + .filter(a => a.permit_amendment_status_code !== 'DFT')[0]; + + if (latestAmendment) { + acc[permit.permit_guid] = latestAmendment; + } + return acc; + }, {}); + return { ...state, permits: action.payload.records, + permitAmendments: { + ...(state.permitAmendments), + ...amendments + } + }; + case actionTypes.STORE_PERMIT_CONDITION_CATEGORY: + const { permitGuid, condition_categories } = action.payload; + const permit = state.permitAmendments[permitGuid]; + + return { + ...state, + permitAmendments: { + ...state.permitAmendments, + [permitGuid]: { + ...permit, + condition_categories: [...condition_categories] + } + } }; case actionTypes.STORE_DRAFT_PERMITS: return { @@ -64,6 +94,7 @@ const permitReducerObject = { export const getUnformattedPermits = (state: RootState): IPermit[] => state[PERMITS].permits; export const getDraftPermits = (state: RootState): IPermit[] => state[PERMITS].draftPermits; +export const getLatestPermitAmendments = (state: RootState): IPermitAmendment[] => state[PERMITS].permitAmendments; export const getPermitConditions = (state: RootState): IPermitCondition[] => state[PERMITS].permitConditions; export const getStandardPermitConditions = (state: RootState): IStandardPermitCondition[] => @@ -72,4 +103,6 @@ export const getEditingConditionFlag = (state: RootState): boolean => state[PERMITS].editingConditionFlag; export const getEditingPreambleFlag = (state: RootState): boolean => state[PERMITS].editingPreambleFlag; + + export default permitReducerObject; diff --git a/services/common/src/redux/reducers/rootReducerShared.ts b/services/common/src/redux/reducers/rootReducerShared.ts index 91aa79a04c..7b74e46d5e 100644 --- a/services/common/src/redux/reducers/rootReducerShared.ts +++ b/services/common/src/redux/reducers/rootReducerShared.ts @@ -37,6 +37,7 @@ import regionsReducer from "@mds/common/redux/slices/regionsSlice"; import complianceCodeReducer, { complianceCodeReducerType } from "../slices/complianceCodesSlice"; import spatialDataReducer, { spatialDataReducerType } from "../slices/spatialDataSlice"; import permitServiceReducer, { permitServiceReducerType } from "../slices/permitServiceSlice"; +import searchConditionCategoriesReducer, { searchConditionCategoriesType } from "../slices/permitConditionCategorySlice"; import helpReducer, { helpReducerType } from "../slices/helpSlice"; export const sharedReducer = { ...activityReducer, @@ -86,4 +87,5 @@ export const sharedReducer = { [complianceCodeReducerType]: complianceCodeReducer, [permitServiceReducerType]: permitServiceReducer, [helpReducerType]: helpReducer, + [searchConditionCategoriesType]: searchConditionCategoriesReducer, }; diff --git a/services/common/src/redux/selectors/permitSelectors.spec.ts b/services/common/src/redux/selectors/permitSelectors.spec.ts index b538e284a1..3f2ebee223 100644 --- a/services/common/src/redux/selectors/permitSelectors.spec.ts +++ b/services/common/src/redux/selectors/permitSelectors.spec.ts @@ -9,6 +9,7 @@ import { permitReducer } from "@mds/common/redux/reducers/permitReducer"; import { storeEditingConditionFlag, storeEditingPreambleFlag, + storePermits, } from "@mds/common/redux/actions/permitActions"; import { PERMITS } from "@mds/common/constants/reducerTypes"; import * as MOCK from "@mds/common/tests/mocks/dataMocks"; @@ -51,10 +52,17 @@ describe("permitSelectors", () => { expect(actual).toEqual(permit); }); it("`getLatestAmendmentByPermitGuid returns the latest permit amendment", () => { + + const storeAction = storePermits({ records: MOCK.PERMITS }); + + const permit = MOCK.PERMITS[0]; + + const storeState = permitReducer({} as any, storeAction); + const localMockState = { - [PERMITS]: { permits: mockPermits }, + [PERMITS]: storeState, }; - const permit = MOCK.PERMITS[0]; + const latestAmendment = getLatestAmendmentByPermitGuid(permit.permit_guid)( localMockState as RootState ); diff --git a/services/common/src/redux/selectors/permitSelectors.ts b/services/common/src/redux/selectors/permitSelectors.ts index 4aa83aed12..4505c5270c 100644 --- a/services/common/src/redux/selectors/permitSelectors.ts +++ b/services/common/src/redux/selectors/permitSelectors.ts @@ -12,6 +12,7 @@ export const { getStandardPermitConditions, getEditingConditionFlag, getEditingPreambleFlag, + getLatestPermitAmendments } = permitReducer; export const getDraftPermitForNOW = createSelector( @@ -34,10 +35,10 @@ export const getDraftPermitAmendmentForNOW = createSelector( ); return draftPermit && draftPermit.permit_amendments.length > 0 ? draftPermit.permit_amendments.filter( - (amendment) => - amendment.now_application_guid === noticeOfWork.now_application_guid && - amendment.permit_amendment_status_code === draft - )[0] + (amendment) => + amendment.now_application_guid === noticeOfWork.now_application_guid && + amendment.permit_amendment_status_code === draft + )[0] : {}; } ); @@ -73,12 +74,8 @@ export const getPermitByGuid = (permitGuid) => }); export const getLatestAmendmentByPermitGuid = (permitGuid) => - createSelector([getPermitByGuid(permitGuid)], (permit) => { - if (!permit?.permit_amendments) { - return undefined; - } - // sorted on BE: 'desc(PermitAmendment.issue_date), desc(PermitAmendment.permit_amendment_id)' - return permit.permit_amendments.filter((a) => a.permit_amendment_status_code !== draft)[0]; + createSelector([getLatestPermitAmendments], (amendments) => { + return amendments ? amendments[permitGuid] : null; }); export const getPermits = createSelector([getUnformattedPermits], (permits) => { diff --git a/services/common/src/redux/slices/permitConditionCategorySlice.spec.ts b/services/common/src/redux/slices/permitConditionCategorySlice.spec.ts new file mode 100644 index 0000000000..a9905731db --- /dev/null +++ b/services/common/src/redux/slices/permitConditionCategorySlice.spec.ts @@ -0,0 +1,99 @@ +import { searchConditionCategories, searchConditionCategoriesReducer, getConditionCategories } from "./permitConditionCategorySlice"; +import { ENVIRONMENT } from "@mds/common/constants"; +import CustomAxios from "@mds/common/redux/customAxios"; +import { configureStore } from "@reduxjs/toolkit"; + +const showLoadingMock = jest.fn().mockReturnValue({ type: "SHOW_LOADING", payload: { show: true } }); +const hideLoadingMock = jest.fn().mockReturnValue({ type: "HIDE_LOADING", payload: { show: false } }); + +jest.mock("@mds/common/redux/customAxios"); +jest.mock("react-redux-loading-bar", () => ({ + showLoading: () => showLoadingMock, + hideLoading: () => hideLoadingMock, +})); + +describe("permitConditionCategorySlice", () => { + let store; + + beforeEach(() => { + store = configureStore({ + reducer: { + searchConditionCategories: searchConditionCategoriesReducer, + } + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("searchConditionCategories", () => { + const mockResponse = { + data: { + records: [ + { code: "TEST1", description: "Test Category 1" }, + { code: "TEST2", description: "Test Category 2" } + ] + } + }; + + it("should fetch condition categories successfully", async () => { + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(mockResponse) + })); + + const payload = { + query: "test", + exclude: ["excluded1"], + limit: 10 + }; + + await store.dispatch(searchConditionCategories(payload)); + + const state = store.getState().searchConditionCategories; + + // Verify loading state management + expect(showLoadingMock).toHaveBeenCalledTimes(1); + expect(hideLoadingMock).toHaveBeenCalledTimes(1); + + expect(getConditionCategories({ searchConditionCategories: state })).toEqual(mockResponse.data.records); + expect(CustomAxios).toHaveBeenCalledWith({ errorToastMessage: "default" }); + }); + + it("should handle API error", async () => { + const error = new Error("API Error"); + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: jest.fn().mockRejectedValue(error) + })); + + await store.dispatch(searchConditionCategories({})); + const state = store.getState(); + + expect(getConditionCategories(state)).toBeUndefined(); + }); + + it("should construct correct URL with query parameters", async () => { + const getMock = jest.fn().mockResolvedValue(mockResponse); + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: getMock + })); + + const payload = { + query: "test", + exclude: ["exc1", "exc2"], + limit: 5 + }; + + await store.dispatch(searchConditionCategories(payload)); + + expect(getMock).toHaveBeenCalledWith( + expect.stringContaining(`${ENVIRONMENT.apiUrl}/mines/permits/condition-category-codes?`), + expect.any(Object) + ); + expect(getMock.mock.calls[0][0]).toContain("query=test"); + expect(getMock.mock.calls[0][0]).toContain("exclude=exc1"); + expect(getMock.mock.calls[0][0]).toContain("exclude=exc2"); + expect(getMock.mock.calls[0][0]).toContain("limit=5"); + }); + }); +}); \ No newline at end of file diff --git a/services/common/src/redux/slices/permitConditionCategorySlice.ts b/services/common/src/redux/slices/permitConditionCategorySlice.ts new file mode 100644 index 0000000000..e3f51cd318 --- /dev/null +++ b/services/common/src/redux/slices/permitConditionCategorySlice.ts @@ -0,0 +1,64 @@ +import { createAppSlice, rejectHandler } from "@mds/common/redux/createAppSlice"; +import { hideLoading, showLoading } from "react-redux-loading-bar"; +import CustomAxios from "@mds/common/redux/customAxios"; +import { ENVIRONMENT } from "@mds/common/constants"; +import { IPermitConditionCategory } from "@mds/common/interfaces"; + +const createRequestHeader = REQUEST_HEADER.createRequestHeader; +export const searchConditionCategoriesType = "searchConditionCategories"; +interface ConditionCategoryState { + condition_categories: IPermitConditionCategory[]; +} +const searchConditionCategoriesSlice = createAppSlice({ + name: searchConditionCategoriesType, + initialState: {}, + selectors: { + getConditionCategories: (state: ConditionCategoryState) => state.condition_categories, + }, + reducers: (create) => ({ + searchConditionCategories: create.asyncThunk( + async ( + payload: { + query?: string; + exclude?: string[]; + limit?: number; + }, + thunkApi + ) => { + const headers = createRequestHeader(); + thunkApi.dispatch(showLoading()); + + const params = new URLSearchParams(); + if (payload.query) params.append("query", payload.query); + if (payload.exclude) payload.exclude.forEach((item) => params.append("exclude", item)); + if (payload.limit) params.append("limit", payload.limit.toString()); + + const response = await CustomAxios({ + errorToastMessage: "default", + }).get(`${ENVIRONMENT.apiUrl}/mines/permits/condition-category-codes?${params.toString()}`, headers); + + thunkApi.dispatch(hideLoading()); + return { + ...response.data, + records: response.data.records.map((item) => ({ + ...item, + description: ['GEC', 'HSC', 'GOC', 'ELC', 'RCC'].includes(item.condition_category_code) ? item.description.replace('Conditions', '').trim() : item.description + })) + }; + }, + { + fulfilled: (state: ConditionCategoryState, action) => { + state.condition_categories = action.payload?.records; + }, + rejected: (state: ConditionCategoryState, action) => { + rejectHandler(action); + } + } + ), + }), +}); + +export const { getConditionCategories } = searchConditionCategoriesSlice.selectors; +export const { searchConditionCategories } = searchConditionCategoriesSlice.actions; +export const searchConditionCategoriesReducer = searchConditionCategoriesSlice.reducer; +export default searchConditionCategoriesReducer; \ No newline at end of file diff --git a/services/common/src/redux/slices/permitServiceSlice.ts b/services/common/src/redux/slices/permitServiceSlice.ts index 4564c13ad1..07e1161714 100644 --- a/services/common/src/redux/slices/permitServiceSlice.ts +++ b/services/common/src/redux/slices/permitServiceSlice.ts @@ -31,7 +31,7 @@ const permitExtractionStatusMap = { SUCCESS: PermitExtractionStatus.complete, }; -interface PermitExtraction { +export interface PermitExtraction { task_status: PermitExtractionStatus; task_id: string; } diff --git a/services/common/src/tests/handlers.ts b/services/common/src/tests/handlers.ts index c64f9dd9d5..4fe6382748 100644 --- a/services/common/src/tests/handlers.ts +++ b/services/common/src/tests/handlers.ts @@ -3,6 +3,8 @@ import { GEOMARK_DATA, HELP_GUIDE_CORE, HELP_GUIDE_MS, + MINE_REPORT_CATEGORY_OPTIONS, + PERMIT_CONDITION_EXTRACTION, PROJECT, PROJECT_SUMMARY_MINISTRY_COMMENTS, } from "@mds/common/tests/mocks/dataMocks"; @@ -26,6 +28,21 @@ const projectHandlers = [ ), ]; +const permitHandlers = [ + http.get("/%3CAPI_URL%3E/mines/permits/condition-extraction", async () => { + return HttpResponse.json({ + "tasks": PERMIT_CONDITION_EXTRACTION + }) + }), + http.get("/%3CAPI_URL%3E/mines/permits/condition-category-codes", async () => { + return HttpResponse.json({ + "tasks": MINE_REPORT_CATEGORY_OPTIONS + }) + }), + + +] + const helpHandler = http.get("/%3CAPI_URL%3E/help/:helpKey", async ({ request, params }) => { const { helpKey } = params; const url = new URL(request.url); @@ -37,6 +54,6 @@ const helpHandler = http.get("/%3CAPI_URL%3E/help/:helpKey", async ({ request, p return HttpResponse.json(response); }); -const commonHandlers = [...geoSpatialHandlers, ...projectHandlers, helpHandler]; +const commonHandlers = [...geoSpatialHandlers, ...projectHandlers, helpHandler, ...permitHandlers]; export default commonHandlers; diff --git a/services/common/src/tests/mocks/dataMocks.tsx b/services/common/src/tests/mocks/dataMocks.tsx index f86ec17d85..581c0476fc 100644 --- a/services/common/src/tests/mocks/dataMocks.tsx +++ b/services/common/src/tests/mocks/dataMocks.tsx @@ -8,6 +8,7 @@ import { IInformationRequirementsTable, IPermit, IMineDocument, + IPermitAmendment, } from "@mds/common/interfaces"; import { MAJOR_MINE_APPLICATION_AND_IRT_STATUS_CODES, @@ -20,6 +21,7 @@ import { VC_CONNECTION_STATES, VC_CRED_ISSUE_STATES, } from "@mds/common/constants"; +import { PermitExtraction } from "@mds/common/redux/slices/permitServiceSlice"; export const createMockHeader = () => ({ headers: { @@ -1132,6 +1134,14 @@ export const MINE_TSF_REQUIRED_REPORTS_HASH = { "faa99067-3639-4d9c-a3e5-5401df15ad4b": "5 year DSR", }; +export const PERMIT_CONDITION_EXTRACTION = [ + { + "task_id": "abc123", + "task_status": "SUCCESS", + } +] + + export const PERMITS: IPermit[] = [ { permit_id: "283", @@ -1157,6 +1167,20 @@ export const PERMITS: IPermit[] = [ permit_conditions_last_updated_by: "Condition Updater", permit_conditions_last_updated_date: "2019-04-04", has_permit_conditions: false, + condition_categories: [ + { + condition_category_code: "HSC", + description: "Health and Safety", + display_order: 0, + step: 'A.' + }, + { + condition_category_code: "RCC", + description: "Reclamation", + display_order: 1, + step: 'B.' + } + ], conditions: [ { permit_condition_id: 1639, @@ -1379,6 +1403,7 @@ export const PERMITS: IPermit[] = [ authorization_end_date: null, liability_adjustment: 1000000, description: "Initial permit issued.", + condition_categories: [], related_documents: [ { permit_id: 283, @@ -1464,6 +1489,7 @@ export const PERMITS: IPermit[] = [ conditions: [], is_generated_in_core: false, preamble_text: null, + condition_categories: [], }, ], remaining_static_liability: null, @@ -1489,6 +1515,11 @@ export const PERMITS: IPermit[] = [ }, ]; +export const PERMIT_AMENDMENT_STATE: { [permitGuid: string]: IPermitAmendment } = PERMITS.reduce((acc, permit) => { + acc[permit.permit_guid] = permit.permit_amendments[0]; + return acc; +}, {}); + export const USER_ACCESS_DATA = [ "core_view_all", "idir", diff --git a/services/core-api/app/api/mines/namespace.py b/services/core-api/app/api/mines/namespace.py index 6ab7ab9db9..697a42fe20 100644 --- a/services/core-api/app/api/mines/namespace.py +++ b/services/core-api/app/api/mines/namespace.py @@ -94,6 +94,12 @@ from app.api.mines.permits.permit_amendment.resources.permit_amendment_vc import ( PermitAmendmentVCResource, ) +from app.api.mines.permits.permit_conditions.resources.permit_amendment_condition_category_list_resource import ( + PermitAmendmentConditionCategoryListResource, +) +from app.api.mines.permits.permit_conditions.resources.permit_amendment_condition_category_resource import ( + PermitAmendmentConditionCategoryResource, +) from app.api.mines.permits.permit_conditions.resources.permit_condition_category_resource import ( PermitConditionCategoryResource, ) @@ -132,7 +138,9 @@ from app.api.mines.reports.resources.mine_report_document import ( MineReportDocumentListResource, ) -from app.api.mines.reports.resources.mine_report_permit_requirement import MineReportPermitRequirementResource +from app.api.mines.reports.resources.mine_report_permit_requirement import ( + MineReportPermitRequirementResource, +) from app.api.mines.reports.resources.mine_report_submission_resource import ( ReportSubmissionResource, ) @@ -272,6 +280,16 @@ PermitAmendmentResource, '//permits//amendments/') +api.add_resource( + PermitAmendmentConditionCategoryListResource, + '//permits//amendments//condition-categories' +) + +api.add_resource( + PermitAmendmentConditionCategoryResource, + '//permits//amendments//condition-categories/' +) + api.add_resource(PermitConditionExtractionResource, '/permits/condition-extraction') api.add_resource(PermitConditionExtractionProgressResource, '/permits/condition-extraction/') api.add_resource( diff --git a/services/core-api/app/api/mines/permits/permit/models/permit.py b/services/core-api/app/api/mines/permits/permit/models/permit.py index 109e97cefa..135fbccc45 100644 --- a/services/core-api/app/api/mines/permits/permit/models/permit.py +++ b/services/core-api/app/api/mines/permits/permit/models/permit.py @@ -1,21 +1,22 @@ +from app.api.constants import * +from app.api.mines.documents.models.mine_document import MineDocument +from app.api.mines.permits.permit.models.mine_permit_xref import MinePermitXref + +#for schema creation +from app.api.mines.permits.permit.models.permit_status_code import PermitStatusCode +from app.api.mines.permits.permit_amendment.models.permit_amendment import ( + PermitAmendment, +) +from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment +from app.api.utils.models_mixins import AuditMixin, Base, SoftDeleteMixin +from app.extensions import db from flask.globals import current_app from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import validates from sqlalchemy.schema import FetchedValue -from app.extensions import db - -from app.api.mines.permits.permit_amendment.models.permit_amendment import PermitAmendment -from app.api.mines.permits.permit.models.mine_permit_xref import MinePermitXref -#for schema creation -from app.api.mines.permits.permit.models.permit_status_code import PermitStatusCode -from app.api.mines.documents.models.mine_document import MineDocument -from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment - -from app.api.utils.models_mixins import SoftDeleteMixin, AuditMixin, Base -from app.api.constants import * class Permit(SoftDeleteMixin, AuditMixin, Base): @@ -41,7 +42,9 @@ class Permit(SoftDeleteMixin, AuditMixin, Base): primaryjoin= 'and_(PermitAmendment.permit_id == Permit.permit_id, PermitAmendment.deleted_ind==False)', order_by='desc(PermitAmendment.issue_date), desc(PermitAmendment.permit_amendment_id)', - lazy='select') + lazy='select', + overlaps='permittee_appointments' + ) _all_mines = db.relationship( 'Mine', diff --git a/services/core-api/app/api/mines/permits/permit_amendment/models/permit_amendment.py b/services/core-api/app/api/mines/permits/permit_amendment/models/permit_amendment.py index a3a3332c65..1a6977c11e 100644 --- a/services/core-api/app/api/mines/permits/permit_amendment/models/permit_amendment.py +++ b/services/core-api/app/api/mines/permits/permit_amendment/models/permit_amendment.py @@ -2,21 +2,22 @@ from datetime import date, datetime from typing import Union -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import validates -from sqlalchemy.schema import FetchedValue - -from app.extensions import db from app.api.constants import * from app.api.mines.permits.permit_amendment.models.permit_amendment_document import ( - PermitAmendmentDocument, ) + PermitAmendmentDocument, +) from app.api.mines.permits.permit_conditions.models.permit_conditions import ( - PermitConditions, ) + PermitConditions, +) +from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment from app.api.utils.models_mixins import AuditMixin, Base, SoftDeleteMixin from app.api.verifiable_credentials.aries_constants import IssueCredentialIssuerState -from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment +from app.extensions import db +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import validates +from sqlalchemy.schema import FetchedValue from . import permit_amendment_status_code, permit_amendment_type_code @@ -62,6 +63,13 @@ class PermitAmendment(SoftDeleteMixin, AuditMixin, Base): mine: 'Mine' = db.relationship( 'Mine', lazy='select', back_populates='_mine_permit_amendments') #type: ignore[reportAssignmentType] + + condition_categories = db.relationship( + 'PermitConditionCategory', + lazy='selectin', + primaryjoin='and_(PermitAmendment.permit_amendment_id==PermitConditionCategory.permit_amendment_id, PermitConditionCategory.deleted_ind==False)', + ) + conditions = db.relationship( 'PermitConditions', lazy='select', diff --git a/services/core-api/app/api/mines/permits/permit_conditions/models/permit_condition_category.py b/services/core-api/app/api/mines/permits/permit_conditions/models/permit_condition_category.py index 1513c6feb8..4531a92743 100644 --- a/services/core-api/app/api/mines/permits/permit_conditions/models/permit_condition_category.py +++ b/services/core-api/app/api/mines/permits/permit_conditions/models/permit_condition_category.py @@ -1,31 +1,110 @@ -from datetime import datetime - import uuid +from datetime import datetime +from app.api.utils.models_mixins import AuditMixin, Base, SoftDeleteMixin +from app.extensions import db +from flask.globals import current_app from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import validates -from app.extensions import db from sqlalchemy.schema import FetchedValue -from app.api.utils.models_mixins import AuditMixin, Base - -class PermitConditionCategory(AuditMixin, Base): +class PermitConditionCategory(SoftDeleteMixin, AuditMixin, Base): __tablename__ = 'permit_condition_category' + __versioned__ = {} + # This is auto-generated for Mines Act Permit Conditions that are unique to a permit amendment. + # for other permit types, thes are standard codes (e.g. GEC for General, HSC for Health and Safety etc.) condition_category_code = db.Column(db.String, nullable=False, primary_key=True) - step = db.Column(db.String) + step = db.Column(db.String) # E.g. A, B, C etc. description = db.Column(db.String, nullable=False) active_ind = db.Column(db.Boolean, nullable=False, server_default=FetchedValue()) display_order = db.Column(db.Integer, nullable=False) + # For Mines Act Permits, condition categories can be unique to a mine. + # This is not the case for other permit types where the condition categories are standard (e.g. General Health and Safety etc.) + # Reasoning: The condition categories have changed over the years, and we need to be able to reflect the correct category + # for the permit at the time the permit was issued even if the category has since been updated. + permit_amendment_id = db.Column(db.Integer, db.ForeignKey('permit_amendment.permit_amendment_id'), nullable=True) + def __repr__(self): return '' % self.condition_category_code + @classmethod + def create(cls, + condition_category_code, + step, + description, + display_order, + permit_amendment_id, + ): + permit_condition_category = PermitConditionCategory( + condition_category_code=str(condition_category_code), + step=step, + description=description, + display_order=display_order, + permit_amendment_id=permit_amendment_id + ) + permit_condition_category.save() + return permit_condition_category + @classmethod def get_all(cls): - return cls.query.order_by(cls.display_order).all() + return cls.query \ + .filter_by(permit_amendment_id=None, deleted_ind=False) \ + .order_by(cls.display_order) \ + .all() + + @classmethod + def search(cls, query=None, exclude=None, limit=None): + quer = cls.query \ + .filter_by(deleted_ind=False) + + if query: + quer = quer.filter(db.func.lower(cls.description).ilike(f'%{query.lower()}%')) + else: + # Return general categories if you're not searching for anything specific + quer = quer.filter_by(permit_amendment_id=None) + + if exclude: + quer = quer.filter(~db.func.lower(cls.condition_category_code).in_([e.lower() for e in exclude])) + + # Make sure we only return distinct descriptions + # and order by display order + quer = quer.distinct(cls.description) \ + .from_self() \ + .order_by(cls.display_order) + + if limit: + quer = quer.limit(limit) + elif query: + quer = quer.limit(7) + + return quer.all() @classmethod def find_by_permit_condition_category_code(cls, code): - return cls.query.filter_by(condition_category_code=code, active_ind=True).one_or_none() \ No newline at end of file + return cls.query.filter_by(condition_category_code=code, active_ind=True, deleted_ind=False).one_or_none() + + @classmethod + def find_by_permit_amendment_id_and_description(cls, permit_amendment_id, description): + return cls.query.filter_by(permit_amendment_id=permit_amendment_id, description=description, deleted_ind=False).one_or_none() + + @classmethod + def find_by_permit_amendment_id(cls, permit_amendment_id): + return cls.query \ + .filter_by(permit_amendment_id=permit_amendment_id, deleted_ind=False) \ + .order_by(cls.display_order) \ + .all() + @classmethod + def delete_all_by_permit_amendment_id(cls, permit_amendment_id): + to_delete = cls.query \ + .filter_by( + permit_amendment_id=permit_amendment_id, + deleted_ind=False + ).all() + + for cat in to_delete: + cat.delete(commit=False) + + db.session.commit() diff --git a/services/core-api/app/api/mines/permits/permit_conditions/models/permit_conditions.py b/services/core-api/app/api/mines/permits/permit_conditions/models/permit_conditions.py index 93b57e1508..a4312a69d8 100644 --- a/services/core-api/app/api/mines/permits/permit_conditions/models/permit_conditions.py +++ b/services/core-api/app/api/mines/permits/permit_conditions/models/permit_conditions.py @@ -32,6 +32,9 @@ class _ModelSchema(Base._ModelSchema): db.String, db.ForeignKey('permit_condition_category.condition_category_code'), nullable=False) + + condition_category = db.relationship('PermitConditionCategory', lazy='select') + condition_type_code = db.Column( db.String, db.ForeignKey('permit_condition_type.condition_type_code'), nullable=False) parent_permit_condition_id = db.Column(db.Integer, @@ -115,18 +118,20 @@ def delete_all_by_permit_amendment_id(cls, permit_amendment_id, commit=False): parent_permit_condition_id=None, deleted_ind=False).order_by(cls.display_order).all() for condition in parent_conditions: - condition.delete_condition() + condition.delete_condition(commit=commit) if commit: condition.save() - def delete_condition(self): + def delete_condition(self, commit=False): if self.all_sub_conditions is not None: subconditions = [c for c in self.all_sub_conditions if c.deleted_ind == False] if len(subconditions) > 0: for item in subconditions: item.deleted_ind = True - item.delete_condition() + item.delete_condition(commit=commit) + if commit: + item.save() self.deleted_ind = True @@ -146,3 +151,8 @@ def find_by_permit_condition_guid(cls, permit_condition_guid): def find_by_permit_condition_id(cls, permit_condition_id): return cls.query.filter_by( permit_condition_id=permit_condition_id, deleted_ind=False).first() + + @classmethod + def find_by_condition_category_code(cls, condition_category_code): + return cls.query.filter_by( + condition_category_code=condition_category_code, deleted_ind=False).all() diff --git a/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_list_resource.py b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_list_resource.py new file mode 100644 index 0000000000..1320e843c1 --- /dev/null +++ b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_list_resource.py @@ -0,0 +1,66 @@ + +import uuid + +from app.api.mines.mine.models.mine import Mine +from app.api.mines.permits.permit.models.permit import Permit +from app.api.mines.permits.permit_amendment.models.permit_amendment import ( + PermitAmendment, +) +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from app.api.mines.response_models import PERMIT_CONDITION_CATEGORY_MODEL +from app.api.utils.access_decorators import requires_role_view_all +from app.api.utils.resources_mixins import UserMixin +from app.extensions import api +from flask import request +from flask_restx import Resource, reqparse +from werkzeug.exceptions import BadRequest + +from .permit_amendment_condition_category_resource_base import ( + validate_permit_amendment_category, +) + + +class PermitAmendmentConditionCategoryListResource(Resource, UserMixin): + parser = reqparse.RequestParser() + parser.add_argument('step', type=str, required=True, help='Step number is required') + parser.add_argument('description', type=str, required=True, help='Description is required') + parser.add_argument('display_order', type=int, required=True, help='Display order is required') + + + @requires_role_view_all + @api.doc(description='Get a list of permit condition categories for the given permit amendment') + @api.marshal_with(PERMIT_CONDITION_CATEGORY_MODEL, code=200, envelope='records') + def get(self, mine_guid, permit_guid, permit_amendment_guid): + mine, permit, permit_amendment, category = validate_permit_amendment_category(mine_guid, permit_guid, permit_amendment_guid) + + categories = PermitConditionCategory.find_by_permit_amendment_id(permit_amendment.permit_amendment_id) + return categories + + @requires_role_view_all + @api.doc(description='Creates a new permit condition category for the given permit amendment') + @api.marshal_with(PERMIT_CONDITION_CATEGORY_MODEL, code=201) + @requires_role_view_all + def post(self, mine_guid, permit_guid, permit_amendment_guid): + mine, permit, permit_amendment, category = validate_permit_amendment_category(mine_guid, permit_guid, permit_amendment_guid) + + data = self.parser.parse_args() + + existing_category = PermitConditionCategory.find_by_permit_amendment_id_and_description( + permit_amendment.permit_amendment_id, description=data['description'] + ) + + if existing_category: + return existing_category, 201 + + condition_category_code = uuid.uuid4() + + permit_condition_category = PermitConditionCategory.create( + permit_amendment_id=permit_amendment.permit_amendment_id, + condition_category_code=condition_category_code, + step=data['step'], + description=data['description'], + display_order=data['display_order'], + ) + return permit_condition_category, 201 diff --git a/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource.py b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource.py new file mode 100644 index 0000000000..178f88a5f0 --- /dev/null +++ b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource.py @@ -0,0 +1,81 @@ + + +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from app.api.mines.permits.permit_conditions.models.permit_conditions import ( + PermitConditions, +) +from app.api.mines.response_models import PERMIT_CONDITION_CATEGORY_MODEL +from app.api.utils.access_decorators import requires_role_view_all +from app.api.utils.resources_mixins import UserMixin +from app.extensions import api +from flask_restx import Resource, reqparse +from werkzeug.exceptions import BadRequest + +from .permit_amendment_condition_category_resource_base import ( + validate_permit_amendment_category, +) + + +class PermitAmendmentConditionCategoryResource(Resource, UserMixin): + parser = reqparse.RequestParser() + parser.add_argument('step', type=str, required=True, help='Step number is required') + parser.add_argument('description', type=str, required=True, help='Description is required') + parser.add_argument('display_order', type=int, required=True, help='Display order is required') + + @requires_role_view_all + @api.doc(description='Deletes a permit condition category') + def delete(self, mine_guid, permit_guid, permit_amendment_guid, permit_condition_category_code): + mine, permit, permit_amendment, category = validate_permit_amendment_category( + mine_guid, permit_guid, permit_amendment_guid, permit_condition_category_code) + + conditions = PermitConditions.find_by_condition_category_code( + permit_condition_category_code) + + if len(conditions) > 0: + raise BadRequest('Cannot delete a category that has conditions associated with it') + + category.delete(commit=True) + + return {'message': 'Permit condition category deleted successfully'}, 204 + + @api.doc(description='Creates a new permit condition category for the given permit amendment. Reorders existing categories if display_order has changed.') + @api.marshal_with(PERMIT_CONDITION_CATEGORY_MODEL, code=201) + @requires_role_view_all + def put(self, mine_guid, permit_guid, permit_amendment_guid, permit_condition_category_code): + data = self.parser.parse_args() + + mine, permit, permit_amendment, existing_category = validate_permit_amendment_category( + mine_guid, permit_guid, permit_amendment_guid, permit_condition_category_code) + + categories = PermitConditionCategory.find_by_permit_amendment_id(permit_amendment.permit_amendment_id) + + if data['display_order'] > len(categories): + raise BadRequest('Display order cannot be greater than the number of categories') + + if data['display_order'] < 0: + raise BadRequest('Display order must be >= 0') + + existing_category.step = data['step'] + existing_category.description = data['description'] + + # Only reorder if the display order has changed + if existing_category.display_order != data['display_order']: + existing_category.display_order = data['display_order'] + + categories = sorted(categories, key=lambda x: x.display_order) + # Remove the current category from the list since it will be repositioned + categories = [c for c in categories if c.condition_category_code != existing_category.condition_category_code] + + # Insert the category at the new position + categories.insert(data['display_order'], existing_category) + + # Update all display orders to ensure sequential ordering + for index, category in enumerate(categories): + category.display_order = index + category.save() + + existing_category.save() + + return existing_category, 200 diff --git a/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource_base.py b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource_base.py new file mode 100644 index 0000000000..d680dafe63 --- /dev/null +++ b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_amendment_condition_category_resource_base.py @@ -0,0 +1,43 @@ +from app.api.mines.mine.models.mine import Mine +from app.api.mines.permits.permit.models.permit import Permit +from app.api.mines.permits.permit_amendment.models.permit_amendment import ( + PermitAmendment, +) +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from werkzeug.exceptions import BadRequest + + +def validate_permit_amendment_category(mine_guid, permit_guid, permit_amendment_guid, permit_condition_category_code=None): + mine = Mine.find_by_mine_guid(mine_guid) + if not mine: + raise BadRequest('Mine not found.') + + permit = None + try: + permit = Permit.find_by_permit_guid(permit_guid, mine_guid) + except IndexError: + raise BadRequest('Permit mine_guid and supplied mine_guid mismatch.') + + if not permit: + raise BadRequest('Permit not found.') + + permit_amendment = PermitAmendment.find_by_permit_amendment_guid(permit_amendment_guid) + if not permit_amendment: + raise BadRequest("Permit Amendment not found.") + + if str(permit_amendment.permit_guid) != str(permit_guid): + raise BadRequest('Permit Amendment permit guid and supplied permit_guid mismatch.') + + if permit_condition_category_code: + category = PermitConditionCategory.find_by_permit_condition_category_code(permit_condition_category_code) + if not category: + raise BadRequest('Permit Condition Category not found.') + + if category.permit_amendment_id != permit_amendment.permit_amendment_id: + raise BadRequest('Permit category is not associated with this permit amendment.') + else: + category = None + + return mine, permit, permit_amendment, category diff --git a/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_condition_category_resource.py b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_condition_category_resource.py index ec7b8aa6a6..58d14a9a6d 100644 --- a/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_condition_category_resource.py +++ b/services/core-api/app/api/mines/permits/permit_conditions/resources/permit_condition_category_resource.py @@ -1,16 +1,28 @@ -from flask_restx import Resource, reqparse from datetime import datetime -from flask import current_app, request -from app.api.mines.permits.permit_conditions.models.permit_condition_category import PermitConditionCategory -from app.extensions import api +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from app.api.mines.response_models import PERMIT_CONDITION_CATEGORY_MODEL from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.resources_mixins import UserMixin -from app.api.mines.response_models import PERMIT_CONDITION_CATEGORY_MODEL +from app.extensions import api +from flask import current_app, request +from flask_restx import Resource, reqparse class PermitConditionCategoryResource(Resource, UserMixin): + reqparser = reqparse.RequestParser() + reqparser.add_argument('query', type=str, required=False) + reqparser.add_argument('exclude', type=str, required=False, action='append') + reqparser.add_argument('limit', type=int, required=False) + @requires_role_view_all @api.marshal_with(PERMIT_CONDITION_CATEGORY_MODEL, envelope='records', code=200) def get(self): - return PermitConditionCategory.get_all() + data = self.reqparser.parse_args() + return PermitConditionCategory.search( + query = data['query'], + exclude = data.get('exclude'), + limit = data.get('limit') or 7, + ) diff --git a/services/core-api/app/api/mines/permits/permit_extraction/create_permit_conditions.py b/services/core-api/app/api/mines/permits/permit_extraction/create_permit_conditions.py index 6e12329710..8cddcf9180 100644 --- a/services/core-api/app/api/mines/permits/permit_extraction/create_permit_conditions.py +++ b/services/core-api/app/api/mines/permits/permit_extraction/create_permit_conditions.py @@ -2,6 +2,9 @@ from difflib import SequenceMatcher from typing import List, Optional +from app.api.mines.permits.permit_amendment.models.permit_amendment import ( + PermitAmendment, +) from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( PermitConditionCategory, ) @@ -28,8 +31,8 @@ 5: 'LIS', } -# For conditions that don't match any category, put them in the "General" category -DEFAULT_CATEGORY = 'GEC' +# For conditions that don't match any category, put them in a "Terms and conditions" category +DEFAULT_CATEGORY_TEXT = 'Terms and Conditions' def create_permit_conditions_from_task(task: PermitExtractionTask): """ @@ -37,37 +40,56 @@ def create_permit_conditions_from_task(task: PermitExtractionTask): """ result = task.task_result last_condition_id_by_hierarchy = {} - condition_categories = PermitConditionCategory.get_all() current_category = None result = CreatePermitConditionsResult.model_validate(result) - has_category = any([condition.is_top_level_section and bool(_map_condition_to_category(condition_categories, condition)) for condition in result.conditions]) + has_category = any([condition.is_top_level_section for condition in result.conditions]) conditions = result.conditions if not has_category: top_level_section = PermitConditionResult( section='A', - condition_text='General' + condition_text=DEFAULT_CATEGORY_TEXT ) for c in conditions: c.set_section(top_level_section) conditions = [top_level_section] + conditions + num_categories = 0 + + default_section = None + for idx, condition in enumerate(conditions): - if condition.is_top_level_section: - section_category = _map_condition_to_category(condition_categories, condition) - - if section_category: - current_category = section_category + section_category = _create_permit_condition_category( + condition=condition, + permit_amendment=task.permit_amendment, + display_order=num_categories, + step=condition.step + ) + if condition.condition_text == DEFAULT_CATEGORY_TEXT: + default_section = section_category + current_category = section_category + num_categories += 1 else: parent = _determine_parent(condition, last_condition_id_by_hierarchy) type_code = _map_condition_to_type_code(condition) title_cond = None - category_code = current_category or DEFAULT_CATEGORY + if not current_category and not default_section: + default_section = _create_permit_condition_category( + condition=PermitConditionResult( + section='A', + condition_text=DEFAULT_CATEGORY_TEXT + ), + permit_amendment=task.permit_amendment, + display_order=num_categories, + step='A' + ) + + category_code = current_category or default_section if condition.condition_title: title_cond = _create_title_condition(task, category_code, condition, parent, idx, type_code) @@ -155,7 +177,7 @@ def _determine_parent(condition: PermitConditionResult, last_condition_id_by_num parent = last_condition_id_by_number_structure.get(parent_key) return parent -def _map_condition_to_category(condition_categories: List[PermitConditionCategory], condition: PermitConditionResult) -> Optional[str]: +def _create_permit_condition_category(condition: PermitConditionResult, permit_amendment: PermitAmendment, display_order: int, step: str) -> Optional[str]: """ Finds the matching PermitConditionCategory code for the given condition based on the title or text it contains. @@ -172,13 +194,17 @@ def _map_condition_to_category(condition_categories: List[PermitConditionCategor condition: Condition object """ - for cat in condition_categories: - desc = cat.description.lower().replace('conditions', '') - text = condition.condition_title if condition.condition_title else condition.condition_text - text = text.lower().replace('conditions', '') - - if SequenceMatcher(None, desc, text).ratio() > 0.6: - return cat.condition_category_code - return None + + text = condition.condition_title if condition.condition_title else condition.condition_text + + cat = PermitConditionCategory.create( + condition_category_code=str(uuid.uuid4()), + description=text, + display_order=display_order, + permit_amendment_id=permit_amendment.permit_amendment_id, + step=step + ) + + return cat.condition_category_code diff --git a/services/core-api/app/api/mines/permits/permit_extraction/models/permit_condition_result.py b/services/core-api/app/api/mines/permits/permit_extraction/models/permit_condition_result.py index fc4c8d6e36..cb8a80a32e 100644 --- a/services/core-api/app/api/mines/permits/permit_extraction/models/permit_condition_result.py +++ b/services/core-api/app/api/mines/permits/permit_extraction/models/permit_condition_result.py @@ -27,7 +27,16 @@ def numbering_structure(self) -> List[str]: @computed_field def is_top_level_section(self) -> bool: # A condition is a top level section if it has a section but no other numbering - return self.section and not self.paragraph and not self.subparagraph and not self.clause and not self.subclause and not self.subsubclause + # and the section is an uppercase letter + return bool( + self.section + and self.section.isupper() + and not self.paragraph + and not self.subparagraph + and not self.clause + and not self.subclause + and not self.subsubclause + ) @computed_field def step(self) -> str: diff --git a/services/core-api/app/api/mines/permits/permit_extraction/models/response_model.py b/services/core-api/app/api/mines/permits/permit_extraction/models/response_model.py index 8ffa5de720..b74737d155 100644 --- a/services/core-api/app/api/mines/permits/permit_extraction/models/response_model.py +++ b/services/core-api/app/api/mines/permits/permit_extraction/models/response_model.py @@ -11,6 +11,7 @@ PERMIT_CONDITION_EXTRACTION_TASK = api.model( 'PermitExtractionTask', { + 'create_timestamp': fields.DateTime, 'permit_extraction_task_id': fields.String, 'task_id': fields.String, 'task_status': fields.String, diff --git a/services/core-api/app/api/mines/permits/permit_extraction/resources/permit_condition_extraction_resource.py b/services/core-api/app/api/mines/permits/permit_extraction/resources/permit_condition_extraction_resource.py index f402addae9..49308e924c 100644 --- a/services/core-api/app/api/mines/permits/permit_extraction/resources/permit_condition_extraction_resource.py +++ b/services/core-api/app/api/mines/permits/permit_extraction/resources/permit_condition_extraction_resource.py @@ -4,6 +4,9 @@ from app.api.mines.permits.permit_amendment.models.permit_amendment_document import ( PermitAmendmentDocument, ) +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) from app.api.mines.permits.permit_conditions.models.permit_conditions import ( PermitConditions, ) @@ -117,6 +120,7 @@ def delete(self): args = parser.parse_args() PermitConditions.delete_all_by_permit_amendment_id(args['permit_amendment_id'], commit=True) + PermitConditionCategory.delete_all_by_permit_amendment_id(args['permit_amendment_id']) class PermitConditionExtractionProgressResource(Resource, UserMixin): diff --git a/services/core-api/app/api/mines/response_models.py b/services/core-api/app/api/mines/response_models.py index 4b91c68aaf..cbd117ff97 100644 --- a/services/core-api/app/api/mines/response_models.py +++ b/services/core-api/app/api/mines/response_models.py @@ -1,13 +1,18 @@ -from flask_restx import fields, marshal - from app.api.compliance.response_models import COMPLIANCE_ARTICLE_MODEL from app.api.dams.dto import DAM_MODEL -from app.api.mines.reports.models.mine_report_permit_requirement import CimOrCpo, OfficeDestination -from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointmentStatus, MinePartyAcknowledgedStatus +from app.api.mines.reports.models.mine_report_permit_requirement import ( + CimOrCpo, + OfficeDestination, +) +from app.api.parties.party_appt.models.mine_party_appt import ( + MinePartyAcknowledgedStatus, + MinePartyAppointmentStatus, +) from app.api.parties.response_models import PARTY +from app.api.utils.feature_flag import Feature, is_feature_enabled from app.extensions import api +from flask_restx import fields, marshal -from app.api.utils.feature_flag import is_feature_enabled, Feature class DateTime(fields.Raw): @@ -256,6 +261,14 @@ def format(self, value): } ) +PERMIT_CONDITION_CATEGORY_MODEL = api.model( + 'PermitConditionCategory', { + 'condition_category_code': fields.String, + 'step': fields.String, + 'description': fields.String, + 'display_order': fields.Integer + }) + PERMIT_AMENDMENT_MODEL = api.model( 'PermitAmendment', { 'permit_amendment_id': @@ -310,7 +323,8 @@ def format(self, value): fields.Boolean, 'preamble_text': fields.String, - 'mine_report_permit_requirements': fields.List(fields.Nested(MINE_REPORT_PERMIT_REQUIREMENT)) + 'mine_report_permit_requirements': fields.List(fields.Nested(MINE_REPORT_PERMIT_REQUIREMENT)), + 'condition_categories': fields.List(fields.Nested(PERMIT_CONDITION_CATEGORY_MODEL)) }) BOND_MODEL = api.model('Bond_guid', {'bond_guid': fields.String}) @@ -869,6 +883,7 @@ def format(self, value): 'orders': fields.List(fields.Nested(ORDER_MODEL)), }) + PERMIT_CONDITION_MODEL = api.model( 'PermitCondition', { 'permit_condition_id': fields.Integer, @@ -888,14 +903,6 @@ def format(self, value): 'sub_conditions': fields.List(PermitConditionTemplate), }) -PERMIT_CONDITION_CATEGORY_MODEL = api.model( - 'PermitConditionCategory', { - 'condition_category_code': fields.String, - 'step': fields.String, - 'description': fields.String, - 'display_order': fields.Integer - }) - PERMIT_CONDITION_TYPE_MODEL = api.model('PermitConditionType', { 'condition_type_code': fields.String, 'description': fields.String, diff --git a/services/core-api/app/api/projects/project_summary/models/project_summary.py b/services/core-api/app/api/projects/project_summary/models/project_summary.py index d6c4ac25be..5cc169a715 100644 --- a/services/core-api/app/api/projects/project_summary/models/project_summary.py +++ b/services/core-api/app/api/projects/project_summary/models/project_summary.py @@ -1,36 +1,43 @@ -from flask import current_app -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.dialects.postgresql import UUID - -from sqlalchemy.schema import FetchedValue -from sqlalchemy import case +import json +from app.api.constants import MDS_EMAIL, PERM_RECL_EMAIL, PROJECT_SUMMARY_EMAILS +from app.api.mines.documents.models.mine_document import MineDocument from app.api.mines.documents.models.mine_document_bundle import MineDocumentBundle +from app.api.mines.mine.models.mine import Mine from app.api.parties.party import PartyOrgBookEntity +from app.api.parties.party.models.address import Address +from app.api.parties.party.models.party import Party +from app.api.projects.project.models.project import Project +from app.api.projects.project_summary.models.project_summary_authorization import ( + ProjectSummaryAuthorization, +) +from app.api.projects.project_summary.models.project_summary_authorization_document_xref import ( + ProjectSummaryAuthorizationDocumentXref, +) +from app.api.projects.project_summary.models.project_summary_document_xref import ( + ProjectSummaryDocumentXref, +) from app.api.regions.models.regions import Regions from app.api.services.ams_api_service import AMSApiService -from app.extensions import db - -from app.api.utils.models_mixins import SoftDeleteMixin, AuditMixin, Base -from app.api.projects.project_summary.models.project_summary_document_xref import ProjectSummaryDocumentXref -from app.api.mines.mine.models.mine import Mine -from app.api.mines.documents.models.mine_document import MineDocument -from app.api.projects.project.models.project import Project -from app.api.projects.project_summary.models.project_summary_authorization import ProjectSummaryAuthorization -from app.api.projects.project_summary.models.project_summary_authorization_document_xref import \ - ProjectSummaryAuthorizationDocumentXref -from app.api.parties.party.models.party import Party -from app.api.parties.party.models.address import Address -from app.api.constants import PROJECT_SUMMARY_EMAILS, MDS_EMAIL, PERM_RECL_EMAIL from app.api.services.email_service import EmailService +from app.api.utils.common_validation_schemas import ( + address_int_schema, + address_na_schema, + base_address_schema, + party_base_schema, + primary_address_schema, + project_summary_base_schema, +) +from app.api.utils.feature_flag import Feature, is_feature_enabled +from app.api.utils.models_mixins import AuditMixin, Base, SoftDeleteMixin from app.config import Config +from app.extensions import db from cerberus import Validator -import json - -from app.api.utils.feature_flag import is_feature_enabled, Feature - -from app.api.utils.common_validation_schemas import primary_address_schema, base_address_schema, address_na_schema, \ - address_int_schema, party_base_schema, project_summary_base_schema +from flask import current_app +from sqlalchemy import case +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.schema import FetchedValue class ProjectSummary(SoftDeleteMixin, AuditMixin, Base): @@ -129,7 +136,8 @@ class ProjectSummary(SoftDeleteMixin, AuditMixin, Base): ) municipality = db.relationship( - 'Municipality', lazy='joined', foreign_keys=nearest_municipality_guid + 'Municipality', lazy='joined', foreign_keys=nearest_municipality_guid, + overlaps="nearest_municipality" ) payment_contact = db.relationship( diff --git a/services/core-api/app/api/projects/project_summary/models/project_summary_authorization.py b/services/core-api/app/api/projects/project_summary/models/project_summary_authorization.py index b6cacf254c..57944e806b 100644 --- a/services/core-api/app/api/projects/project_summary/models/project_summary_authorization.py +++ b/services/core-api/app/api/projects/project_summary/models/project_summary_authorization.py @@ -1,17 +1,22 @@ +import json from datetime import datetime -from sqlalchemy.dialects.postgresql import UUID +from app.api.mines.documents.models.mine_document import MineDocument +from app.api.projects.project_summary.models.project_summary_authorization_document_xref import ( + ProjectSummaryAuthorizationDocumentXref, +) +from app.api.projects.project_summary.models.project_summary_authorization_type import ( + ProjectSummaryAuthorizationType, +) +from app.api.projects.project_summary.models.project_summary_permit_type import ( + ProjectSummaryPermitType, +) +from app.api.utils.models_mixins import AuditMixin, Base, SoftDeleteMixin +from app.extensions import db from cerberus import Validator -import json - +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.schema import FetchedValue -from app.extensions import db -from app.api.utils.models_mixins import SoftDeleteMixin, AuditMixin, Base -from app.api.projects.project_summary.models.project_summary_authorization_type import ProjectSummaryAuthorizationType -from app.api.projects.project_summary.models.project_summary_permit_type import ProjectSummaryPermitType -from app.api.mines.documents.models.mine_document import MineDocument -from app.api.projects.project_summary.models.project_summary_authorization_document_xref import ProjectSummaryAuthorizationDocumentXref class ProjectSummaryAuthorization(SoftDeleteMixin, AuditMixin, Base): __tablename__ = 'project_summary_authorization' @@ -50,43 +55,50 @@ def __repr__(self): location_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "MAP", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "MAP", MineDocument.is_archived == False)', + overlaps="amendment_documents" ) discharge_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "DFA", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "DFA", MineDocument.is_archived == False)', + overlaps="amendment_documents,location_documents" ) consent_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CSL", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CSL", MineDocument.is_archived == False)', + overlaps="amendment_documents,discharge_documents,location_documents" ) clause_amendment_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CAF", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CAF", MineDocument.is_archived == False)', + overlaps="amendment_documents,consent_documents,discharge_documents,location_documents" ) change_ownership_name_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CON", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "CON", MineDocument.is_archived == False)', + overlaps="amendment_documents,change_ownership_name_documents,clause_amendment_documents,consent_documents,discharge_documents,location_documents" ) exemption_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "EXL", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "EXL", MineDocument.is_archived == False)', + overlaps="amendment_documents,change_ownership_name_documents,clause_amendment_documents,consent_documents,discharge_documents,location_documents" ) support_documents = db.relationship( 'ProjectSummaryAuthorizationDocumentXref', lazy='select', - primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "SPR", MineDocument.is_archived == False)' + primaryjoin='and_(ProjectSummaryAuthorizationDocumentXref.project_summary_authorization_guid == ProjectSummaryAuthorization.project_summary_authorization_guid, ProjectSummaryAuthorizationDocumentXref.mine_document_guid == MineDocument.mine_document_guid, ProjectSummaryAuthorizationDocumentXref.project_summary_document_type_code == "SPR", MineDocument.is_archived == False)', + overlaps="amendment_documents,change_ownership_name_documents,clause_amendment_documents,consent_documents,discharge_documents,exemption_documents,location_documents" ) @classmethod diff --git a/services/core-api/app/api/utils/static_data.py b/services/core-api/app/api/utils/static_data.py index 3b3b56693e..52f2581bbf 100644 --- a/services/core-api/app/api/utils/static_data.py +++ b/services/core-api/app/api/utils/static_data.py @@ -1,9 +1,9 @@ +from app.api.constants import STATIC_DATA +from app.api.now_applications import models as app_models from flask import current_app -from sqlalchemy.inspection import inspect from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.inspection import inspect -from app.api.now_applications import models as app_models -from app.api.constants import STATIC_DATA """ This function is run right before setup_marshmallow and it looks through all of tables in our database. It creates a mapping of classes to lists if their PK's, this is only done for code tables. To find the code tables @@ -24,22 +24,24 @@ def setup_static_data(Base): for col in mapper.columns: if col.name == 'active_ind': if type(pk.type) != UUID and pk.type.python_type == str: - STATIC_DATA[class_.__name__] = [ - a for a, in class_.query.unbound_unsafe().with_entities( - getattr(class_, pk.name, None)).filter_by( - active_ind=True).all() - ] + if hasattr(class_, 'query') and hasattr(class_.query, 'unbound_unsafe') and callable(class_.query.unbound_unsafe): + STATIC_DATA[class_.__name__] = [ + a for a, in class_.query.unbound_unsafe().with_entities( + getattr(class_, pk.name, None)).filter_by( + active_ind=True).all() + ] # This section is specific to NoW_submissions. Some of the code values that NROS and vFCBC send are # in long form so they are stored in the descriptions of the code tables so the descriptions of those # tables are also added under a (class name)_description in STATIC_DATA. if class_ in app_models.model_list and col.name == 'description': if type(pk.type) != UUID and pk.type.python_type == str: - STATIC_DATA[f'{class_.__name__}_description'] = [ - a for a, in class_.query.unbound_unsafe().with_entities( - getattr(class_, 'description', None)).filter_by( - active_ind=True).all() - ] + if hasattr(class_, 'query') and hasattr(class_.query, 'unbound_unsafe') and callable(class_.query.unbound_unsafe): + STATIC_DATA[f'{class_.__name__}_description'] = [ + a for a, in class_.query.unbound_unsafe().with_entities( + getattr(class_, 'description', None)).filter_by( + active_ind=True).all() + ] except Exception as e: current_app.logger.error(class_.__name__) diff --git a/services/core-api/app/flask_jwt_oidc_local/jwt_manager.py b/services/core-api/app/flask_jwt_oidc_local/jwt_manager.py index 11e75f0958..706e5e9d57 100644 --- a/services/core-api/app/flask_jwt_oidc_local/jwt_manager.py +++ b/services/core-api/app/flask_jwt_oidc_local/jwt_manager.py @@ -21,9 +21,9 @@ import ssl # pylint: disable=unused-import # noqa: F401; for local hacks from functools import wraps +import jwt from cachelib import SimpleCache from flask import current_app, g, jsonify, request -import jwt from six.moves.urllib.request import urlopen from .exceptions import AuthError @@ -53,7 +53,6 @@ def __init__(self, app=None, well_known_config=None, well_known_obj_cache=None, self.jwt_oidc_test_private_key_pem = jwt_oidc_test_private_key_pem self.jwt_role_callback = jwt_role_callback - print("Running constructor") if app is not None: self.init_app(app) diff --git a/services/core-api/tests/mines/permit/resources/test_permit_amendment_condition_category_resource.py b/services/core-api/tests/mines/permit/resources/test_permit_amendment_condition_category_resource.py new file mode 100644 index 0000000000..b4f69d211d --- /dev/null +++ b/services/core-api/tests/mines/permit/resources/test_permit_amendment_condition_category_resource.py @@ -0,0 +1,411 @@ +import json +import uuid + +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from tests.factories import MineFactory, PermitConditionsFactory, create_mine_and_permit + +PERMIT_CONDITION_CATEGORY_DATA = { + 'step': "A", + 'description': 'Test Category', + 'display_order': 1 +} + +def test_post_permit_condition_category_missing_step(test_client, db_session, auth_headers): + """Should return 400 if step is missing from payload""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + data = {k: v for k, v in PERMIT_CONDITION_CATEGORY_DATA.items() if k != 'step'} + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories', + headers=auth_headers['full_auth_header'], + json=data) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert 'step' in post_data['errors'] + +def test_post_permit_condition_category_missing_description(test_client, db_session, auth_headers): + """Should return 400 if description is missing from payload""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + data = {k: v for k, v in PERMIT_CONDITION_CATEGORY_DATA.items() if k != 'description'} + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories', + headers=auth_headers['full_auth_header'], + json=data) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert 'description' in post_data['errors'] + +def test_post_permit_condition_category_missing_display_order(test_client, db_session, auth_headers): + """Should return 400 if display_order is missing from payload""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + data = {k: v for k, v in PERMIT_CONDITION_CATEGORY_DATA.items() if k != 'display_order'} + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories', + headers=auth_headers['full_auth_header'], + json=data) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert 'display_order' in post_data['errors'] + +def test_post_permit_condition_category(test_client, db_session, auth_headers): + """Should create a new permit condition category""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories', + headers=auth_headers['full_auth_header'], + json=PERMIT_CONDITION_CATEGORY_DATA) + assert post_resp.status_code == 201 + post_data = json.loads(post_resp.data.decode()) + assert post_data['description'] == PERMIT_CONDITION_CATEGORY_DATA['description'] + assert post_data['step'] == PERMIT_CONDITION_CATEGORY_DATA['step'] + assert post_data['display_order'] == PERMIT_CONDITION_CATEGORY_DATA['display_order'] + +def test_post_permit_condition_category_mine_not_found(test_client, db_session, auth_headers): + """Should return 400 if mine not found""" + post_resp = test_client.post( + f'/mines/{uuid.uuid4()}/permits/{uuid.uuid4()}/amendments/{uuid.uuid4()}/condition-categories', + headers=auth_headers['full_auth_header'], + json=PERMIT_CONDITION_CATEGORY_DATA) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert post_data['message'] == '400 Bad Request: Mine not found.' + +def test_post_permit_condition_category_permit_not_found(test_client, db_session, auth_headers): + """Should return 400 if permit not found""" + mine = MineFactory() + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{uuid.uuid4()}/amendments/{uuid.uuid4()}/condition-categories', + headers=auth_headers['full_auth_header'], + json=PERMIT_CONDITION_CATEGORY_DATA) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert post_data['message'] == '400 Bad Request: Permit not found.' + +def test_post_permit_condition_category_permit_amendment_not_found(test_client, db_session, auth_headers): + """Should return 400 if permit amendment not found""" + mine, permit = create_mine_and_permit() + + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{uuid.uuid4()}/condition-categories', + headers=auth_headers['full_auth_header'], + json=PERMIT_CONDITION_CATEGORY_DATA) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert post_data['message'] == '400 Bad Request: Permit Amendment not found.' + +def test_post_permit_condition_category_mine_amendment_permit_mismatch(test_client, db_session, auth_headers): + """Should return 400 if mine and don't match""" + + mine, permit = create_mine_and_permit() + other_mine, other_permit = create_mine_and_permit() + post_resp = test_client.post( + f'/mines/{mine.mine_guid}/permits/{other_permit.permit_guid}/amendments/{uuid.uuid4()}/condition-categories', + headers=auth_headers['full_auth_header'], + json=PERMIT_CONDITION_CATEGORY_DATA) + + assert post_resp.status_code == 400 + post_data = json.loads(post_resp.data.decode()) + assert post_data['message'] == '400 Bad Request: Permit mine_guid and supplied mine_guid mismatch.' + +def test_delete_permit_condition_category(test_client, db_session, auth_headers): + """Should delete a permit condition category""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + code = str(uuid.uuid4()) + PermitConditionCategory.create( + condition_category_code=code, + step='a', + description='Test category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + categories = PermitConditionCategory.find_by_permit_amendment_id(permit_amendment.permit_amendment_id) + + # Delete the category + delete_resp = test_client.delete( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{code}', + headers=auth_headers['full_auth_header']) + + assert delete_resp.status_code == 204 + + after_categories = PermitConditionCategory.find_by_permit_amendment_id(permit_amendment.permit_amendment_id) + + assert len(after_categories) == len(categories) - 1 + +def test_delete_permit_condition_category_with_conditions(test_client, db_session, auth_headers): + """Should return 400 if category has conditions""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create category + code = str(uuid.uuid4()) + PermitConditionCategory.create( + condition_category_code=code, + step='a', + description='Test category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + PermitConditionsFactory(condition_category_code=code, permit_amendment=permit_amendment) + # Try to delete category + delete_resp = test_client.delete( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{code}', + headers=auth_headers['full_auth_header']) + + assert delete_resp.status_code == 400 + delete_data = json.loads(delete_resp.data.decode()) + assert delete_data['message'] == '400 Bad Request: Cannot delete a category that has conditions associated with it' + + +def test_delete_permit_condition_category_not_associated(test_client, db_session, auth_headers): + """Should return 400 if category is not associated with permit amendment""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create category + code = str(uuid.uuid4()) + PermitConditionCategory.create( + condition_category_code=code, + step='a', + description='Test category', + display_order=1, + permit_amendment_id=None + ) + + PermitConditionsFactory(condition_category_code=code, permit_amendment=permit_amendment) + # Try to delete category + delete_resp = test_client.delete( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{code}', + headers=auth_headers['full_auth_header']) + + assert delete_resp.status_code == 400 + delete_data = json.loads(delete_resp.data.decode()) + assert delete_data['message'] == '400 Bad Request: Permit category is not associated with this permit amendment.' + +def test_delete_permit_condition_category_not_found(test_client, db_session, auth_headers): + """Should return 400 if category not found""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + delete_resp = test_client.delete( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{uuid.uuid4()}', + headers=auth_headers['full_auth_header']) + + assert delete_resp.status_code == 400 + delete_data = json.loads(delete_resp.data.decode()) + assert delete_data['message'] == '400 Bad Request: Permit Condition Category not found.' + +def test_put_permit_condition_category(test_client, db_session, auth_headers): + """Should update an existing permit condition category""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create initial category + code = str(uuid.uuid4()) + category = PermitConditionCategory.create( + condition_category_code=code, + step='a', + description='Initial category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + update_data = { + 'step': 'b', + 'description': 'Updated category', + 'display_order': 1 + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{code}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 200 + put_data = put_resp.json + assert put_data['step'] == update_data['step'] + assert put_data['description'] == update_data['description'] + assert put_data['display_order'] == update_data['display_order'] + + +def test_put_permit_condition_category_not_found(test_client, db_session, auth_headers): + """Should return 400 if category not found""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + update_data = { + 'step': 'b', + 'description': 'Updated category', + 'display_order': 1 + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{uuid.uuid4()}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 400 + put_data = put_resp.json + assert 'Permit Condition Category not found' in put_data['message'] + +def test_put_permit_condition_category_mine_amendment_permit_mismatch(test_client, db_session, auth_headers): + """Should return 400 if mine and permit don't match""" + mine, permit = create_mine_and_permit() + other_mine, other_permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create initial category + code = str(uuid.uuid4()) + PermitConditionCategory.create( + condition_category_code=code, + step='a', + description='Initial category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + update_data = { + 'step': 'b', + 'description': 'Updated category', + 'display_order': 1 + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{other_permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{code}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 400 + put_data = put_resp.json + assert 'Permit mine_guid and supplied mine_guid mismatch' in put_data['message'] + +def test_put_permit_condition_category_reorder(test_client, db_session, auth_headers): + """Should update display order and reorder categories""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create initial categories + category1 = PermitConditionCategory.create( + condition_category_code='Category 1', + step='a', + description='Category 1', + display_order=0, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + category2 = PermitConditionCategory.create( + condition_category_code='Category 2', + step='b', + description='Category 2', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + category3 = PermitConditionCategory.create( + condition_category_code='Category 3', + step='c', + description='Category 3', + display_order=2, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + update_data = { + 'step': 'b', + 'description': 'Updated Category 2', + 'display_order': 0 # Move to the first position + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{category2.condition_category_code}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 200 + put_data = put_resp.json + assert put_data['step'] == update_data['step'] + assert put_data['description'] == update_data['description'] + assert put_data['display_order'] == update_data['display_order'] + + db_session.flush() + + # Verify the reordering + categories = PermitConditionCategory.find_by_permit_amendment_id(permit_amendment.permit_amendment_id) + categories = sorted(categories, key=lambda x: x.display_order) + assert categories[0].condition_category_code == category2.condition_category_code + assert categories[1].condition_category_code == category1.condition_category_code + assert categories[2].condition_category_code == category3.condition_category_code + +def test_put_permit_condition_category_invalid_display_order_greater(test_client, db_session, auth_headers): + """Should return 400 if display_order is greater than the number of categories""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create initial category + category = PermitConditionCategory.create( + condition_category_code=str(uuid.uuid4()), + step='a', + description='Initial category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + update_data = { + 'step': 'b', + 'description': 'Updated category', + 'display_order': 5 # Invalid display order + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{category.condition_category_code}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 400 + put_data = put_resp.json + assert 'Display order cannot be greater than the number of categories' in put_data['message'] + +def test_put_permit_condition_category_invalid_display_order_negative(test_client, db_session, auth_headers): + """Should return 400 if display_order is negative""" + mine, permit = create_mine_and_permit() + permit_amendment = permit.permit_amendments[0] + + # Create initial category + category = PermitConditionCategory.create( + condition_category_code=str(uuid.uuid4()), + step='a', + description='Initial category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id, + ) + + update_data = { + 'step': 'b', + 'description': 'Updated category', + 'display_order': -1 # Invalid display order + } + + put_resp = test_client.put( + f'/mines/{mine.mine_guid}/permits/{permit.permit_guid}/amendments/{permit_amendment.permit_amendment_guid}/condition-categories/{category.condition_category_code}', + headers=auth_headers['full_auth_header'], + json=update_data) + + assert put_resp.status_code == 400 + put_data = put_resp.json + assert 'Display order must be >= 0' in put_data['message'] \ No newline at end of file diff --git a/services/core-api/tests/mines/permit/resources/test_permit_condition_category_resource.py b/services/core-api/tests/mines/permit/resources/test_permit_condition_category_resource.py new file mode 100644 index 0000000000..d5b8129628 --- /dev/null +++ b/services/core-api/tests/mines/permit/resources/test_permit_condition_category_resource.py @@ -0,0 +1,224 @@ +import json +import uuid +from datetime import datetime, timedelta + +import pytest +from app.api.mines.permits.permit_conditions.models.permit_condition_category import ( + PermitConditionCategory, +) +from app.api.mines.permits.permit_conditions.models.permit_conditions import ( + PermitConditions, +) +from app.api.mines.response_models import PermitCondition +from dateutil import parser +from tests.factories import PermitAmendmentFactory, create_mine_and_permit + + +def test_get_permit_condition_categories(test_client, db_session, auth_headers): + """Test getting all permit condition categories.""" + + # Call the endpoint + get_resp = test_client.get( + '/mines/permits/condition-category-codes', + headers=auth_headers['full_auth_header']) + + # Check response code + assert get_resp.status_code == 200 + + # Check response format + get_data = get_resp.json + assert 'records' in get_data + + # Verify records are returned + assert len(get_data['records']) > 0 + + # Check record structure + first_record = get_data['records'][0] + assert first_record['condition_category_code'] == 'GEC' + assert first_record['description'] == 'General Conditions' + assert first_record['display_order'] == 10 + assert first_record['step'] == 'A.' + +def test_get_permit_condition_categories_with_limit(test_client, db_session, auth_headers): + """Test getting permit condition categories with a limit.""" + + # Call the endpoint with limit parameter + get_resp = test_client.get( + '/mines/permits/condition-category-codes?limit=2', + headers=auth_headers['full_auth_header']) + + # Check response code + assert get_resp.status_code == 200 + + # Check response format + get_data = get_resp.json + assert 'records' in get_data + + # Verify the number of records returned is equal to the limit + assert len(get_data['records']) == 2 + +def test_get_permit_condition_categories_with_query(test_client, db_session, auth_headers): + """Test getting permit condition categories with a query.""" + + # Call the endpoint with query parameter + get_resp = test_client.get( + '/mines/permits/condition-category-codes?query=General', + headers=auth_headers['full_auth_header']) + + # Check response code + assert get_resp.status_code == 200 + + # Check response format + get_data = get_resp.json + assert 'records' in get_data + + # Verify records are returned + assert len(get_data['records']) > 0 + + # Check that the returned records match the query + for record in get_data['records']: + assert 'General' in record['description'] + +def test_get_permit_condition_categories_with_exclude(test_client, db_session, auth_headers): + """Test getting permit condition categories with exclude parameter.""" + + # Call the endpoint with exclude parameter + get_resp = test_client.get( + '/mines/permits/condition-category-codes?exclude=GEC', + headers=auth_headers['full_auth_header']) + + # Check response code + assert get_resp.status_code == 200 + + # Check response format + get_data = get_resp.json + assert 'records' in get_data + + # Verify records are returned + assert len(get_data['records']) > 0 + + # Check that the excluded code is not in the returned records + for record in get_data['records']: + assert record['condition_category_code'] != 'GEC' + +def test_create_and_search_permit_condition_category(test_client, db_session, auth_headers): + """Test creating a new permit condition category and searching for it.""" + + mine, permit = create_mine_and_permit() + + permit_amendment = permit.permit_amendments[0] + + new_cat = PermitConditionCategory.create( + condition_category_code='abc1234567', + step='B', + description='New Test Category', + display_order=5, + permit_amendment_id=permit_amendment.permit_amendment_id + ) + + # Search for the newly added category + search_resp = test_client.get( + f'/mines/permits/condition-category-codes?query=New%20Test%20Cat', + headers=auth_headers['full_auth_header']) + + assert search_resp.status_code == 200 + search_data = search_resp.json + assert 'records' in search_data + assert len(search_data['records']) > 0 + + # Verify the newly added category is in the search results + found = False + for record in search_data['records']: + if record['condition_category_code'] == new_cat.condition_category_code: + found = True + assert record['description'] == new_cat.description + assert record['display_order'] == new_cat.display_order + assert record['step'] == new_cat.step + break + + assert found + +def test_get_permit_condition_categories_with_duplicate_descriptions(test_client, db_session, auth_headers): + """Test getting permit condition categories when duplicate descriptions exist.""" + + # Create two categories with the same description + PermitConditionCategory.create( + condition_category_code='DUP1', + step='A', + description='Duplicate Description', + display_order=1, + permit_amendment_id=None + ) + + PermitConditionCategory.create( + condition_category_code='DUP2', + step='B', + description='Duplicate Description', + display_order=2, + permit_amendment_id=None + ) + + # Call the endpoint + get_resp = test_client.get( + '/mines/permits/condition-category-codes?query=Duplicate%20Description', + headers=auth_headers['full_auth_header']) + + # Check response code + assert get_resp.status_code == 200 + + # Check response format + get_data = get_resp.json + assert 'records' in get_data + + # Verify records are returned + assert len(get_data['records']) > 0 + + # Check that only one of the duplicate descriptions is returned + descriptions = [record['description'] for record in get_data['records']] + assert descriptions.count('Duplicate Description') == 1 + +def test_create_permit_condition_excludes_permit_amendment_specific_categories(test_client, db_session, auth_headers): + """Test getting all permit condition categories.""" + + def fetch_categories(): + # Call the endpoint + get_resp = test_client.get( + '/mines/permits/condition-category-codes', + headers=auth_headers['full_auth_header']) + + assert get_resp.status_code == 200 + return get_resp.json['records'] + + records = fetch_categories() + assert len(records) > 0 + # Create new category and verify that it gets returned when + # no permit amendment is specified + PermitConditionCategory.create( + condition_category_code='NEW', + step='A', + description='Test Category', + display_order=1, + permit_amendment_id=None + ) + + nr = fetch_categories() + + assert len(nr) == len(records) + 1 + + # Create new category and verify that it does not get returned when + # a permit amendment is specified + mine, permit = create_mine_and_permit() + + permit_amendment = permit.permit_amendments[0] + + PermitConditionCategory.create( + condition_category_code='ANOTHER', + step='A', + description='Test Category', + display_order=1, + permit_amendment_id=permit_amendment.permit_amendment_id + ) + + new_records = fetch_categories() + + assert len(new_records) == len(records) + 1 diff --git a/services/core-api/tests/permits/permit_extraction/test_create_permit_conditions_from_task.py b/services/core-api/tests/permits/permit_extraction/test_create_permit_conditions_from_task.py index 33e425f961..b6091f6a73 100644 --- a/services/core-api/tests/permits/permit_extraction/test_create_permit_conditions_from_task.py +++ b/services/core-api/tests/permits/permit_extraction/test_create_permit_conditions_from_task.py @@ -21,8 +21,8 @@ def permit_amendment(test_client, db_session): yield permit_amendment -def test_create_permit_conditions_from_task(permit_amendment, db_session): - +@pytest.fixture(scope="function") +def permit_conditions(permit_amendment): task = PermitExtractionTask( task_result={ "conditions": [ @@ -97,40 +97,61 @@ def test_create_permit_conditions_from_task(permit_amendment, db_session): "subsubclause": None, "condition_text": "Another paragraph", }, - + # Custom section + { + "section": "C", + "paragraph": None, + "subparagraph": None, + "clause": None, + "subclause": None, + "subsubclause": None, + "condition_title": None, + "condition_text": "This is just a test", + }, + { + "section": "C", + "paragraph": "1", + "subparagraph": None, + "clause": None, + "subclause": None, + "subsubclause": None, + "condition_text": "A test paragraph", + }, ] }, permit_amendment=permit_amendment, ) - # Call the function create_permit_conditions_from_task(task) # Retrieve the created permit conditions from the database permit_conditions = PermitConditions.query.all() - # Assert the created permit conditions - assert len(permit_conditions) == 6 + return permit_conditions + +def test_create_permit_conditions_from_task(permit_conditions, permit_amendment, db_session): ### General Section + gen_cat = permit_conditions[0] # Top level sections are not created as a PermitCondition. They are mapped to a PermitConditionCategory instead assert permit_conditions[0].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[0].condition_category_code == "GEC" + assert permit_conditions[0].condition_category_code != "GEC" + assert permit_conditions[0].condition_category.description == "General" assert permit_conditions[0].condition == "This is a paragraph" assert permit_conditions[0].condition_type_code == "SEC" # First level is a section assert permit_conditions[0].parent_permit_condition_id is None assert permit_conditions[0]._step == "1" assert permit_conditions[1].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[1].condition_category_code == "GEC" + assert permit_conditions[1].condition_category_code == gen_cat.condition_category_code assert permit_conditions[1].condition == "This is a subparagraph" assert permit_conditions[1].condition_type_code == "CON" # Second level is a condition - assert permit_conditions[1].parent_permit_condition_id == permit_conditions[0].permit_condition_id + assert permit_conditions[1].parent_permit_condition_id == gen_cat.permit_condition_id assert permit_conditions[1]._step == "1" assert permit_conditions[2].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[2].condition_category_code == "GEC" + assert permit_conditions[2].condition_category_code == gen_cat.condition_category_code assert permit_conditions[2].condition == "This is a clause" assert permit_conditions[2].condition_type_code == "LIS" # Third level on is a list item assert permit_conditions[2].parent_permit_condition_id == permit_conditions[1].permit_condition_id @@ -139,24 +160,36 @@ def test_create_permit_conditions_from_task(permit_amendment, db_session): # When a condition both has a title and text, they are created as two conditions, with the text as a child of the title # Note: This was an assumption made to make the display more accurately reflect the PDF. May need a revision. assert permit_conditions[3].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[3].condition_category_code == "GEC" + assert permit_conditions[3].condition_category_code == gen_cat.condition_category_code assert permit_conditions[3].condition == "This condition has a title" assert permit_conditions[3].condition_type_code == "LIS" assert permit_conditions[3].parent_permit_condition_id == permit_conditions[2].permit_condition_id assert permit_conditions[3]._step == "b" assert permit_conditions[4].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[4].condition_category_code == "GEC" + assert permit_conditions[4].condition_category_code == gen_cat.condition_category_code assert permit_conditions[4].condition == "This is a subclause" assert permit_conditions[4].condition_type_code == "LIS" assert permit_conditions[4].parent_permit_condition_id == permit_conditions[3].permit_condition_id assert permit_conditions[4]._step == "" # This is a child of the title condition - which in the PDFs do not have a step - ## Protection of Land and Watercourses Section +def test_creates_general_conditions_as_unique_for_permit_amendment(permit_conditions, permit_amendment, db_session): + # Protection of Land and Watercourses Section assert permit_conditions[5].permit_amendment_id == permit_amendment.permit_amendment_id - assert permit_conditions[5].condition_category_code == "ELC" + assert permit_conditions[5].condition_category_code != "ELC" + assert permit_conditions[5].condition_category.description == "Protection of Land and Watercourses" assert permit_conditions[5].condition == "Another paragraph" assert permit_conditions[5].condition_type_code == "SEC" assert permit_conditions[5].parent_permit_condition_id is None - assert permit_conditions[5]._step == "2" \ No newline at end of file + assert permit_conditions[5]._step == "2" + + +def test_creates_custom_conditions(permit_conditions, permit_amendment, db_session): + # Can handle custom sections + assert permit_conditions[6].permit_amendment_id == permit_amendment.permit_amendment_id + assert permit_conditions[6].condition_category.description == "This is just a test" + assert permit_conditions[6].condition == "A test paragraph" + assert permit_conditions[6].parent_permit_condition_id is None + assert permit_conditions[6].condition_category.permit_amendment_id == permit_amendment.permit_amendment_id + assert permit_conditions[6]._step == "1" \ No newline at end of file diff --git a/services/core-api/tests/reports/resource/test_reports_resource.py b/services/core-api/tests/reports/resource/test_reports_resource.py index d27c55c02b..dd9c45ec18 100644 --- a/services/core-api/tests/reports/resource/test_reports_resource.py +++ b/services/core-api/tests/reports/resource/test_reports_resource.py @@ -1,14 +1,9 @@ import json import uuid -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta -from flask import current_app +from tests.factories import MineFactory -from app.api.mines.mine.models.mine import Mine -from app.api.mines.reports.models.mine_report_definition import MineReportDefinition -from app.api.constants import MINE_REPORT_TYPE - -from tests.factories import MineFactory, MineReportFactory THREE_REPORTS = 3 ONE_REPORT = 1 GUID = str(uuid.uuid4) @@ -69,7 +64,5 @@ def test_get_reports(test_client, db_session, auth_headers): for report in get_data['records']: received_date = datetime.strptime(report['received_date'], '%Y-%m-%d') - assert (start_date <= received_date.date()) - assert (received_date.date() <= end_date) - - + assert (start_date.date() <= received_date.date()) + assert (received_date.date() <= end_date.date()) diff --git a/services/core-web/jest.config.js b/services/core-web/jest.config.js index 6a94846269..0285a38263 100644 --- a/services/core-web/jest.config.js +++ b/services/core-web/jest.config.js @@ -8,7 +8,7 @@ module.exports = { }, ], }, - maxWorkers: 4, + maxWorkers: '50%', verbose: true, testEnvironmentOptions: { url: "http://localhost", diff --git a/services/core-web/src/components/Forms/MajorProject/MajorProjectSearchForm.tsx b/services/core-web/src/components/Forms/MajorProject/MajorProjectSearchForm.tsx index 81189bf5bc..e2ba39b2f9 100644 --- a/services/core-web/src/components/Forms/MajorProject/MajorProjectSearchForm.tsx +++ b/services/core-web/src/components/Forms/MajorProject/MajorProjectSearchForm.tsx @@ -15,6 +15,7 @@ interface MajorProjectsSearchFormProps { isAdvanceSearch: boolean; } + export const MajorProjectsSearchForm: FC = ({ handleSubmit, toggleAdvancedSearch, diff --git a/services/core-web/src/components/dashboard/majorProjectHomePage/MajorProjectTable.tsx b/services/core-web/src/components/dashboard/majorProjectHomePage/MajorProjectTable.tsx index 0c26835309..1ac4b94d34 100644 --- a/services/core-web/src/components/dashboard/majorProjectHomePage/MajorProjectTable.tsx +++ b/services/core-web/src/components/dashboard/majorProjectHomePage/MajorProjectTable.tsx @@ -1,7 +1,7 @@ import React, { FC } from "react"; import { Link } from "react-router-dom"; import { Button, Col, Row } from "antd"; -import { uniqBy, flattenDeep } from "lodash"; +import { uniqBy, flattenDeep, uniq } from "lodash"; import * as Strings from "@mds/common/constants/strings"; import { PROJECT_SUMMARY_STATUS_CODES, @@ -48,7 +48,7 @@ const transformRowData = (projects, mineCommodityHash) => project_lead_name: project.project_lead_name, commodity: project?.mine?.mine_type && project.mine.mine_type.length > 0 - ? uniqBy( + ? uniq( flattenDeep( project.mine.mine_type.reduce((result, type) => { if (type.mine_type_detail && type.mine_type_detail.length > 0) { diff --git a/services/core-web/src/components/mine/Permit/PermitConditionCategory.spec.tsx b/services/core-web/src/components/mine/Permit/PermitConditionCategory.spec.tsx new file mode 100644 index 0000000000..114f0384c4 --- /dev/null +++ b/services/core-web/src/components/mine/Permit/PermitConditionCategory.spec.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import { ReduxWrapper } from "@mds/common/tests/utils/ReduxWrapper"; +import { EditPermitConditionCategoryInline } from "./PermitConditionCategory"; + +const mockCategory = { + condition_category_code: "TEST-CAT", + step: 'A', + display_order: 1, + description: "Test Category" +}; + +const mockProps = { + category: mockCategory, + conditionCount: 0, + currentPosition: 1, + categoryCount: 3, + onChange: jest.fn(), + onDelete: jest.fn(), + moveUp: jest.fn(), + moveDown: jest.fn() +}; + +const initialState = {}; + +describe("PermitConditionCategory", () => { + it("renders category title with count in view mode", () => { + render( + + + + ); + + expect(screen.getByText(`A. Test Category (0)`)).toBeInTheDocument(); + }); + + it("switches to edit mode on click", () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (0)`)); + expect(screen.getByRole("textbox", { name: 'step' })).toBeInTheDocument(); + }); + + it("calls moveUp when up arrow clicked", () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (0)`)); + const upButton = screen.getByRole("button", { name: "Move Category Up" }); + fireEvent.click(upButton); + + expect(mockProps.moveUp).toHaveBeenCalledWith(mockCategory); + }); + + it("calls moveDown when down arrow clicked", () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (0)`)); + const upButton = screen.getByRole("button", { name: "Move Category Down" }); + fireEvent.click(upButton); + + expect(mockProps.moveDown).toHaveBeenCalledWith(mockCategory); + }); + + it("disables delete button when condition count > 0", () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (1)`)); + const deleteButton = screen.getByRole("button", { name: "Delete Category" }); + + expect(deleteButton).toBeDisabled(); + }); + + it("enables delete button when condition count = 0", () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (0)`)); + const deleteButton = screen.getByRole("button", { name: "Delete Category" }); + + expect(deleteButton).not.toBeDisabled(); + + fireEvent.click(deleteButton); + + const confirmDeleteButton = screen.getByRole("button", { name: "Yes, Delete Category" }); + fireEvent.click(confirmDeleteButton); + + expect(mockProps.onDelete).toHaveBeenCalledWith(mockCategory); + }); + + it("submits form with updated values", async () => { + render( + + + + ); + + fireEvent.click(screen.getByText(`A. Test Category (0)`)); + + const stepInput = screen.getByRole("textbox", { name: 'step' }); + fireEvent.change(stepInput, { target: { value: "B" } }); + + const submitButton = screen.getByRole("button", { name: "Confirm" }); + expect(submitButton).not.toBeDisabled(); + }); +}); diff --git a/services/core-web/src/components/mine/Permit/PermitConditionCategory.tsx b/services/core-web/src/components/mine/Permit/PermitConditionCategory.tsx new file mode 100644 index 0000000000..b32509273c --- /dev/null +++ b/services/core-web/src/components/mine/Permit/PermitConditionCategory.tsx @@ -0,0 +1,124 @@ +import FormWrapper from "@mds/common/components/forms/FormWrapper"; +import RenderField from "@mds/common/components/forms/RenderField"; +import RenderSubmitButton from "@mds/common/components/forms/RenderSubmitButton"; +import { FORM } from "@mds/common/constants/forms"; +import { IPermitConditionCategory } from "@mds/common/interfaces"; +import { Button, Popconfirm, Row, Tooltip, Typography } from "antd"; +import React, { useState } from "react"; +import { Field } from "redux-form"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowDown, faArrowUp, faCheck, faTrash, faXmark } from "@fortawesome/pro-light-svg-icons"; +import PermitConditionCategorySelector from "./PermitConditionCategorySelector"; +import { required } from "@mds/common/redux/utils/Validate"; +import { reset } from 'redux-form'; +import { useDispatch } from "react-redux"; + +export interface IPermitConditionCategoryProps { + onChange: (category: IPermitConditionCategory) => void | Promise; + onDelete: (category: IPermitConditionCategory) => void | Promise; + moveUp: (category: IPermitConditionCategory) => void | Promise; + moveDown: (category: IPermitConditionCategory) => void | Promise; + category: IPermitConditionCategory; + conditionCount: number; + currentPosition: number; + categoryCount: number; +} + +export const EditPermitConditionCategoryInline = (props: IPermitConditionCategoryProps) => { + const [isEditMode, setIsEditMode] = useState(false); + + const dispatch = useDispatch(); + const formName = `${FORM.INLINE_EDIT_PERMIT_CONDITION_CATEGORY}}-${props.category.condition_category_code}`; + const enableEditMode = (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + setIsEditMode(true); + }; + + const handleSubmit = (cat) => { + props.onChange(cat); + setIsEditMode(false); + } + + const handleDelete = (cat) => { + props.onDelete(cat); + } + + const cancel = (evt) => { + evt.stopPropagation(); + + + dispatch(reset(formName)); + setIsEditMode(false); + } + + + if (!isEditMode) { + return ( + +
+ {props.category.step ? `${props.category.step}. ` : ''}{props.category.description} ({props.conditionCount}) +
+
+ ); + } + + return ( + + + + +
+
- Health and Safety - ( - 1 - ) +
+

+ A.. + Health and Safety + ( + 1 + ) +

+

- Environmental Land and Watercourses - ( - 2 - ) +
+

+ B.. + Reclamation + ( + 1 + ) +

+

+
+
+
+

+ 1. + + Reflect miss police tough such single force. +

+
+
+
+
+

+
+

+ + Environmental Land and Watercourses + ( + 2 + ) +

+
+

+ +
+
+
-
-
-

- Reclamation and Closure Program - ( - 1 - ) -

- -
-
-
-
-
-

- 1. - - Reflect miss police tough such single force. -

-
-
-
diff --git a/services/core-web/src/components/navigation/NavBar.tsx b/services/core-web/src/components/navigation/NavBar.tsx index 5c414ce00a..55d1b63d35 100644 --- a/services/core-web/src/components/navigation/NavBar.tsx +++ b/services/core-web/src/components/navigation/NavBar.tsx @@ -227,7 +227,7 @@ export const NavBar: FC = ({ activeButton, isMenuOpen, toggleHambur - diff --git a/services/core-web/src/styles/components/Button.scss b/services/core-web/src/styles/components/Button.scss index 85c6cfd02d..0b2e793859 100644 --- a/services/core-web/src/styles/components/Button.scss +++ b/services/core-web/src/styles/components/Button.scss @@ -53,18 +53,6 @@ } } - &.ant-btn-danger { - background-color: $danger-btn-color; - color: white; - border: 1px solid $danger-btn-color; - - &:hover, - &:focus { - background-color: $danger-btn-color-hover; - color: white; - border: 1px solid $danger-btn-color-hover; - } - } &.core-btn-tertiary { border: 2px solid $tertiary-btn-color; @@ -84,6 +72,24 @@ background-color: $tertiary-btn-color-hover; } } + + &.ant-btn-danger, + &.ant-btn-dangerous { + color: $danger-btn-color; + border: 1px solid $danger-btn-color; + + &:hover, + &:focus { + color: $danger-btn-color-hover; + border: 1px solid $danger-btn-color-hover; + } + + &:disabled { + color: $danger-btn-color-disabled; + border: 1px solid $danger-btn-color-disabled; + } + } + } .ant-btn+.ant-btn { diff --git a/services/core-web/src/styles/components/Forms.scss b/services/core-web/src/styles/components/Forms.scss index 2848f8821e..5ee7bb73ad 100644 --- a/services/core-web/src/styles/components/Forms.scss +++ b/services/core-web/src/styles/components/Forms.scss @@ -86,6 +86,15 @@ margin-bottom: 10px; } +.ant-form-inline { + + .ant-form-item, + .ant-form-item-row, + .ant-btn { + margin-bottom: 0; + } +} + .common-form.form-view { .ant-form-item-required::before { display: none !important; diff --git a/services/core-web/src/styles/components/ScrollSideMenuWrapper.scss b/services/core-web/src/styles/components/ScrollSideMenuWrapper.scss index dc36d0df21..5fd2046cdf 100644 --- a/services/core-web/src/styles/components/ScrollSideMenuWrapper.scss +++ b/services/core-web/src/styles/components/ScrollSideMenuWrapper.scss @@ -18,6 +18,26 @@ } } + .side-menu, + .side-menu--fixed { + + .ant-anchor-link, + .ant-anchor-link-title { + overflow: visible; + } + } + + .side-menu, + .side-menu--fixed { + &>div { + margin-left: -4px; // Menu items have a 4px border on the left, adjust to align. + } + + .ant-anchor-link:not(.ant-anchor-link-active) { + border-left: 4px solid transparent; // Add a transparent border for non-active items to prvent jumping + } + } + .side-menu--content { padding-top: $fixed-page-margin; // equal to the margin-top on side-menu to align them left: $side-menu-width + $fixed-page-margin; @@ -33,6 +53,25 @@ color: $darkest-grey; line-height: 21px; font-size: 14px; + display: flex; + } + + .side-nav-title .side-nav-title-icon { + position: relative; + } + + .side-nav-title .side-nav-title-content { + overflow: hidden; + text-overflow: ellipsis; + } + + &.scroll-side-menu-view--steps .ant-anchor-link:not(:last-child) .side-nav-title .side-nav-title-icon::before { + content: ''; + position: absolute; + left: calc(50% - 1px); + top: 24px; + bottom: -16px; + border-left: 1px solid $gov-grey; } } diff --git a/services/core-web/src/styles/settings/variables.scss b/services/core-web/src/styles/settings/variables.scss index c72d47b259..d1c8c2efa8 100755 --- a/services/core-web/src/styles/settings/variables.scss +++ b/services/core-web/src/styles/settings/variables.scss @@ -61,6 +61,7 @@ $tertiary-btn-color-hover: color.adjust($tertiary-btn-color, $lightness: -20%); $danger-btn-color: $btn-red; $danger-btn-color-hover: color.adjust($danger-btn-color, $lightness: -20%); +$danger-btn-color-disabled: rgba($danger-btn-color, 0.5); // Nav $nav-color: $darkest-grey; diff --git a/services/core-web/src/tests/components/Forms/reports/ReportPermitRequirementForm.spec.tsx b/services/core-web/src/tests/components/Forms/reports/ReportPermitRequirementForm.spec.tsx index 3f6ac690f7..3bce2ec473 100644 --- a/services/core-web/src/tests/components/Forms/reports/ReportPermitRequirementForm.spec.tsx +++ b/services/core-web/src/tests/components/Forms/reports/ReportPermitRequirementForm.spec.tsx @@ -16,6 +16,7 @@ const initialState = { [MINES]: MOCK.MINES, [PERMITS]: { permits: MOCK.PERMITS, + permitAmendments: MOCK.PERMIT_AMENDMENT_STATE, }, [AUTHENTICATION]: { systemFlag: SystemFlagEnum.core, @@ -29,7 +30,7 @@ describe("RequestReportForm", () => { {}} + onSubmit={() => { }} condition={MOCK.PERMITS[0].permit_amendments[0].conditions[0]} /> diff --git a/services/core-web/src/tests/components/Forms/reports/__snapshots__/ReportPage-prr.spec.tsx.snap b/services/core-web/src/tests/components/Forms/reports/__snapshots__/ReportPage-prr.spec.tsx.snap index 0dc5cb367d..8aba13b16e 100644 --- a/services/core-web/src/tests/components/Forms/reports/__snapshots__/ReportPage-prr.spec.tsx.snap +++ b/services/core-web/src/tests/components/Forms/reports/__snapshots__/ReportPage-prr.spec.tsx.snap @@ -2,7 +2,7 @@ exports[`ReportPage renders view mode properly 1`] = ` + +